fix: stabilize detail replacement page layout
This commit is contained in:
parent
29930f3c14
commit
1a97f56169
@ -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},输出必须保持这个画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。`
|
||||
: "输出必须保持当前参考图画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。",
|
||||
"如果参考图中存在文字、标签或价格信息,必须完整保留在画布安全区内,不得贴边、出血、半截显示或被裁切。",
|
||||
"只输出一张完整页面,不允许上下拼接两个方案、重复画面、长图瀑布流或明显横向分割线。",
|
||||
"输出结果必须保持最后一张参考图的原始纵横比与画布尺寸。",
|
||||
"输出要自然融合,不要改变参考图原有风格和景别。",
|
||||
];
|
||||
|
||||
105
app/api/agent-workflow/detail-image-normalize/route.ts
Normal file
105
app/api/agent-workflow/detail-image-normalize/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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) =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user