diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 637809b5d..4903081ff 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -791,8 +791,18 @@ function getScaleAdjustVisualGuidePlacement(params: { sourceTop + 1, height ); - const sourceWidth = Math.max(1, Math.round(sourceRight - sourceLeft)); - const sourceHeight = Math.max(1, Math.round(sourceBottom - sourceTop)); + const sourceFootprintBox = { + left: Math.round(sourceLeft), + top: Math.round(sourceTop), + width: Math.max(1, Math.round(sourceRight - sourceLeft)), + height: Math.max(1, Math.round(sourceBottom - sourceTop)), + }; + const sourceBox = getScaleAdjustFocusedSourceBox({ + region, + sourceBox: sourceFootprintBox, + }); + const sourceWidth = sourceBox.width; + const sourceHeight = sourceBox.height; const widthScale = target.width / Math.max(1, sourceWidth); const heightScale = target.height / Math.max(1, sourceHeight); const uniformScale = Math.max( @@ -831,16 +841,64 @@ function getScaleAdjustVisualGuidePlacement(params: { placedTop: Math.max(0, Math.round(placedTop)), placedWidth: Math.max(1, Math.round(placedWidth)), sourceBox: { - left: Math.round(sourceLeft), - top: Math.round(sourceTop), + left: sourceBox.left, + top: sourceBox.top, width: sourceWidth, height: sourceHeight, }, + sourceFootprintBox, targetBox: target, uniformScale, }; } +function getScaleAdjustFocusDirection(region: NormalizedLocalEditRegion) { + const text = normalizeScaleAdjustModelText(region.prompt); + const compact = text.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, ""); + if (text.includes("left") || compact.includes("左")) return "left"; + if (text.includes("right") || compact.includes("右")) return "right"; + if (text.includes("top") || compact.includes("上")) return "top"; + if (text.includes("bottom") || compact.includes("下")) return "bottom"; + return ""; +} + +function getScaleAdjustFocusedSourceBox(params: { + region: NormalizedLocalEditRegion; + sourceBox: { left: number; top: number; width: number; height: number }; +}) { + const { region, sourceBox } = params; + const direction = getScaleAdjustFocusDirection(region); + if (!direction) return sourceBox; + + const minWidth = Math.max(1, Math.round(sourceBox.width * 0.42)); + const minHeight = Math.max(1, Math.round(sourceBox.height * 0.42)); + let left = sourceBox.left; + let top = sourceBox.top; + let width = sourceBox.width; + let height = sourceBox.height; + + if (direction === "left") { + width = Math.max(minWidth, Math.round(sourceBox.width * 0.68)); + } else if (direction === "right") { + const nextWidth = Math.max(minWidth, Math.round(sourceBox.width * 0.68)); + left = sourceBox.left + sourceBox.width - nextWidth; + width = nextWidth; + } else if (direction === "top") { + height = Math.max(minHeight, Math.round(sourceBox.height * 0.72)); + } else if (direction === "bottom") { + const nextHeight = Math.max(minHeight, Math.round(sourceBox.height * 0.72)); + top = sourceBox.top + sourceBox.height - nextHeight; + height = nextHeight; + } + + return { + left, + top, + width, + height, + }; +} + function buildScaleAdjustPatchMaskSvg(params: { height: number; width: number }) { const { height, width } = params; const blur = Math.max(2, Math.min(14, Math.round(Math.min(width, height) * 0.035))); @@ -966,7 +1024,7 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { scalePercent, width: sourceWidth, }); - sourceFootprintBoxes.push(placement.sourceBox); + sourceFootprintBoxes.push(placement.sourceFootprintBox); placements.push(placement); const resizedCropBuffer = await sharp(sourceBuffer, { failOn: "none" }) @@ -1989,6 +2047,7 @@ export async function POST(request: NextRequest) { : undefined, sourceImageUrl, regions: regions.map((region) => region.rect), + regionPrompts: regions.map((region) => region.prompt), ...(typeof scalePercent === "number" ? { scalePercent: scalePercent as ScaleAdjustPercent } : {}), diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index c5a6e8f40..bda4f43a6 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -56,6 +56,7 @@ export interface CanvasScaleAdjustVerificationConfig { allowOutsideChangedWhenChangedInside?: boolean; protectedRegions?: CanvasLocalEditCompositeRegion[]; rejectFailedCandidates?: boolean; + regionPrompts?: string[]; sourceImageUrl: string; regions: CanvasLocalEditCompositeRegion[]; targetRegions?: CanvasLocalEditCompositeRegion[]; @@ -1405,6 +1406,63 @@ function getRawPixelIndex(width: number, x: number, y: number) { return (y * width + x) * 3; } +function normalizeScaleAdjustFocusText(input: unknown) { + return String(input || "") + .trim() + .toLowerCase() + .normalize("NFKC") + .replace(/[「」『』【】[\]()(){}]/g, " ") + .replace(/[_/|::,,.。+]+/g, " ") + .replace(/\s+/g, " "); +} + +function getScaleAdjustFallbackFocusDirection(prompt: unknown) { + const text = normalizeScaleAdjustFocusText(prompt); + const compact = text.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, ""); + if (text.includes("left") || compact.includes("左")) return "left"; + if (text.includes("right") || compact.includes("右")) return "right"; + if (text.includes("top") || compact.includes("上")) return "top"; + if (text.includes("bottom") || compact.includes("下")) return "bottom"; + return ""; +} + +function getScaleAdjustFallbackFocusedSourceBox(params: { + prompt?: string; + sourceBox: ReturnType; +}) { + const { prompt, sourceBox } = params; + const direction = getScaleAdjustFallbackFocusDirection(prompt); + if (!direction) return sourceBox; + + const minWidth = Math.max(1, Math.round(sourceBox.width * 0.42)); + const minHeight = Math.max(1, Math.round(sourceBox.height * 0.42)); + let left = sourceBox.left; + let top = sourceBox.top; + let width = sourceBox.width; + let height = sourceBox.height; + + if (direction === "left") { + width = Math.max(minWidth, Math.round(sourceBox.width * 0.68)); + } else if (direction === "right") { + const nextWidth = Math.max(minWidth, Math.round(sourceBox.width * 0.68)); + left = sourceBox.left + sourceBox.width - nextWidth; + width = nextWidth; + } else if (direction === "top") { + height = Math.max(minHeight, Math.round(sourceBox.height * 0.72)); + } else if (direction === "bottom") { + const nextHeight = Math.max(minHeight, Math.round(sourceBox.height * 0.72)); + top = sourceBox.top + sourceBox.height - nextHeight; + height = nextHeight; + } + + return { + left, + top, + width, + height, + }; +} + function getLuma(r: number, g: number, b: number) { return r * 0.299 + g * 0.587 + b * 0.114; } @@ -1662,13 +1720,21 @@ async function buildScaleAdjustDeterministicFallbackImages(params: { region: targetRegion, width: sourceImage.sourceWidth, }); + const prompt = verification.regionPrompts?.[index] || ""; + const focusedSourceBox = getScaleAdjustFallbackFocusedSourceBox({ + prompt, + sourceBox, + }); const mask = buildScaleAdjustFallbackSubjectMask({ - box: sourceBox, + box: focusedSourceBox, raw: baseRaw, width: sourceImage.sourceWidth, }); const maskedPixels = mask.reduce((sum, value) => sum + (value ? 1 : 0), 0); - if (maskedPixels < Math.max(24, sourceBox.width * sourceBox.height * 0.015)) { + if ( + maskedPixels < + Math.max(24, focusedSourceBox.width * focusedSourceBox.height * 0.015) + ) { console.warn("[Canvas Generate Image] Scale adjust deterministic fallback skipped sparse mask:", { debugLabel, maskedPixels, @@ -1677,19 +1743,19 @@ async function buildScaleAdjustDeterministicFallbackImages(params: { continue; } const background = estimateScaleAdjustFallbackBackground({ - box: sourceBox, + box: focusedSourceBox, raw: baseRaw, width: sourceImage.sourceWidth, }); const objectBuffer = await buildScaleAdjustFallbackObjectPng({ - box: sourceBox, + box: focusedSourceBox, mask, sourceBuffer: sourceImage.sourceBuffer, targetBox, }); baseRaw = inpaintScaleAdjustMaskedRegion({ background, - box: sourceBox, + box: focusedSourceBox, mask, raw: baseRaw, width: sourceImage.sourceWidth,