diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts
index 0432bb999..bd13e2a1c 100644
--- a/app/api/canvas/local-edit/route.ts
+++ b/app/api/canvas/local-edit/route.ts
@@ -1249,6 +1249,128 @@ function buildScaleAdjustAnnotationSvg(params: {
`);
}
+function buildScaleAdjustZoomedCoordinateSvg(params: {
+ width: number;
+ height: number;
+ regions: NormalizedLocalEditRegion[];
+ scalePercent?: ScaleAdjustPercent | null;
+}) {
+ const { width, height, regions, scalePercent } = params;
+ const minSide = Math.max(1, Math.min(width, height));
+ const strokeWidth = Math.max(3, Math.round(minSide * 0.006));
+ const fontSize = Math.max(22, Math.round(minSide * 0.026));
+ const smallFontSize = Math.max(18, Math.round(fontSize * 0.72));
+ const panelMarginX = Math.round(width * 0.1);
+ const panelMarginY = Math.round(height * 0.12);
+ const panelLeft = panelMarginX;
+ const panelTop = panelMarginY;
+ const panelWidth = Math.max(1, width - panelMarginX * 2);
+ const panelHeight = Math.max(1, height - panelMarginY * 2);
+ const sourceColor = "#9ca3af";
+ const targetColor = "#111827";
+ const helperColor = "#4b5563";
+ const padX = Math.round(fontSize * 0.42);
+ const labelHeight = Math.round(fontSize * 1.35);
+ const label = (text: string, x: number, y: number, color: string) => {
+ const labelWidth = Math.min(
+ Math.round(text.length * fontSize * 0.58 + padX * 2),
+ Math.max(160, width - 16)
+ );
+ const left = clamp(x, 8, Math.max(8, width - labelWidth - 8));
+ const top = clamp(y, 8, Math.max(8, height - labelHeight - 8));
+ return `
+
+ ${escapeXml(text)}
+ `;
+ };
+
+ const regionSvg = regions
+ .map((region) => {
+ const source = region.pixelRect;
+ const targetRect = getScaleAdjustTargetRect(region, scalePercent);
+ const target =
+ region.targetPixelRect || rectToPixels(targetRect, width, height);
+ const sourceRight = source.left + source.width;
+ const sourceBottom = source.top + source.height;
+ const targetRight = target.left + target.width;
+ const targetBottom = target.top + target.height;
+ const unionLeft = Math.min(source.left, target.left);
+ const unionTop = Math.min(source.top, target.top);
+ const unionRight = Math.max(sourceRight, targetRight);
+ const unionBottom = Math.max(sourceBottom, targetBottom);
+ const marginX = Math.max(12, Math.round((unionRight - unionLeft) * 0.35));
+ const marginY = Math.max(12, Math.round((unionBottom - unionTop) * 0.35));
+ const viewLeft = unionLeft - marginX;
+ const viewTop = unionTop - marginY;
+ const viewWidth = Math.max(1, unionRight - unionLeft + marginX * 2);
+ const viewHeight = Math.max(1, unionBottom - unionTop + marginY * 2);
+ const scale = Math.min(
+ panelWidth / viewWidth,
+ panelHeight / viewHeight
+ );
+ const drawnWidth = viewWidth * scale;
+ const drawnHeight = viewHeight * scale;
+ const originX = panelLeft + (panelWidth - drawnWidth) / 2 - viewLeft * scale;
+ const originY = panelTop + (panelHeight - drawnHeight) / 2 - viewTop * scale;
+ const project = (box: { left: number; top: number; width: number; height: number }) => ({
+ left: Math.round(originX + box.left * scale),
+ top: Math.round(originY + box.top * scale),
+ width: Math.max(1, Math.round(box.width * scale)),
+ height: Math.max(1, Math.round(box.height * scale)),
+ });
+ const sourceBox = project(source);
+ const targetBox = project(target);
+ const sourceCenterX = sourceBox.left + sourceBox.width / 2;
+ const sourceCenterY = sourceBox.top + sourceBox.height / 2;
+ const targetCenterX = targetBox.left + targetBox.width / 2;
+ const targetCenterY = targetBox.top + targetBox.height / 2;
+ const widthFactor = source.width > 0 ? target.width / source.width : 1;
+ const heightFactor = source.height > 0 ? target.height / source.height : 1;
+ const actionLabel =
+ widthFactor < 0.92 || heightFactor < 0.92
+ ? "SHRINK"
+ : widthFactor > 1.08 || heightFactor > 1.08
+ ? "ENLARGE"
+ : "MOVE";
+ const scaleLabel = `${actionLabel} ${widthFactor.toFixed(2)}W ${heightFactor.toFixed(2)}H`;
+ const radius = Math.max(10, Math.round(strokeWidth * 2.2));
+ const centerMoved =
+ Math.hypot(targetCenterX - sourceCenterX, targetCenterY - sourceCenterY) >
+ Math.max(10, minSide * 0.018);
+
+ return `
+
+
+
+
+ ${
+ centerMoved
+ ? ``
+ : ""
+ }
+ ${label(`OLD ${region.index}`, sourceBox.left, sourceBox.top - labelHeight - strokeWidth, sourceColor)}
+ ${label(`TARGET ${region.index}`, targetBox.left, targetBox.top + strokeWidth, targetColor)}
+ ${label(scaleLabel, targetBox.left, targetBox.top + targetBox.height + strokeWidth * 2, helperColor)}
+ ${label("OLD OUTSIDE = BACKGROUND", sourceBox.left, sourceBox.top + sourceBox.height + strokeWidth * 4 + labelHeight, helperColor)}
+ `;
+ })
+ .join("");
+
+ return Buffer.from(`
+
+ `);
+}
+
function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
const regionLines = regions
.map((region) => {
@@ -1438,7 +1560,7 @@ function buildScaleAdjustCoordinatePrompt(
"中文硬约束:只调整 OLD 框中的主要家具主体。TARGET 框是最终占位;如果是缩小,OLD 框里超出 TARGET 的旧家具必须消失并补成自然背景;如果是放大,同一件家具必须明显变大。禁止整图缩放、整图裁切、整图重绘。",
"一次成功硬约束:不要保守返回原尺寸。目标家具的可见轮廓必须明显从 OLD 原始占位变到 TARGET 占位;如果最终看起来和原图一样,就是失败。",
`Return one seamless realistic photo with the same frame and pixel size as input image 1: ${sourceSize.width} x ${sourceSize.height} px.`,
- "Input image 2 is a neutral coordinate instruction map, not a photo and not output content. The OLD box/label locates the current furniture in input image 1; the TARGET box/label gives the required final footprint, center, and scale factor. If the map says OLD OUTSIDE = BACKGROUND, the old full-size furniture outside TARGET must disappear and be healed as natural background in the final photo. Read the boxes, labels, arrows, and scale text only as instructions; never output any map graphics.",
+ "Input image 2 is a zoomed neutral coordinate instruction map, not a photo and not output content. It enlarges the OLD and TARGET boxes so their relative scale is easy to read; use the numeric pixel coordinates below for the exact absolute position in input image 1. The OLD box/label locates the current furniture; the TARGET box/label gives the required final footprint, center, and scale factor. If the map says OLD OUTSIDE = BACKGROUND, the old full-size furniture outside TARGET must disappear and be healed as natural background in the final photo. Read the boxes, labels, arrows, and scale text only as instructions; never output any map graphics.",
cropLines ? `${cropLines}` : "",
"",
"Task:",
@@ -1463,7 +1585,7 @@ function buildScaleAdjustCoordinatePrompt(
function buildScaleAdjustCoordinateSystemPrompt() {
return [
"You are an image editing model for local furniture scale adjustment.",
- "Input image 1 is the only final photo base. Input image 2 is a neutral coordinate instruction map, not a photo layer.",
+ "Input image 1 is the only final photo base. Input image 2 is a zoomed neutral coordinate instruction map, not a photo layer.",
"Follow both English and Chinese instructions. The Chinese lines are hard constraints for avoiding unchanged furniture and full-canvas zoom.",
"Generate a clean realistic final photo by editing the target furniture itself and naturally rebuilding only the tiny surrounding background that becomes visible.",
"Prefer a clear visible size change over a conservative unchanged result; unchanged furniture is a failed edit.",
@@ -1640,7 +1762,7 @@ export async function POST(request: NextRequest) {
const annotatedBuffer = isScaleAdjustOperation
? useScaleAdjustCoordinateGuide
? await sharp(
- buildScaleAdjustAnnotationSvg({
+ buildScaleAdjustZoomedCoordinateSvg({
height: sourceHeight,
regions,
scalePercent,
@@ -1799,7 +1921,7 @@ export async function POST(request: NextRequest) {
.length,
scaleAdjustPromptStrategy: isScaleAdjustOperation
? useScaleAdjustCoordinateGuide
- ? "same-name-neutral-coordinate-map-no-old-crop-v39-one-shot"
+ ? "same-name-zoomed-coordinate-map-no-old-crop-v41-one-shot"
: "locked-plate-semantic-target-map-no-old-crop-v15"
: undefined,
scaleAdjustPostCompositeApplied: false,