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

@@ -0,0 +1,142 @@
import { inspectLayerAlpha, MAX_GROUP_PIXELS, MAX_IMAGE_LAYERS, MAX_LAYER_PIXELS, validateLayerRasters, validateImageLayerOutput, type LayerRasterInfo } from "@/lib/canvas/canvas-image-layers";
import { getImageBlob } from "@/services/image-storage";
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
export type LayerInput = { dataUrl: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string };
type DecodedLayer = { bitmap: ImageBitmap; blob: Blob; info: LayerRasterInfo };
export async function decodeLayer(input: LayerInput): Promise<DecodedLayer> {
let blob = input.storageKey ? await getImageBlob(input.storageKey) : null;
if (!blob) {
const response = await fetch(input.dataUrl);
if (!response.ok) throw new Error("无法读取完整图层资源");
blob = await response.blob();
}
const bitmap = await createImageBitmap(blob);
try {
if (bitmap.width * bitmap.height > MAX_LAYER_PIXELS) throw new Error("图层尺寸超过处理上限");
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("浏览器不支持图层合成");
context.drawImage(bitmap, 0, 0);
const info = inspectLayerAlpha(bitmap.width, bitmap.height, context.getImageData(0, 0, bitmap.width, bitmap.height).data);
canvas.width = canvas.height = 0;
return { bitmap, blob, info };
} catch (error) {
bitmap.close();
throw error;
}
}
export function pngBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("图层合成 PNG 失败"))), "image/png"));
}
export async function inspectImageLayer(input: LayerInput) {
const decoded = await decodeLayer(input);
try {
return decoded.info;
} finally {
decoded.bitmap.close();
}
}
/** 模型尺寸取整最多允许 1% 比例差,保存时统一为源图画布;明显改构图的结果拒绝。 */
export async function normalizeImageLayerCanvas(input: LayerInput, target?: { width: number; height: number }) {
const decoded = await decodeLayer(input);
try {
const { info } = decoded;
if (!target) return { image: { ...input, width: info.width, height: info.height }, info };
if (!Number.isSafeInteger(target.width) || !Number.isSafeInteger(target.height) || target.width <= 0 || target.height <= 0 || target.width * target.height > MAX_LAYER_PIXELS) throw new Error("源图画布尺寸无效或超过处理上限");
if (Math.abs(info.width / info.height / (target.width / target.height) - 1) > 0.01)
throw new Error(`模型结果比例与原图不一致(原图 ${target.width}×${target.height},实际 ${info.width}×${info.height}),已拒绝该图层;不会拉伸或裁切来掩盖构图变化`);
if (info.width === target.width && info.height === target.height) return { image: { ...input, width: info.width, height: info.height }, info };
const canvas = document.createElement("canvas");
canvas.width = target.width;
canvas.height = target.height;
try {
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("浏览器不支持图层画布归一化");
context.drawImage(decoded.bitmap, 0, 0, target.width, target.height);
const normalized = inspectLayerAlpha(target.width, target.height, context.getImageData(0, 0, target.width, target.height).data);
const blob = await pngBlob(canvas);
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("无法读取归一化 PNG 图层"));
reader.readAsDataURL(blob);
});
return { image: { dataUrl, width: target.width, height: target.height, bytes: blob.size, mimeType: "image/png" }, info: normalized };
} finally {
canvas.width = canvas.height = 0;
}
} finally {
decoded.bitmap.close();
}
}
/** 验收所有输出后一次提交;失败时不留下部分画布图层。 */
export async function decodeAndComposeImageLayers(inputs: LayerInput[], roles?: number[]) {
if (roles && (roles.length !== inputs.length || !inputs.length || inputs.length > MAX_IMAGE_LAYERS)) throw new Error("拆层合成角色或数量无效");
if (!roles && (inputs.length < 2 || inputs.length > MAX_IMAGE_LAYERS)) validateLayerRasters(inputs.map(() => ({ width: 1, height: 1, transparent: true, nonempty: true })));
if (inputs.some((input) => !input || (!input.dataUrl && !input.storageKey))) throw new Error("拆层结果包含缺失的图层资源");
const decoded: DecodedLayer[] = [];
try {
let pixels = 0;
for (const input of inputs) {
const layer = await decodeLayer(input);
decoded.push(layer);
pixels += layer.info.width * layer.info.height;
if (pixels > MAX_GROUP_PIXELS) throw new Error("图层总像素量超过处理上限");
}
const order = roles
? decoded.map((layer, index) => {
validateImageLayerOutput(layer.info);
if (layer.info.width !== decoded[0].info.width || layer.info.height !== decoded[0].info.height) throw new Error("图层尺寸不一致,无法确定合成坐标");
return index;
})
: validateLayerRasters(decoded.map((layer) => layer.info));
const { width, height } = decoded[0].info;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器不支持图层合成");
for (const index of order) context.drawImage(decoded[index].bitmap, 0, 0);
const composite = await pngBlob(canvas);
canvas.width = canvas.height = 0;
return { width, height, order, composite, layers: decoded.map(({ blob, info }) => ({ blob, ...info })) };
} finally {
decoded.forEach((layer) => layer.bitmap.close());
}
}
/** 编辑、删除、调整顺序后用同一组坐标重建合成图,画布展开位置不参与。 */
export async function composeCanvasImageLayerGroup(group: NonNullable<CanvasNodeMetadata["imageLayerGroup"]>, nodes: CanvasNodeData[]) {
if (!Number.isSafeInteger(group.width) || !Number.isSafeInteger(group.height) || group.width <= 0 || group.height <= 0 || group.width * group.height > MAX_LAYER_PIXELS) throw new Error("图层组合成尺寸无效");
if (group.layers.length > MAX_IMAGE_LAYERS || group.width * group.height * group.layers.length > MAX_GROUP_PIXELS || group.layers.some((layer) => !Number.isFinite(layer.x) || !Number.isFinite(layer.y)))
throw new Error("图层组合成参数无效或超过处理上限");
const canvas = document.createElement("canvas");
canvas.width = group.width;
canvas.height = group.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器不支持图层合成");
for (const layer of group.layers) {
if (!layer.visible) continue;
const node = nodes.find((item) => item.id === layer.nodeId);
if (!node?.metadata?.content) throw new Error("图层资源缺失,无法更新合成图");
const decoded = await decodeLayer({ dataUrl: node.metadata.content, storageKey: node.metadata.storageKey });
try {
if (decoded.info.width !== group.width || decoded.info.height !== group.height) throw new Error("编辑后的图层尺寸与图层组不一致,无法合成");
if (node.metadata.imageLayer?.kind) validateImageLayerOutput(decoded.info);
context.drawImage(decoded.bitmap, layer.x, layer.y);
} finally {
decoded.bitmap.close();
}
}
const blob = await pngBlob(canvas);
canvas.width = canvas.height = 0;
return blob;
}

View File

@@ -0,0 +1,114 @@
import { hasUsableLayerTransparency, imageLayerGenerationConfig, MAX_GROUP_PIXELS, parseImageLayerGeneration, parseExperimentalLayerTargets, validateImageLayerOutput, type ImageLayerGeneration } from "@/lib/canvas/canvas-image-layers";
import { imageLayerRemovalPrompt } from "@/lib/canvas/canvas-image-layer-plan";
import { inspectImageLayer, normalizeImageLayerCanvas } from "@/services/canvas-image-layer-compositor";
import type { AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import { resolveImageLayerExtractions, type ImageLayerExtraction } from "@/lib/canvas/canvas-image-layer-strategy";
import { extractImageLayerFromSource } from "@/services/canvas-image-layer-source";
export type ImageLayerStage = "extract" | "remove-background";
type LayerImage = { dataUrl: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string };
export type ImageLayerStageRequest = {
index: number;
stage: ImageLayerStage;
prompt: string;
config: AiConfig;
reference: ReferenceImage;
canvas?: { width: number; height: number };
};
/** 各层独立并发;去背景只依赖本层提取,不依赖其他层或底图。 */
export async function runImageLayerExtraction({
source,
targets,
extractions,
removeFromBackground,
generations,
config,
removalConfig,
signal,
assertActive,
runStage,
onProgress,
onLocalResult,
onLayerError,
}: {
source: ReferenceImage;
targets: string[];
extractions?: ImageLayerExtraction[];
removeFromBackground?: boolean[];
generations: ImageLayerGeneration[];
config: AiConfig;
removalConfig?: AiConfig;
signal: AbortSignal;
assertActive(index: number): void;
runStage(request: ImageLayerStageRequest): Promise<{ images?: LayerImage[] }>;
onProgress?(index: number, phase: ImageLayerStage | "complete" | "error"): void;
onLocalResult?(index: number, image: LayerImage): Promise<void>;
onLayerError?(index: number, error: unknown): void;
}) {
parseExperimentalLayerTargets(targets.join("\n"));
if (!Array.isArray(generations) || generations.length !== targets.length) throw new Error("每层独立生成参数数量与图层计划不一致,未提交拆图任务");
const layerGenerations = generations.map((generation, index) => parseImageLayerGeneration(generation, index));
const methods = resolveImageLayerExtractions(targets.length, extractions, removeFromBackground);
if (methods.some((extraction) => extraction.method !== "generate") && !onLocalResult) throw new Error("原图提取结果保存回调缺失,未提交模型请求");
if (signal.aborted) throw new DOMException("拆层已停止", "AbortError");
const sourceInfo = await inspectImageLayer(source);
if (!sourceInfo.nonempty) throw new Error("源图是完全透明的空图");
const canvas = { width: sourceInfo.width, height: sourceInfo.height };
if (canvas.width * canvas.height * targets.length > MAX_GROUP_PIXELS) throw new Error("图层总像素量超过处理上限");
const check = (index: number) => {
if (signal.aborted) throw new DOMException("拆层已停止", "AbortError");
assertActive(index);
};
const inspect = async (images: LayerImage[] | undefined) => {
if (images?.length !== 1) throw new Error("逐层提取必须返回一张独立图片,不能返回拼版或多张候选图");
const { image, info } = await normalizeImageLayerCanvas(images[0], canvas);
if (!info.nonempty) throw new Error("提取结果是完全透明的空图层");
return { image, info };
};
const outcomes = await Promise.allSettled(
targets.map(async (_, index) => {
try {
check(index);
onProgress?.(index, "extract");
if (methods[index].method !== "generate") {
const image = await extractImageLayerFromSource(source, methods[index], canvas);
check(index);
await onLocalResult!(index, image);
check(index);
onProgress?.(index, "complete");
return;
}
let result = await inspect((await runStage({ index, stage: "extract", prompt: layerGenerations[index].prompt, config: imageLayerGenerationConfig(config, layerGenerations[index], index), reference: source, canvas })).images);
check(index);
if (index > 0 && !hasUsableLayerTransparency(result.info) && removalConfig) {
check(index);
onProgress?.(index, "remove-background");
result = await inspect(
(
await runStage({
index,
stage: "remove-background",
prompt: imageLayerRemovalPrompt(targets[index]),
config: { ...removalConfig, count: "1", transparentBackground: "true" },
canvas,
reference: { id: `${source.id}-layer-${index}`, name: `${targets[index].slice(0, 60)}.png`, type: result.image.mimeType || "image/png", ...result.image },
})
).images,
);
check(index);
}
validateImageLayerOutput(result.info);
onProgress?.(index, "complete");
} catch (error) {
onLayerError?.(index, error);
onProgress?.(index, "error");
throw error;
}
}),
);
if (signal.aborted) throw new DOMException("拆层已停止", "AbortError");
const failures = outcomes.flatMap((outcome, index) => (outcome.status === "rejected" ? [`${targets[index]}:${outcome.reason instanceof Error ? outcome.reason.message : "图层生成失败"}`] : []));
if (failures.length) throw new Error(`${failures.length}/${targets.length} 个图层未通过;其他图层结果已保留。${failures.join(";")}`);
}

View File

@@ -0,0 +1,60 @@
import { MAX_LAYER_PIXELS } from "@/lib/canvas/canvas-image-layers";
import { latestImageLayerPlan } from "@/lib/canvas/canvas-image-layer-plan";
import { listGenerationTasks, queryGenerationTask } from "@/services/api/task-center";
import { getImageBlob } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
/** 列表只有安全摘要,按源节点筛选后通过已有鉴权详情接口读取计划。 */
export async function readImageLayerPlanningHistory(projectId: string, sourceNodeId: string, sourceStorageKey: string | undefined, signal: AbortSignal) {
const tasks = await listGenerationTasks(50, { projectId }, undefined, signal);
const candidates = tasks
.filter((task) => task.projectId === projectId && task.type === "canvas_text" && task.status === "succeeded" && task.clientContext?.nodeId?.startsWith(`${sourceNodeId}-layer-plan-`))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
for (const summary of candidates) {
signal.throwIfAborted();
const task = await queryGenerationTask(summary.id, { signal });
signal.throwIfAborted();
const plan = latestImageLayerPlan([task], projectId, sourceNodeId, sourceStorageKey);
if (plan) return plan;
}
throw new Error("最近 50 条任务中没有这张源图的可用规划,请重新识图或关闭规划后手工填写目标");
}
/** 只预处理识图输入,最终拆层仍使用完整源图;读取和上传沿用账号资源链路。 */
export async function prepareImageLayerPlanningReference(source: ReferenceImage, input: "preview" | "original", signal: AbortSignal) {
signal.throwIfAborted();
let blob = source.storageKey ? await getImageBlob(source.storageKey) : null;
signal.throwIfAborted();
if (!blob) {
const response = await fetch(source.url || source.dataUrl, { signal });
if (!response.ok) throw new Error("无法读取识图源图,请重新上传图片");
blob = await response.blob();
}
signal.throwIfAborted();
const bitmap = await createImageBitmap(blob);
try {
signal.throwIfAborted();
const sourceSize = { width: bitmap.width, height: bitmap.height };
if (!bitmap.width || !bitmap.height || bitmap.width * bitmap.height > MAX_LAYER_PIXELS) throw new Error("识图源图尺寸无效或超过处理上限");
if (input === "original" || Math.max(bitmap.width, bitmap.height) <= 1000) return { reference: { ...source, ...sourceSize }, sourceSize };
const ratio = 1000 / Math.max(bitmap.width, bitmap.height);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * ratio));
canvas.height = Math.max(1, Math.round(bitmap.height * ratio));
try {
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器不支持识图预览,请选择原图输入");
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const type = blob.type === "image/jpeg" ? "image/jpeg" : "image/png";
const dataUrl = canvas.toDataURL(type, 0.92);
if (!dataUrl.startsWith(`data:${type};base64,`)) throw new Error("识图预览编码失败,请选择原图输入");
signal.throwIfAborted();
const reference: ReferenceImage = { id: source.id, name: `layer-plan-${source.id}.${type === "image/jpeg" ? "jpg" : "png"}`, type, dataUrl, width: canvas.width, height: canvas.height };
return { reference, sourceSize };
} finally {
canvas.width = canvas.height = 0;
}
} finally {
bitmap.close();
}
}

View File

@@ -0,0 +1,158 @@
import { inspectLayerAlpha, validateImageLayerRole, validateImageLayerOutput } from "@/lib/canvas/canvas-image-layers";
import { validateImageLayerRegion, type ImageLayerBackgroundPatch, type ImageLayerExtraction, type ImageLayerRegion } from "@/lib/canvas/canvas-image-layer-strategy";
import { decodeLayer, pngBlob, type LayerInput } from "@/services/canvas-image-layer-compositor";
import { imageLayerAlphaBounds, suggestImageLayerRegion } from "@/lib/canvas/canvas-image-layer-region";
import type { ImageLayerPlan } from "@/lib/canvas/canvas-image-layer-plan";
function regionPath(context: CanvasRenderingContext2D, region: ImageLayerRegion, width: number, height: number) {
const { bbox, shape, radiusRatio } = validateImageLayerRegion(region);
const [left, top, right, bottom] = bbox.map((n, i) => Math.round((n * (i % 2 ? height : width)) / 1000));
const w = right - left,
h = bottom - top;
if (w < 1 || h < 1) throw new Error("原图提取区域小于一个像素");
if (shape === "ellipse") {
context.moveTo(right, top + h / 2);
context.ellipse(left + w / 2, top + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
context.closePath();
} else if (shape === "rounded-rect") context.roundRect(left, top, w, h, Math.min(w, h) * radiusRatio!);
else context.rect(left, top, w, h);
}
async function encode(canvas: HTMLCanvasElement) {
const blob = await pngBlob(canvas);
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("无法读取原图提取 PNG"));
reader.readAsDataURL(blob);
});
return { dataUrl, width: canvas.width, height: canvas.height, bytes: blob.size, mimeType: "image/png" };
}
/** 生成独立设计素材,保持参与原位置合成的图层不变。 */
export async function cropImageLayerMaterial(source: LayerInput) {
const decoded = await decodeLayer(source);
const canvas = document.createElement("canvas");
try {
canvas.width = decoded.info.width;
canvas.height = decoded.info.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("浏览器不支持独立素材提取");
context.drawImage(decoded.bitmap, 0, 0);
const bounds = imageLayerAlphaBounds(canvas.width, canvas.height, context.getImageData(0, 0, canvas.width, canvas.height).data);
canvas.width = bounds.width;
canvas.height = bounds.height;
context.drawImage(decoded.bitmap, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, bounds.width, bounds.height);
return { ...(await encode(canvas)), bounds };
} finally {
decoded.bitmap.close();
canvas.width = canvas.height = 0;
}
}
export async function calibrateImageLayerRegions(source: LayerInput, regions: ImageLayerRegion[]) {
const decoded = await decodeLayer(source);
const canvas = document.createElement("canvas");
try {
const scale = Math.min(1, 1200 / Math.max(decoded.info.width, decoded.info.height));
canvas.width = Math.round(decoded.info.width * scale);
canvas.height = Math.round(decoded.info.height * scale);
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("浏览器不支持区域边界校准");
context.drawImage(decoded.bitmap, 0, 0, canvas.width, canvas.height);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
return regions.map((region) => suggestImageLayerRegion(canvas.width, canvas.height, pixels, region));
} finally {
decoded.bitmap.close();
canvas.width = canvas.height = 0;
}
}
export async function calibrateImageLayerPlan(source: LayerInput, plan: ImageLayerPlan): Promise<ImageLayerPlan> {
const indices = plan.layers.flatMap((layer, i) => (layer.extraction?.method === "source-region" && layer.extraction.region ? [i] : []));
if (!indices.length) return plan;
const regions = indices.map((i) => plan.layers[i].extraction!.region!);
let proposals: Array<ImageLayerRegion | undefined>;
try {
proposals = await calibrateImageLayerRegions(source, regions);
} catch {
return { ...plan, warnings: [...(plan.warnings || []), "规则边界校准不可用,保留已有规划;请核对预览并框选。没有新增模型调用。"] };
}
const layers = plan.layers.map((layer) => ({ ...layer }));
const warnings = [...(plan.warnings || [])];
indices.forEach((index, n) => {
const region = proposals[n];
// 边界校准不能扩展到其他已规划的面板。
const crossesPanel =
region &&
regions.some((original, j) => {
const other = proposals[j] || original;
return j !== n && Math.min(region.bbox[2], other.bbox[2]) - Math.max(region.bbox[0], other.bbox[0]) > 2 && Math.min(region.bbox[3], other.bbox[3]) - Math.max(region.bbox[1], other.bbox[1]) > 2;
});
if (region && !crossesPanel) {
layers[index] = { ...layers[index], bbox: region.bbox, extraction: { method: "source-region", region } };
warnings.push(`“${layers[index].name}”已按规则区域边缘校准,请核对完整场景预览;校准不是精确分割。`);
} else warnings.push(`“${layers[index].name}”未找到可靠的规则边界,请核对预览并框选;没有新增模型调用。`);
});
return { ...plan, layers, warnings };
}
/** 按原坐标复制源图像素,不裁切或重新居中输出画布。 */
export async function extractImageLayerFromSource(source: LayerInput, extraction: ImageLayerExtraction, target?: { width: number; height: number }) {
if (extraction.method === "generate" || (extraction.method === "source-region" && !extraction.region)) throw new Error("原图提取方式或区域缺失");
if (extraction.region) validateImageLayerRegion(extraction.region);
const decoded = await decodeLayer(source);
const canvas = document.createElement("canvas");
try {
const { width, height } = decoded.info;
if (target && (width !== target.width || height !== target.height)) throw new Error("原图资源尺寸已变化,请重新规划");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("浏览器不支持原图图层提取");
if (extraction.method === "source-region") {
context.beginPath();
regionPath(context, extraction.region!, width, height);
context.clip();
}
context.drawImage(decoded.bitmap, 0, 0);
const info = inspectLayerAlpha(width, height, context.getImageData(0, 0, width, height).data);
validateImageLayerOutput(info);
return await encode(canvas);
} finally {
decoded.bitmap.close();
canvas.width = canvas.height = 0;
}
}
/** 选定区域外保留原图,仅在区域内使用生成的修补结果。 */
export async function patchImageLayerBackground(generated: LayerInput, patch: ImageLayerBackgroundPatch) {
if (!patch.regions.length || patch.regions.length > 7) throw new Error("底图修补区域无效");
patch.regions.forEach(validateImageLayerRegion);
const source = await decodeLayer(patch.source);
const canvas = document.createElement("canvas");
try {
validateImageLayerRole(source.info, 0);
const result = await decodeLayer(generated);
try {
validateImageLayerOutput(result.info);
const { width, height } = source.info;
if (result.info.width !== width || result.info.height !== height) throw new Error("底图修补结果与原图尺寸不一致");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器不支持底图局部修补");
context.drawImage(source.bitmap, 0, 0);
context.beginPath();
patch.regions.forEach((region) => regionPath(context, region, width, height));
context.clip();
context.drawImage(result.bitmap, 0, 0);
return await encode(canvas);
} finally {
result.bitmap.close();
}
} finally {
source.bitmap.close();
canvas.width = canvas.height = 0;
}
}

View File

@@ -1,4 +1,4 @@
import { canvasNodeToAsset, declaredCanvasNodeAssetCategory, findCanvasNodeAsset, type CanvasAssetSource } from "@/lib/canvas/canvas-node-asset";
import { canvasNodeToAsset, declaredCanvasNodeAssetCategory, findCanvasNodeAsset, requiresMaterializedCanvasAsset, type CanvasAssetSource } from "@/lib/canvas/canvas-node-asset";
import { canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { readImageMeta } from "@/lib/image-utils";
import { parseBackendGenerationResult, type BackendGenerationResult } from "@/services/api/generation-task";
@@ -86,7 +86,8 @@ export async function retryCanvasAssetSyncAfterRateLimit<T>(operation: () => Pro
export function ensureCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions) {
const scope = getActiveUserScope();
const identity = options.taskId || options.node.metadata?.taskId || options.node.metadata?.storageKey || options.node.id;
const taskIdentity = options.taskId || options.node.metadata?.taskId || options.node.metadata?.storageKey || options.node.id;
const identity = options.node.metadata?.imageLayerGroup || options.node.metadata?.layerExtraction ? `${taskIdentity}:${options.node.metadata.storageKey || ""}` : taskIdentity;
const key = [scope, options.domainProjectId || "personal", options.canvasId, options.node.id, identity].join(":");
const pending = pendingAssetSyncs.get(key);
if (pending) return pending;
@@ -108,7 +109,7 @@ async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Pr
let created = false;
if (!asset) {
// 受管生成结果必须采用已登记的身份,不能以随机 ID 再建一份。
if ((options.taskId || options.node.metadata?.taskId) && options.node.metadata?.storageKey?.startsWith("resource:")) throw new Error("生成素材尚未加载,请重新读取生成结果");
if (requiresMaterializedCanvasAsset(options.node, options.taskId)) throw new Error("生成素材尚未加载,请重新读取生成结果");
const input = canvasNodeToAsset(options.node, { canvasId: options.canvasId, source: options.source, taskId: options.taskId });
if (!input) throw new Error("当前节点没有可保存的素材内容");
const assetId = store.addAsset(options.category ? { ...input, category: options.category } : input);