feat(canvas): AI 拆图 - 支持图层规划编辑与素材复用
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
178
web/src/pages/canvas/use-canvas-image-layer-groups.ts
Normal file
178
web/src/pages/canvas/use-canvas-image-layer-groups.ts
Normal 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]);
|
||||
}
|
||||
150
web/src/pages/canvas/use-canvas-image-layer-materials.ts
Normal file
150
web/src/pages/canvas/use-canvas-image-layer-materials.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user