diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index d64c10869..12819f9fb 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -288,6 +288,26 @@ function rectToPixels( return { left, top, width, height }; } +function scaleRectAroundCenter( + rect: NormalizedLocalEditRegion["rect"], + scalePercent: ScaleAdjustPercent +) { + const scale = Math.max(0.1, 1 + scalePercent / 100); + const targetWidth = Math.min(1, rect.width * scale); + const targetHeight = Math.min(1, rect.height * scale); + const centerX = rect.x + rect.width / 2; + const centerY = rect.y + rect.height / 2; + const x = clamp(centerX - targetWidth / 2, 0, Math.max(0, 1 - targetWidth)); + const y = clamp(centerY - targetHeight / 2, 0, Math.max(0, 1 - targetHeight)); + + return { + x, + y, + width: targetWidth, + height: targetHeight, + }; +} + function wrapText(text: string, maxChars: number, maxLines = 3) { const compact = text.replace(/\s+/g, " ").trim(); if (!compact) return []; @@ -416,6 +436,99 @@ function buildAnnotationSvg(params: { `); } +function buildScaleAdjustAnnotationSvg(params: { + width: number; + height: number; + regions: NormalizedLocalEditRegion[]; + scalePercent: ScaleAdjustPercent; +}) { + const { width, height, regions, scalePercent } = params; + const minSide = Math.max(1, Math.min(width, height)); + const strokeWidth = Math.max(4, Math.round(minSide * 0.006)); + const fontSize = Math.max(22, Math.round(minSide * 0.024)); + const smallFontSize = Math.max(18, Math.round(fontSize * 0.78)); + const targetColor = scalePercent > 0 ? "#00d4ff" : "#00c853"; + const sourceColor = "#ff3b30"; + const actionLabel = getScaleAdjustLabel(scalePercent); + const targetLabel = getScaleAdjustTargetLabel(scalePercent); + const dash = Math.max(12, Math.round(strokeWidth * 2.8)); + const gap = Math.max(8, Math.round(strokeWidth * 1.8)); + + const regionSvg = regions + .map((region) => { + const source = region.pixelRect; + const targetRect = scaleRectAroundCenter(region.rect, scalePercent); + const target = rectToPixels(targetRect, width, height); + const sourceCx = source.left + source.width / 2; + const sourceCy = source.top + source.height / 2; + const targetCx = target.left + target.width / 2; + const targetCy = target.top + target.height / 2; + const badgeSize = Math.max(32, Math.round(fontSize * 1.3)); + const labelWidth = Math.min( + Math.max(260, Math.round(fontSize * 12.8)), + Math.max(180, width - 24) + ); + const labelHeight = Math.round(fontSize * 2.55); + const labelX = clamp( + target.left, + 12, + Math.max(12, width - labelWidth - 12) + ); + const labelY = clamp( + target.top - labelHeight - Math.round(fontSize * 0.55), + 12, + Math.max(12, height - labelHeight - 12) + ); + + const sourceCorners = [ + [source.left, source.top], + [source.left + source.width, source.top], + [source.left + source.width, source.top + source.height], + [source.left, source.top + source.height], + ]; + const targetCorners = [ + [target.left, target.top], + [target.left + target.width, target.top], + [target.left + target.width, target.top + target.height], + [target.left, target.top + target.height], + ]; + const arrows = sourceCorners + .map(([sx, sy], index) => { + const [tx, ty] = targetCorners[index]; + const fromX = scalePercent > 0 ? sx : tx; + const fromY = scalePercent > 0 ? sy : ty; + const toX = scalePercent > 0 ? tx : sx; + const toY = scalePercent > 0 ? ty : sy; + return ``; + }) + .join(""); + + return ` + + + ${arrows} + + + ${region.index} + + 目标:${escapeXml(actionLabel)},主体约 ${escapeXml(targetLabel)} + 让主体轮廓接近虚线框,不要保持原红框大小 + `; + }) + .join(""); + + return Buffer.from(` + + + + + + + ${regionSvg} + + `); +} + function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) { const regionLines = regions .map((region) => { @@ -471,17 +584,23 @@ function buildScaleAdjustPrompt( const top = Math.round(region.rect.y * 1000) / 10; const width = Math.round(region.rect.width * 1000) / 10; const height = Math.round(region.rect.height * 1000) / 10; + const targetRect = scaleRectAroundCenter(region.rect, scalePercent); + const targetLeft = Math.round(targetRect.x * 1000) / 10; + const targetTop = Math.round(targetRect.y * 1000) / 10; + const targetWidth = Math.round(targetRect.width * 1000) / 10; + const targetHeight = Math.round(targetRect.height * 1000) / 10; const pixel = region.pixelRect; const right = pixel.left + pixel.width; const bottom = pixel.top + pixel.height; const centerX = pixel.left + Math.round(pixel.width / 2); const centerY = pixel.top + Math.round(pixel.height / 2); - return `区域 ${region.index}(相对位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%;像素范围 left=${pixel.left}, top=${pixel.top}, right=${right}, bottom=${bottom}, center=(${centerX}, ${centerY})):该矩形只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标视觉尺寸必须约为原主体的 ${targetLabel}。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`; + return `区域 ${region.index}(原始红色实线框:x=${left}%,y=${top}%,w=${width}%,h=${height}%;目标虚线框:x=${targetLeft}%,y=${targetTop}%,w=${targetWidth}%,h=${targetHeight}%;像素范围 left=${pixel.left}, top=${pixel.top}, right=${right}, bottom=${bottom}, center=(${centerX}, ${centerY})):红色实线框只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标主体最终外接轮廓必须接近第 1 张图中的青绿色/绿色虚线目标框,视觉尺寸约为原主体的 ${targetLabel}。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`; }) .join("\n"); return [ "输入图片1是定位参考图:它只用于告诉你用户框选的目标主体位置。输入图片1里的彩色框、编号、标签、半透明遮罩、文字和 UI 只是定位标记,绝不能出现在最终画面中。", + "输入图片1中的红色实线框表示目标主体当前大致位置;青绿色或绿色虚线框表示比例调整后的目标视觉轮廓。最终画面不得出现这些框线,但目标主体大小必须接近虚线框所表达的新尺寸。", "输入图片2是原始干净图,也是最终输出必须继承的唯一画面基底。如果系统或模型只能读取一张参考图,则必须使用输入图片1中的框选位置定位目标,同时主动去除所有定位标记并恢复干净画面。", "系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。", "最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。", @@ -491,10 +610,10 @@ function buildScaleAdjustPrompt( regionLines, "", "关键要求:", - `1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸;请把主体轮廓宽度和高度都调整到约 ${targetLabel},而不是仅改变亮度、清晰度、颜色或画面裁切。`, + `1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸;请把主体轮廓宽度和高度都调整到约 ${targetLabel},并尽量贴近第 1 张图的虚线目标框,而不是仅改变亮度、清晰度、颜色或画面裁切。`, "2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。", `3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`, - `4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。`, + `4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};主体边缘应越过原始红框并接近虚线目标框,边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。`, "5. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。", "6. 未受比例调整影响的其它画面区域必须尽量保持原图不变,不允许整体重绘、改色、补光、移动物体或改变背景细节。", "7. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。", @@ -587,12 +706,19 @@ export async function POST(request: NextRequest) { operation === SCALE_ADJUST_OPERATION && scalePercent !== null; const displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent }); - const annotationSvg = buildAnnotationSvg({ - height: sourceHeight, - hidePromptText: isScaleAdjustOperation, - regions, - width: sourceWidth, - }); + const annotationSvg = isScaleAdjustOperation + ? buildScaleAdjustAnnotationSvg({ + height: sourceHeight, + regions, + scalePercent: scalePercent as ScaleAdjustPercent, + width: sourceWidth, + }) + : buildAnnotationSvg({ + height: sourceHeight, + hidePromptText: false, + regions, + width: sourceWidth, + }); const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" }) .composite([{ input: annotationSvg, left: 0, top: 0 }]) .png({ compressionLevel: 6 })