Make canvas connections resolve live inputs and respect disconnects

This commit is contained in:
134 2026-09-18 11:09:31 +08:00
parent 0d5558c50c
commit b17b8b7689
7 changed files with 387 additions and 123 deletions

View File

@ -9,14 +9,16 @@ import {
type Model = { id: string; modelId: string; name: string; kind: string };
export function AudioGenerationComposer({
initial,
connectedText = "",
busy,
onSubmit,
onUpload,
onDraft,
}: {
initial?: Record<string, unknown>;
connectedText?: string;
busy: boolean;
onSubmit: (params: AudioRequest) => Promise<unknown>;
onSubmit: (params: AudioRequest, draft: AudioRequest) => Promise<unknown>;
onUpload: (file: File) => Promise<void>;
onDraft: (params: Record<string, unknown>) => void;
}) {
@ -73,6 +75,7 @@ export function AudioGenerationComposer({
if (!model) throw Error("当前模式没有已启用的官方模型");
const params = audioRequestSchema.parse({
...draft,
text: [draft.text, connectedText].filter(Boolean).join("\n\n"),
modelRef: model.id,
voice,
format,
@ -80,8 +83,9 @@ export function AudioGenerationComposer({
...(!contract?.instructions ? { instructions: "" } : {}),
});
buildAudioRequest(params, model.modelId);
patch(params);
await onSubmit(params);
const savedDraft = { ...params, text: String(draft.text || "") };
patch(savedDraft);
await onSubmit(params, savedDraft);
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
@ -96,6 +100,7 @@ export function AudioGenerationComposer({
>
<h3 className="font-semibold">语音与音乐 · 阿里云官方</h3>
<fieldset disabled={busy || sending} className="mt-3 space-y-3 disabled:opacity-60">
{connectedText && <p className="mb-3 whitespace-pre-wrap rounded bg-white/5 p-2 text-xs" aria-label="已连接文本">已连接文本:{connectedText}</p>}
<div className="grid grid-cols-2 gap-3">
<label>
生成类型
@ -142,7 +147,7 @@ export function AudioGenerationComposer({
</label>
{draft.mode === "tts" ? (
<>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-3">
<label>
音色
<select

View File

@ -1,4 +1,5 @@
"use client";
import { requireResolvedReferenceImages, connectionError, connectionTypeError, videoInputLimitError, connectedText, connectedInputNodes, assertConnectedInputsReady, withConnectedText, connectedImageUrls } from "@/lib/video-canvas/connections";
import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
import { SmartEditDialog } from "./smart-edit/SmartEditDialog";
import { SmartEditNodeCard, SmartEditNodeComposer } from "./smart-edit/SmartEditNode";
@ -2164,6 +2165,22 @@ function getNodeSourceMediaUrl(node: CloneNode | null | undefined) {
return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node);
}
function readyCanvasInputs(nodes: CloneNode[], edges: CloneEdge[], target: string) {
try {
const incoming = connectedInputNodes(nodes, edges, target);
const targetNode = nodes.find(node => node.id === target);
if (targetNode) for (const source of incoming) {
const error = connectionTypeError(source, targetNode);
if (error) throw Error(error);
}
assertConnectedInputsReady(incoming);
return incoming;
} catch (error) {
toast.error(error instanceof Error ? error.message : "上游输入不可用");
return null;
}
}
function getNodeReferenceMediaUrl(node: CloneNode | null | undefined) {
return getNodeMediaUrl(node) || getDraftString(node, "sourceUrl");
}
@ -3485,6 +3502,8 @@ function getVideoFunctionKeyForMode(mode: VideoGenerationMode) {
}
function selectVideoReferenceImages(images: string[], mode: VideoGenerationMode) {
const error = videoInputLimitError(mode, images.length);
if (error) throw Error(error);
if (mode === "text-to-video") return [];
if (mode === "first-frame") return images.slice(0, 1);
if (mode === "first-last-frame") return images.slice(0, 2);
@ -4073,14 +4092,7 @@ async function resolveNodeReferenceImages(node: CloneNode | null | undefined, li
})
);
const urls = Array.from(
new Set(
resolved
.map((item) => String(item || "").trim())
.filter(Boolean)
)
);
return urls.length > 0 ? urls : fallbackUrl ? [fallbackUrl] : [];
return requireResolvedReferenceImages(resolved, selectedIndices.length);
}
function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
@ -4632,7 +4644,7 @@ export function LiblibVideoCanvasReplica({
const initialProjectRef = useRef(initialProject);
initialProjectRef.current = initialProject;
const [loadedProject, setLoadedProject] = useState<{ id: string; revision: string } | null>(null);
const generationTasks = useVideoCanvasTasks(canvasId, loadedProject, setNodes, setEdges);
const generationTasks = useVideoCanvasTasks(canvasId, loadedProject, setNodes, setEdges, edges);
const [, setShareEnabled] = useState(false);
const [publishPublic, setPublishPublic] = useState(false);
const [canvasCommand, setCanvasCommand] = useState<CanvasCommandState>(null);
@ -5033,10 +5045,9 @@ export function LiblibVideoCanvasReplica({
const route = getDraftString(node, "route") || getToolRoute(tool) || definition.route;
const promptInput = getDraftString(node, "prompt") || definition.prompt || node.title;
const modelOverride = getDraftString(node, "specifiedModel") || getDraftString(node, "model");
const incomingNodes = edges
.filter((edge) => edge.target === nodeId)
.map((edge) => nodes.find((item) => item.id === edge.source))
.filter((item): item is CloneNode => Boolean(item));
const incomingNodes = readyCanvasInputs(nodes, edges, nodeId);
if (!incomingNodes) return;
const connectedPrompt = withConnectedText(promptInput, incomingNodes);
const uniqueUrls = (items: string[]) =>
Array.from(new Set(items.map((item) => item.trim()).filter(Boolean))).slice(0, 12);
const imageReferenceLists = await Promise.all(
@ -5044,7 +5055,7 @@ export function LiblibVideoCanvasReplica({
.filter((item) => item.inputType === "image")
.map((item) => resolveNodeReferenceImages(item))
);
const imageUrls = uniqueUrls(imageReferenceLists.flat());
const imageUrls = connectedImageUrls(imageReferenceLists);
const videoUrls = uniqueUrls(
incomingNodes
.filter((item) => item.inputType === "video")
@ -5081,7 +5092,7 @@ export function LiblibVideoCanvasReplica({
toast.error("图片反推提示词需要先连接图片素材");
return;
}
const finalPrompt = composeFunctionPrompt(definition, promptInput);
const finalPrompt = composeFunctionPrompt(definition, connectedPrompt);
setNodes((current) =>
current.map((item) =>
item.id === nodeId
@ -5164,7 +5175,7 @@ export function LiblibVideoCanvasReplica({
toast.error("首尾帧视频至少需要 2 张参考图");
return;
}
const finalPrompt = composeFunctionPrompt(definition, promptInput);
const finalPrompt = composeFunctionPrompt(definition, connectedPrompt);
const previewUrl = referenceImages[0] || "";
setNodes((current) =>
current.map((item) =>
@ -5241,10 +5252,10 @@ export function LiblibVideoCanvasReplica({
const gridLayout = getPresetGridLayout(functionKey);
const finalPrompt =
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
? composeMultiViewNineGridPrompt(definition, promptInput)
? composeMultiViewNineGridPrompt(definition, connectedPrompt)
: isOfficialImagePresetFunctionKey(functionKey)
? composeConfiguredImagePresetPrompt(definition, promptInput)
: composeFunctionPrompt(definition, promptInput);
? composeConfiguredImagePresetPrompt(definition, connectedPrompt)
: composeFunctionPrompt(definition, connectedPrompt);
const resultWidth = isWideGeneration ? OFFICIAL_NINE_GRID_WIDTH : Math.max(node.width, 420);
const resultHeight = isWideGeneration ? OFFICIAL_NINE_GRID_HEIGHT : Math.max(node.height, 280);
setNodes((current) =>
@ -5885,6 +5896,9 @@ export function LiblibVideoCanvasReplica({
? { id: makeId("edge"), source: anchor.id, target: node.id }
: { id: makeId("edge"), source: node.id, target: anchor.id };
const connectionIssue = connectionError([...nodes, node], edges, edge.source, edge.target)
|| connectionTypeError(connectionMenu.side === "source" ? anchor : node, connectionMenu.side === "source" ? node : anchor);
if (connectionIssue) { toast.info(connectionIssue); return; }
addNodes([node], [edge]);
setSelectedNodeId(node.id);
setCanvasCommand(null);
@ -5893,7 +5907,7 @@ export function LiblibVideoCanvasReplica({
setConnectionMenu(null);
toast.success(`已添加并连接 ${node.title}`);
},
[addNodes, connectionMenu, nodes]
[addNodes, connectionMenu, nodes, edges]
);
const connectExistingNodes = useCallback(
@ -5909,6 +5923,10 @@ export function LiblibVideoCanvasReplica({
toast.error("未找到可连接的节点");
return false;
}
const graphError = connectionError(nodes, edges, sourceId, targetId);
if (graphError) { toast.info(graphError); return false; }
const typeError = connectionTypeError(source, target);
if (typeError) { toast.info(typeError); return false; }
if (getTargetModuleKind(target) === "smart-edit") {
if (source.inputType !== "video" || !getNodeReferenceMediaUrl(source)) {
toast.info("智能剪辑需要已上传或已生成的视频素材");
@ -7295,14 +7313,22 @@ export function LiblibVideoCanvasReplica({
}
activeGenerationSubmissions.current.add(nodeId);
try {
const hasAnchorOutput = Boolean(getNodeMediaUrl(anchor));
const inputEdges = hasAnchorOutput
? edges.filter(edge => edge.target !== nodeId || nodes.some(node => node.id === edge.source && node.inputType === "text"))
: edges;
const incoming = readyCanvasInputs(nodes, inputEdges, nodeId);
if (!incoming) return;
if (hasAnchorOutput) assertConnectedInputsReady([anchor]);
const anchorSplit = getNodeGridSplitState(anchor);
const anchorReferenceImages = await resolveNodeReferenceImages(anchor);
const referencedNodes = payload.references
const referenceIds = Array.from(new Set([...incoming.map(node => node.id), ...payload.references]));
const referencedNodes = referenceIds
.filter((referenceId) => referenceId !== anchor.id)
.map((referenceId) => nodes.find((node) => node.id === referenceId))
.filter((node): node is CloneNode => Boolean(node));
const referencedImageLists = await Promise.all(
referencedNodes.map((node) => resolveNodeReferenceImages(node)),
referencedNodes.filter(node => node.inputType === "image").map((node) => resolveNodeReferenceImages(node)),
);
const mergedReferenceImages = Array.from(
new Set(
@ -7316,7 +7342,7 @@ export function LiblibVideoCanvasReplica({
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
const starterMode = getPresetMode(anchor);
const slashScene = payload.slashScene || "";
const promptInput = payload.prompt.trim();
const promptInput = withConnectedText(payload.prompt, incoming);
const slashFunctionKey = getOfficialImagePresetFunctionKey(slashScene);
const functionKey =
slashFunctionKey ||
@ -7405,7 +7431,7 @@ export function LiblibVideoCanvasReplica({
toolbarOption: slashScene,
},
};
const referenceEdges = payload.references
const referenceEdges = referenceIds
.filter(
(referenceId) =>
referenceId !== anchor.id && nodes.some((node) => node.id === referenceId),
@ -7431,11 +7457,13 @@ export function LiblibVideoCanvasReplica({
allowFailover:false,billingFeature:getImageGenerationBillingFeature(functionKey),prompt:finalPrompt,promptExtend:!isWideGeneration,
},[{id:makeId("edge"),source:anchor.id,target:resultNode.id},...referenceEdges]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交失败,请检查上游输入");
} finally {
activeGenerationSubmissions.current.delete(nodeId);
}
},
[nodes, videoCanvasFunctionConfig, generationTasks.submit],
[edges, nodes, videoCanvasFunctionConfig, generationTasks.submit],
);
const persistVideoComposerSettings = useCallback((nodeId: string, settings: ComposerSettings, mode: VideoGenerationMode) => {
@ -7457,10 +7485,8 @@ export function LiblibVideoCanvasReplica({
}
activeGenerationSubmissions.current.add(nodeId);
try {
const incomingNodes = edges
.filter((edge) => edge.target === nodeId)
.map((edge) => nodes.find((item) => item.id === edge.source))
.filter((item): item is CloneNode => Boolean(item));
const incomingNodes = readyCanvasInputs(nodes, edges, nodeId);
if (!incomingNodes) return;
const imageReferenceLists = await Promise.all(
incomingNodes
.filter((item) => item.inputType === "image")
@ -7483,19 +7509,7 @@ export function LiblibVideoCanvasReplica({
tags: getDraftString(item, "roleTags"),
thumbnailUrl: getDraftString(item, "thumbnailUrl") || getNodeMediaUrl(item),
}));
const imageUrls = Array.from(
new Set(
[
...imageReferenceLists.flat(),
...(Array.isArray(getNodeParams(node).referenceImages)
? (getNodeParams(node).referenceImages as string[])
: []),
...getVideoCanvasMagicMarks(node).map((mark) => mark.imageUrl),
]
.map((item) => String(item || "").trim())
.filter(Boolean),
),
).slice(0, 12);
const imageUrls = connectedImageUrls(imageReferenceLists, roleMaterials.map(getNodeReferenceMediaUrl).filter(Boolean));
const params = (node.draftValues?.params || {}) as Record<string, unknown>;
const paramsSettings =
params.settings && typeof params.settings === "object"
@ -7535,6 +7549,8 @@ export function LiblibVideoCanvasReplica({
const markedImageUrls = Array.from(
new Set(magicMarks.map((mark) => mark.imageUrl).filter(Boolean)),
).slice(0, 4);
const limitError = videoInputLimitError(requestedMode, imageUrls.length);
if (limitError) throw Error(limitError);
const referenceImages =
requestedMode === "text-to-video" && markedImageUrls.length > 0
? markedImageUrls
@ -7552,9 +7568,9 @@ export function LiblibVideoCanvasReplica({
return;
}
const rawPrompt = String(
const rawPrompt = withConnectedText(String(
payload?.prompt ?? params.prompt ?? node.draftValues?.prompt ?? "",
).trim();
), incomingNodes);
const markLabels = magicMarks.map(
(mark) => mark.labelOptions[mark.selectedIndex] || mark.labelOptions[0],
);
@ -7695,6 +7711,8 @@ export function LiblibVideoCanvasReplica({
}
toast.success(`已提交 ${batchCount} 个视频任务,可离开页面后返回查看`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交失败,请检查上游输入");
} finally {
activeGenerationSubmissions.current.delete(nodeId);
}
@ -7715,10 +7733,8 @@ export function LiblibVideoCanvasReplica({
};
if (action === "regenerate") {
const incomingNodes = edges
.filter((edge) => edge.target === nodeId)
.map((edge) => nodes.find((item) => item.id === edge.source))
.filter((item): item is CloneNode => Boolean(item));
const incomingNodes = readyCanvasInputs(nodes, edges, nodeId);
if (!incomingNodes) return;
const imageUrls = incomingNodes
.filter((item) => item.inputType === "image")
.map((item) => getNodeReferenceMediaUrl(item))
@ -7727,17 +7743,11 @@ export function LiblibVideoCanvasReplica({
.filter((item) => item.inputType === "video")
.map((item) => getNodeReferenceMediaUrl(item))
.filter(Boolean);
const textInputs = incomingNodes
.flatMap((item) => {
const content = item.draftValues?.content;
return Array.isArray(content) ? content.filter((value): value is string => typeof value === "string") : [];
})
.join("\n\n");
const definition = getConfiguredFunction(videoCanvasFunctionConfig, "story-script");
const currentParams = getNodeParams(anchor);
const selectedModelRef = normalizeModelRef(currentParams.model);
const draftPrompt = getDraftString(anchor, "prompt") || STORY_SCRIPT_PROMPT;
const prompt = composeFunctionPrompt(definition, `${draftPrompt}\n\n${textInputs}`.trim());
const prompt = composeFunctionPrompt(definition, withConnectedText(draftPrompt, incomingNodes));
if (!videoCanvasFunctionConfig.enabled || !definition.enabled) {toast.error("脚本生成功能尚未启用");return;}
void generationTasks.submit({...anchor,draftValues:{...anchor.draftValues,params:{...currentParams,functionKey:"story-script",prompt},prompt}},"analysis",{
imageUrls,videoUrls,mode:"story-script",nodeTitle:anchor.title,prompt,
@ -8526,10 +8536,8 @@ export function LiblibVideoCanvasReplica({
const node = nodes.find((item) => item.id === nodeId);
if (!node) return;
if (isOfficialImagePresetFunctionKey(mode)) return;
const incomingNodes = edges
.filter((edge) => edge.target === nodeId)
.map((edge) => nodes.find((item) => item.id === edge.source))
.filter((item): item is CloneNode => Boolean(item));
const incomingNodes = readyCanvasInputs(nodes, edges, nodeId);
if (!incomingNodes) return;
const imageUrls = incomingNodes
.filter((item) => item.inputType === "image")
.map((item) => getNodeReferenceMediaUrl(item))
@ -8546,13 +8554,7 @@ export function LiblibVideoCanvasReplica({
try {
if (mode === "story-script") {
const definition = getConfiguredFunction(videoCanvasFunctionConfig, "story-script");
const textInputs = incomingNodes
.flatMap((item) => {
const content = item.draftValues?.content;
return Array.isArray(content) ? content.filter((value): value is string => typeof value === "string") : [];
})
.join("\n\n");
const prompt = composeFunctionPrompt(definition, `${draftPrompt}\n\n${textInputs}`.trim());
const prompt = composeFunctionPrompt(definition, withConnectedText(draftPrompt, incomingNodes));
const selectedModelRef = normalizeModelRef(settings?.model) || undefined;
setNodes((current) =>
current.map((item) =>
@ -8778,8 +8780,11 @@ export function LiblibVideoCanvasReplica({
if (getTargetModuleKind(node) === "smart-edit") { setSmartEditNodeId(node.id); return; }
if (node.inputType === "audio" && node.draftValues?.audioParams) {
const incoming = readyCanvasInputs(nodes, edges, nodeId);
if (!incoming) return;
const audioParams = node.draftValues.audioParams as Record<string, unknown>;
await generationTasks.submit(
node, "audio", node.draftValues.audioParams as Record<string, unknown>,
node, "audio", { ...audioParams, text: [audioParams.text, ...incoming.map(connectedText)].filter(Boolean).join("\n\n") },
edges.filter(edge => edge.target === nodeId),
);
return;
@ -8789,27 +8794,18 @@ export function LiblibVideoCanvasReplica({
return;
}
const incomingNodes = edges
.filter((edge) => edge.target === nodeId)
.map((edge) => nodes.find((item) => item.id === edge.source))
.filter((item): item is CloneNode => Boolean(item));
const imageReferenceLists = await Promise.all(
incomingNodes
.filter((item) => item.inputType === "image")
.map((item) => resolveNodeReferenceImages(item)),
);
const imageUrls = Array.from(
new Set(
[
...imageReferenceLists.flat(),
...(Array.isArray(getNodeParams(node).referenceImages)
? (getNodeParams(node).referenceImages as string[])
: []),
]
.map((item) => String(item || "").trim())
.filter(Boolean),
),
).slice(0, 12);
const incomingNodes = readyCanvasInputs(nodes, edges, nodeId);
if (!incomingNodes) return;
let imageReferenceLists: string[][];
try {
imageReferenceLists = await Promise.all(incomingNodes
.filter(item => item.inputType === "image")
.map(item => resolveNodeReferenceImages(item)));
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图准备失败,尚未提交任务");
return;
}
const imageUrls = Array.from(new Set(connectedImageUrls(imageReferenceLists)));
const videoUrls = incomingNodes
.filter((item) => item.inputType === "video")
.map((item) => getNodeReferenceMediaUrl(item))
@ -8821,7 +8817,7 @@ export function LiblibVideoCanvasReplica({
? (params.settings as Record<string, unknown>)
: {};
const nodeModelRef = normalizeModelRef(params.model || paramsSettings.model);
const rawPrompt = String(params.prompt || node.draftValues?.prompt || "");
const rawPrompt = withConnectedText(String(params.prompt || node.draftValues?.prompt || ""), incomingNodes);
const delegatesVideo = node.inputType === "video" || /VIDEO_GENERATE/.test(action);
if (!delegatesVideo && activeGenerationSubmissions.current.has(nodeId)) return;
@ -8867,7 +8863,8 @@ export function LiblibVideoCanvasReplica({
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
if (!videoCanvasFunctionConfig.enabled || !definition.enabled)
throw Error("该图片功能尚未启用");
const sourceUrl = imageUrls[0] || getNodeReferenceMediaUrl(node);
const sourceUrl = imageUrls[0] || "";
if (!sourceUrl && functionRequiresMedia(definition)) throw Error("该图片功能需要有效的上游图片,请重新连接素材");
const extraReferenceImages = imageUrls.slice(1);
const prompt =
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
@ -8964,12 +8961,7 @@ export function LiblibVideoCanvasReplica({
throw Error("该分析功能尚未启用");
const prompt = composeFunctionPrompt(
definition,
[
rawPrompt || STORY_SCRIPT_PROMPT,
...incomingNodes.flatMap((item) =>
Array.isArray(item.draftValues?.content) ? item.draftValues.content : [],
),
].join("\n\n"),
rawPrompt || STORY_SCRIPT_PROMPT,
);
await generationTasks.submit(
{
@ -9447,20 +9439,10 @@ export function LiblibVideoCanvasReplica({
Boolean(getNodeReferenceMediaUrl(node));
if (!eligible) return;
setEdges((current) => {
const connected = current.some(
(edge) => edge.source === node.id && edge.target === referenceSelectionTargetId
);
if (connected) {
return current.filter(
(edge) => !(edge.source === node.id && edge.target === referenceSelectionTargetId)
);
}
return [
...current,
{ id: makeId("edge"), source: node.id, target: referenceSelectionTargetId },
];
});
const connected = edges.some(edge => edge.source === node.id && edge.target === referenceSelectionTargetId);
if (connected) {
setEdges(current => current.filter(edge => !(edge.source === node.id && edge.target === referenceSelectionTargetId)));
} else if (!connectExistingNodes(node.id, referenceSelectionTargetId, { notify: false })) return;
setSelectedNodeId(referenceSelectionTargetId);
setSelectedEdgeId(null);
setInspectorNodeId(null);
@ -10409,7 +10391,7 @@ export function LiblibVideoCanvasReplica({
availableSources={nodes.filter(node => node.id !== selectedNode.id && node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),duration:getVideoNodeDurationSeconds(node)}))}
busy={generationTasks.tasks.some(task => task.request.node.id === selectedNode.id && (task.status === "pending" || task.status === "running"))}
onDraft={patch => setNodes(current => current.map(node => node.id === selectedNode.id ? {...node,draftValues:{...node.draftValues,smartEditDraft:{...(node.draftValues?.smartEditDraft as Record<string,unknown> || {}),...patch}}} : node))}
onAddSource={source => setEdges(current => current.some(edge => edge.source === source && edge.target === selectedNode.id) ? current : [...current,{id:makeId("edge"),source,target:selectedNode.id}])}
onAddSource={source => { connectExistingNodes(source, selectedNode.id); }}
onRemoveSource={source => {
setEdges(current => current.filter(edge => !(edge.source === source && edge.target === selectedNode.id)));
setNodes(current => current.map(node => node.id === selectedNode.id ? {...node,draftValues:{...node.draftValues,smartEditDraft:{...(node.draftValues?.smartEditDraft as Record<string,any> || {}),assets:((node.draftValues?.smartEditDraft as Record<string,any>)?.assets || []).filter((asset:{id:string}) => asset.id !== source)}}} : node));
@ -10417,6 +10399,7 @@ export function LiblibVideoCanvasReplica({
onReference={() => setLineConnectState({sourceId:null,targetId:selectedNode.id,cursorCanvasX:selectedNode.x-80,cursorCanvasY:selectedNode.y+selectedNode.height/2})}
onUpload={files => addUploadedFilesAtPoint(files,{x:selectedNode.x-600,y:selectedNode.y},selectedNode.id)}
onSubmit={async () => {
assertConnectedInputsReady(selectedTargetSourceNodes);
const draft = resolveSmartEditIntent((selectedNode.draftValues?.smartEditDraft || {}) as Record<string,any>);
assertBeatMusic(draft);
const assets = selectedTargetSourceNodes.filter(node => node.inputType === "video" && getNodeReferenceMediaUrl(node)).map(node => ({id:node.id,name:node.title,url:getNodeReferenceMediaUrl(node),subtitles:[]}));
@ -10619,15 +10602,16 @@ export function LiblibVideoCanvasReplica({
{selectedNode && selectedTargetModuleKind === "audio" && selectedTargetAnchor ? (
<AudioGenerationComposer
connectedText={selectedTargetSourceNodes.map(connectedText).filter(Boolean).join("\n\n")}
key={selectedNode.id}
initial={selectedNode.draftValues?.audioParams as Record<string, unknown> | undefined}
busy={selectedNode.draftValues?.generationStatus === "pending"}
onDraft={params => setNodes(current => current.map(node => node.id === selectedNode.id
? { ...node, draftValues: { ...node.draftValues, audioParams: params } } : node))}
onSubmit={params => generationTasks.submit({
...selectedNode, kind: "media", inputType: "audio",
draftValues: { ...selectedNode.draftValues, audioParams: params },
}, "audio", params, edges.filter(edge => edge.target === selectedNode.id))}
onSubmit={(params, draft) => {
assertConnectedInputsReady(selectedTargetSourceNodes);
return generationTasks.submit({ ...selectedNode, kind: "media", inputType: "audio", draftValues: { ...selectedNode.draftValues, audioParams: draft } }, "audio", params, edges.filter(edge => edge.target === selectedNode.id));
}}
onUpload={async file => {
const form = new FormData(); form.append("file", file);
const response = await fetch("/api/canvas/media/upload", {method:"POST",body:form,signal:AbortSignal.timeout(120000)});
@ -10848,7 +10832,7 @@ export function LiblibVideoCanvasReplica({
onAddReference={(node) => addReferenceModulesForTool(node.id)}
onChangeProperty={updateNodeDraft}
onClose={() => setInspectorNodeId(null)}
onGenerate={(node) => void runConfiguredToolNode(node.id)}
onGenerate={(node) => void runConfiguredToolNode(node.id).catch(error => toast.error(error instanceof Error ? error.message : "上游输入不可用"))}
/>
) : null}
@ -18133,7 +18117,7 @@ function CloneEdges({
strokeOpacity={selected ? "1" : "0.98"}
strokeWidth={selected ? "2.8" : "2"}
/>
<EdgeFlowSegments pathD={path} uniqueId={edge.id} visible />
<EdgeFlowSegments pathD={path} uniqueId={edge.id} visible={["pending", "running"].includes(getDraftString(nodeMap.get(edge.target), "generationStatus"))} />
</g>
))}
{preview ? (

View File

@ -0,0 +1,54 @@
# 画布连线语义与请求验收(2026-09-18)
## 核对范围
参考 LibTV 同一项目的素材混剪:7条视频输入边进入智能剪辑节点,进入真实剪辑器可见14秒时间轴。只查看原有节点、连线及编辑器,未调用参考站生成、修改素材或导出。
本地读取前端节点、连线、任务提交、轮询恢复及后端请求契约,并使用真实Next页面、隔离账号和隔离画布进行浏览器验收。拦截付费生成请求,检查浏览器实际序列化的请求体;不以“画出一条线”作为功能完成依据。
## 连线应该表示什么
- `source → target`:target在执行时读取source当前已完成的输出。节点移动不改变依赖,输入顺序由保存的连线顺序决定。
- 图片/视频/音频输出与输入预览是两回事。`sourceUrl`、poster不能证明上游已生成成功;失败、排队和未完成的上游应阻止下游提交。
- 普通多图参考可以按URL去重;首尾帧是两个位置,即使两张图URL相同也必须保留两个槽位。
- 文字输出作为下游提示词或配音内容;视频素材作为剪辑输入;角色图片和角色/特效描述应进入相应视频请求。
- 当前连线是下一次执行的输入关系,已提交任务中的参数是不可变的历史快照。改线不会自动付费重生成,也不篡改已经生成的成片。
- 完整“整组执行”还需要独立调度器:拓扑排序、等待上游成功、失败阻断、重试和幂等提交。当前没有这套完整实现,不能把连线动画或逐个点生成当作整组调度。
## 本轮实际修复
| 问题 | 修复 |
| --- | --- |
| 视频/图片重跑会混入params.referenceImages中的旧URL | 标准重跑路径从当前连线重新解析输入,断线后不使用旧URL或节点自身预览补位 |
| 首尾帧相同URL被Set去重 | 保留连线顺序及两个槽位,后端引用校验也保留相同URL |
| 文字连线没有参与视频/图片/配音请求 | 接入文字输出;再次执行替换此前连接的文本区段。音频保存原始表单,提交时组合当前文本,避免重复朗读;编辑表单移除解析器标记,任务参数仍保留已解析文本 |
| 上游失败但参考预览仍可被使用 | 提交前检查上游状态与实际输出,缺失节点、空文本、未完成媒体阻止下游 |
| 可以自连、循环连接或把分组背景当输入 | 手动连线、从端口创建节点和选参考图统一检查;旧画布循环在执行前也拦截 |
| 不匹配素材可以挂一条无作用的线 | 常用图片、视频、文字、音频、剪辑节点检查输入类型;多余图片超出视频模式容量明确提示,不静默截取 |
| 任务响应/轮询晚到重新补回删除的线 | 记录本页断开的source/target关系,恢复任务边时不覆盖这次断线;明确重连会解除该限制 |
| 角色素材只有描述没有参考图 | 视频请求同时包含角色图片和角色描述 |
| 宫格裁切失败会退回整张图或少数切片 | 所选切片必须全部准备成功,失败则停止提交并提示重试 |
| 空闲连线一直流动 | 仅下游任务处于pending/running时显示流动,空闲关系保留静态连线 |
有成品的图片继续派生新图时,该成品本身作为新输入,不自动把它所有历史图片依赖再追加一遍。尚无成品的图像节点则读取其当前直接输入。显式选取的额外参考仍会建立对应输入边。
## 验收证据
浏览器脚本位于本地忽略目录 `.qa/connection-audit-0918/browser.cjs`,请求证据位于同目录`request-evidence.json`。使用隔离测试账号,不提交供应商,不导出账号凭据。
- 真实视频页面提交:相同URL的首尾帧保留2项,连接文字进入prompt,历史stale.png没有进入请求。
- 改接素材且更新源URL后,下一次请求只包含新地址,断开的文字不再进入prompt。
- 删除所有图片连线后,首帧模式提示缺少参考图,无新增请求。
- 上游failed时提示先完成上游,无新增请求。
- 暂缓任务POST响应,在页面删除连线后再释放响应,连线不会重新出现。
- 文字连入音频节点,真实生成音频提交中的text包含该文字;通过右键再次执行,文字没有重复追加。
- TypeScript全量检查 `tsc --noEmit --incremental false` 通过。
- 回归测试:62通过、1跳过(命令PATH未提供FFmpeg;本轮未修改编解码实现)。覆盖循环、端点缺失、类型、模式容量、宫格部分失败、文本更新及首尾帧位置语义。
## 尚未完成与边界
- 整组依赖执行器、完整编组成员关系尚未实现。
- 剪辑器内部独立上传的素材、编辑好的时间轴与画布输入边是不同层数据,现有成片/时间轴不会被改线自动改写;编辑器内素材增删和画布编组之间还需要完整双向同步验收。
- 部分专用节点有自己的提交路径,本轮不宣称所有导演台、标记对象和第三方工具组合已全链路验收。
- 模型结果质量、耗时、真实供应商生成没有在本轮重测。本轮验证输入关系和请求边界。
- 代码仅本地修复,未推送、未更新主站服务器。

View File

@ -1,4 +1,5 @@
"use client";
import { withConnectedText } from "@/lib/video-canvas/connections";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
@ -40,10 +41,18 @@ export function useVideoCanvasTasks<T extends Node>(
loaded: { id: string; revision: string } | null,
setNodes: React.Dispatch<React.SetStateAction<T[]>>,
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>,
canvasEdges: Edge[] = [],
) {
const [tasks, setTasks] = useState<CanvasGenerationTask[]>([]);
const hiddenNodes = useRef(new Set<string>());
const recoveredEdges = useRef(new Set<string>());
const edgeEdits = useRef({ projectId, current: new Set<string>(), removed: new Set<string>() });
if (edgeEdits.current.projectId !== projectId)
edgeEdits.current = { projectId, current: new Set(), removed: new Set() };
const currentEdgeKeys = new Set(canvasEdges.map(edge => `${edge.source}>${edge.target}`));
for (const key of edgeEdits.current.current) if (!currentEdgeKeys.has(key)) edgeEdits.current.removed.add(key);
for (const key of currentEdgeKeys) edgeEdits.current.removed.delete(key);
edgeEdits.current.current = currentEdgeKeys;
const refreshRef = useRef<() => void>(() => {});
const activeProject = useRef(projectId);
activeProject.current = projectId;
@ -80,7 +89,9 @@ export function useVideoCanvasTasks<T extends Node>(
)
.filter((edge) => {
const key = `${edge.source}>${edge.target}`;
if (existing.has(key)) return false;
// A request is a historical snapshot, not permission to undo a
// later disconnect while its response/poll is in flight.
if (existing.has(key) || edgeEdits.current.removed.has(key)) return false;
existing.add(key);
return true;
});
@ -153,10 +164,19 @@ export function useVideoCanvasTasks<T extends Node>(
) => {
if (!loaded || loaded.id !== projectId) throw Error("画布尚未加载完成,请稍后提交");
const requestId = crypto.randomUUID();
// The task params retain resolved text; the editable form retains only
// the user's prompt, so linked content is neither duplicated nor exposed
// as resolver markup when the node is reopened.
const editableDraft = { ...node.draftValues };
if (typeof editableDraft.prompt === "string") editableDraft.prompt = withConnectedText(editableDraft.prompt, []);
if (editableDraft.params && typeof editableDraft.params === "object" && !Array.isArray(editableDraft.params)) {
const params = editableDraft.params as Record<string, unknown>;
if (typeof params.prompt === "string") editableDraft.params = { ...params, prompt: withConnectedText(params.prompt, []) };
}
const snapshot = {
...node,
draftValues: {
...node.draftValues,
...editableDraft,
taskRequestId: requestId,
generationTaskId: undefined,
appliedTaskStatus: undefined,

View File

@ -0,0 +1,116 @@
/** Directed canvas edges are live inputs, not a cache of earlier request URLs. */
export type CanvasConnection = { id: string; source: string; target: string };
export type ConnectionNode = {
id: string;
title?: string;
kind?: string;
inputType?: string;
draftValues?: Record<string, any>;
};
export function connectionError(nodes: ConnectionNode[], edges: CanvasConnection[], source: string, target: string) {
if (source === target) return "不能连接到同一个节点";
const from = nodes.find(node => node.id === source), to = nodes.find(node => node.id === target);
if (!from || !to) return "连接的节点已不存在";
if ([from, to].some(node => node.kind === "group" || node.draftValues?.action === "TOOLBOX_TEMPLATE_GROUP"))
return "请连接分组内的具体节点,分组框不是素材输入";
if (edges.some(edge => edge.source === source && edge.target === target)) return "这两个节点已经连接";
const pending = [target], seen = new Set<string>();
while (pending.length) {
const id = pending.pop()!;
if (id === source) return "这条连线会形成循环依赖,请调整连接方向";
if (seen.has(id)) continue;
seen.add(id);
pending.push(...edges.filter(edge => edge.source === id).map(edge => edge.target));
}
return null;
}
export function connectedInputNodes<T extends ConnectionNode>(nodes: T[], edges: CanvasConnection[], target: string): T[] {
const seen = new Set<string>();
return edges.filter(edge => edge.target === target).flatMap(edge => {
if (seen.has(edge.source)) return [];
seen.add(edge.source);
const node = nodes.find(item => item.id === edge.source);
if (!node) throw Error("上游节点已删除,请移除失效连线后重试");
// Also reject cycles restored from an older saved canvas.
const remaining = edges.filter(item => item.id !== edge.id);
const error = connectionError(nodes, remaining, edge.source, target);
if (error && !error.includes("已经连接")) throw Error(error);
return [node];
});
}
export function connectedText(node: ConnectionNode) {
if (node.inputType !== "text" && node.kind !== "script" && node.kind !== "text") return "";
const content = node.draftValues?.content;
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) return content.filter(value => typeof value === "string").join("\n").trim();
const rows = node.draftValues?.rows;
return Array.isArray(rows) ? rows.map(row => [row.shot, row.sceneDescription, row.dialogue, row.imagePrompt, row.motionPrompt].filter(Boolean).join(" · ")).join("\n").trim() : "";
}
export function assertConnectedInputsReady(nodes: ConnectionNode[]) {
for (const node of nodes) {
const draft = node.draftValues || {};
if (["pending", "running", "queued", "failed", "cancelled", "canceled", "interrupted"].includes(draft.generationStatus))
throw Error(`上游「${node.title || node.id}」尚未成功完成,请先完成该节点`);
if (node.inputType === "text" || node.kind === "script" || node.kind === "text") {
if (!connectedText(node)) throw Error(`上游「${node.title || node.id}」没有文本内容`);
} else if (["image", "video", "audio"].includes(node.inputType || "")) {
// sourceUrl/poster is an input preview; it is never proof of a generated output.
const output = draft.mediaUrl || draft.resultImage || draft.url?.[0] || draft.thumbnailUrl;
if (!output) throw Error(`上游「${node.title || node.id}」尚无可用素材,请先上传或生成`);
}
}
}
const inputStart = "\n\n<canvas-connected-text>\n";
const inputEnd = "\n</canvas-connected-text>";
export function withConnectedText(prompt: string, nodes: ConnectionNode[]) {
// Remove only a section created by this resolver, so editing/disconnecting an
// upstream text node replaces its previous content on a subsequent run.
const start = prompt.indexOf(inputStart);
const base = start >= 0 && prompt.endsWith(inputEnd) ? prompt.slice(0, start) : prompt;
const text = nodes.map(connectedText).filter(Boolean).join("\n\n");
return text ? `${base.trim()}${inputStart}${text}${inputEnd}` : base.trim();
}
export function connectedImageUrls(lists: string[][], extra: string[] = []) {
// Keep edge order and identical URLs in separate slots (first/last frame).
return [...lists.flat(), ...extra].map(url => url.trim()).filter(Boolean);
}
export function connectionTypeError(source: ConnectionNode, target: ConnectionNode) {
const type = source.inputType || source.draftValues?.mediaType || (source.kind === "text" || source.kind === "script" ? "text" : "");
const draft = target.draftValues || {};
const key = draft.functionKey || draft.params?.functionKey || draft.targetPreset;
const nodeType = draft.nodeType;
if (source.kind === "material-effect" || source.kind === "material-role")
return target.inputType === "video" && nodeType !== "smart-edit" ? null : "角色和视频特效素材只能连接到视频生成节点";
const allowed = nodeType === "smart-edit" || nodeType === "video-clip" ? ["video"]
: target.inputType === "image" ? ["image", "text"]
: target.inputType === "video" ? (key === "audio-to-video" ? ["image", "text", "audio"] : ["image", "text"])
: target.inputType === "audio" ? ["text"]
: target.inputType === "text" || target.kind === "script" || target.kind === "text" ? ["image", "video", "text"]
: null;
return allowed && !allowed.includes(type) ? "此节点不能消费该类型的输入,请连接匹配的素材或文本节点" : null;
}
export function videoInputLimitError(mode: string, count: number) {
if (mode === "text-to-video" && count) return "已连接图片,请切换图生视频或全能参考模式后生成";
if (mode === "first-frame" && count > 1) return "首帧模式只能使用 1 张图片,请移除多余连线或切换模式";
if (mode === "first-last-frame" && count > 2) return "首尾帧模式只能使用 2 张图片,请移除多余连线或切换模式";
if (mode === "reference-images" && count > 8) return "全能参考最多使用 8 张图片,请减少输入";
return null;
}
export function requireResolvedReferenceImages(urls: string[], expectedCount: number) {
const images = urls.map(url => url.trim());
if (images.length !== expectedCount || images.some(url => !url))
throw Error("部分宫格参考图准备失败,尚未提交生成,请重试;不会使用整张原图代替");
return images;
}

View File

@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
testMatch: [
/routes\/video-canvas-connections\.test\.ts/,
/routes\/video-canvas-smart-edit\.test\.ts/,
/routes\/video-canvas-tasks\.test\.ts/,
/routes\/video-canvas-director\.test\.ts/,

View File

@ -0,0 +1,84 @@
import { test, expect } from "@playwright/test";
import { requireResolvedReferenceImages, connectionError, connectionTypeError, videoInputLimitError, connectedInputNodes, connectedImageUrls, connectedText, assertConnectedInputsReady, withConnectedText, type ConnectionNode } from "@/lib/video-canvas/connections";
import { canvasImageRequest } from "@/lib/video-canvas/generation-contract";
import { validateVideoReferences } from "@/lib/video-canvas/reference-input";
const image = (id: string, url = `https://media.example/${id}.png`): ConnectionNode => ({ id, title: id, inputType: "image", draftValues: { mediaUrl: url } });
const target: ConnectionNode = { id: "target", inputType: "video", draftValues: { params: { referenceImages: ["https://media.example/stale.png"] } } };
const edge = (source: string, target = "target") => ({ id: `${source}-${target}`, source, target });
test("rewiring changes serialized image request and disconnect never revives cached references", () => {
const a = image("a"), b = image("b"), nodes = [a, b, target];
const request = (edges: ReturnType<typeof edge>[]) => canvasImageRequest({ ratio: "16:9", resolution: "1K", count: "1" }, connectedImageUrls(connectedInputNodes(nodes, edges, target.id).map(n => [n.draftValues!.mediaUrl])));
expect(JSON.parse(JSON.stringify(request([edge("a")]))).referenceImage).toBe(a.draftValues!.mediaUrl);
b.draftValues!.mediaUrl = "https://media.example/new-b.png";
expect(request([edge("b")]).referenceImage).toContain("new-b");
expect(request([]).referenceImage).toBeUndefined();
expect(JSON.stringify(request([]))).not.toContain("stale");
});
test("separate first and last frame slots preserve order even with identical URLs", async () => {
const a = image("a"), b = image("b", a.draftValues!.mediaUrl);
const inputs = connectedInputNodes([a, b, target], [edge("b"), edge("a")], "target");
expect(inputs.map(n => n.id)).toEqual(["b", "a"]);
const images = connectedImageUrls(inputs.map(n => [n.draftValues!.mediaUrl]));
expect(images).toHaveLength(2);
expect((await validateVideoReferences(images, async () => "reachable")).images).toEqual(images);
});
test("cycles, missing endpoints, group frames and duplicate connections are rejected", () => {
const nodes = [image("a"), image("b"), image("c"), { id: "g", kind: "group" }];
const edges = [edge("a", "b"), edge("b", "c")];
expect(connectionError(nodes, edges, "c", "a")).toContain("循环");
expect(connectionError(nodes, edges, "a", "a")).toContain("同一个");
expect(connectionError(nodes, edges, "a", "b")).toContain("已经连接");
expect(connectionError(nodes, edges, "a", "gone")).toContain("不存在");
expect(connectionError(nodes, edges, "g", "a")).toContain("分组");
expect(connectionError(nodes, edges, "a", "c")).toBeNull();
expect(() => connectedInputNodes(nodes, [...edges, edge("c", "a")], "a")).toThrow("循环");
});
test("upstream failure or pending preview blocks downstream execution", () => {
for (const status of ["pending", "running", "failed", "cancelled"]) {
const n = image("a"); n.draftValues!.generationStatus = status;
expect(() => assertConnectedInputsReady([n])).toThrow("尚未成功");
}
expect(() => assertConnectedInputsReady([{ ...image("a"), draftValues: { sourceUrl: "https://media.example/preview.png", poster: "preview.png" } }])).toThrow("尚无可用素材");
expect(() => assertConnectedInputsReady([image("a")])).not.toThrow();
expect(() => connectedInputNodes([target], [edge("missing")], "target")).toThrow("已删除");
});
test("connected text enters prompts, refreshes on edit and disappears on disconnect", () => {
const text: ConnectionNode = { id: "text", inputType: "text", draftValues: { content: ["白色柜门"] } };
const first = withConnectedText("自然光", [text]);
expect(first).toContain("白色柜门");
text.draftValues!.content = ["红色柜门"];
const next = withConnectedText(first, [text]);
expect(next).toContain("红色柜门"); expect(next).not.toContain("白色柜门");
expect(withConnectedText(next, [])).toBe("自然光");
expect(connectedText({ id: "script", kind: "script", draftValues: { rows: [{ shot: "01", sceneDescription: "开门" }] } })).toContain("开门");
});
test("unsupported input types cannot produce decorative connections", () => {
expect(connectionTypeError({ id: "v", inputType: "video" }, target)).toContain("不能消费");
expect(connectionTypeError(image("a"), { id: "edit", inputType: "video", draftValues: { nodeType: "smart-edit" } })).toContain("不能消费");
expect(connectionTypeError({ id: "v", inputType: "video" }, { id: "edit", inputType: "video", draftValues: { nodeType: "smart-edit" } })).toBeNull();
expect(connectionTypeError({ id: "t", inputType: "text" }, { id: "tts", inputType: "audio" })).toBeNull();
expect(connectionTypeError({ id: "a", inputType: "audio" }, { ...target, draftValues: { targetPreset: "audio-to-video" } })).toBeNull();
});
test("video modes reject extra connected images rather than silently dropping edges", () => {
expect(videoInputLimitError("first-frame", 2)).toContain("1 张");
expect(videoInputLimitError("first-last-frame", 3)).toContain("2 张");
expect(videoInputLimitError("reference-images", 9)).toContain("8 张");
expect(videoInputLimitError("text-to-video", 1)).toContain("切换");
expect(videoInputLimitError("first-last-frame", 2)).toBeNull();
});
test("a failed selected-grid crop aborts instead of substituting the composite or a partial set", () => {
expect(() => requireResolvedReferenceImages(["https://media.example/cell.png", ""], 2)).toThrow("准备失败");
expect(() => requireResolvedReferenceImages([], 2)).toThrow("准备失败");
expect(requireResolvedReferenceImages(["https://media.example/1.png", "https://media.example/2.png"], 2)).toHaveLength(2);
});