Make canvas connections resolve live inputs and respect disconnects
This commit is contained in:
parent
0d5558c50c
commit
b17b8b7689
@ -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
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
54
docs/video-canvas-connections-2026-09-18.md
Normal file
54
docs/video-canvas-connections-2026-09-18.md
Normal 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;本轮未修改编解码实现)。覆盖循环、端点缺失、类型、模式容量、宫格部分失败、文本更新及首尾帧位置语义。
|
||||
|
||||
## 尚未完成与边界
|
||||
|
||||
- 整组依赖执行器、完整编组成员关系尚未实现。
|
||||
- 剪辑器内部独立上传的素材、编辑好的时间轴与画布输入边是不同层数据,现有成片/时间轴不会被改线自动改写;编辑器内素材增删和画布编组之间还需要完整双向同步验收。
|
||||
- 部分专用节点有自己的提交路径,本轮不宣称所有导演台、标记对象和第三方工具组合已全链路验收。
|
||||
- 模型结果质量、耗时、真实供应商生成没有在本轮重测。本轮验证输入关系和请求边界。
|
||||
- 代码仅本地修复,未推送、未更新主站服务器。
|
||||
@ -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,
|
||||
|
||||
116
lib/video-canvas/connections.ts
Normal file
116
lib/video-canvas/connections.ts
Normal 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;
|
||||
}
|
||||
@ -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/,
|
||||
|
||||
84
tests/routes/video-canvas-connections.test.ts
Normal file
84
tests/routes/video-canvas-connections.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user