Add canvas generation pending placeholders
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-19 10:22:52 +08:00
parent e21f99eaca
commit f3869f5e63

View File

@ -1116,6 +1116,12 @@ function getDraftString(node: CloneNode | null | undefined, key: string) {
return typeof value === "string" ? value : ""; return typeof value === "string" ? value : "";
} }
function getDraftNumber(node: CloneNode | null | undefined, key: string, fallback = 0) {
const value = node?.draftValues?.[key];
const numericValue = typeof value === "number" ? value : Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function getNodeMediaUrl(node: CloneNode | null | undefined) { function getNodeMediaUrl(node: CloneNode | null | undefined) {
const direct = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage"); const direct = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
if (direct) return direct; if (direct) return direct;
@ -1846,6 +1852,39 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (last) setSelectedNodeId(last.id); if (last) setSelectedNodeId(last.id);
}, []); }, []);
useEffect(() => {
const timer = window.setInterval(() => {
setNodes((current) => {
let changed = false;
const nextNodes = current.map((node) => {
if (getDraftString(node, "generationStatus") !== "pending") return node;
const currentProgress = getDraftNumber(node, "generationProgress", 2);
const step = currentProgress < 24 ? 2 : currentProgress < 68 ? 1 : 0.35;
const nextProgress = Math.min(96, Number((currentProgress + step).toFixed(1)));
if (nextProgress === currentProgress) return node;
changed = true;
return {
...node,
draftValues: {
...(node.draftValues || {}),
generationProgress: nextProgress,
},
};
});
return changed ? nextNodes : current;
});
}, 850);
return () => window.clearInterval(timer);
}, []);
const cancelGenerationNode = useCallback((nodeId: string) => {
setNodes((current) => current.filter((node) => node.id !== nodeId));
setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
setSelectedNodeId((current) => (current === nodeId ? null : current));
toast.success("已取消生成");
}, []);
const addWorkflowPreset = useCallback( const addWorkflowPreset = useCallback(
(workflow: WorkflowPreset, index = 0) => { (workflow: WorkflowPreset, index = 0) => {
const title = String(workflow.name || workflow.title || "工具箱预设"); const title = String(workflow.name || workflow.title || "工具箱预设");
@ -2489,7 +2528,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (action === "grid") { if (action === "grid") {
const { columns, rows } = normalizeGridOption(option || "9宫格"); const { columns, rows } = normalizeGridOption(option || "9宫格");
const gridDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "multi-view-nine-grid"); const gridDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "multi-view-nine-grid");
let gridNode = createNineGridCompositeNode({ const gridNode = createNineGridCompositeNode({
anchor, anchor,
columns, columns,
option, option,
@ -2498,6 +2537,30 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
x: rightX, x: rightX,
y: centerY(347), y: centerY(347),
}); });
const shouldGenerate = videoCanvasFunctionConfig.enabled && gridDefinition?.enabled;
const pendingGridNode: CloneNode = {
...gridNode,
draftValues: {
...(gridNode.draftValues || {}),
generated: !shouldGenerate,
generationProgress: shouldGenerate ? 2 : 100,
generationStatus: shouldGenerate ? "pending" : "complete",
mediaUrl: shouldGenerate ? "" : getDraftString(gridNode, "mediaUrl"),
pendingLabel: "生成中",
sourceNodeId: anchor.id,
sourceUrl,
url: shouldGenerate ? [] : gridNode.draftValues?.url,
},
};
setNodes((current) => [...current, pendingGridNode]);
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingGridNode.id }]);
setSelectedNodeId(pendingGridNode.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
if (videoCanvasFunctionConfig.enabled && gridDefinition?.enabled) { if (videoCanvasFunctionConfig.enabled && gridDefinition?.enabled) {
const prompt = composeFunctionPrompt(gridDefinition, option); const prompt = composeFunctionPrompt(gridDefinition, option);
const response = await imageGenerationService.generateImage({ const response = await imageGenerationService.generateImage({
@ -2509,30 +2572,46 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
}); });
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
if (generatedUrl) { if (generatedUrl) {
gridNode = { setNodes((current) =>
...gridNode, current.map((node) =>
draftValues: { node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
...(gridNode.draftValues || {}), ? {
generated: true, ...node,
mediaUrl: generatedUrl, draftValues: {
prompt, ...(node.draftValues || {}),
sourceNodeId: anchor.id, generated: true,
sourceUrl, generationProgress: 100,
url: [generatedUrl], generationStatus: "complete",
}, mediaUrl: generatedUrl,
}; prompt,
} else if (!response.success) { sourceNodeId: anchor.id,
toast.error(response.error || "九宫格生成失败,已保留可切分占位节点"); sourceUrl,
url: [generatedUrl],
},
}
: node
)
);
} else {
const errorMessage = response.error || "九宫格生成未返回结果";
setNodes((current) =>
current.map((node) =>
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
? {
...node,
draftValues: {
...(node.draftValues || {}),
generationStatus: "failed",
generationError: errorMessage,
},
}
: node
)
);
toast.error(`${errorMessage},已保留可切分占位节点`);
} }
} }
setNodes((current) => [...current, gridNode]); toast.success(shouldGenerate ? "已创建生成中占位节点" : "已按预设提示词生成多机位九宫格结果图");
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: gridNode.id }]);
setSelectedNodeId(gridNode.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
toast.success("已按预设提示词生成多机位九宫格结果图");
return; return;
} }
@ -2554,6 +2633,45 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const prompt = composeFunctionPrompt(definition, option?.trim() || title); const prompt = composeFunctionPrompt(definition, option?.trim() || title);
if (videoCanvasFunctionConfig.enabled && definition?.enabled) { if (videoCanvasFunctionConfig.enabled && definition?.enabled) {
const pendingResultNode = createMediaResourceNode({
action: "IMAGE_RESOURCE",
height: 280,
mediaType: "image",
mediaUrl: "",
prompt,
title: definition.label || title,
width: 420,
x: rightX,
y: centerY(280),
});
const pendingNode: CloneNode = {
...pendingResultNode,
draftValues: {
...(pendingResultNode.draftValues || {}),
generated: false,
generationProgress: 2,
generationStatus: "pending",
params: {
mode: action,
prompt,
sourceNodeId: anchor.id,
sourceUrl,
},
pendingLabel: "生成中",
sourceNodeId: anchor.id,
sourceUrl,
toolbarOption: option || "",
url: [],
},
};
setNodes((current) => [...current, pendingNode]);
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingNode.id }]);
setSelectedNodeId(pendingNode.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
let generatedUrl = ""; let generatedUrl = "";
if (definition.route === "enhance") { if (definition.route === "enhance") {
const response = await imageGenerationService.enhanceImage(sourceUrl); const response = await imageGenerationService.enhanceImage(sourceUrl);
@ -2580,45 +2698,50 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
} }
if (generatedUrl) { if (generatedUrl) {
const resultNode = createMediaResourceNode({ setNodes((current) =>
action: "IMAGE_RESOURCE", current.map((node) =>
height: 280, node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
mediaType: "image", ? {
mediaUrl: generatedUrl, ...node,
prompt, draftValues: {
title: definition.label || title, ...(node.draftValues || {}),
width: 420, generated: true,
x: rightX, generationProgress: 100,
y: centerY(280), generationStatus: "complete",
}); mediaUrl: generatedUrl,
setNodes((current) => [ params: {
...current, mode: action,
{ prompt,
...resultNode, sourceNodeId: anchor.id,
draftValues: { sourceUrl,
...(resultNode.draftValues || {}), },
generated: true, sourceNodeId: anchor.id,
params: { sourceUrl,
mode: action, toolbarOption: option || "",
prompt, url: [generatedUrl],
sourceNodeId: anchor.id, },
sourceUrl, }
}, : node
sourceNodeId: anchor.id, )
sourceUrl, );
toolbarOption: option || "",
},
},
]);
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: resultNode.id }]);
setSelectedNodeId(resultNode.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
toast.success(`已生成「${definition.label || title}」结果`); toast.success(`已生成「${definition.label || title}」结果`);
return; } else {
setNodes((current) =>
current.map((node) =>
node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
? {
...node,
draftValues: {
...(node.draftValues || {}),
generationStatus: "failed",
generationError: `${definition.label || title}处理失败`,
},
}
: node
)
);
} }
return;
} }
const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1); const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1);
@ -3360,6 +3483,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
node={node} node={node}
selected={node.id === selectedNodeId} selected={node.id === selectedNodeId}
onContextMenu={(event) => openNodeContext(event, node)} onContextMenu={(event) => openNodeContext(event, node)}
onCancelGeneration={() => cancelGenerationNode(node.id)}
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)} onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)} onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
onPointerDown={(event) => startNodeDrag(event, node)} onPointerDown={(event) => startNodeDrag(event, node)}
@ -6342,6 +6466,7 @@ function EdgeFlowSegments({
function CloneCanvasNode({ function CloneCanvasNode({
node, node,
onCancelGeneration,
onContextMenu, onContextMenu,
onModuleAction, onModuleAction,
onOpenConnectionMenu, onOpenConnectionMenu,
@ -6350,6 +6475,7 @@ function CloneCanvasNode({
selected, selected,
}: { }: {
node: CloneNode; node: CloneNode;
onCancelGeneration: () => void;
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void; onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
onModuleAction: (actionLabel: TargetModuleActionLabel) => void; onModuleAction: (actionLabel: TargetModuleActionLabel) => void;
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void; onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
@ -6377,7 +6503,7 @@ function CloneCanvasNode({
if (node.kind === "media") { if (node.kind === "media") {
return ( return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}> <CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
<PresetMediaCanvasNode node={node} selected={selected} /> <PresetMediaCanvasNode node={node} onCancelGeneration={onCancelGeneration} selected={selected} />
</CanvasNodeShell> </CanvasNodeShell>
); );
} }
@ -6835,11 +6961,22 @@ function CanvasNodeShell({
); );
} }
function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) { function PresetMediaCanvasNode({
node,
onCancelGeneration,
selected,
}: {
node: CloneNode;
onCancelGeneration: () => void;
selected: boolean;
}) {
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image"; const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
const mediaUrl = getNodeMediaUrl(node); const mediaUrl = getNodeMediaUrl(node);
const sourceUrl = getNodeSourceMediaUrl(node); const sourceUrl = getNodeSourceMediaUrl(node);
const generated = Boolean(node.draftValues?.generated); const generated = Boolean(node.draftValues?.generated);
const generationStatus = getDraftString(node, "generationStatus");
const isGenerating = generationStatus === "pending";
const isGenerationFailed = generationStatus === "failed";
const annotationMode = Boolean(node.draftValues?.annotationMode); const annotationMode = Boolean(node.draftValues?.annotationMode);
const rotation = Number(node.draftValues?.rotation || 0); const rotation = Number(node.draftValues?.rotation || 0);
const outputSizeLabel = getDraftString(node, "outputSizeLabel"); const outputSizeLabel = getDraftString(node, "outputSizeLabel");
@ -6897,7 +7034,14 @@ function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected:
selected && "outline outline-2 outline-[#8b96ff]" selected && "outline outline-2 outline-[#8b96ff]"
)} )}
> >
{isNineGridComposite ? ( {isGenerating || isGenerationFailed ? (
<GenerationPendingPreview
errorMessage={getDraftString(node, "generationError")}
onCancel={onCancelGeneration}
progress={getDraftNumber(node, "generationProgress", 2)}
status={generationStatus}
/>
) : isNineGridComposite ? (
<NineGridCompositePreview node={node} sourceUrl={sourceUrl || mediaUrl} /> <NineGridCompositePreview node={node} sourceUrl={sourceUrl || mediaUrl} />
) : cropTile && mediaUrl && gridCellVisual ? ( ) : cropTile && mediaUrl && gridCellVisual ? (
<GridCellImage alt={node.title} src={mediaUrl} visual={gridCellVisual} /> <GridCellImage alt={node.title} src={mediaUrl} visual={gridCellVisual} />
@ -6921,7 +7065,7 @@ function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected:
<span className="absolute left-[38%] top-[calc(32%-26px)] rounded-[7px] bg-black/68 px-2 py-1 text-[11px] text-white">主体</span> <span className="absolute left-[38%] top-[calc(32%-26px)] rounded-[7px] bg-black/68 px-2 py-1 text-[11px] text-white">主体</span>
</div> </div>
) : null} ) : null}
{!generated ? ( {!generated && !isGenerating && !isGenerationFailed ? (
<button <button
className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-[8px] bg-black/56 text-white/90" className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-[8px] bg-black/56 text-white/90"
onClick={() => toast.info(`${node.title}素材替换接口待接入。`)} onClick={() => toast.info(`${node.title}素材替换接口待接入。`)}
@ -6936,6 +7080,71 @@ function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected:
); );
} }
function GenerationPendingPreview({
errorMessage,
onCancel,
progress,
status,
}: {
errorMessage?: string;
onCancel: () => void;
progress: number;
status: string;
}) {
const displayProgress = Math.max(2, Math.min(99, Math.round(progress)));
const failed = status === "failed";
return (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden bg-[#303030]">
<style>{`
@keyframes libtv-loading-sweep {
0% { transform: translateX(0); opacity: 0; }
18% { opacity: 1; }
100% { transform: translateX(300%); opacity: 0; }
}
`}</style>
{!failed ? (
<div className="absolute inset-0 opacity-45">
<span className="absolute left-[-30%] top-0 h-full w-[44%] animate-[libtv-loading-sweep_1.8s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-white/8 to-transparent" />
</div>
) : null}
<div className="relative flex h-[34px] min-w-[164px] items-center justify-center gap-3 rounded-[7px] border border-white/14 bg-[#1f1f1f]/86 px-5 text-[14px] font-semibold text-white shadow-[0_10px_28px_rgba(0,0,0,0.34)]">
{failed ? (
<>
<span className="text-white/78">{errorMessage || "生成失败"}</span>
<button
className="text-white/44 transition hover:text-white/80"
onClick={(event) => {
event.stopPropagation();
onCancel();
}}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
移除
</button>
</>
) : (
<>
<span>生成中 {displayProgress}%...</span>
<button
className="font-medium text-white/42 transition hover:text-white/80"
onClick={(event) => {
event.stopPropagation();
onCancel();
}}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
取消
</button>
</>
)}
</div>
</div>
);
}
function PureTextResourceCanvasNode({ function PureTextResourceCanvasNode({
node, node,
onTextResourceChange, onTextResourceChange,