diff --git a/app/(chat)/api/chat/_lib/detail-replacement.ts b/app/(chat)/api/chat/_lib/detail-replacement.ts index 023a26bcb..b5e458e8b 100644 --- a/app/(chat)/api/chat/_lib/detail-replacement.ts +++ b/app/(chat)/api/chat/_lib/detail-replacement.ts @@ -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},输出必须保持这个画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。` : "输出必须保持当前参考图画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。", + "如果参考图中存在文字、标签或价格信息,必须完整保留在画布安全区内,不得贴边、出血、半截显示或被裁切。", + "只输出一张完整页面,不允许上下拼接两个方案、重复画面、长图瀑布流或明显横向分割线。", "输出结果必须保持最后一张参考图的原始纵横比与画布尺寸。", "输出要自然融合,不要改变参考图原有风格和景别。", ]; diff --git a/app/api/agent-workflow/detail-image-normalize/route.ts b/app/api/agent-workflow/detail-image-normalize/route.ts new file mode 100644 index 000000000..b584baaa2 --- /dev/null +++ b/app/api/agent-workflow/detail-image-normalize/route.ts @@ -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; + 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 }, + ); + } +} diff --git a/app/api/agent-workflow/detail-stitch/route.ts b/app/api/agent-workflow/detail-stitch/route.ts index 1d5a19adf..164ee9388 100644 --- a/app/api/agent-workflow/detail-stitch/route.ts +++ b/app/api/agent-workflow/detail-stitch/route.ts @@ -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({ diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index b69fd121d..d2c1b8f91 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -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)} > -
+