fix: stabilize detail replacement page layout

This commit is contained in:
Your Name 2026-06-02 20:42:59 +08:00
parent 29930f3c14
commit 1a97f56169
4 changed files with 179 additions and 15 deletions

View File

@ -226,14 +226,24 @@ export async function normalizeReplacementResultImage(params: {
}
const imagePipeline = sharp(generatedBuffer, { failOn: "none" }).rotate();
const targetAspectRatio =
targetWidth && targetHeight ? targetWidth / targetHeight : null;
const generatedAspectRatio =
generatedMetadata.width && generatedMetadata.height
? generatedMetadata.width / generatedMetadata.height
: null;
const shouldCropTallComposite =
Boolean(targetAspectRatio && generatedAspectRatio) &&
Number(generatedAspectRatio) < Number(targetAspectRatio) * 0.78;
const normalizedBuffer =
targetWidth && targetHeight
? await imagePipeline
.resize(targetWidth, targetHeight, {
// Keep the model output proportional when adapting it back to the reference page.
// `fill` stretches the picture and can squash products; `cover` behaves like a screenshot crop.
fit: "cover",
position: "centre",
// Wide outputs need containment to avoid cropping edge text; overly tall outputs
// usually mean the model stacked two page variants, so crop to the top page.
fit: shouldCropTallComposite ? "cover" : "contain",
position: shouldCropTallComposite ? "top" : "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
.png()
@ -1872,6 +1882,8 @@ export function buildAgentReplacementPrompt(params: {
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"文字安全区硬约束:所有标题、正文、编号、标签、按钮、品牌字样和图标必须完整落在画布内,左右至少保留 6% 内边距,上下至少保留 4% 内边距;不得贴边、出血、半截显示、跑出画面或被裁切。",
"单页画布硬约束:只生成当前这一张详情页,不允许把两个页面、两个方案或两张场景图上下拼接,不允许出现明显横向分割线、重复画面、瀑布流长图或截图拼接感。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前产品区域。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张上传参考图可能是同一产品的多角度图,也可能是材质、板材、原材料或细节图;请综合理解结构、材质、颜色、比例、纹理、边角、开孔、五金与细节,不可只参考第一张。`
@ -1919,6 +1931,8 @@ export function buildAgentReplacementPrompt(params: {
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"文字安全区硬约束:所有标题、正文、编号、标签、按钮、品牌字样和图标必须完整落在画布内,左右至少保留 6% 内边距,上下至少保留 4% 内边距;不得贴边、出血、半截显示、跑出画面或被裁切。若参考图原文字靠边,也要在保持层级和相对位置的前提下向内收。",
"单页画布硬约束:只生成当前这一张详情页,不允许把两个页面、两个方案或两张场景图上下拼接,不允许出现明显横向分割线、重复画面、瀑布流长图或截图拼接感。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
shouldApplyPromptPack
? "前面的上传图和模特图用于生成新的背景图/产品场景图/材质视觉内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。"
@ -1969,6 +1983,8 @@ export function buildAgentReplacementPrompt(params: {
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。`
: "输出必须保持当前参考图画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。",
"如果参考图中存在文字、标签或价格信息,必须完整保留在画布安全区内,不得贴边、出血、半截显示或被裁切。",
"只输出一张完整页面,不允许上下拼接两个方案、重复画面、长图瀑布流或明显横向分割线。",
"输出结果必须保持最后一张参考图的原始纵横比与画布尺寸。",
"输出要自然融合,不要改变参考图原有风格和景别。",
];

View File

@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 120;
const DEFAULT_WIDTH = 790;
const DEFAULT_HEIGHT = 1300;
const MAX_DIMENSION = 4096;
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
function normalizeDimension(value: unknown, fallback: number) {
const next = Math.round(Number(value));
if (!Number.isFinite(next) || next <= 0) return fallback;
return Math.min(MAX_DIMENSION, Math.max(64, next));
}
function normalizeFit(value: unknown) {
const fit = String(value || "").trim();
return fit === "contain" ? "contain" : "cover-top";
}
async function createThumbnailBuffer(buffer: Buffer) {
const sharp = (await import("sharp")).default;
return sharp(buffer, { failOn: "none" })
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 82, mozjpeg: true })
.toBuffer();
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const imageUrl = String(body.imageUrl || "").trim();
if (!/^https?:\/\//i.test(imageUrl)) {
return NextResponse.json({ success: false, error: "缺少有效图片地址" }, { status: 400 });
}
const width = normalizeDimension(body.width, DEFAULT_WIDTH);
const height = normalizeDimension(body.height, DEFAULT_HEIGHT);
const fit = normalizeFit(body.fit);
const sourceBuffer = await fetchImageBufferFromUrl(imageUrl);
const sharp = (await import("sharp")).default;
const pipeline = sharp(sourceBuffer, { failOn: "none" }).rotate();
const normalizedBuffer =
fit === "contain"
? await pipeline
.resize(width, height, {
fit: "contain",
position: "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
.png()
.toBuffer()
: await pipeline
.resize(width, height, {
fit: "cover",
position: "top",
withoutEnlargement: false,
})
.png()
.toBuffer();
const thumbnailBuffer = await createThumbnailBuffer(normalizedBuffer);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(`agent-detail-normalized-${suffix}.png`, bufferToArrayBuffer(normalizedBuffer)),
uploadFile(`agent-detail-normalized-thumb-${suffix}.jpg`, bufferToArrayBuffer(thumbnailBuffer)),
]);
return NextResponse.json({
success: true,
data: {
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
imageSize: `${width}x${height}`,
},
});
} catch (error) {
console.error("[Agent Detail Image Normalize] Failed:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "详情图尺寸归一化失败",
},
{ status: 500 },
);
}
}

View File

@ -356,7 +356,9 @@ async function applyStitch(params: {
return sharp(buffer, { failOn: "none" })
.rotate()
.resize(params.width, source.height, {
fit: "fill",
fit: "contain",
position: "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
.jpeg({

View File

@ -738,6 +738,36 @@ async function requestCanvasImageGeneration(
throw new Error(lastError || "图片生成超时,请稍后重试");
}
async function normalizeAgentDetailGeneratedImage(image: {
originalUrl?: string;
thumbnailUrl?: string;
}) {
const imageUrl = String(image.originalUrl || image.thumbnailUrl || "").trim();
if (!imageUrl) {
throw new Error("缺少详情图地址");
}
const response = await fetch("/api/agent-workflow/detail-image-normalize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageUrl,
width: AGENT_DETAIL_TARGET_WIDTH,
height: AGENT_DETAIL_TARGET_HEIGHT,
fit: "cover-top",
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success || !result?.data?.originalUrl) {
throw new Error(result?.error || "详情图尺寸归一化失败");
}
return {
originalUrl: String(result.data.originalUrl || "").trim(),
thumbnailUrl: String(result.data.thumbnailUrl || result.data.originalUrl || "").trim(),
};
}
function buildReferenceCardsFromCandidate(params: {
candidate: AgentModeReferenceCandidate | null | undefined;
target: AgentTaskDetailReferenceSource;
@ -3628,7 +3658,7 @@ export function AgentGenerationWorkspaceDarkV2({
"fallback",
]);
const remainingIds = resultCards
.filter((card) => !processedStatuses.has(card.status))
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
.map((card) => card.id);
const processedCount = resultCards.length - remainingIds.length;
const shouldResume =
@ -3637,7 +3667,7 @@ export function AgentGenerationWorkspaceDarkV2({
return {
targetCards: shouldResume
? references.filter((card) => remainingIds.includes(card.id))
: references,
: references.filter((card) => !card.passthrough),
isResume: shouldResume,
};
},
@ -4671,6 +4701,8 @@ export function AgentGenerationWorkspaceDarkV2({
screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "",
screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "",
`输出为竖版电商详情图,尺寸固定 ${AGENT_DETAIL_TARGET_WIDTH}x${AGENT_DETAIL_TARGET_HEIGHT} 像素。`,
"文字安全区硬约束:所有标题、副标题、正文、标签和数字必须完整落在画布内,左右至少保留 8% 内边距,上下至少保留 5% 内边距,不得贴边、出血、半截显示或跑出画面。",
"单页画布硬约束:只生成一张完整详情页,不允许把两个方案上下拼接,不允许重复同一张场景图,不允许出现明显横向分割线造成上下两段截图感。",
"要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰。",
]
.filter(Boolean)
@ -4779,6 +4811,15 @@ export function AgentGenerationWorkspaceDarkV2({
console.warn("[AgentGenerationWorkspaceDarkV2] AI detail review failed:", reviewError);
}
try {
finalImage = await normalizeAgentDetailGeneratedImage(finalImage);
} catch (normalizeError) {
console.warn(
"[AgentGenerationWorkspaceDarkV2] AI detail image normalize failed:",
normalizeError
);
}
const nextImage: DirectDetailGenerationImage = {
originalUrl: finalImage.originalUrl,
thumbnailUrl: finalImage.thumbnailUrl,
@ -6644,7 +6685,7 @@ export function AgentGenerationWorkspaceDarkV2({
)}
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<div className="relative min-h-[220px] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_55%),linear-gradient(180deg,#0b0d0f_0%,#101316_100%)]">
<div className="relative aspect-[790/1300] overflow-hidden bg-white/95">
<div className="absolute right-3 top-3 z-10 flex flex-col items-end gap-2">
<button
type="button"
@ -6677,7 +6718,7 @@ export function AgentGenerationWorkspaceDarkV2({
<AgentPreviewImage
src={card.imageUrl}
alt={card.label}
className="block h-auto w-full"
className="h-full w-full object-contain"
previewWidth={720}
previewQuality={76}
onError={(event) =>
@ -6767,11 +6808,11 @@ export function AgentGenerationWorkspaceDarkV2({
)}
>
{hasResult ? (
<div className="relative min-h-[220px] overflow-hidden bg-black/30">
<div className="relative aspect-[790/1300] overflow-hidden bg-white/95">
<AgentPreviewImage
src={card.resultUrl!}
alt={`结果图 ${card.index + 1}`}
className="block h-auto w-full cursor-zoom-in"
className="h-full w-full cursor-zoom-in object-contain"
previewWidth={720}
previewQuality={78}
onError={(event) =>
@ -7175,7 +7216,7 @@ export function AgentGenerationWorkspaceDarkV2({
: null;
return (
<>
<div className="relative aspect-[5/3] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_55%),linear-gradient(180deg,#0b0d0f_0%,#101316_100%)]">
<div className="relative aspect-[5/3] overflow-hidden bg-white/95">
<button
type="button"
onClick={() => handleRemoveSkuReferenceCard(card.id)}
@ -7187,7 +7228,7 @@ export function AgentGenerationWorkspaceDarkV2({
<AgentPreviewImage
src={card.imageUrl}
alt={card.label}
className="h-full w-full object-cover"
className="h-full w-full object-contain"
previewWidth={720}
previewQuality={76}
onError={(event) =>
@ -7294,11 +7335,11 @@ export function AgentGenerationWorkspaceDarkV2({
)}
>
{hasResult ? (
<div className="relative aspect-[5/3] overflow-hidden bg-black/30">
<div className="relative aspect-[5/3] overflow-hidden bg-white/95">
<AgentPreviewImage
src={card.resultUrl!}
alt={`结果图 ${card.index + 1}`}
className="h-full w-full cursor-zoom-in object-cover"
className="h-full w-full cursor-zoom-in object-contain"
previewWidth={720}
previewQuality={78}
onError={(event) =>