feat(canvas): AI 拆图 - 支持图层规划编辑与素材复用
This commit is contained in:
142
web/src/services/canvas-image-layer-compositor.ts
Normal file
142
web/src/services/canvas-image-layer-compositor.ts
Normal 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;
|
||||
}
|
||||
114
web/src/services/canvas-image-layer-extraction.ts
Normal file
114
web/src/services/canvas-image-layer-extraction.ts
Normal 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(";")}`);
|
||||
}
|
||||
60
web/src/services/canvas-image-layer-planning.ts
Normal file
60
web/src/services/canvas-image-layer-planning.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
158
web/src/services/canvas-image-layer-source.ts
Normal file
158
web/src/services/canvas-image-layer-source.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user