feat(canvas): AI 拆图 - 支持图层规划编辑与素材复用

This commit is contained in:
ddcat666
2026-10-10 04:49:54 +08:00
57 changed files with 3559 additions and 166 deletions

View File

@@ -97,6 +97,11 @@ export async function executeImageGeneration({
height: rootHeight,
metadata: {
...resetGenerationTaskMetadata(reuseSourceNode ? sourceNode?.metadata : undefined, NODE_STATUS_LOADING),
imageLayerGroup: undefined,
layerDecomposition: undefined,
imageLayerWorkflow: undefined,
experimentalLayerPlan: undefined,
layerExtraction: undefined,
...canvasGenerationPromptMetadata(prompt, effectivePrompt),
status: NODE_STATUS_LOADING,
size: generationConfig.size,

View File

@@ -75,7 +75,7 @@ export function CanvasProjectMediaDialogs({
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={{ ...config, model: maskEditNode.metadata.model || config.model, imageModel: maskEditNode.metadata.model || config.imageModel, size: maskEditNode.metadata.size || config.size, quality: maskEditNode.metadata.quality || config.quality, count: String(maskEditNode.metadata.count || config.count) }} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open onClose={onCloseUpscale} onConfirm={(params) => onUpscale(upscaleNode, params)} /> : null}
{imageEditNode?.metadata?.content ? <CanvasNodeImageEditDialog dataUrl={imageEditNode.metadata.content} preset={imageEditPreset} config={{ ...config, model: imageEditNode.metadata.model || config.model, imageModel: imageEditNode.metadata.model || config.imageModel, size: imageEditNode.metadata.size || config.size, quality: imageEditNode.metadata.quality || config.quality }} open onClose={onCloseImageEdit} onConfirm={(payload) => onImageOperation(imageEditNode, payload)} /> : null}
{layerDecompositionNode?.metadata?.content ? <CanvasNodeLayerDecompositionDialog dataUrl={layerDecompositionNode.metadata.content} config={{ ...config, model: layerDecompositionNode.metadata.model || config.model, imageModel: layerDecompositionNode.metadata.model || config.imageModel, size: layerDecompositionNode.metadata.size || config.size, quality: layerDecompositionNode.metadata.quality || config.quality }} open onClose={onCloseLayerDecomposition} onConfirm={(payload) => onLayerDecomposition(layerDecompositionNode, payload)} /> : null}
{layerDecompositionNode?.metadata?.content ? <CanvasNodeLayerDecompositionDialog dataUrl={layerDecompositionNode.metadata.content} sourceSize={layerDecompositionNode.metadata.naturalWidth && layerDecompositionNode.metadata.naturalHeight ? { width: layerDecompositionNode.metadata.naturalWidth, height: layerDecompositionNode.metadata.naturalHeight } : undefined} config={{ ...config, model: layerDecompositionNode.metadata.model || config.model, imageModel: layerDecompositionNode.metadata.model || config.imageModel, size: layerDecompositionNode.metadata.size || config.size, quality: layerDecompositionNode.metadata.quality || config.quality }} open onClose={onCloseLayerDecomposition} onConfirm={(payload) => onLayerDecomposition(layerDecompositionNode, payload)} /> : null}
{textEditNode?.metadata?.content ? <CanvasNodeTextEditDialog dataUrl={textEditNode.metadata.content} open onClose={onCloseTextEdit} onDetect={onDetectText} onConfirm={(payload) => onTextEdit(textEditNode, payload)} /> : null}
</>
);

View File

@@ -1,4 +1,6 @@
import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel";
import { useCanvasImageLayerGroups } from "./use-canvas-image-layer-groups";
import { useCanvasImageLayerMaterials } from "./use-canvas-image-layer-materials";
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
import { canCancelGenerationTask } from "@/lib/generation-task-display";
@@ -888,6 +890,7 @@ function CanvasViewportPage() {
openBackgroundRemoval,
openLayerDecomposition,
decomposeImageLayers,
activeLayerGroupIds,
setLayerDecompositionNodeId,
setTextEditNodeId,
openTextEditNode,
@@ -921,8 +924,12 @@ function CanvasViewportPage() {
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
});
useCanvasImageLayerGroups({ projectId, enabled: projectLoaded, nodes, nodesRef, setNodes, runningNodeId, activeLayerGroupIds });
const extractLayerMaterials = useCanvasImageLayerMaterials({ projectId, domainProjectId: currentProject?.projectId, enabled: projectLoaded, nodes, nodesRef, setNodes, setConnections });
const handleNodesDeleted = useCallback(
(removedIds: Set<string>, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => {
const clearDeletedId = (current: string | null) => (current && removedIds.has(current) ? null : current);
@@ -1581,8 +1588,9 @@ function CanvasViewportPage() {
updateMediaNode: updateMediaNodeFromContent,
openArtCritique,
addPanoramaCaptureNode,
extractLayerMaterials,
}),
[addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent],
[addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, extractLayerMaterials, openArtCritique, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent],
);
const { dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({
projectId,
@@ -2430,6 +2438,12 @@ function CanvasViewportPage() {
);
const retryCanvasNode = useCallback(
(node: CanvasNodeData) => {
if (node.metadata?.imageLayerWorkflow && !node.metadata.experimentalLayerPlan && !node.metadata.layerDecomposition) {
const source = nodesRef.current.find((item) => item.id === node.metadata!.imageLayerWorkflow!.sourceNodeId);
if (source?.metadata?.content) setLayerDecompositionNodeId(source.id);
else message.error("拆层源图片已不存在,请重新选择图片");
return;
}
if (node.type === CanvasNodeType.Script) {
const prompt = (node.metadata?.composerContent || node.metadata?.prompt || "").trim();
if (!prompt) {
@@ -2439,6 +2453,10 @@ function CanvasViewportPage() {
void generateScriptRows(node.id, prompt);
return;
}
if (node.metadata?.experimentalLayerPlan || node.metadata?.layerExtraction) {
void handleRetryNode(node);
return;
}
if (node.type === CanvasNodeType.Image && node.metadata?.isBatchRoot) {
const failedChildren = failedImageBatchChildren(node, nodesRef.current);
if (!failedChildren.length) {
@@ -2455,7 +2473,7 @@ function CanvasViewportPage() {
}
void handleRetryNode(node);
},
[generateScriptRows, handleRetryNode, message, nodesRef, retryImageBatchChildren],
[generateScriptRows, handleRetryNode, message, nodesRef, retryImageBatchChildren, setLayerDecompositionNodeId],
);
const openCanvasNodeTaskDetails = useCallback(
(node: CanvasNodeData) => {

View File

@@ -3,6 +3,12 @@ import { App } from "antd";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import { imageLayerProviderMetadata, layerDecompositionConfig, supportsExperimentalLayerExtraction, supportsLayerDecomposition } from "@/lib/canvas/canvas-image-layers";
import { imageLayerOutputSize, imageLayerRemovalPrompt } from "@/lib/canvas/canvas-image-layer-plan";
import { extractImageLayerFromSource } from "@/services/canvas-image-layer-source";
import { buildSourceImageLayerNodeResult } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { resolveImageRequestSize } from "@/services/api/image-validation";
import { producedModelCandidateForGeneration } from "@/lib/canvas/produced-model";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { buildEmotionImageArtifacts, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
@@ -77,9 +83,49 @@ export function useCanvasGenerationRetry({
return useCallback(
async (node: CanvasNodeData) => {
if (node.metadata?.experimentalLayerPlan) {
const complete = node.metadata.experimentalLayerPlan.requests.every((request) => {
const child = nodesRef.current.find((item) => item.id === request.nodeId);
return child?.metadata?.status === "success" && Boolean(child.metadata.content);
});
if (!complete) {
message.info("请展开图层组,单独重试未通过的图层;已完成图层会保留,不会自动扣费重试");
return;
}
setNodes((current) =>
current.map((item) =>
item.id === node.id && item.metadata?.experimentalLayerPlan
? { ...item, metadata: { ...item.metadata, status: "loading", errorDetails: undefined, experimentalLayerPlan: { ...item.metadata.experimentalLayerPlan, errorSignature: undefined, composedSignature: undefined } } }
: item,
),
);
return;
}
// Canvas caches may predate the failure. Consult the original task
// before deciding to submit another paid generation.
let sourceTask: GenerationTask | undefined;
const localExtraction = node.metadata?.layerExtraction;
if (localExtraction?.strategy && localExtraction.strategy.method !== "generate") {
if (!localExtraction.source || !localExtraction.canvas) { message.error("本层原图资源或区域已丢失,请重新规划;没有提交模型请求"); return; }
const controller = startGenerationRequest(node.id, localExtraction.sourceNodeId, node.id);
setRunningNodeId(node.id);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "loading", errorDetails: undefined } } : item));
try {
const image = await extractImageLayerFromSource(localExtraction.source, localExtraction.strategy, localExtraction.canvas);
const completed = await buildSourceImageLayerNodeResult(node, image, controller.signal);
if (!nodesRef.current.some((item) => item.id === node.id)) return;
setNodes((current) => current.map((item) => item.id === node.id ? completed : item));
try {
const asset = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: completed, source: "canvas-manual" });
if (!controller.signal.aborted) setNodes((current) => current.map((item) => item.id === node.id && item.metadata?.storageKey === completed.metadata?.storageKey ? { ...item, metadata: { ...item.metadata, assetId: asset.assetId } } : item));
} catch (error) { message.warning(`图层已保存,但素材库写入失败:${error instanceof Error ? error.message : "未知错误"}`); }
} catch (error) {
const details = error instanceof Error ? error.message : "原图提取失败";
if (!controller.signal.aborted) message.error(details);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "error", errorDetails: details } } : item));
} finally { finishGenerationRequest(node.id, controller); setRunningNodeId((current) => current === node.id ? null : current); }
return;
}
if (node.metadata?.taskId) {
try {
sourceTask = await queryGenerationTask(node.metadata.taskId);
@@ -88,6 +134,53 @@ export function useCanvasGenerationRetry({
return;
}
}
if ((node.metadata?.layerDecomposition || node.metadata?.layerExtraction) && sourceTask?.status === "succeeded" && node.metadata?.layerExtraction?.rejectedTaskId !== sourceTask.id && node.metadata?.layerExtraction?.phase !== "background-removal-required") {
try {
await applyGenerationTaskResult(node.id, sourceTask);
} catch (error) {
message.error(error instanceof Error ? error.message : "图层结果恢复失败");
}
return;
}
if (node.metadata?.layerExtraction) {
const extraction = node.metadata.layerExtraction;
const removing = extraction.phase === "background-removal-required" || extraction.phase === "remove-background";
const source = removing ? node : nodesRef.current.find((item) => item.id === extraction.sourceNodeId);
if ((!source?.metadata?.content && (removing || !extraction.source)) || !extraction.canvas) { message.error("本层源图或画布尺寸已丢失,无法单独重试"); return; }
const model = removing ? extraction.backgroundRemovalModel || node.metadata.model : node.metadata.model;
if (!model) { message.error("本层未保存可用模型,请重新从源图规划"); return; }
const config = layerDecompositionConfig(buildGenerationConfig(effectiveConfig, node, "image"), model, { size: imageLayerOutputSize(effectiveConfig, model, extraction.canvas) });
config.transparentBackground = extraction.index > 0 ? "true" : "false";
if (!config.size || !supportsExperimentalLayerExtraction(config, model) || !isAiConfigReady(config, model)) { message.error("本层模型无法接收源图或不支持原图比例,请重新选择模型规划"); return; }
try {
const profile = modelCapabilityConfigFor(config, model).image;
if (profile) config.size = resolveImageRequestSize(profile, config.quality, config.size)?.value || "auto";
} catch (error) { message.error(error instanceof Error ? error.message : "模型尺寸无效"); return; }
const parent = nodesRef.current.find((item) => item.id === extraction.groupId);
const target = parent?.metadata?.experimentalLayerPlan?.requests.find((request) => request.nodeId === node.id)?.target || node.title;
const prompt = removing ? imageLayerRemovalPrompt(target) : nodeGenerationPrompt(node);
const reference = !removing && extraction.source ? { id: extraction.sourceNodeId, name: "原图", type: extraction.source.mimeType || "image/png", ...extraction.source } : { id: source!.id, name: `${source!.title || "图层"}.png`, type: source!.metadata!.mimeType || "image/png", dataUrl: source!.metadata!.content!, storageKey: source!.metadata!.storageKey };
const controller = startGenerationRequest(node.id, extraction.sourceNodeId, node.id);
setRunningNodeId(node.id);
try {
if (sourceTask && (sourceTask.status === "queued" || sourceTask.status === "running")) {
bindGenerationTask(node.id, sourceTask);
const completed = await waitForGenerationTask(sourceTask.id, { initialTask: sourceTask, signal: controller.signal, timeoutMs: 25 * 60_000, onTaskUpdate: (task) => bindGenerationTask(node.id, task) });
await applyGenerationTaskResult(node.id, completed);
return;
}
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, ...buildImageGenerationMetadata("edit", config, 1, [reference]), prompt, status: "loading", errorDetails: undefined, taskId: undefined, layerExtraction: { ...extraction, phase: removing ? "remove-background" : "extract", rejectedTaskId: undefined } } } : item));
const retryContext = sourceTask ? await createGenerationRetryContext(sourceTask.id, node.metadata.attemptGroupId) : {};
await runCanvasGenerationTaskToConsumer({ projectId, nodeId: node.id, mode: "image", prompt, config, referenceImages: [reference], signal: controller.signal, ...retryContext, metadata: { retry: true, sourceNodeId: extraction.sourceNodeId, edit: removing ? "layer-background-removal" : "layer-extraction", ...imageLayerProviderMetadata(config, extraction.index) } }, { bindTask: (task) => bindGenerationTask(node.id, task), consumeTask: (task) => applyGenerationTaskResult(node.id, task) });
// 刷新后的手动恢复一次只发起一项付费调用,后续去背景由用户继续。
setNodes((current) => current.map((item) => item.id === node.id && item.metadata?.layerExtraction?.phase === "background-removal-required" ? { ...item, metadata: { ...item.metadata, status: "error", errorDetails: "提取已完成,需要去背景;点击重新生成只调用本层去背景模型" } } : item));
} catch (error) {
const details = error instanceof Error ? error.message : "本层重试失败";
message.error(details);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "error", errorDetails: details, layerExtraction: { ...item.metadata!.layerExtraction!, rejectedTaskId: item.metadata?.taskId } } } : item));
} finally { finishGenerationRequest(node.id, controller); }
return;
}
if (sourceTask?.mediaStage && sourceTask.status !== "cancelled") {
const controller = startGenerationRequest(node.id, node.id, node.id);
try {
@@ -128,12 +221,17 @@ export function useCanvasGenerationRetry({
count: "1",
}
: { ...sourceGenerationConfig, count: "1" };
if (node.metadata?.layerDecomposition) generationConfig = layerDecompositionConfig(generationConfig, generationConfig.model);
if (node.metadata?.layerDecomposition && !supportsLayerDecomposition(generationConfig, generationConfig.model)) {
message.error("原拆层模型未配置专用拆层协议,无法重试");
return;
}
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
const retryPromptSource = nodeGenerationPrompt(sourceNode) || nodeGenerationPrompt(node);
const retryPromptSource = node.metadata?.layerDecomposition || node.metadata?.layerExtraction ? nodeGenerationPrompt(node) : nodeGenerationPrompt(sourceNode) || nodeGenerationPrompt(node);
const retryContextPrompt = retryMode === "image" && sourceNode.metadata?.portraitTexture ? buildPortraitTexturePrompt(retryPromptSource, sourceNode.metadata.portraitTexture) : retryPromptSource;
if (unchangedModeratedPrompt(node.metadata, retryPromptSource)) {
message.warning("该提示词未通过内容审核,请先修改提示词再重新生成");
@@ -421,6 +519,7 @@ export function useCanvasGenerationRetry({
metadata: {
retry: true,
sourceNodeId: sourceNode.id,
layerDecomposition: Boolean(node.metadata?.layerDecomposition),
resolvedCharacterVersions: context?.resolvedCharacterVersions || [],
promptTemplateOperation: node.metadata?.promptTemplateOperation,
promptTemplateVariables: node.metadata?.promptTemplateVariables,

View File

@@ -5,7 +5,7 @@ import { App } from "antd";
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId, generationTaskOutputsApplied, shouldRecoverCanvasImageOutputs } from "@/lib/canvas/canvas-generation-task-sync";
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
import { markCanvasTaskRecoveryUnconfirmed } from "@/lib/canvas/canvas-task-state";
import { canvasTaskBindingStatus, markCanvasTaskRecoveryUnconfirmed } from "@/lib/canvas/canvas-task-state";
import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskNode, ensureCanvasNodeAsset, retryCanvasAssetSyncAfterRateLimit } from "@/services/project-asset-sync";
import { listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center";
@@ -185,6 +185,8 @@ export async function recoverCanvasGenerationTaskNode(input: {
...item.metadata,
status: input.continuationOnly ? item.metadata?.status : NODE_STATUS_ERROR,
...(input.continuationOnly ? {} : failure),
...(item.metadata?.layerExtraction && item.metadata.taskId === input.completed.id && input.completed.status === "succeeded"
? { layerExtraction: { ...item.metadata.layerExtraction, rejectedTaskId: input.completed.id } } : {}),
...(item.metadata?.agentGenerationContinuation?.status === "pending"
? {
agentGenerationContinuation: { ...item.metadata.agentGenerationContinuation, status: "failed" as const },
@@ -260,15 +262,16 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
current.map((node) => {
if (node.id !== targetNodeId) return node;
const failed = task.status === "failed" || task.status === "cancelled";
const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content || node.metadata?.storageKey);
const extraction = node.metadata?.layerExtraction;
const rejectedLayer = extraction?.rejectedTaskId === task.id;
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), nodeGenerationPrompt(node) || task.prompt || "") : undefined;
return {
...node,
metadata: {
...node.metadata,
...generationTaskMetadata(task),
status: failed ? NODE_STATUS_ERROR : hasCompletedContent ? NODE_STATUS_SUCCESS : NODE_STATUS_LOADING,
...(failure || { errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined }),
status: canvasTaskBindingStatus(node, task),
...(failure || (rejectedLayer ? {} : { errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined })),
},
};
}),
@@ -281,7 +284,11 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
async (node: CanvasNodeData, taskId: string, signal?: AbortSignal) => {
const result = await retryCanvasAssetSyncAfterRateLimit(() => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId, signal }), { signal });
if (signal?.aborted) return;
setNodes((current) => current.map((item) => (item.id === node.id && item.metadata?.taskId === taskId ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)));
setNodes((current) =>
current.map((item) =>
item.id === node.id && item.metadata?.taskId === taskId && (!node.metadata?.imageLayerGroup || item.metadata.storageKey === node.metadata.storageKey) ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item,
),
);
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
},
[domainProjectId, projectId, queryClient, setNodes],
@@ -381,6 +388,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
async (startedProjectId: string, signal: AbortSignal, isCurrentProject: () => boolean) => {
if (!isCurrentProject()) return;
const recoveryNodes = nodesRef.current.filter((node) => {
if (node.metadata?.experimentalLayerPlan) return false;
const pendingAgentContinuation = node.metadata?.agentGenerationContinuation?.status === "pending";
const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length;
if (aggregateBatchRoot && !pendingAgentContinuation) return false;
@@ -509,7 +517,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
nodes.forEach((node) => {
const taskId = node.metadata?.taskId;
if (!taskId || !node.metadata?.content || node.metadata.status !== NODE_STATUS_SUCCESS || (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio)) return;
const saveKey = `${taskId}:${node.id}:${domainProjectId || "personal"}`;
const saveKey = `${taskId}:${node.id}:${domainProjectId || "personal"}${node.metadata.imageLayerGroup ? `:${node.metadata.storageKey}` : ""}`;
if (autoSavedTaskIdsRef.current.has(saveKey)) return;
autoSavedTaskIdsRef.current.add(saveKey);
void runGenerationConsumer(consumerControllerRef.current.signal, async (signal) => {

View File

@@ -0,0 +1,178 @@
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { experimentalLayerSignature, imageLayerCompositeSignature } from "@/lib/canvas/canvas-image-layers";
import { buildExperimentalImageLayerResult, imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { composeCanvasImageLayerGroup } from "@/services/canvas-image-layer-compositor";
import { uploadImage } from "@/services/image-storage";
import type { CanvasNodeData } from "@/types/canvas";
/** 只观察合成参数和媒体身份;拖动画布或展开/收起不会重建或请求模型。 */
export function useCanvasImageLayerGroups({
projectId,
enabled,
nodes,
nodesRef,
setNodes,
runningNodeId,
activeLayerGroupIds,
}: {
projectId: string;
enabled: boolean;
nodes: CanvasNodeData[];
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
runningNodeId?: string | null;
activeLayerGroupIds?: ReadonlySet<string>;
}) {
const jobs = useRef(new Map<string, string>());
const failed = useRef(new Map<string, string>());
const scope = useRef(0);
useEffect(() => {
scope.current += 1;
jobs.current.clear();
failed.current.clear();
return () => {
scope.current += 1;
};
}, [projectId]);
useEffect(() => {
if (!enabled) return;
for (const root of nodes) {
if (root.metadata?.status === "loading" && root.metadata.imageLayerWorkflow?.stage === "planning" && !activeLayerGroupIds?.has(root.id)) {
setNodes((current) => current.map((node) => (node.id === root.id ? { ...node, metadata: { ...node.metadata, status: "error", errorDetails: "拆解规划等待已中断,未自动提交拆图任务;请重新发起拆分。" } } : node)));
continue;
}
const plan = root.metadata?.experimentalLayerPlan;
if (!plan || jobs.current.has(root.id)) continue;
const children = plan.requests.map((request) => nodes.find((node) => node.id === request.nodeId));
const ready = children.filter((node) => node?.metadata?.content && node.metadata.status === "success" && (!node.metadata.layerExtraction?.phase || node.metadata.layerExtraction.phase === "complete"));
if (ready.length !== children.length && (activeLayerGroupIds?.has(root.id) || runningNodeId === root.id || children.some((node) => node?.metadata?.status === "loading" && node.metadata.taskId))) continue;
if (!ready.length) {
if (root.metadata?.status !== "error") {
setNodes((current) => current.map((node) => (node.id === root.id ? { ...node, metadata: { ...node.metadata, status: "error", errorDetails: "拆层未完成:没有已验收图层;不会自动继续扣费。请展开检查并单独重试失败层。" } } : node)));
}
continue;
}
const signature = experimentalLayerSignature(root, nodes);
if (plan.errorSignature === signature || plan.composedSignature === signature) continue;
const editSignature = root.metadata?.imageLayerGroup ? imageLayerCompositeSignature(root.metadata.imageLayerGroup, nodes) : undefined;
const epoch = scope.current;
jobs.current.set(root.id, signature);
void (async () => {
try {
const result = await buildExperimentalImageLayerResult(root, nodes);
if (scope.current !== epoch) return;
setNodes((current) => {
const live = current.find((node) => node.id === root.id);
if (
!live?.metadata?.experimentalLayerPlan ||
experimentalLayerSignature(live, current) !== signature ||
(live.metadata.imageLayerGroup ? imageLayerCompositeSignature(live.metadata.imageLayerGroup, current) : undefined) !== editSignature
)
return current;
const byId = new Map(result.additionalNodes.map((node) => [node.id, node]));
return current.map((node) =>
node.id === root.id
? {
...result.node,
title: node.title,
position: node.position,
...(node.width !== root.width || node.height !== root.height ? { width: node.width, height: node.height } : {}),
metadata: { ...result.node.metadata, imageBatchExpanded: node.metadata?.imageBatchExpanded ?? false },
}
: byId.has(node.id)
? { ...node, title: byId.get(node.id)!.title, metadata: { ...node.metadata, ...byId.get(node.id)!.metadata, batchRootId: root.id } }
: node,
);
});
} catch (error) {
if (scope.current !== epoch) return;
setNodes((current) =>
current.map((node) =>
node.id === root.id &&
experimentalLayerSignature(node, current) === signature &&
node.metadata?.experimentalLayerPlan &&
(node.metadata.imageLayerGroup ? imageLayerCompositeSignature(node.metadata.imageLayerGroup, current) : undefined) === editSignature
? {
...node,
metadata: { ...node.metadata, status: "error", errorDetails: error instanceof Error ? error.message : "实验拆层合成失败", experimentalLayerPlan: { ...node.metadata.experimentalLayerPlan, errorSignature: signature } },
}
: node,
),
);
} finally {
if (scope.current === epoch) {
jobs.current.delete(root.id);
setNodes((current) => [...current]);
}
}
})();
}
}, [enabled, nodes, runningNodeId, activeLayerGroupIds, setNodes]);
useEffect(() => {
if (!enabled) return;
for (const root of nodes) {
const group = root.metadata?.imageLayerGroup;
if (!group || root.metadata?.status === "loading") continue;
const signature = imageLayerCompositeSignature(group, nodes);
if (group.compositeSignature === signature) {
if (group.compositeStatus !== "ready")
setNodes((current) =>
current.map((node) => {
const live = node.metadata?.imageLayerGroup;
return node.id === root.id && live && imageLayerCompositeSignature(live, current) === live.compositeSignature
? { ...node, metadata: { ...node.metadata, imageLayerGroup: { ...live, compositeStatus: "ready", compositeError: undefined } } }
: node;
}),
);
continue;
}
if (jobs.current.has(root.id) || (group.compositeStatus === "error" && failed.current.get(root.id) === signature)) continue;
const epoch = scope.current;
jobs.current.set(root.id, signature);
setNodes((current) =>
current.map((node) =>
node.id === root.id && node.metadata?.imageLayerGroup
? {
...node,
metadata: { ...node.metadata, imageLayerGroup: { ...node.metadata.imageLayerGroup, compositeStatus: "updating", compositeError: undefined } },
}
: node,
),
);
void (async () => {
try {
const blob = await composeCanvasImageLayerGroup(group, nodes);
const liveGroup = nodesRef.current.find((node) => node.id === root.id)?.metadata?.imageLayerGroup;
if (scope.current !== epoch || !liveGroup || imageLayerCompositeSignature(liveGroup, nodesRef.current) !== signature) return;
const uploaded = await uploadImage(blob);
if (uploaded.pendingRemoteUpload) throw new Error("合成图尚未保存到服务端,请重试");
if (scope.current !== epoch) return;
setNodes((current) =>
current.map((node) => {
const liveGroup = node.metadata?.imageLayerGroup;
if (node.id !== root.id || !liveGroup || imageLayerCompositeSignature(liveGroup, current) !== signature) return node;
return { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded), assetId: undefined, imageLayerGroup: { ...liveGroup, compositeSignature: signature, compositeStatus: "ready", compositeError: undefined } } };
}),
);
failed.current.delete(root.id);
} catch (error) {
if (scope.current !== epoch) return;
failed.current.set(root.id, signature);
setNodes((current) =>
current.map((node) => {
const liveGroup = node.metadata?.imageLayerGroup;
if (node.id !== root.id || !liveGroup || imageLayerCompositeSignature(liveGroup, current) !== signature) return node;
return { ...node, metadata: { ...node.metadata, imageLayerGroup: { ...liveGroup, compositeStatus: "error", compositeError: error instanceof Error ? error.message : "合成图更新失败" } } };
}),
);
} finally {
if (scope.current === epoch) {
jobs.current.delete(root.id);
// 期间若有新编辑,再触发一次观察,旧异步结果不会覆盖新版本。
setNodes((current) => [...current]);
}
}
})();
}
}, [enabled, nodes, nodesRef, setNodes]);
}

View File

@@ -0,0 +1,150 @@
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { nanoid } from "nanoid";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { fitImageMaterialNodeSize } from "@/lib/canvas/canvas-node-size";
import { cropImageLayerMaterial } from "@/services/canvas-image-layer-source";
import { uploadImage } from "@/services/image-storage";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
export function useCanvasImageLayerMaterials({
projectId,
domainProjectId,
enabled,
nodes,
nodesRef,
setNodes,
setConnections,
}: {
projectId: string;
domainProjectId?: string;
enabled: boolean;
nodes: CanvasNodeData[];
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
}) {
const jobs = useRef(new Set<string>());
const epoch = useRef(0);
useEffect(() => {
epoch.current++;
jobs.current.clear();
return () => {
epoch.current++;
};
}, [projectId]);
const extractLayerMaterials = useCallback(
async (root: CanvasNodeData) => {
if (!enabled || jobs.current.has(root.id)) return;
const group = root.metadata?.imageLayerGroup;
if (!group) return;
const children = group.layers
.map((layer) => nodesRef.current.find((node) => node.id === layer.nodeId))
.filter((node): node is CanvasNodeData => Boolean(node?.metadata?.content && node.metadata.status === "success" && node.metadata.imageLayer?.kind !== "base"));
if (!children.length) {
setNodes((current) =>
current.map((node) => (node.id === root.id ? { ...node, metadata: { ...node.metadata, imageLayerMaterials: { ...node.metadata?.imageLayerMaterials, status: "error", error: "没有已验收的前景图层可制作独立素材" } } } : node)),
);
return;
}
const scope = epoch.current;
jobs.current.add(root.id);
setNodes((current) => current.map((node) => (node.id === root.id ? { ...node, metadata: { ...node.metadata, imageLayerMaterials: { ...node.metadata?.imageLayerMaterials, status: "loading", error: undefined } } } : node)));
const active = () => scope === epoch.current && nodesRef.current.some((node) => node.id === root.id);
try {
const materials: CanvasNodeData[] = [];
const groupId = nanoid();
const errors: string[] = [];
// 每次只解码一个完整图层,限制浏览器内存占用,不调用模型。
for (const child of children) {
if (!active()) return;
try {
const cropped = await cropImageLayerMaterial({ dataUrl: child.metadata!.content!, storageKey: child.metadata?.storageKey });
const image = await uploadImage(cropped.dataUrl);
if (image.pendingRemoteUpload) throw new Error("素材尚未保存到服务端");
if (!active()) return;
const size = fitImageMaterialNodeSize(image.width, image.height);
const material: CanvasNodeData = {
id: nanoid(),
type: CanvasNodeType.Image,
title: `${child.title || "图层"} · 独立素材`,
position: { x: 0, y: 0 },
...size,
metadata: { ...imageMetadata(image), batchRootId: groupId, imageLayerMaterial: { sourceLayerId: child.id, sourceGroupId: root.id, bounds: cropped.bounds } },
};
try {
const asset = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: material, source: "canvas-manual" });
material.metadata!.assetId = asset.assetId;
} catch (error) {
errors.push(`素材库写入失败:${error instanceof Error ? error.message : "未知错误"}`);
}
materials.push(material);
} catch (error) {
errors.push(`${child.title || "图层"}:${error instanceof Error ? error.message : "提取失败"}`);
}
}
if (!active()) return;
if (!materials.length) throw new Error(errors.join(";") || "没有可用的独立素材");
// 编辑或删除后的旧异步结果不能作为当前图层的素材显示。
const live = nodesRef.current.find((node) => node.id === root.id);
if (
children.some((child) => !nodesRef.current.some((node) => node.id === child.id && node.metadata?.content === child.metadata?.content && node.metadata?.storageKey === child.metadata?.storageKey)) ||
live?.metadata?.imageLayerGroup?.layers.map((layer) => layer.nodeId).join() !== group.layers.map((layer) => layer.nodeId).join()
)
throw new Error("图层已变化,请重新提取独立素材");
const cell = { width: Math.max(...materials.map((node) => node.width)), height: Math.max(...materials.map((node) => node.height)) };
const position = findAvailableGenerationGroupPosition(nodesRef.current, { x: root.position.x + root.width + 96, y: root.position.y }, imageGenerationGroupSize(cell, cell, materials.length));
materials.forEach((node, index) => {
node.position = imageGenerationChildPosition(position, cell.width, cell, index);
});
const materialRoot: CanvasNodeData = {
id: groupId,
type: CanvasNodeType.Image,
title: `独立素材 · ${materials.length} 张`,
position,
width: materials[0].width,
height: materials[0].height,
metadata: {
...imageMetadata({
url: materials[0].metadata!.content!,
storageKey: materials[0].metadata!.storageKey!,
width: materials[0].metadata!.naturalWidth!,
height: materials[0].metadata!.naturalHeight!,
bytes: materials[0].metadata!.bytes!,
mimeType: "image/png",
}),
isBatchRoot: true,
imageBatchExpanded: false,
batchChildIds: materials.map((node) => node.id),
primaryImageId: materials[0].id,
},
};
setNodes((current) => [
...current.map((node): CanvasNodeData =>
node.id === root.id ? { ...node, metadata: { ...node.metadata, imageLayerMaterials: { ...node.metadata?.imageLayerMaterials, status: "ready", groupId, ...(errors.length ? { error: errors.join(";") } : {}) } } } : node,
),
materialRoot,
...materials,
]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: root.id, toNodeId: groupId }, ...materials.map((node) => ({ id: nanoid(), fromNodeId: groupId, toNodeId: node.id }))]);
} catch (error) {
if (active())
setNodes((current) =>
current.map((node) =>
node.id === root.id ? { ...node, metadata: { ...node.metadata, imageLayerMaterials: { ...node.metadata?.imageLayerMaterials, status: "error", error: error instanceof Error ? error.message : "独立素材提取失败" } } } : node,
),
);
} finally {
if (scope === epoch.current) jobs.current.delete(root.id);
}
},
[domainProjectId, enabled, nodesRef, projectId, setConnections, setNodes],
);
useEffect(() => {
if (!enabled) return;
for (const node of nodes)
if (node.metadata?.imageLayerMaterials?.auto && !node.metadata.imageLayerMaterials.status && node.metadata.imageLayerGroup?.compositeStatus === "ready" && node.metadata.status !== "loading") void extractLayerMaterials(node);
}, [enabled, extractLayerMaterials, nodes]);
return extractLayerMaterials;
}

View File

@@ -19,21 +19,18 @@ import type { CanvasVideoFrameParams } from "@/components/canvas/canvas-video-fr
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { isValidGridSplit, layoutGridSplitCells } from "@/lib/canvas/canvas-grid-split";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { audioMetadata, buildSourceImageLayerNodeResult, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { commitProducedModel } from "@/lib/canvas/produced-model";
import { findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { cancelIncompleteImageBatch } from "@/lib/canvas/canvas-image-batch-retry";
import { experimentalLayerPrompt, imageLayerGenerationConfig, imageLayerProviderMetadata, parseExperimentalLayerTargets, layerDecompositionConfig, supportsExperimentalLayerExtraction, supportsLayerDecomposition } from "@/lib/canvas/canvas-image-layers";
import { applyImageLayerPlanningPurpose, imageLayerOutputSize, imageLayerPlannerError, imageLayerPlanningPrompt, imageLayerPlanTargets, imageLayerTargetName, parseImageLayerPlan, type ImageLayerPlanningOptions } from "@/lib/canvas/canvas-image-layer-plan";
import { imageLayerBackgroundPatch, imageLayerExtractionTargets, resolveImageLayerExtractions, type ImageLayerExtraction } from "@/lib/canvas/canvas-image-layer-strategy";
import { buildAngleLabel, buildAnglePrompt, createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { validateVideoSegmentBatch } from "@/lib/canvas/canvas-video-regeneration";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import {
buildGenerationConfig,
buildImageGenerationMetadata,
nodeReferenceImage,
isGenerationCanceled,
runBackendCanvasGenerationTask,
} from "@/lib/canvas/canvas-project-generation";
import { buildGenerationConfig, buildImageGenerationMetadata, nodeReferenceImage, isGenerationCanceled, runBackendCanvasGenerationTask, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture";
@@ -45,13 +42,19 @@ import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-
import { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment";
import { generationErrorMessage } from "@/lib/generation-error";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import { defaultImageParamsForModel, modelCompatibilityError } from "@/lib/model-selection";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedVideo } from "@/services/api/video";
import { getTool } from "@/services/api/tools";
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
import { getImageBlob, uploadImage } from "@/services/image-storage";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { runImageLayerExtraction } from "@/services/canvas-image-layer-extraction";
import { inspectImageLayer } from "@/services/canvas-image-layer-compositor";
import { calibrateImageLayerPlan } from "@/services/canvas-image-layer-source";
import { prepareImageLayerPlanningReference, readImageLayerPlanningHistory } from "@/services/canvas-image-layer-planning";
import { resolveImageRequestSize } from "@/services/api/image-validation";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
import type { GenerationTask } from "@/services/api/task-center";
function normalizeMaskEditQuality(quality: string | undefined, size: string | undefined) {
@@ -85,6 +88,7 @@ type UseCanvasMediaToolsOptions = {
startGenerationRequest: (targetNodeId: string, originNodeId: string, runningId?: string, controller?: AbortController) => AbortController;
finishGenerationRequest: (targetNodeId: string, controller: AbortController) => void;
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => void;
applyGenerationTaskResult: (targetNodeId: string, task: GenerationTask) => Promise<void>;
};
const NODE_STATUS_LOADING = "loading" as const;
@@ -111,12 +115,15 @@ export function useCanvasMediaTools({
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
}: UseCanvasMediaToolsOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const extractingVideoFramesNodeIdRef = useRef<string | null>(null);
const mergeVideoRunningRef = useRef(false);
const layerSubmissionIds = useRef(new Set<string>());
const [activeLayerGroupIds, setActiveLayerGroupIds] = useState<ReadonlySet<string>>(new Set());
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
const [annotationNodeId, setAnnotationNodeId] = useState<string | null>(null);
const [annotationEditNodeId, setAnnotationEditNodeId] = useState<string | null>(null);
@@ -986,74 +993,512 @@ export function useCanvasMediaTools({
setImageEditNodeId(node.id);
}, []);
const decomposeImageLayers = useCallback(async (node: CanvasNodeData, payload: CanvasImageLayerDecompositionPayload) => {
if (!node.metadata?.content || !payload.prompt.trim()) return;
const baseGenerationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
const selectedModel = payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model;
const generationConfig = { ...baseGenerationConfig, ...payload.generationConfig, model: selectedModel, imageModel: payload.generationConfig?.imageModel || selectedModel, count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
const planImageLayers = useCallback(async (node: CanvasNodeData, model: string, prompt: string, signal: AbortSignal, options?: ImageLayerPlanningOptions) => {
const source = nodeReferenceImage(node);
if (!source) return;
const prompt = payload.prompt.trim();
const taskNodeId = nanoid();
const imageSize = { width: node.width, height: node.height };
const position = { x: node.position.x + node.width + 96, y: node.position.y };
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
const taskNode: CanvasNodeData = {
id: taskNodeId,
type: CanvasNodeType.Image,
title: "AI 图层拆分",
position,
width: imageSize.width,
height: imageSize.height,
metadata: { ...canvasGenerationPromptMetadata(prompt, prompt), status: NODE_STATUS_LOADING, pluginId: "image-tools", pluginNodeId: "layer-decomposition", ...generationMetadata },
};
setLayerDecompositionNodeId(null);
setRunningNodeId(taskNodeId);
setNodes((current) => [...current, taskNode]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: taskNodeId }]);
setSelectedNodeIds(new Set([taskNodeId]));
setSelectedConnectionId(null);
setDialogNodeId(null);
const controller = startGenerationRequest(taskNodeId, node.id, taskNodeId);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: taskNodeId, mode: "image", prompt, config: generationConfig, referenceImages: [source], signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "layer-decomposition", layerDecomposition: true }, onTaskCreated: (task) => bindGenerationTask(taskNodeId, task) });
const outputs = result.images?.filter((item) => item?.dataUrl) || [];
if (!outputs.length) throw new Error("图层拆分任务没有返回图片");
const layerNodes: CanvasNodeData[] = [];
for (let index = 0; index < outputs.length; index += 1) {
const uploaded = await uploadImage(outputs[index].dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, imageSize.width, imageSize.height);
const id = index === 0 ? taskNodeId : nanoid();
layerNodes.push({
id,
type: CanvasNodeType.Image,
title: `${node.title || "图片"} · 图层 ${index + 1}`,
position: { x: position.x + (index % 2) * (size.width + 48), y: position.y + Math.floor(index / 2) * (size.height + 48) },
width: size.width,
height: size.height,
metadata: commitProducedModel({ ...imageMetadata(uploaded), prompt, status: NODE_STATUS_SUCCESS, pluginId: "image-tools", pluginNodeId: "layer-decomposition", pluginData: { layerIndex: index + 1, sourceNodeId: node.id }, ...generationMetadata }),
});
}
setNodes((current) => [...current.filter((item) => item.id !== taskNodeId), ...layerNodes]);
setConnections((current) => [...current.filter((connection) => connection.toNodeId !== taskNodeId), ...layerNodes.map((layer) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: layer.id }))]);
setSelectedNodeIds(new Set(layerNodes.map((layer) => layer.id)));
await persistMediaNodes(layerNodes);
message.success(`已拆分出 ${layerNodes.length} 个图片图层`);
} catch (error) {
if (!isGenerationCanceled(error)) {
const details = generationErrorMessage(error);
message.error(details);
setNodes((current) => current.map((item) => item.id === taskNodeId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
}
} finally {
finishGenerationRequest(taskNodeId, controller);
setRunningNodeId(null);
if (!source) throw new Error("源图为空,无法规划拆层");
if (options?.history) {
const plan = await readImageLayerPlanningHistory(projectId, node.id, node.metadata?.storageKey, signal);
return calibrateImageLayerPlan(source, applyImageLayerPlanningPurpose(plan, options?.purpose || "recompose"));
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, persistMediaNodes, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
const config = { ...effectiveConfig, model, textModel: model, taskWorkflowProvider: "model" as const };
const error = imageLayerPlannerError(config, model);
if (error) throw new Error(error);
if (!isAiConfigReady(config, model)) throw new Error("所选识图规划模型未配置可用渠道");
const { reference, sourceSize } = await prepareImageLayerPlanningReference(source, options?.input || "preview", signal);
const referenceSize = { width: reference.width!, height: reference.height! };
let taskId: string | undefined;
const result = await runBackendCanvasGenerationTask({
projectId, nodeId: `${node.id}-layer-plan-${nanoid()}`, mode: "text", prompt: imageLayerPlanningPrompt(prompt, referenceSize, options?.purpose), config,
referenceImages: [reference], signal, metadata: { sourceNodeId: node.id, sourceStorageKey: source.storageKey, sourceSize, planningReferenceSize: referenceSize, edit: "layer-planning" }, onTaskCreated: (task) => { taskId = task.id; },
});
const plan = { ...parseImageLayerPlan(result.text || "", referenceSize), model, taskId };
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
return calibrateImageLayerPlan(source, applyImageLayerPlanningPurpose(plan, options?.purpose || "recompose"));
}, [effectiveConfig, isAiConfigReady, projectId]);
const decomposeImageLayers = useCallback(
async (node: CanvasNodeData, payload: CanvasImageLayerDecompositionPayload) => {
if (layerSubmissionIds.current.has(node.id)) return;
if (!node.metadata?.content || !payload.prompt.trim()) return;
const baseGenerationConfig = {
...buildGenerationConfig(effectiveConfig, node, "image"),
count: "1",
};
const selectedModel = payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model;
const generationConfig = layerDecompositionConfig(baseGenerationConfig, selectedModel, payload.generationConfig);
const plannerError = imageLayerPlannerError(effectiveConfig, payload.plannerModel);
if (plannerError) {
message.error(plannerError);
return;
}
if (
!isAiConfigReady(
{
...effectiveConfig,
model: payload.plannerModel,
textModel: payload.plannerModel,
},
payload.plannerModel,
)
) {
message.error("所选视觉规划模型未配置可用渠道");
return;
}
if (!supportsLayerDecomposition(generationConfig, selectedModel) && !supportsExperimentalLayerExtraction(generationConfig, selectedModel)) {
message.error("请选择可编辑参考图的图片模型或专用拆层模型");
return;
}
if (!isAiConfigReady(generationConfig, selectedModel)) {
navigateToSettings({ continueCreation: true });
return;
}
const taskNodeId = nanoid();
const startedAt = new Date().toISOString();
const imageSize = { width: node.width, height: node.height };
const position = findAvailableGenerationGroupPosition(nodesRef.current, { x: node.position.x + node.width + 96, y: node.position.y }, imageSize);
const controller = startGenerationRequest(taskNodeId, node.id, taskNodeId);
let pendingRequests: NonNullable<NonNullable<CanvasNodeData["metadata"]>["experimentalLayerPlan"]>["requests"] | undefined;
layerSubmissionIds.current.add(node.id);
setLayerDecompositionNodeId(null);
setActiveLayerGroupIds((current) => new Set([...current, taskNodeId]));
setRunningNodeId(taskNodeId);
setNodes((current) => [
...current,
{
id: taskNodeId,
type: CanvasNodeType.Image,
title: "AI 图层拆分",
position,
...imageSize,
metadata: {
...canvasGenerationPromptMetadata(payload.prompt, payload.prompt),
...buildImageGenerationMetadata("edit", generationConfig, 1, [nodeReferenceImage(node)!]),
status: NODE_STATUS_LOADING,
imageLayerWorkflow: {
sourceNodeId: node.id,
startedAt,
stage: "planning",
plannerModel: payload.plannerModel,
},
},
},
]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: taskNodeId }]);
setSelectedNodeIds(new Set([taskNodeId]));
setSelectedConnectionId(null);
setDialogNodeId(null);
try {
const plan = await planImageLayers(node, payload.plannerModel, payload.prompt, controller.signal, { input: "preview", purpose: "recompose" });
if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
if (!nodesRef.current.some((item) => item.id === taskNodeId)) throw new Error("拆层节点已删除,未提交拆图任务");
payload = {
...payload,
planning: plan,
...(supportsLayerDecomposition(generationConfig, selectedModel)
? {
prompt: `按以下独立图层规划拆分参考图片,各层只执行自己的 generation;第一层为完整不透明底图,其余层为独立透明素材。保持原图画布尺寸和位置。\n${JSON.stringify(plan.layers)}`,
backgroundRemovalModel: undefined,
}
: {
experimentalTargets: imageLayerPlanTargets(plan),
extractions: plan.layers.map(
(layer) =>
layer.extraction || {
method: "generate",
},
),
removeFromBackground: plan.layers.map((layer, index) => index > 0 && Boolean(layer.removeFromBackground)),
}),
};
const experimental = Boolean(payload.experimentalTargets);
let targets: string[] | undefined;
let extractions: ImageLayerExtraction[] | undefined;
let removeFromBackground: boolean[] | undefined;
try {
if (experimental) {
targets = parseExperimentalLayerTargets(payload.experimentalTargets!.join("\n"));
removeFromBackground = targets.map((_, index) => index > 0 && (payload.removeFromBackground?.[index] ?? index === 1));
extractions = resolveImageLayerExtractions(targets.length, payload.extractions, removeFromBackground);
targets = imageLayerExtractionTargets(targets, extractions);
}
} catch (error) {
throw new Error(generationErrorMessage(error));
}
const needsModel = !experimental || extractions!.some((extraction) => extraction.method === "generate");
const hasGeneratedObjects = extractions?.some((extraction, index) => index > 0 && extraction.method === "generate");
if (needsModel && !supportsLayerDecomposition(generationConfig, selectedModel) && !(experimental && supportsExperimentalLayerExtraction(generationConfig, selectedModel))) {
throw new Error("请选择支持独立透明图层输出的专用拆层模型");
}
if (needsModel && !isAiConfigReady(generationConfig, generationConfig.model)) {
throw new Error("所选图层拆分模型未配置可用渠道");
}
const source = nodeReferenceImage(node);
if (!source) throw new Error("源图为空,未提交拆图任务");
let sourceCanvas: { width: number; height: number };
try {
const info = await inspectImageLayer(source);
if (!info.nonempty) throw new Error("源图是完全透明的空图");
sourceCanvas = { width: info.width, height: info.height };
generationConfig.size = needsModel ? imageLayerOutputSize(generationConfig, selectedModel, sourceCanvas) : `${info.width}x${info.height}`;
if (needsModel && !generationConfig.size) throw new Error("所选拆层模型没有支持原图比例的尺寸,请选择其他模型");
if (extractions?.[0].method === "source" && !info.opaque) throw new Error("原图含透明区域,不能直接作为完整不透明底图,请选择模型修补底图");
const profile = needsModel && modelCapabilityConfigFor(generationConfig, selectedModel).image;
// 持久化任务先经过服务端配置校验,必须提交已解析的协议尺寸,不能只校验后仍发送比例字符串。
if (profile) generationConfig.size = resolveImageRequestSize(profile, generationConfig.quality, generationConfig.size)?.value || "auto";
} catch (error) {
throw new Error(generationErrorMessage(error));
}
if (controller.signal.aborted || !nodesRef.current.some((item) => item.id === taskNodeId)) throw new DOMException("Aborted", "AbortError");
const liveSource = nodesRef.current.find((item) => item.id === node.id);
if (!liveSource || liveSource.metadata?.content !== node.metadata.content || liveSource.metadata?.storageKey !== node.metadata.storageKey) throw new Error("源图已更改或删除,请重新发起拆层");
const removalConfig =
hasGeneratedObjects && payload.backgroundRemovalModel
? layerDecompositionConfig(
{
...baseGenerationConfig,
...defaultImageParamsForModel(baseGenerationConfig, payload.backgroundRemovalModel),
},
payload.backgroundRemovalModel,
{
size: imageLayerOutputSize(baseGenerationConfig, payload.backgroundRemovalModel, sourceCanvas),
},
)
: undefined;
if (removalConfig && (!supportsExperimentalLayerExtraction(removalConfig, removalConfig.model) || !isAiConfigReady(removalConfig, removalConfig.model))) {
throw new Error("默认去背景模型不可用,请在模型配置中检查图片编辑能力");
}
const removalSizeError =
removalConfig &&
(!removalConfig.size
? "所选去背景模型没有支持原图比例的尺寸,请选择其他模型"
: modelCompatibilityError(removalConfig, removalConfig.model, {
capability: "image",
imageSize: removalConfig.size,
}));
if (removalSizeError) throw new Error(removalSizeError);
try {
const profile = removalConfig && modelCapabilityConfigFor(removalConfig, removalConfig.model).image;
if (profile && removalConfig) removalConfig.size = resolveImageRequestSize(profile, removalConfig.quality, removalConfig.size)?.value || "auto";
} catch (error) {
throw new Error(generationErrorMessage(error));
}
const prompt = payload.prompt.trim();
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
const requests = targets?.map((target, index) => ({
nodeId: nanoid(),
target,
removeFromBackground: removeFromBackground![index],
extraction: extractions![index],
generation: { ...plan.layers[index].generation, prompt: experimentalLayerPrompt(plan.layers[index].generation, plan.layers[index].bbox) },
}));
pendingRequests = requests;
const backgroundPatch =
extractions &&
imageLayerBackgroundPatch(
{
dataUrl: source.dataUrl,
storageKey: source.storageKey,
},
extractions,
removeFromBackground!,
);
const taskNode: CanvasNodeData = {
id: taskNodeId,
type: CanvasNodeType.Image,
title: "AI 图层拆分",
position,
width: imageSize.width,
height: imageSize.height,
metadata: {
...canvasGenerationPromptMetadata(prompt, prompt),
status: NODE_STATUS_LOADING,
pluginId: "image-tools",
pluginNodeId: "layer-decomposition",
imageLayerMaterials: payload.independentMaterials ? { auto: true } : undefined,
imageLayerWorkflow: {
sourceNodeId: node.id,
startedAt,
stage: "extract",
total: plan.layers.length,
plannerModel: payload.plannerModel,
planningTaskId: plan.taskId,
},
...(requests
? {
experimentalLayerPlan: {
sourceNodeId: node.id,
requests,
plannerModel: payload.planning?.model,
planningTaskId: payload.planning?.taskId,
progress: `并发拆层:0/${requests.length} 层完成`,
},
isBatchRoot: true,
batchChildIds: requests.map((request) => request.nodeId),
imageBatchExpanded: false,
}
: {
layerDecomposition: {
sourceNodeId: node.id,
canvas: sourceCanvas,
},
}),
...generationMetadata,
...(!needsModel
? {
model: undefined,
producedModelCandidate: undefined,
}
: {}),
},
};
const extractionNodes: CanvasNodeData[] = (requests || []).map((request, index) => {
const extractionPrompt = request.generation.prompt;
return {
id: request.nodeId,
type: CanvasNodeType.Image,
title: imageLayerTargetName(request.target),
...imageSize,
position: imageGenerationChildPosition(position, imageSize.width, imageSize, index),
metadata: {
...canvasGenerationPromptMetadata(extractionPrompt, extractionPrompt),
...buildImageGenerationMetadata(
"edit",
imageLayerGenerationConfig(generationConfig, request.generation, index),
1,
[source],
),
status: NODE_STATUS_LOADING,
batchRootId: taskNodeId,
layerExtraction: {
sourceNodeId: node.id,
groupId: taskNodeId,
index,
phase: "extract",
canvas: sourceCanvas,
strategy: request.extraction,
source: {
dataUrl: source.dataUrl,
storageKey: source.storageKey,
mimeType: source.type,
},
...(index === 0 && backgroundPatch ? { backgroundPatch } : {}),
allowBackgroundRemoval: Boolean(removalConfig) && request.extraction.method === "generate",
backgroundRemovalModel: removalConfig?.model,
},
},
};
});
setNodes((current) => [...current.map((item) => (item.id === taskNodeId ? taskNode : item)), ...extractionNodes]);
setConnections((current) => [
...current,
...extractionNodes.map((item) => ({
id: nanoid(),
fromNodeId: taskNode.id,
toNodeId: item.id,
})),
]);
setSelectedNodeIds(new Set([taskNodeId]));
setSelectedConnectionId(null);
setDialogNodeId(null);
if (requests && targets) {
let observedGroup = false;
const phases = requests.map(() => "extract");
await runImageLayerExtraction({
source,
targets,
extractions,
removeFromBackground,
generations: requests.map((request) => request.generation),
config: generationConfig,
removalConfig,
signal: controller.signal,
assertActive: (index) => {
const present = nodesRef.current.some((item) => item.id === taskNodeId);
if (present) observedGroup = true;
if (observedGroup && (!present || !nodesRef.current.some((item) => item.id === requests[index].nodeId))) throw new Error("拆层节点已删除,本层后续请求已停止");
},
onProgress: (index, phase) => {
phases[index] = phase;
const count = (value: string) => phases.filter((item) => item === value).length;
const progress = `并发拆层:${count("complete")}/${requests.length} 层完成,提取 ${count("extract")},去背景 ${count("remove-background")}${count("error") ? `,失败 ${count("error")}` : ""}`;
setNodes((current) =>
current.map((item) =>
item.id === taskNodeId && item.metadata?.experimentalLayerPlan
? {
...item,
metadata: {
...item.metadata,
experimentalLayerPlan: {
...item.metadata.experimentalLayerPlan,
progress,
},
},
}
: item,
),
);
},
onLocalResult: async (index, image) => {
const targetId = requests[index].nodeId;
const current = nodesRef.current.find((item) => item.id === targetId) || extractionNodes[index];
const completed = await buildSourceImageLayerNodeResult(current, image, controller.signal);
if (!nodesRef.current.some((item) => item.id === targetId) || controller.signal.aborted) throw new Error("拆层节点已删除或停止,本层结果不再写入");
setNodes((items) => items.map((item) => (item.id === targetId ? completed : item)));
await persistMediaNodes([completed]);
},
onLayerError: (index, error) => {
setNodes((current) =>
current.map((item) =>
item.id === requests[index].nodeId
? {
...item,
metadata: {
...item.metadata,
status: NODE_STATUS_ERROR,
errorDetails: generationErrorMessage(error),
},
}
: item,
),
);
},
runStage: async ({ index, stage, prompt: stagePrompt, config: stageConfig, reference, canvas }) => {
const targetId = requests[index].nodeId;
setNodes((current) =>
current.map((item) => {
if (item.id !== targetId) return item;
return {
...item,
metadata: {
...resetGenerationTaskMetadata(item.metadata, "loading"),
...buildImageGenerationMetadata("edit", stageConfig, 1, [reference]),
...canvasGenerationPromptMetadata(stagePrompt, stagePrompt),
layerExtraction: {
...item.metadata!.layerExtraction!,
phase: stage,
canvas,
rejectedTaskId: undefined,
},
},
};
}),
);
startGenerationRequest(targetId, node.id, taskNodeId, controller);
try {
return await runCanvasGenerationTaskToConsumer(
{
projectId,
nodeId: targetId,
mode: "image",
prompt: stagePrompt,
config: stageConfig,
referenceImages: [reference],
signal: controller.signal,
metadata: {
sourceNodeId: node.id,
edit: stage === "extract" ? "layer-extraction" : "layer-background-removal",
...imageLayerProviderMetadata(stageConfig, index),
},
},
{
bindTask: (task) => bindGenerationTask(targetId, task),
consumeTask: (task) => applyGenerationTaskResult(targetId, task),
},
);
} catch (error) {
setNodes((current) =>
current.map((item) =>
item.id === targetId
? {
...item,
metadata: {
...item.metadata,
status: NODE_STATUS_ERROR,
errorDetails: generationErrorMessage(error),
layerExtraction: {
...item.metadata!.layerExtraction!,
rejectedTaskId: item.metadata?.taskId,
},
},
}
: item,
),
);
throw error;
} finally {
finishGenerationRequest(targetId, controller);
}
},
});
return;
}
await runCanvasGenerationTaskToConsumer(
{
projectId,
nodeId: taskNodeId,
mode: "image",
prompt,
config: generationConfig,
referenceImages: [source],
signal: controller.signal,
metadata: {
sourceNodeId: node.id,
edit: "layer-decomposition",
layerDecomposition: true,
},
},
{
bindTask: (task) => bindGenerationTask(taskNodeId, task),
consumeTask: (task) => applyGenerationTaskResult(taskNodeId, task),
},
);
message.success("图层已拆分,可展开查看并独立编辑");
} catch (error) {
{
const details = isGenerationCanceled(error) ? "拆层已停止,未提交的图层不会继续调用" : generationErrorMessage(error);
message.error(details);
setNodes((current) =>
current.map((item) =>
item.id === taskNodeId || (pendingRequests?.some((request) => request.nodeId === item.id) && (item.metadata?.status === NODE_STATUS_IDLE || item.metadata?.status === NODE_STATUS_LOADING))
? {
...item,
metadata: {
...item.metadata,
status: NODE_STATUS_ERROR,
errorDetails: item.id === taskNodeId ? details : "本层未完成,未自动重试或继续提交请求",
},
}
: item,
),
);
}
} finally {
finishGenerationRequest(taskNodeId, controller);
layerSubmissionIds.current.delete(node.id);
setActiveLayerGroupIds((current) => new Set([...current].filter((id) => id !== taskNodeId)));
setRunningNodeId((current) => (current === taskNodeId ? null : current));
}
},
[
applyGenerationTaskResult,
bindGenerationTask,
effectiveConfig,
finishGenerationRequest,
isAiConfigReady,
message,
nodesRef,
projectId,
persistMediaNodes,
planImageLayers,
setConnections,
setDialogNodeId,
setNodes,
setRunningNodeId,
setSelectedConnectionId,
setSelectedNodeIds,
startGenerationRequest,
],
);
const openLayerDecomposition = useCallback((node: CanvasNodeData) => {
setLayerDecompositionNodeId(node.id);
@@ -1284,6 +1729,7 @@ export function useCanvasMediaTools({
openBackgroundRemoval,
openLayerDecomposition,
decomposeImageLayers,
activeLayerGroupIds,
setLayerDecompositionNodeId,
setTextEditNodeId,
openTextEditNode,

View File

@@ -197,6 +197,7 @@ export function useCanvasNodeEditor({
}, [canvasId, domainProjectId, message, nodesRef, queryClient, setNodes]);
const downloadNodeImage = useCallback(async (node: CanvasNodeData) => {
if (node.metadata?.imageLayerGroup?.compositeStatus && node.metadata.imageLayerGroup.compositeStatus !== "ready") { message.warning("请等待图层合成成功后再下载"); return; }
const supported = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
const content = node.metadata?.content?.trim();
const storageKey = node.metadata?.storageKey?.trim();
@@ -210,6 +211,7 @@ export function useCanvasNodeEditor({
}, [canvasTitle, message]);
const saveNodeAsset = useCallback(async (node: CanvasNodeData) => {
if (node.metadata?.imageLayerGroup?.compositeStatus && node.metadata.imageLayerGroup.compositeStatus !== "ready") { message.warning("请等待图层合成成功后再保存素材"); return; }
if (node.type !== CanvasNodeType.Text && node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) return message.error("当前节点类型不能保存为素材");
if (!node.metadata?.content?.trim()) return message.error("当前节点没有可保存的内容");
try {

View File

@@ -9,6 +9,7 @@ import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes
import { applyCanvasConnectionPromptSync } from "@/lib/canvas/canvas-resource-references";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
import { copyImageLayerSources } from "@/lib/canvas/canvas-image-layers";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas";
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
@@ -363,7 +364,7 @@ export function useCanvasNodeOperations({
const duplicateNode = useCallback((nodeId: string, duplicateMode: "variant" | "copy" = "variant") => {
const source = nodesRef.current.find((node) => node.id === nodeId);
if (!source) return;
const sources = isFrameNode(source) ? [source, ...getFrameChildren(source.id, nodesRef.current)] : [source];
const sources = isFrameNode(source) ? [source, ...getFrameChildren(source.id, nodesRef.current)] : copyImageLayerSources(source, nodesRef.current);
const idMap = new Map(sources.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`]));
const versionRootId = duplicateMode === "variant" && !isFrameNode(source) ? source.metadata?.versionOfNodeId || source.id : undefined;
const versionLabel = versionRootId ? nextCanvasVersionLabel(versionRootId, nodesRef.current) : undefined;
@@ -428,6 +429,7 @@ export function useCanvasNodeOperations({
const copyIds = new Set(targetIds);
nodesRef.current.forEach((node) => {
if (targetIds.has(node.id) && isFrameNode(node)) getFrameChildIds(node.id, nodesRef.current).forEach((childId) => copyIds.add(childId));
if (targetIds.has(node.id) && node.metadata?.imageLayerGroup) node.metadata.imageLayerGroup.layers.forEach((layer) => copyIds.add(layer.nodeId));
});
const copiedNodes = nodesRef.current
.filter((node) => copyIds.has(node.id))