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 : "";
}
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) {
const direct = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
if (direct) return direct;
@ -1846,6 +1852,39 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
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(
(workflow: WorkflowPreset, index = 0) => {
const title = String(workflow.name || workflow.title || "工具箱预设");
@ -2489,7 +2528,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (action === "grid") {
const { columns, rows } = normalizeGridOption(option || "9宫格");
const gridDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "multi-view-nine-grid");
let gridNode = createNineGridCompositeNode({
const gridNode = createNineGridCompositeNode({
anchor,
columns,
option,
@ -2498,6 +2537,30 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
x: rightX,
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) {
const prompt = composeFunctionPrompt(gridDefinition, option);
const response = await imageGenerationService.generateImage({
@ -2509,30 +2572,46 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
});
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
if (generatedUrl) {
gridNode = {
...gridNode,
draftValues: {
...(gridNode.draftValues || {}),
generated: true,
mediaUrl: generatedUrl,
prompt,
sourceNodeId: anchor.id,
sourceUrl,
url: [generatedUrl],
},
};
} else if (!response.success) {
toast.error(response.error || "九宫格生成失败,已保留可切分占位节点");
setNodes((current) =>
current.map((node) =>
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
? {
...node,
draftValues: {
...(node.draftValues || {}),
generated: true,
generationProgress: 100,
generationStatus: "complete",
mediaUrl: generatedUrl,
prompt,
sourceNodeId: anchor.id,
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]);
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("已按预设提示词生成多机位九宫格结果图");
toast.success(shouldGenerate ? "已创建生成中占位节点" : "已按预设提示词生成多机位九宫格结果图");
return;
}
@ -2554,6 +2633,45 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const prompt = composeFunctionPrompt(definition, option?.trim() || title);
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 = "";
if (definition.route === "enhance") {
const response = await imageGenerationService.enhanceImage(sourceUrl);
@ -2580,45 +2698,50 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
}
if (generatedUrl) {
const resultNode = createMediaResourceNode({
action: "IMAGE_RESOURCE",
height: 280,
mediaType: "image",
mediaUrl: generatedUrl,
prompt,
title: definition.label || title,
width: 420,
x: rightX,
y: centerY(280),
});
setNodes((current) => [
...current,
{
...resultNode,
draftValues: {
...(resultNode.draftValues || {}),
generated: true,
params: {
mode: action,
prompt,
sourceNodeId: anchor.id,
sourceUrl,
},
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);
setNodes((current) =>
current.map((node) =>
node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
? {
...node,
draftValues: {
...(node.draftValues || {}),
generated: true,
generationProgress: 100,
generationStatus: "complete",
mediaUrl: generatedUrl,
params: {
mode: action,
prompt,
sourceNodeId: anchor.id,
sourceUrl,
},
sourceNodeId: anchor.id,
sourceUrl,
toolbarOption: option || "",
url: [generatedUrl],
},
}
: node
)
);
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);
@ -3360,6 +3483,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
node={node}
selected={node.id === selectedNodeId}
onContextMenu={(event) => openNodeContext(event, node)}
onCancelGeneration={() => cancelGenerationNode(node.id)}
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
onPointerDown={(event) => startNodeDrag(event, node)}
@ -6342,6 +6466,7 @@ function EdgeFlowSegments({
function CloneCanvasNode({
node,
onCancelGeneration,
onContextMenu,
onModuleAction,
onOpenConnectionMenu,
@ -6350,6 +6475,7 @@ function CloneCanvasNode({
selected,
}: {
node: CloneNode;
onCancelGeneration: () => void;
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
onModuleAction: (actionLabel: TargetModuleActionLabel) => void;
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
@ -6377,7 +6503,7 @@ function CloneCanvasNode({
if (node.kind === "media") {
return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
<PresetMediaCanvasNode node={node} selected={selected} />
<PresetMediaCanvasNode node={node} onCancelGeneration={onCancelGeneration} selected={selected} />
</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 mediaUrl = getNodeMediaUrl(node);
const sourceUrl = getNodeSourceMediaUrl(node);
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 rotation = Number(node.draftValues?.rotation || 0);
const outputSizeLabel = getDraftString(node, "outputSizeLabel");
@ -6897,7 +7034,14 @@ function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected:
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} />
) : cropTile && mediaUrl && 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>
</div>
) : null}
{!generated ? (
{!generated && !isGenerating && !isGenerationFailed ? (
<button
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}素材替换接口待接入。`)}
@ -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({
node,
onTextResourceChange,