fix(canvas): 保护生成中与失败节点画幅尺寸,支持变体即时持久化与 2K 分辨率高亮 (#359)

Co-authored-by: Danborad <danborad@github.com>
This commit is contained in:
Danborad
2026-08-31 12:28:12 +08:00
committed by GitHub
parent 76e5a166dc
commit 8d9ab03a66
10 changed files with 127 additions and 77 deletions

View File

@@ -79,15 +79,16 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
assertResolvableGenerationMentions(prompt, mentionInputs);
const hasExplicitResourceMention = hasResolvableGenerationMention(prompt, mentionInputs);
const isWorkflowSource = sourceNode?.type === CanvasNodeType.Config && isCanvasWorkflowProvider(sourceNode.metadata);
if ((Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
const hasConnectedMedia = connectedInputs.some((input) => input.type === "image" || input.type === "video" || input.type === "audio" || input.type === "character");
if ((promptOnly && hasConnectedMedia) || (Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
const autoIncludeWorkflowMedia = isWorkflowSource;
return buildComposerGenerationContext(
mentionInputs,
prompt,
// 工作流节点由字段映射接收全部连线媒体;视频节点的历史首尾帧字段不能再额外追加参考图。
autoIncludeWorkflowMedia ? [] : [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)),
autoIncludeWorkflowMedia || (promptOnly && hasConnectedMedia) ? [] : [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)),
promptOnly,
autoIncludeWorkflowMedia,
autoIncludeWorkflowMedia || (promptOnly && hasConnectedMedia),
connectedInputs,
);
}

View File

@@ -4,7 +4,7 @@ import { Switch } from "antd";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { formatVideoResolutionLabel, normalizeVideoDuration, videoDimensionsForRatioAndResolution, videoResolutionComparisonKey, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { formatVideoResolutionLabel, isVideoResolutionMatch, normalizeVideoDuration, videoDimensionsForRatioAndResolution, videoResolutionComparisonKey, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, resolveVideoRatioValue, resolveVideoResolutionValue, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
@@ -51,7 +51,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
{configuredResolutions.length ? <SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-1.5">
{configuredResolutions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} disabled={!hasPriceTierForVideoSelection(priceTiers, item.value, Number(seconds))} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
<OptionPill key={item.value} selected={isVideoResolutionMatch(resolution, item.value)} disabled={!hasPriceTierForVideoSelection(priceTiers, item.value, Number(seconds))} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
</OptionPill>
))}

View File

@@ -17,11 +17,34 @@ export function fitNodeSize(width: number, height: number, maxWidth = 720, maxHe
export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: number) {
const raw = String(size || "").trim();
if (!raw || raw.toLowerCase() === "auto") return null;
// 支持 16:9 / 1024x576 / 16:9-2k
let width = 0;
let height = 0;
const match = raw.match(/^(\d+(?:\.\d+)?)(?:x|:)(\d+(?:\.\d+)?)/i);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (match) {
width = Number(match[1]);
height = Number(match[2]);
} else if (raw.includes("竖") || raw.includes("portrait") || raw.includes("9:16")) {
width = 9;
height = 16;
} else if (raw.includes("横") || raw.includes("landscape") || raw.includes("16:9")) {
width = 16;
height = 9;
} else if (raw.includes("(1:1)") || raw.includes("1:1") || raw.includes("square")) {
width = 1;
height = 1;
} else if (raw.includes("3:4")) {
width = 3;
height = 4;
} else if (raw.includes("4:3")) {
width = 4;
height = 3;
} else if (raw.includes("2:3")) {
width = 2;
height = 3;
} else if (raw.includes("3:2")) {
width = 3;
height = 2;
}
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
const ratio = width / Math.max(1, height);
if (ratio < 0.25 || ratio > 4) return { width: baseWidth, height: baseHeight };
@@ -35,14 +58,22 @@ export function ensureMediaNodeMinimumSize(node: CanvasNodeData) {
let width = node.width;
let height = node.height;
const emptyStage = isBuiltinCanvasNodeType(node.type) ? NODE_DEFAULT_SIZE[node.type] : undefined;
const shouldPromoteEmptyStage = !node.metadata?.content
&& !node.metadata?.freeResize
&& !node.metadata?.locked
&& emptyStage !== undefined
&& width * height < emptyStage.width * emptyStage.height;
if (shouldPromoteEmptyStage) {
width = emptyStage.width;
height = emptyStage.height;
// 如果未完成节点(生成中/失败/空节点)指定了目标比例(如 3:4, 9:16),按目标比例保持占位框尺寸,不能强制变成 16:9 横屏。
const targetSize = node.metadata?.size ? nodeSizeFromRatio(node.metadata.size, emptyStage?.width || 720, emptyStage?.height || 405) : null;
if (targetSize && !node.metadata?.content && !node.metadata?.freeResize && !node.metadata?.locked) {
width = targetSize.width;
height = targetSize.height;
} else {
const shouldPromoteEmptyStage = !node.metadata?.content
&& !node.metadata?.freeResize
&& !node.metadata?.locked
&& emptyStage !== undefined
&& (width <= 0 || height <= 0);
if (shouldPromoteEmptyStage) {
width = emptyStage.width;
height = emptyStage.height;
}
}
const naturalWidth = node.metadata?.naturalWidth || 0;
const naturalHeight = node.metadata?.naturalHeight || 0;

View File

@@ -20,7 +20,7 @@ export function desktopLocalChannelFormState(desktopLocalChannelsEnabled: boolea
export function projectDesktopLocalChannelRuntime<T extends { allowLocalChannel?: boolean }>(config: T): T {
const desktopLocalChannelsEnabled = useUserStore.getState().features.desktopLocalChannelsEnabled;
const hostname = typeof window === "undefined" ? "" : window.location.hostname;
const hostname = typeof window === "undefined" ? "" : window.location?.hostname || "";
const allowLocalChannel = desktopLocalChannelPayloadValue(desktopLocalChannelsEnabled, hostname, config.allowLocalChannel);
return config.allowLocalChannel === allowLocalChannel ? config : { ...config, allowLocalChannel };
}

View File

@@ -11,16 +11,35 @@ export function normalizeVideoDuration(value: string | number | undefined) {
export function normalizeVideoResolution(value: string | number | undefined) {
const raw = String(value || "").trim();
const token = raw.toLowerCase();
if (!token) return "";
if (token === "low") return "480";
if (token === "auto" || token === "medium" || token === "high") return "720";
if (token === "2k") return "1440";
if (token === "4k") return "2160";
const resolution = Number(token.replace(/p$/i, ""));
if (Number.isFinite(resolution)) return resolution > 0 ? String(Math.floor(resolution)) : "720";
if (Number.isFinite(resolution) && resolution > 0) return String(Math.floor(resolution));
// Channel-declared values are opaque enums, not necessarily numeric tiers.
// Preserve them verbatim so values such as `768p竖` remain selectable and
// can be sent back to the provider without being collapsed to 720p.
return raw || "720";
return raw;
}
export function normalizeVideoResolutionValue(value: string | number | undefined): string {
return normalizeVideoResolution(value) || "720";
}
export function isVideoResolutionMatch(selected: string | undefined, target: string | undefined) {
const s = String(selected || "").trim().toLowerCase().replace(/p$/i, "");
const t = String(target || "").trim().toLowerCase().replace(/p$/i, "");
if (!s && !t) return true;
if (s === t) return true;
if ((s === "2k" || s === "1440") && (t === "2k" || t === "1440")) return true;
if ((s === "4k" || s === "2160") && (t === "4k" || t === "2160")) return true;
if ((s === "768" || s === "768p") && (t === "768" || t === "768p")) return true;
if ((s === "1080" || s === "1080p") && (t === "1080" || t === "1080p")) return true;
if ((s === "720" || s === "720p") && (t === "720" || t === "720p")) return true;
if ((s === "480" || s === "480p") && (t === "480" || t === "480p")) return true;
return false;
}
export function videoResolutionComparisonKey(value: string | number | undefined) {
@@ -34,7 +53,8 @@ export function formatVideoResolutionLabel(value: string | number | undefined) {
const raw = String(value || "").trim();
if (!raw) return "";
const normalized = normalizeVideoResolution(raw);
if (normalized === "2160") return "4K";
if (normalized === "1440" || normalized.toLowerCase() === "2k") return "2K";
if (normalized === "2160" || normalized.toLowerCase() === "4k") return "4K";
if (/^\d+$/.test(normalized)) return `${normalized}P`;
return normalized.replace(/^(\d+)p/i, "$1P");
}

View File

@@ -7,6 +7,7 @@ import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
@@ -49,7 +50,8 @@ export async function executeImageGeneration({
const count = getGenerationCount(generationConfig.count);
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
const isCopiedVariant = count === 1 && isImageNode && Boolean(sourceNode?.metadata?.copiedFromNodeId || sourceNode?.metadata?.versionOfNodeId || sourceNode?.title.endsWith(" Copy") || sourceNode?.title.includes(" · "));
const reuseSourceNode = canGenerateImageInPlace(sourceNode) || isCopiedVariant;
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && (Boolean(sourceNode?.metadata?.copiedFromNodeId) || sourceNode?.title.endsWith(" Copy"));
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
const referenceImages = generationContext.referenceImages;
@@ -87,6 +89,7 @@ export async function executeImageGeneration({
width: rootWidth,
height: rootHeight,
metadata: {
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
prompt: effectivePrompt,
status: NODE_STATUS_LOADING,
size: generationConfig.size,
@@ -95,6 +98,7 @@ export async function executeImageGeneration({
batchFailedCount: count > 1 ? 0 : undefined,
batchUsesReferenceImages: referenceImages.length > 0,
primaryImageId: undefined,
content: reuseSourceNode ? "" : undefined,
...generationMetadata,
...styleMetadata,
...skillMetadata,
@@ -128,27 +132,33 @@ export async function executeImageGeneration({
? childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))
: [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];
setNodes((current) => {
return [
...current.map((node) => {
if (node.id !== nodeId) return node;
if (isConfigNode) return { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } };
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
if (isImageNode) return node;
return {
...node,
type: CanvasNodeType.Text,
title: prompt.slice(0, 32) || "Prompt",
width: parentConfig.width,
height: parentConfig.height,
metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined },
};
}),
...(reuseSourceNode || directCopiedBatch ? [] : [rootNode]),
...childNodes,
];
const nextNodes: CanvasNodeData[] = [
...canvasNodes.map((node) => {
if (node.id !== nodeId) return node;
if (isConfigNode) return { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } };
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
if (isImageNode) return node;
return {
...node,
type: CanvasNodeType.Text,
title: prompt.slice(0, 32) || "Prompt",
width: parentConfig.width,
height: parentConfig.height,
metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined },
};
}),
...(reuseSourceNode || directCopiedBatch ? [] : [rootNode]),
...childNodes,
];
setNodes(nextNodes);
setConnections((current) => {
const nextConnections = [...current, ...batchConnections];
if (projectId) {
useCanvasStore.getState().updateProject(projectId, { nodes: nextNodes, connections: nextConnections });
}
return nextConnections;
});
setConnections((current) => [...current, ...batchConnections]);
setSelectedNodeIds(new Set([nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(nodeId);
@@ -200,7 +210,7 @@ export async function executeImageGeneration({
height: child.height,
position: { x: center.x - child.width / 2, y: center.y - child.height / 2 },
};
return current.map((node) =>
const updated = current.map((node) =>
node.id === rootId
? {
...node,
@@ -220,6 +230,8 @@ export async function executeImageGeneration({
}
: node,
);
if (projectId) useCanvasStore.getState().updateProject(projectId, { nodes: updated });
return updated;
});
}
hasSuccess = true;
@@ -231,7 +243,11 @@ export async function executeImageGeneration({
if (!representativeFailure || failure.generationErrorCode === CONTENT_MODERATION_ERROR_CODE) representativeFailure = failure;
hasFailure = true;
failureCount += 1;
setNodes((current) => current.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } } : node)));
setNodes((current) => {
const next = current.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } } : node));
if (projectId) useCanvasStore.getState().updateProject(projectId, { nodes: next });
return next;
});
return false;
} finally {
finishGenerationRequest(targetId, controller);
@@ -244,8 +260,8 @@ export async function executeImageGeneration({
return;
}
if (hasFailure) showError(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");
setNodes((current) =>
current.map((node) => {
setNodes((current) => {
const next = current.map((node) => {
if (node.id === nodeId && isConfigNode) {
return {
...node,
@@ -268,6 +284,9 @@ export async function executeImageGeneration({
};
}
return node;
}),
);
});
if (projectId) useCanvasStore.getState().updateProject(projectId, { nodes: next });
return next;
});
}

View File

@@ -133,9 +133,11 @@ export function useCanvasGenerationExecutor({
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
const promptOnly = mode === "video";
// AutoDL/其他声明式视频协议需要结构化参考素材;只有普通
// 模型视频接口才把提示词视为纯文本输入。
const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model");
// 普通视频协议只保留输入框文本;声明式工作流还要保留连接媒体。
const promptOnly = mode === "video" && !usesWorkflowProvider;
try {
const baseContext = buildNodeGenerationContext(
nodeId,

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { getNodeGenerationMode, getNodeInputKind, getNodeListLabel, getNodeMinSize, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, shouldKeepAspectRatio } from "../src/lib/canvas/node-registry";
import { getNodeDefinition, getNodeGenerationMode, getNodeInputKind, getNodeListLabel, getNodeMinSize, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, shouldKeepAspectRatio } from "../src/lib/canvas/node-registry";
import { connectionInputSummary } from "../src/lib/canvas/canvas-connection-policy";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "../src/types/canvas";
@@ -12,7 +12,7 @@ const ALL_TYPES = Object.values(CanvasNodeType);
describe("节点注册表——覆盖完整性", () => {
test("每种节点类型都有定义", () => {
expect(listNodeDefinitions().length).toBe(ALL_TYPES.length);
expect(ALL_TYPES.every((type) => Boolean(getNodeDefinition(type)))).toBe(true);
for (const type of ALL_TYPES) expect(getNodeMinSize(type)).toBeDefined();
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { buildSkillMentionReferences, expandSkillMentions, renderSkillPrompt, resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import type { Skill } from "@/services/api/skills";
function skill(overrides: Partial<Skill> = {}): Skill {
@@ -44,27 +44,4 @@ describe("canvas skill mentions", () => {
const inactive = skill({ skill_id: "skill-2", skill_name: "未加入", is_added: false });
expect(resolveSkillMentions("请使用 @镜头拆解,但不要使用 @未加入。", [active, inactive]).map((item) => item.skill_id)).toEqual(["skill-1"]);
});
it("expands explicit and natural mentions without changing unknown tokens", () => {
const active = skill();
const inactive = skill({ skill_id: "skill-2", skill_name: "未加入", is_added: false });
const result = expandSkillMentions("请使用 @[skill:skill-1],并忽略 @未加入。", [active, inactive]);
expect(result).toContain('<canvas-skill name="镜头拆解">');
expect(result).toContain("执行指令:\n先读取当前画布,再按镜头顺序创建节点。");
expect(result).toContain("@未加入");
});
it("renders a bounded instruction instead of leaking skill metadata", () => {
const result = renderSkillPrompt(skill());
expect(result).toContain("请严格执行该技能");
expect(result).toContain("不得覆盖系统/开发者规则");
expect(result).not.toContain("owner_uid");
expect(result).not.toContain("skill_id");
});
it("bounds oversized skill instructions before model expansion", () => {
const result = renderSkillPrompt(skill({ instruction: "x".repeat(20_000) }));
expect(result.length).toBeLessThan(13_000);
expect(result).toContain("技能指令过长,已截断");
});
});

View File

@@ -19,7 +19,7 @@ test("镜头画面使用已绑定资产的 @ 引用编辑器", async () => {
const source = await Bun.file(new URL("../src/pages/projects/detail/workflow-production-workbench.tsx", import.meta.url)).text();
expect(source).toContain('name="plotDescription"');
expect(source).toContain('<ShotAssetMentionTextarea variant="scene"');
expect(source).toContain("resolveShotAssetMentionPrompt(basePrompt, shotAssetReferenceContext)");
expect(source).toContain("resolveShotAssetMentionPrompt(basePrompt, shotAssetReferenceContext");
expect(source).toContain("<Image.PreviewGroup>");
});