703 lines
22 KiB
TypeScript
703 lines
22 KiB
TypeScript
import "server-only";
|
|
|
|
import { and, eq } from "drizzle-orm";
|
|
import {
|
|
analyzeReplacementExecutionPlan,
|
|
buildAgentReplacementPrompt,
|
|
createAgentImageThumbnailBuffer,
|
|
normalizeReplacementResultImage,
|
|
resolveDirectReplacementModelRef,
|
|
resolveReplacementReferenceGeometry,
|
|
resolveReplacementScenePlanningRuntime,
|
|
type AgentDetailReplacementStrategy,
|
|
} from "@/app/(chat)/api/chat/_lib/detail-replacement";
|
|
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
|
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
|
import { db } from "@/lib/db";
|
|
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
|
import { generatedImage } from "@/lib/db/schema";
|
|
import {
|
|
generateCanvasImage,
|
|
type CanvasGenerateImageResult,
|
|
} from "@/lib/canvas/generate-image";
|
|
import { uploadFile } from "@/lib/storage/service";
|
|
|
|
export interface CanvasLayoutReplaceTaskRequest {
|
|
taskKind: "canvas-layout-replace";
|
|
sourceImageUrl: string;
|
|
referenceImageUrl: string;
|
|
supplementalPrompt?: string;
|
|
model?: string;
|
|
imageSize?: string;
|
|
sourceNodeId?: string;
|
|
agentProjectId?: string;
|
|
referenceLabel?: string;
|
|
debugMeta?: Record<string, unknown>;
|
|
batchId?: string;
|
|
batchItemId?: string;
|
|
batchPosition?: number;
|
|
batchTotal?: number;
|
|
canvasProjectId?: string;
|
|
}
|
|
|
|
function normalizeHttpUrl(value: unknown) {
|
|
const url = String(value || "").trim();
|
|
return /^https?:\/\//i.test(url) ? url : "";
|
|
}
|
|
|
|
export function isCanvasLayoutReplaceTaskRequest(
|
|
request: unknown,
|
|
): request is CanvasLayoutReplaceTaskRequest {
|
|
return Boolean(
|
|
request &&
|
|
typeof request === "object" &&
|
|
!Array.isArray(request) &&
|
|
(request as { taskKind?: unknown }).taskKind === "canvas-layout-replace",
|
|
);
|
|
}
|
|
|
|
function normalizeLayoutReplacePromptSupplement(value: unknown) {
|
|
return String(value || "")
|
|
.trim()
|
|
.replace(/\s+/g, " ")
|
|
.slice(0, 600);
|
|
}
|
|
|
|
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
|
return buffer.buffer.slice(
|
|
buffer.byteOffset,
|
|
buffer.byteOffset + buffer.byteLength,
|
|
) as ArrayBuffer;
|
|
}
|
|
|
|
async function saveLayoutReplaceResultToGallery(params: {
|
|
request: CanvasLayoutReplaceTaskRequest;
|
|
result: CanvasGenerateImageResult;
|
|
userId: string;
|
|
}) {
|
|
const image = params.result.images[0];
|
|
const originalUrl = normalizeHttpUrl(image?.originalUrl || image?.thumbnailUrl);
|
|
const thumbnailUrl = normalizeHttpUrl(image?.thumbnailUrl || image?.originalUrl);
|
|
if (!originalUrl || !thumbnailUrl) return;
|
|
|
|
const [existing] = await db
|
|
.select({ id: generatedImage.id })
|
|
.from(generatedImage)
|
|
.where(
|
|
and(
|
|
eq(generatedImage.userId, params.userId),
|
|
eq(generatedImage.originalUrl, originalUrl)
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (existing) return;
|
|
|
|
const imageSize = params.request.imageSize || "2K";
|
|
const prompt = params.result.prompt || "AI 模版替换结果";
|
|
const modelId = params.result.modelRef || params.result.model || "canvas-layout-replace";
|
|
await saveGeneratedImages(
|
|
``,
|
|
params.userId,
|
|
null,
|
|
null,
|
|
prompt,
|
|
modelId,
|
|
{
|
|
sourceImageUrls: [params.request.sourceImageUrl],
|
|
relationType: "edited_from",
|
|
sourceType: "canvas_layout_replace",
|
|
projectId: params.request.canvasProjectId || null,
|
|
userPrompt: params.request.supplementalPrompt || "AI 模版替换",
|
|
optimizedPrompt: prompt,
|
|
requestedImageCount: 1,
|
|
imageSize,
|
|
skipBilling: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
function selectLayoutPatchMask(params: {
|
|
generated: Buffer;
|
|
height: number;
|
|
reference: Buffer;
|
|
width: number;
|
|
}) {
|
|
const { generated, height, reference, width } = params;
|
|
const pixelCount = width * height;
|
|
const rawMask = new Uint8Array(pixelCount);
|
|
const diffThreshold = 34;
|
|
|
|
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
|
const offset = pixelIndex * 3;
|
|
const diff = Math.max(
|
|
Math.abs((generated[offset] || 0) - (reference[offset] || 0)),
|
|
Math.abs((generated[offset + 1] || 0) - (reference[offset + 1] || 0)),
|
|
Math.abs((generated[offset + 2] || 0) - (reference[offset + 2] || 0)),
|
|
);
|
|
if (diff >= diffThreshold) {
|
|
rawMask[pixelIndex] = 1;
|
|
}
|
|
}
|
|
|
|
const visited = new Uint8Array(pixelCount);
|
|
const queue = new Int32Array(pixelCount);
|
|
const components: Array<{
|
|
area: number;
|
|
maxX: number;
|
|
maxY: number;
|
|
minX: number;
|
|
minY: number;
|
|
}> = [];
|
|
|
|
for (let start = 0; start < pixelCount; start += 1) {
|
|
if (!rawMask[start] || visited[start]) continue;
|
|
let head = 0;
|
|
let tail = 0;
|
|
let area = 0;
|
|
let minX = width;
|
|
let minY = height;
|
|
let maxX = 0;
|
|
let maxY = 0;
|
|
queue[tail] = start;
|
|
tail += 1;
|
|
visited[start] = 1;
|
|
|
|
while (head < tail) {
|
|
const current = queue[head];
|
|
head += 1;
|
|
area += 1;
|
|
const x = current % width;
|
|
const y = Math.floor(current / width);
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
|
|
const left = current - 1;
|
|
const right = current + 1;
|
|
const up = current - width;
|
|
const down = current + width;
|
|
if (x > 0 && rawMask[left] && !visited[left]) {
|
|
visited[left] = 1;
|
|
queue[tail] = left;
|
|
tail += 1;
|
|
}
|
|
if (x < width - 1 && rawMask[right] && !visited[right]) {
|
|
visited[right] = 1;
|
|
queue[tail] = right;
|
|
tail += 1;
|
|
}
|
|
if (y > 0 && rawMask[up] && !visited[up]) {
|
|
visited[up] = 1;
|
|
queue[tail] = up;
|
|
tail += 1;
|
|
}
|
|
if (y < height - 1 && rawMask[down] && !visited[down]) {
|
|
visited[down] = 1;
|
|
queue[tail] = down;
|
|
tail += 1;
|
|
}
|
|
}
|
|
|
|
components.push({ area, maxX, maxY, minX, minY });
|
|
}
|
|
|
|
const minArea = Math.max(900, Math.round(pixelCount * 0.0015));
|
|
const minBoxArea = Math.max(3200, Math.round(pixelCount * 0.008));
|
|
const candidates = components
|
|
.filter((component) => {
|
|
const boxArea =
|
|
(component.maxX - component.minX + 1) *
|
|
(component.maxY - component.minY + 1);
|
|
return component.area >= minArea && boxArea >= minBoxArea;
|
|
})
|
|
.sort((left, right) => right.area - left.area);
|
|
const primary = candidates[0];
|
|
if (!primary) {
|
|
return null;
|
|
}
|
|
|
|
const selected = candidates.filter((component, index) => {
|
|
if (index === 0) return true;
|
|
if (component.area >= primary.area * 0.18) return true;
|
|
const expandedPrimary = {
|
|
maxX: Math.min(width - 1, primary.maxX + Math.round(width * 0.05)),
|
|
maxY: Math.min(height - 1, primary.maxY + Math.round(height * 0.05)),
|
|
minX: Math.max(0, primary.minX - Math.round(width * 0.05)),
|
|
minY: Math.max(0, primary.minY - Math.round(height * 0.05)),
|
|
};
|
|
return !(
|
|
component.maxX < expandedPrimary.minX ||
|
|
component.minX > expandedPrimary.maxX ||
|
|
component.maxY < expandedPrimary.minY ||
|
|
component.minY > expandedPrimary.maxY
|
|
);
|
|
});
|
|
|
|
const selectedMask = new Uint8Array(pixelCount);
|
|
for (const component of selected) {
|
|
for (let y = component.minY; y <= component.maxY; y += 1) {
|
|
const rowOffset = y * width;
|
|
for (let x = component.minX; x <= component.maxX; x += 1) {
|
|
const index = rowOffset + x;
|
|
if (rawMask[index]) selectedMask[index] = 255;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
mask: selectedMask,
|
|
maskSource: "generated-diff",
|
|
selectedComponentCount: selected.length,
|
|
selectedPixelCount: selected.reduce((sum, component) => sum + component.area, 0),
|
|
};
|
|
}
|
|
|
|
function selectReferenceProductMask(params: {
|
|
height: number;
|
|
reference: Buffer;
|
|
width: number;
|
|
}) {
|
|
const { height, reference, width } = params;
|
|
const pixelCount = width * height;
|
|
const rawMask = new Uint8Array(pixelCount);
|
|
|
|
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
|
const offset = pixelIndex * 3;
|
|
const red = reference[offset] || 0;
|
|
const green = reference[offset + 1] || 0;
|
|
const blue = reference[offset + 2] || 0;
|
|
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
const channelSpan = Math.max(red, green, blue) - Math.min(red, green, blue);
|
|
if (luminance <= 222 || channelSpan >= 38) {
|
|
rawMask[pixelIndex] = 1;
|
|
}
|
|
}
|
|
|
|
const visited = new Uint8Array(pixelCount);
|
|
const queue = new Int32Array(pixelCount);
|
|
const components: Array<{
|
|
area: number;
|
|
boxArea: number;
|
|
maxX: number;
|
|
maxY: number;
|
|
minX: number;
|
|
minY: number;
|
|
}> = [];
|
|
|
|
for (let start = 0; start < pixelCount; start += 1) {
|
|
if (!rawMask[start] || visited[start]) continue;
|
|
let head = 0;
|
|
let tail = 0;
|
|
let area = 0;
|
|
let minX = width;
|
|
let minY = height;
|
|
let maxX = 0;
|
|
let maxY = 0;
|
|
queue[tail] = start;
|
|
tail += 1;
|
|
visited[start] = 1;
|
|
|
|
while (head < tail) {
|
|
const current = queue[head];
|
|
head += 1;
|
|
area += 1;
|
|
const x = current % width;
|
|
const y = Math.floor(current / width);
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
|
|
const left = current - 1;
|
|
const right = current + 1;
|
|
const up = current - width;
|
|
const down = current + width;
|
|
if (x > 0 && rawMask[left] && !visited[left]) {
|
|
visited[left] = 1;
|
|
queue[tail] = left;
|
|
tail += 1;
|
|
}
|
|
if (x < width - 1 && rawMask[right] && !visited[right]) {
|
|
visited[right] = 1;
|
|
queue[tail] = right;
|
|
tail += 1;
|
|
}
|
|
if (y > 0 && rawMask[up] && !visited[up]) {
|
|
visited[up] = 1;
|
|
queue[tail] = up;
|
|
tail += 1;
|
|
}
|
|
if (y < height - 1 && rawMask[down] && !visited[down]) {
|
|
visited[down] = 1;
|
|
queue[tail] = down;
|
|
tail += 1;
|
|
}
|
|
}
|
|
|
|
const boxArea = (maxX - minX + 1) * (maxY - minY + 1);
|
|
components.push({ area, boxArea, maxX, maxY, minX, minY });
|
|
}
|
|
|
|
const minArea = Math.max(1400, Math.round(pixelCount * 0.002));
|
|
const minBoxArea = Math.max(6000, Math.round(pixelCount * 0.012));
|
|
const candidates = components
|
|
.filter((component) => {
|
|
const boxWidth = component.maxX - component.minX + 1;
|
|
const boxHeight = component.maxY - component.minY + 1;
|
|
const aspect = boxWidth / Math.max(1, boxHeight);
|
|
const centerY = (component.minY + component.maxY) / 2;
|
|
const looksLikeCtaButton = aspect >= 6 && boxHeight <= height * 0.08;
|
|
const looksLikeBottomUtility = centerY >= height * 0.78 && aspect >= 3.5;
|
|
return (
|
|
component.area >= minArea &&
|
|
component.boxArea >= minBoxArea &&
|
|
!looksLikeCtaButton &&
|
|
!looksLikeBottomUtility
|
|
);
|
|
})
|
|
.sort((left, right) => right.area - left.area);
|
|
const primary = candidates[0];
|
|
if (!primary) {
|
|
return null;
|
|
}
|
|
|
|
const selected = candidates.filter((component, index) => {
|
|
if (index === 0) return true;
|
|
if (component.area >= primary.area * 0.22) return true;
|
|
const expandedPrimary = {
|
|
maxX: Math.min(width - 1, primary.maxX + Math.round(width * 0.08)),
|
|
maxY: Math.min(height - 1, primary.maxY + Math.round(height * 0.08)),
|
|
minX: Math.max(0, primary.minX - Math.round(width * 0.08)),
|
|
minY: Math.max(0, primary.minY - Math.round(height * 0.08)),
|
|
};
|
|
return !(
|
|
component.maxX < expandedPrimary.minX ||
|
|
component.minX > expandedPrimary.maxX ||
|
|
component.maxY < expandedPrimary.minY ||
|
|
component.minY > expandedPrimary.maxY
|
|
);
|
|
});
|
|
|
|
const selectedMask = new Uint8Array(pixelCount);
|
|
for (const component of selected) {
|
|
for (let y = component.minY; y <= component.maxY; y += 1) {
|
|
const rowOffset = y * width;
|
|
for (let x = component.minX; x <= component.maxX; x += 1) {
|
|
const index = rowOffset + x;
|
|
if (rawMask[index]) selectedMask[index] = 255;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
mask: selectedMask,
|
|
maskSource: "reference-product",
|
|
selectedComponentCount: selected.length,
|
|
selectedPixelCount: selected.reduce((sum, component) => sum + component.area, 0),
|
|
};
|
|
}
|
|
|
|
async function buildLayoutLockedCompositeImage(params: {
|
|
generatedImageUrl: string;
|
|
referenceGeometry: { height: number; width: number };
|
|
referenceImageUrl: string;
|
|
}) {
|
|
const sharp = (await import("sharp")).default;
|
|
const [referenceSourceBuffer, generatedSourceBuffer] = await Promise.all([
|
|
fetchImageBufferFromUrl(params.referenceImageUrl),
|
|
fetchImageBufferFromUrl(params.generatedImageUrl),
|
|
]);
|
|
const targetWidth = params.referenceGeometry.width;
|
|
const targetHeight = params.referenceGeometry.height;
|
|
const referenceBuffer = await sharp(referenceSourceBuffer, { failOn: "none" })
|
|
.rotate()
|
|
.resize(targetWidth, targetHeight, {
|
|
fit: "fill",
|
|
withoutEnlargement: false,
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
const generatedBuffer = await sharp(generatedSourceBuffer, { failOn: "none" })
|
|
.rotate()
|
|
.resize(targetWidth, targetHeight, {
|
|
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
|
fit: "contain",
|
|
position: "centre",
|
|
withoutEnlargement: false,
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
const [referenceRaw, generatedRaw] = await Promise.all([
|
|
sharp(referenceBuffer, { failOn: "none" })
|
|
.removeAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true }),
|
|
sharp(generatedBuffer, { failOn: "none" })
|
|
.removeAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true }),
|
|
]);
|
|
if (
|
|
referenceRaw.info.width !== generatedRaw.info.width ||
|
|
referenceRaw.info.height !== generatedRaw.info.height
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const selectedMask =
|
|
selectReferenceProductMask({
|
|
height: referenceRaw.info.height,
|
|
reference: referenceRaw.data,
|
|
width: referenceRaw.info.width,
|
|
}) ||
|
|
selectLayoutPatchMask({
|
|
generated: generatedRaw.data,
|
|
height: referenceRaw.info.height,
|
|
reference: referenceRaw.data,
|
|
width: referenceRaw.info.width,
|
|
});
|
|
if (!selectedMask) {
|
|
return null;
|
|
}
|
|
|
|
const usesReferenceProductMask = selectedMask.maskSource === "reference-product";
|
|
const firstBlurRadius = usesReferenceProductMask ? 14 : 10;
|
|
const maskThreshold = usesReferenceProductMask ? 12 : 2;
|
|
const pixelCount = targetWidth * targetHeight;
|
|
const alphaMask = await sharp(selectedMask.mask, {
|
|
raw: {
|
|
channels: 1,
|
|
height: targetHeight,
|
|
width: targetWidth,
|
|
},
|
|
})
|
|
.blur(firstBlurRadius)
|
|
.threshold(maskThreshold)
|
|
.blur(4)
|
|
.extractChannel(0)
|
|
.raw()
|
|
.toBuffer();
|
|
if (alphaMask.length !== pixelCount) {
|
|
throw new Error(
|
|
`排版替换蒙版通道异常: expected ${pixelCount}, got ${alphaMask.length}`,
|
|
);
|
|
}
|
|
const generatedPatchRaw = Buffer.alloc(pixelCount * 4);
|
|
const sourceChannels = generatedRaw.info.channels;
|
|
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
|
const sourceOffset = pixelIndex * sourceChannels;
|
|
const targetOffset = pixelIndex * 4;
|
|
generatedPatchRaw[targetOffset] = generatedRaw.data[sourceOffset] || 0;
|
|
generatedPatchRaw[targetOffset + 1] =
|
|
generatedRaw.data[sourceOffset + 1] || 0;
|
|
generatedPatchRaw[targetOffset + 2] =
|
|
generatedRaw.data[sourceOffset + 2] || 0;
|
|
generatedPatchRaw[targetOffset + 3] = alphaMask[pixelIndex] || 0;
|
|
}
|
|
const generatedPatch = await sharp(generatedPatchRaw, {
|
|
raw: {
|
|
channels: 4,
|
|
height: targetHeight,
|
|
width: targetWidth,
|
|
},
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
const compositeBuffer = await sharp(referenceBuffer, { failOn: "none" })
|
|
.composite([{ input: generatedPatch }])
|
|
.png()
|
|
.toBuffer();
|
|
|
|
return {
|
|
buffer: compositeBuffer,
|
|
height: targetHeight,
|
|
maskSource: selectedMask.maskSource,
|
|
selectedComponentCount: selectedMask.selectedComponentCount,
|
|
selectedPixelCount: selectedMask.selectedPixelCount,
|
|
width: targetWidth,
|
|
};
|
|
}
|
|
|
|
export async function executeCanvasLayoutReplaceTask(params: {
|
|
request: CanvasLayoutReplaceTaskRequest;
|
|
userId: string;
|
|
}): Promise<CanvasGenerateImageResult> {
|
|
const sourceImageUrl = normalizeHttpUrl(params.request.sourceImageUrl);
|
|
const referenceImageUrl = normalizeHttpUrl(params.request.referenceImageUrl);
|
|
if (!sourceImageUrl) {
|
|
throw new Error("缺少当前产品图片");
|
|
}
|
|
if (!referenceImageUrl) {
|
|
throw new Error("缺少排版参考图");
|
|
}
|
|
|
|
const models = await getEnabledAiModelsWithProvider().catch((error) => {
|
|
console.warn("[Canvas Layout Replace] Failed to load models:", error);
|
|
return undefined;
|
|
});
|
|
const resolvedModelRef = await resolveDirectReplacementModelRef({
|
|
selectedChatModel: params.request.model,
|
|
finalModelId: params.request.model,
|
|
models,
|
|
});
|
|
if (!resolvedModelRef || resolvedModelRef === "auto") {
|
|
throw new Error("当前风格化未配置可用的生图模型");
|
|
}
|
|
|
|
const replacementStrategy: AgentDetailReplacementStrategy = "product_only";
|
|
const referenceLabel =
|
|
String(params.request.referenceLabel || "").trim() || "画布排版参考图";
|
|
const referenceGeometry =
|
|
await resolveReplacementReferenceGeometry(referenceImageUrl);
|
|
const planningRuntime = await resolveReplacementScenePlanningRuntime({ models });
|
|
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
|
|
modeType: "detail_replacement",
|
|
replacementStrategy,
|
|
referenceImageUrl,
|
|
referenceLabel,
|
|
productImageUrls: [sourceImageUrl],
|
|
modelImageUrl: null,
|
|
workflowPrompt: null,
|
|
planningRuntime,
|
|
models,
|
|
});
|
|
|
|
const basePrompt = "仅替换参考详情图中的产品主体";
|
|
const replacementPrompt = buildAgentReplacementPrompt({
|
|
basePrompt,
|
|
modeType: "detail_replacement",
|
|
replacementStrategy,
|
|
marketplaceProductDetailReference: false,
|
|
referenceLabel,
|
|
referenceIndex: 0,
|
|
totalReferences: 1,
|
|
hasModelImage: false,
|
|
productImageCount: 1,
|
|
originalProductImageCount: 1,
|
|
rerunReferenceCount: 0,
|
|
templateReferenceCount: 0,
|
|
scenePlan: replacementExecutionPlan.scenePlan,
|
|
referenceGeometry,
|
|
});
|
|
const supplement = normalizeLayoutReplacePromptSupplement(
|
|
params.request.supplementalPrompt,
|
|
);
|
|
const finalPrompt = [
|
|
replacementPrompt,
|
|
supplement
|
|
? `【画布排版替换补充】${supplement}。这段只用于理解新产品材质、颜色、结构或替换细节;不得改写、移动、删除、重排参考图中的任何文字、图标、按钮、标签、分割线、装饰、留白或页面结构。`
|
|
: "",
|
|
"【最终验收标准】输出是一张与最后一张参考图相同页面比例的单页结果;文字、图标、按钮、标签、分割线、品牌区、留白和信息层级必须视觉上与参考图一致,只允许旧产品主体/产品展示区被当前产品替换。",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
|
|
const generated = await generateCanvasImage({
|
|
prompt: finalPrompt,
|
|
displayPrompt: "排版替换结果",
|
|
model: resolvedModelRef,
|
|
ownerKey: params.userId,
|
|
aspectRatio: referenceGeometry?.aspectRatio || "auto",
|
|
imageSize: params.request.imageSize || "2K",
|
|
imageCount: 1,
|
|
promptExtend: false,
|
|
referenceImages: [sourceImageUrl, referenceImageUrl],
|
|
debugLabel: "canvas-layout-replace",
|
|
debugMeta: {
|
|
...(params.request.debugMeta || {}),
|
|
operation: "layout-replace",
|
|
layoutReplaceServerAlgorithm: "agent-detail-replacement/product_only",
|
|
sourceNodeId: params.request.sourceNodeId || null,
|
|
referenceWidth: referenceGeometry?.width,
|
|
referenceHeight: referenceGeometry?.height,
|
|
referenceAspectRatio: referenceGeometry?.aspectRatio,
|
|
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
|
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
|
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
|
},
|
|
});
|
|
const firstImage = generated.images[0];
|
|
if (!firstImage?.originalUrl) {
|
|
throw new Error("排版替换未返回结果图片");
|
|
}
|
|
|
|
let normalizedImage = await normalizeReplacementResultImage({
|
|
generatedImageUrl: firstImage.originalUrl,
|
|
generatedThumbnailUrl: firstImage.thumbnailUrl,
|
|
referenceImageUrl,
|
|
referenceGeometry,
|
|
scenePlan: replacementExecutionPlan.scenePlan,
|
|
uploadPrefix: "canvas-layout-replace",
|
|
});
|
|
|
|
if (referenceGeometry) {
|
|
const lockedComposite = await buildLayoutLockedCompositeImage({
|
|
generatedImageUrl: firstImage.originalUrl,
|
|
referenceGeometry,
|
|
referenceImageUrl,
|
|
}).catch((error) => {
|
|
console.warn("[Canvas Layout Replace] Layout locked composite failed:", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
referenceImageUrl,
|
|
resultImageUrl: firstImage.originalUrl,
|
|
});
|
|
return null;
|
|
});
|
|
|
|
if (lockedComposite?.buffer) {
|
|
const thumbnailBuffer = await createAgentImageThumbnailBuffer(
|
|
lockedComposite.buffer,
|
|
);
|
|
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const [originalUpload, thumbnailUpload] = await Promise.all([
|
|
uploadFile(
|
|
`canvas-layout-replace-locked-orig-${suffix}.png`,
|
|
bufferToArrayBuffer(lockedComposite.buffer),
|
|
),
|
|
uploadFile(
|
|
`canvas-layout-replace-locked-thumb-${suffix}.jpg`,
|
|
bufferToArrayBuffer(thumbnailBuffer),
|
|
),
|
|
]);
|
|
normalizedImage = {
|
|
originalUrl: originalUpload.url,
|
|
thumbnailUrl: thumbnailUpload.url || originalUpload.url,
|
|
imageSize: `${lockedComposite.width}x${lockedComposite.height}`,
|
|
};
|
|
console.info("[Canvas Layout Replace] Applied layout locked composite:", {
|
|
maskSource: lockedComposite.maskSource,
|
|
referenceWidth: lockedComposite.width,
|
|
referenceHeight: lockedComposite.height,
|
|
selectedComponentCount: lockedComposite.selectedComponentCount,
|
|
selectedPixelCount: lockedComposite.selectedPixelCount,
|
|
});
|
|
}
|
|
}
|
|
|
|
const result: CanvasGenerateImageResult = {
|
|
images: [
|
|
{
|
|
originalUrl: normalizedImage.originalUrl,
|
|
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
|
|
},
|
|
],
|
|
model: generated.model,
|
|
modelRef: generated.modelRef,
|
|
prompt: generated.prompt,
|
|
};
|
|
|
|
await saveLayoutReplaceResultToGallery({
|
|
request: params.request,
|
|
result,
|
|
userId: params.userId,
|
|
}).catch((error) => {
|
|
console.error("[Canvas Layout Replace] Failed to save result to gallery:", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
batchId: params.request.batchId,
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}
|