From 16a6134c77898fd5f63a4aa6ddbff0807a6d1e1f Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 29 Jun 2026 14:38:49 +0800 Subject: [PATCH] fix: improve single-image scale adjustment --- app/api/canvas/local-edit/route.ts | 195 +++++++++++++++-------------- 1 file changed, 98 insertions(+), 97 deletions(-) diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 94ffb1bd0..8b3b7b78f 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -98,9 +98,14 @@ const MAX_SCALE_ADJUST_CROP_REFERENCES = 4; const SCALE_ADJUST_CROP_PADDING_RATIO = 0.18; const SCALE_ADJUST_CROP_MAX_EDGE = 1536; const SCALE_ADJUST_FAL_MOCKUP_PADDING_RATIO = 0.02; -const SCALE_ADJUST_FAL_MOCKUP_BLUR_PX = 30; -const SCALE_ADJUST_FAL_MOCKUP_SUBJECT_OPACITY = 0.82; -const SCALE_ADJUST_FAL_MOCKUP_WORKSPACE_OPACITY = 0.32; +const SCALE_ADJUST_FAL_MOCKUP_BLUR_PX = 42; +const SCALE_ADJUST_FAL_MOCKUP_WORKSPACE_FEATHER_RATIO = 0.095; +const SCALE_ADJUST_FAL_MOCKUP_WORKSPACE_INSET_RATIO = 0.19; +const SCALE_ADJUST_FAL_MOCKUP_SUBJECT_BLUR_PX = 0.65; +const SCALE_ADJUST_FAL_MOCKUP_SUBJECT_FEATHER_RATIO = 0.16; +const SCALE_ADJUST_FAL_MOCKUP_SUBJECT_INSET_RATIO = 0.25; +const SCALE_ADJUST_FAL_MOCKUP_SUBJECT_OPACITY = 0.72; +const SCALE_ADJUST_FAL_MOCKUP_WORKSPACE_OPACITY = 0.07; const MIN_REGION_EDGE_PX = 24; const SCALE_ADJUST_OPERATION = "scale-adjust"; type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION; @@ -655,18 +660,21 @@ async function buildScaleAdjustFalTargetMockup(params: { const blurredWorkspace = await sharp(sourceBuffer, { failOn: "none" }) .extract(workspacePixelRect) .blur(SCALE_ADJUST_FAL_MOCKUP_BLUR_PX) - .modulate({ brightness: 1.05, saturation: 0.86 }) + .modulate({ brightness: 1.05, saturation: 0.78 }) .png({ compressionLevel: 6 }) .toBuffer(); const workspaceFeather = Math.max( - 18, - Math.round(Math.min(workspacePixelRect.width, workspacePixelRect.height) * 0.1) + 52, + Math.round(minSide * SCALE_ADJUST_FAL_MOCKUP_WORKSPACE_FEATHER_RATIO) ); const featheredWorkspace = await addFeatherAlpha({ buffer: blurredWorkspace, feather: workspaceFeather, height: workspacePixelRect.height, - inset: Math.max(workspaceFeather * 2.6, workspaceFeather + 1), + inset: Math.max( + 92, + Math.round(minSide * SCALE_ADJUST_FAL_MOCKUP_WORKSPACE_INSET_RATIO) + ), opacity: SCALE_ADJUST_FAL_MOCKUP_WORKSPACE_OPACITY, width: workspacePixelRect.width, }); @@ -677,17 +685,28 @@ async function buildScaleAdjustFalTargetMockup(params: { fit: "cover", position: "center", }) + .blur(SCALE_ADJUST_FAL_MOCKUP_SUBJECT_BLUR_PX) + .modulate({ brightness: 1.01, saturation: 0.94 }) .png({ compressionLevel: 6 }) .toBuffer(); const subjectFeather = Math.max( - 8, - Math.round(Math.min(targetPixelRect.width, targetPixelRect.height) * 0.08) + 24, + Math.round( + Math.min(targetPixelRect.width, targetPixelRect.height) * + SCALE_ADJUST_FAL_MOCKUP_SUBJECT_FEATHER_RATIO + ) ); const featheredSubject = await addFeatherAlpha({ buffer: subjectCrop, feather: subjectFeather, height: targetPixelRect.height, - inset: Math.max(subjectFeather * 1.6, subjectFeather + 1), + inset: Math.max( + 34, + Math.round( + Math.min(targetPixelRect.width, targetPixelRect.height) * + SCALE_ADJUST_FAL_MOCKUP_SUBJECT_INSET_RATIO + ) + ), opacity: SCALE_ADJUST_FAL_MOCKUP_SUBJECT_OPACITY, width: targetPixelRect.width, }); @@ -1125,6 +1144,7 @@ function buildFalScaleAdjustMockupPrompt( function buildFalScaleAdjustGuideOnlyPrompt( regions: NormalizedLocalEditRegion[], scalePercent: ScaleAdjustPercent | null | undefined, + cropReferences: ScaleAdjustCropReference[] = [], sourceSize: { width: number; height: number } ) { const regionLines = regions @@ -1141,26 +1161,41 @@ function buildFalScaleAdjustGuideOnlyPrompt( return `Region ${region.index}: original subject box approximately x=${region.pixelRect.left}, y=${region.pixelRect.top}, w=${region.pixelRect.width}, h=${region.pixelRect.height}; target visual box approximately x=${targetPixelRect.left}, y=${targetPixelRect.top}, w=${targetPixelRect.width}, h=${targetPixelRect.height}; target footprint is about ${widthFactor.toFixed(2)}x width and ${heightFactor.toFixed(2)}x height of the original.`; }) .join("\n"); + const cropLines = cropReferences + .map( + (reference, index) => + `Input image ${index + 2} is a clean crop reference for region ${reference.regionIndex}. Use it only to preserve the selected furniture identity, visible structure, proportions, and top objects; do not paste the crop into the final photo.` + ) + .join("\n"); return [ "This is a local furniture scale adjustment edit in an indoor home photo.", - "Input image 1 is a soft target-layout draft derived from the original photo. It already places the selected furniture at the intended final size and center.", - `Recreate input image 1 as one clean realistic final photo with the same frame and pixel size as the original source: ${sourceSize.width} x ${sourceSize.height} px.`, - "Use the draft only as geometry and scene guidance. Remove all blur, soft edges, rectangular patch borders, pasted texture, seams, color mismatch, and rough-composite artifacts.", - "The selected furniture size and center shown in the draft is mandatory. Do not restore the furniture to its old size and do not average toward the old footprint.", - "Synthesize the selected furniture naturally at the target size and center, preserving realistic material, perspective, light, shadow, and occlusion.", - "Naturally reconstruct the exposed surrounding background, preserving floor, rug, wall, shadow, perspective, and object continuity.", + "Input image 1 is a very soft target-layout guide derived from the original photo after a local furniture size edit.", + cropLines, + `Create one clean realistic final photo with the same camera frame and pixel size as the original source: ${sourceSize.width} x ${sourceSize.height} px.`, + "The selected furniture ghost in the guide already shows the desired final size and center. Keep the furniture at that size and center in the final photo.", + "The guide is not a layer, patch, sticker, crop, or pasted block to copy. It is only a geometry hint.", + "Do not preserve or copy any rectangular area from input image 1. No straight patch boundary may remain around the furniture.", + cropLines + ? "Use the crop reference only to preserve the target furniture's exact identity and structure. Do not invent duplicate parts, extra legs, rails, side panels, shelves, cabinets, a second copy, or a different furniture design." + : "Preserve the target furniture's exact identity and structure. Do not invent duplicate parts, extra legs, rails, side panels, shelves, cabinets, a second copy, or a different furniture design.", + "Redraw the furniture and scene as one coherent photo with consistent sharpness, exposure, color, perspective, and texture across the entire canvas.", + "Remove all guide artifacts completely: haze, translucent rectangle, blur, hard patch border, seams, pasted edges, different-resolution crop, annotations, and color mismatch.", + "There must be no visible rectangular area around the furniture. Do not preserve any straight crop boundary from the guide.", + "Naturally reconstruct the exposed surrounding background, preserving floor, rug, wall, tabletop, shadows, perspective, and object continuity.", "", "Region requirements:", regionLines, "", - "If the result contains any visible guide overlay, blur patch, hard rectangle, seam, annotation, or unchanged old-size furniture, regenerate.", + "Do not return the old furniture size. Do not zoom, crop, pan, resize, or redesign the whole photo.", + "Return only a seamless realistic edited photo.", ].join("\n"); } function buildFalScaleAdjustSimpleGuideOnlyPrompt( regions: NormalizedLocalEditRegion[], scalePercent: ScaleAdjustPercent | null | undefined, + cropReferences: ScaleAdjustCropReference[] = [], sourceSize: { width: number; height: number } ) { const regionLines = regions @@ -1177,14 +1212,30 @@ function buildFalScaleAdjustSimpleGuideOnlyPrompt( return `Selected furniture region ${region.index}: target box x=${targetPixelRect.left}, y=${targetPixelRect.top}, w=${targetPixelRect.width}, h=${targetPixelRect.height}; about ${widthFactor.toFixed(2)}x width and ${heightFactor.toFixed(2)}x height of the original.`; }) .join("\n"); + const cropLines = cropReferences + .map( + (reference, index) => + `Input image ${index + 2} is a clean crop reference for selected furniture region ${reference.regionIndex}; use it only to preserve identity, visible structure, proportions, and top objects.` + ) + .join("\n"); return [ - "Input image 1 is a rough preview of an indoor room after a local furniture size edit.", - "Create one clean realistic photo from this preview. Keep the same camera frame, room layout, lighting, wall, floor, windows, and background objects.", - "The selected furniture in the preview already shows the desired final size and center. Keep that smaller/larger size in the final photo.", - "Remove all preview artifacts: blur, translucent haze, rectangular patch borders, seams, pasted edges, annotations, and color mismatch.", + "Input image 1 is a very soft target-layout guide from the original photo after a local furniture size edit.", + cropLines, + `Create one clean realistic final photo with the same camera frame and pixel size: ${sourceSize.width} x ${sourceSize.height} px.`, + "The selected furniture ghost in the guide already shows the desired final size and center. Keep the furniture at that size and center in the final photo.", + "The guide is not a layer, patch, sticker, crop, or pasted block to copy. It is only a geometry hint.", + "Do not preserve or copy any rectangular area from input image 1. No straight patch boundary may remain around the furniture.", + cropLines + ? "Use the crop reference only for furniture identity and structure. Do not paste it into the output, and do not invent duplicate parts, extra legs, rails, side panels, shelves, cabinets, a second copy, or a different furniture design." + : "Preserve the furniture identity and structure. Do not invent duplicate parts, extra legs, rails, side panels, shelves, cabinets, a second copy, or a different furniture design.", + "Redraw the furniture and scene as one coherent photo with consistent sharpness, exposure, color, perspective, and texture across the entire canvas.", + "Remove all guide artifacts completely: haze, translucent rectangle, blur, hard patch border, seams, pasted edges, different-resolution crop, annotations, and color mismatch.", + "There must be no visible rectangular area around the furniture. Do not preserve any straight crop boundary from the guide.", + "Naturally reconstruct the exposed surrounding background, preserving floor, rug, wall, tabletop, shadows, perspective, and object continuity.", regionLines, - "Do not return the old furniture size. Do not zoom, crop, or redesign the whole photo.", + "Do not return the old furniture size. Do not zoom, crop, pan, resize, or redesign the whole photo.", + "Return only a seamless realistic edited photo.", ].join("\n"); } @@ -1396,21 +1447,24 @@ export async function POST(request: NextRequest) { toArrayBuffer(annotatedBuffer) ); const scaleAdjustCropReferences = isScaleAdjustOperation - ? useFalScaleAdjustMockup - ? [] - : await createScaleAdjustCropReferences({ - regions, - sourceBuffer, - sourceHeight, - sourceWidth, - }) + ? await createScaleAdjustCropReferences({ + regions, + sourceBuffer, + sourceHeight, + sourceWidth, + }) : []; const prompt = isScaleAdjustOperation ? useFalScaleAdjustMockup - ? buildFalScaleAdjustMockupPrompt(regions, scalePercent, { - width: sourceWidth, - height: sourceHeight, - }) + ? buildFalScaleAdjustSimpleGuideOnlyPrompt( + regions, + scalePercent, + scaleAdjustCropReferences, + { + width: sourceWidth, + height: sourceHeight, + } + ) : buildScaleAdjustPrompt( regions, scalePercent, @@ -1418,22 +1472,8 @@ export async function POST(request: NextRequest) { { width: sourceWidth, height: sourceHeight } ) : buildLocalEditPrompt(regions); - const falScaleAdjustGuideOnlyPrompt = - isScaleAdjustOperation && useFalScaleAdjustMockup - ? buildFalScaleAdjustGuideOnlyPrompt(regions, scalePercent, { - width: sourceWidth, - height: sourceHeight, - }) - : ""; - const falScaleAdjustSimpleGuideOnlyPrompt = - isScaleAdjustOperation && useFalScaleAdjustMockup - ? buildFalScaleAdjustSimpleGuideOnlyPrompt(regions, scalePercent, { - width: sourceWidth, - height: sourceHeight, - }) - : ""; const falScaleAdjustNegativePrompt = - "visible annotation, guide overlay, translucent overlay, text label, arrows, boxes, rectangle border, UI marks, pasted patch, seam, hard rectangular edge, color mismatch, whole image zoom, whole image crop, whole image pan, whole image redesign, unchanged target furniture, stretched furniture, squashed furniture"; + "visible annotation, guide overlay, translucent overlay, text label, arrows, boxes, rectangle border, UI marks, pasted patch, seam, hard rectangular edge, color mismatch, different-resolution area, whole image zoom, whole image crop, whole image pan, whole image redesign, unchanged target furniture, stretched furniture, squashed furniture, duplicate furniture, second copy, extra legs, extra rails, side panels, shelves, cabinets, lattice panels"; const manualRegionReferenceImageUrls = regions.flatMap((region) => region.referenceImages.map((reference) => reference.url) ); @@ -1442,7 +1482,7 @@ export async function POST(request: NextRequest) { ); const referenceImages = isScaleAdjustOperation ? useFalScaleAdjustMockup - ? [annotatedUpload.url, sourceImageUrl] + ? [annotatedUpload.url, ...scaleAdjustCropReferenceImageUrls] : [ sourceImageUrl, annotatedUpload.url, @@ -1455,6 +1495,9 @@ export async function POST(request: NextRequest) { : []; const scaleAdjustVerificationConfig = isScaleAdjustOperation ? { + allowOutsideChangedWhenChangedInside: useFalScaleAdjustMockup + ? true + : undefined, protectedRegions: scaleAdjustCompositeRegions, rejectFailedCandidates: useFalScaleAdjustMockup ? true : undefined, sourceImageUrl, @@ -1476,13 +1519,6 @@ export async function POST(request: NextRequest) { : {}), } : undefined; - const scaleAdjustFinalRetryVerificationConfig = - scaleAdjustVerificationConfig && useFalScaleAdjustMockup - ? { - ...scaleAdjustVerificationConfig, - allowOutsideChangedWhenChangedInside: true, - } - : scaleAdjustVerificationConfig; const imageRequest: CanvasGenerateImageRequest = { aspectRatio: resolvedAspectRatio, @@ -1494,10 +1530,10 @@ export async function POST(request: NextRequest) { annotatedReferenceSentToModel: isScaleAdjustOperation, annotatedReferenceType: isScaleAdjustOperation ? useFalScaleAdjustMockup - ? "scale-adjust-fal-soft-target-guide-plus-source" + ? "scale-adjust-fal-soft-target-guide-plus-crop" : "scale-adjust-neutral-coordinate-guide-map" : "source-overlay", - cleanSourceReferenceIndex: useFalScaleAdjustMockup ? 2 : 1, + cleanSourceReferenceIndex: useFalScaleAdjustMockup ? null : 1, configuredDetailModelId: configuredDetailModel || null, detailFixedModelApplied: Boolean(detailFixedModel), detailFixedModelId: detailFixedModel || null, @@ -1522,7 +1558,7 @@ export async function POST(request: NextRequest) { .length, scaleAdjustPromptStrategy: isScaleAdjustOperation ? useFalScaleAdjustMockup - ? "fal-guide-plus-source-soft-target-guide-v20" + ? "fal-faint-guide-crop-clean-target-single-v22b" : "locked-plate-neutral-coordinate-guide-map-pixel-anchors-v8" : undefined, scaleAdjustPostCompositeApplied: false, @@ -1536,7 +1572,7 @@ export async function POST(request: NextRequest) { falSystemPrompt: undefined, falThinkingLevel: undefined, allowFailover: useFalScaleAdjustMockup ? false : undefined, - imageCount: useFalScaleAdjustMockup ? 4 : 1, + imageCount: 1, imageSize: typeof body.imageSize === "string" && body.imageSize.trim() ? body.imageSize.trim() @@ -1551,42 +1587,7 @@ export async function POST(request: NextRequest) { prompt, promptExtend: false, referenceImages, - retryVariants: useFalScaleAdjustMockup - ? [ - { - debugMeta: { - annotatedReferenceType: "scale-adjust-fal-soft-target-guide-only", - cleanSourceReferenceIndex: null, - regionReferenceImageCount: 1, - scaleAdjustPromptStrategy: "fal-guide-only-simple-clean-target-v20", - }, - falSafetyTolerance: "6", - imageCount: 4, - label: "fal-guide-only-simple-clean-target-v20", - negativePrompt: falScaleAdjustNegativePrompt, - prompt: falScaleAdjustSimpleGuideOnlyPrompt, - promptExtend: false, - referenceImages: [annotatedUpload.url], - scaleAdjustVerification: scaleAdjustVerificationConfig, - }, - { - debugMeta: { - annotatedReferenceType: "scale-adjust-fal-soft-target-guide-only", - cleanSourceReferenceIndex: null, - regionReferenceImageCount: 1, - scaleAdjustPromptStrategy: "fal-guide-only-clean-target-v20", - }, - falSafetyTolerance: "6", - imageCount: 4, - label: "fal-guide-only-clean-target-v20", - negativePrompt: falScaleAdjustNegativePrompt, - prompt: falScaleAdjustGuideOnlyPrompt, - promptExtend: false, - referenceImages: [annotatedUpload.url], - scaleAdjustVerification: scaleAdjustFinalRetryVerificationConfig, - }, - ] - : undefined, + retryVariants: undefined, scaleAdjustVerification: scaleAdjustVerificationConfig, localEditComposite: isScaleAdjustOperation ? undefined