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(`
+
+ `);
+}
+
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 })