fix: lock scale adjust enlargement structure

This commit is contained in:
134 2026-07-03 13:55:53 +08:00
parent 39ee88bd12
commit cf85caeb5a

View File

@ -104,6 +104,8 @@ const SCALE_ADJUST_CROP_MAX_EDGE = 1536;
const SCALE_ADJUST_PATCH_WORKSPACE_MIN_MARGIN = 0.045;
const SCALE_ADJUST_PATCH_WORKSPACE_MAX_MARGIN = 0.18;
const SCALE_ADJUST_LAYOUT_GUIDE_WASH_COLOR = "#f5f7fb";
const SCALE_ADJUST_STRUCTURE_LOCK_MIN_MASK_RATIO = 0.006;
const SCALE_ADJUST_STRUCTURE_LOCK_COMPONENT_MIN_RATIO = 0.00018;
const MIN_REGION_EDGE_PX = 24;
const SCALE_ADJUST_OPERATION = "scale-adjust";
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
@ -116,6 +118,7 @@ type ScaleAdjustCropReference = {
};
type ScaleAdjustPatchReference = {
sourceCropUrl: string;
structureLockedDraft?: boolean;
targetDraftUrl: string;
workspaceRect: NormalizedLocalEditRegion["rect"];
workspacePixelRect: {
@ -1103,6 +1106,621 @@ function buildScaleAdjustTargetFootprintShadowSvg(params: {
);
}
function getScaleAdjustRawPixelIndex(width: number, x: number, y: number) {
return (y * width + x) * 3;
}
function getScaleAdjustPixelLuma(r: number, g: number, b: number) {
return r * 0.299 + g * 0.587 + b * 0.114;
}
function getScaleAdjustPixelStats(r: number, g: number, b: number) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
return {
luma: getScaleAdjustPixelLuma(r, g, b),
saturation: max > 0 ? (max - min) / max : 0,
};
}
function isScaleAdjustLikelySkinPixel(r: number, g: number, b: number) {
return (
r > 132 &&
g > 82 &&
b > 58 &&
r > g * 1.06 &&
g > b * 1.03 &&
r - b < 135
);
}
function isScaleAdjustLikelyWhiteFabricPixel(
r: number,
g: number,
b: number,
luma: number,
saturation: number
) {
return (
luma > 158 &&
saturation < 0.3 &&
Math.abs(r - g) < 34 &&
Math.abs(g - b) < 44
);
}
function estimateScaleAdjustBoxBackground(params: {
box: { left: number; top: number; width: number; height: number };
raw: Buffer;
width: number;
}) {
const { box, raw, width } = params;
const samples: Array<{ b: number; g: number; luma: number; r: number }> = [];
const borderX = Math.max(2, Math.round(box.width * 0.08));
const borderY = Math.max(2, Math.round(box.height * 0.08));
for (let localY = 0; localY < box.height; localY += 1) {
const y = box.top + localY;
const isBorderY = localY < borderY || localY >= box.height - borderY;
for (let localX = 0; localX < box.width; localX += 1) {
const isBorderX = localX < borderX || localX >= box.width - borderX;
if (!isBorderX && !isBorderY) continue;
const x = box.left + localX;
const rawIndex = getScaleAdjustRawPixelIndex(width, x, y);
const r = raw[rawIndex] || 0;
const g = raw[rawIndex + 1] || 0;
const b = raw[rawIndex + 2] || 0;
const { luma, saturation } = getScaleAdjustPixelStats(r, g, b);
if (isScaleAdjustLikelySkinPixel(r, g, b)) continue;
if (saturation > 0.42 && luma < 185) continue;
samples.push({ b, g, luma, r });
}
}
if (samples.length === 0) {
return { b: 245, g: 247, luma: 246, r: 245 };
}
samples.sort((left, right) => right.luma - left.luma);
const kept = samples.slice(0, Math.max(12, Math.ceil(samples.length * 0.38)));
const totals = kept.reduce(
(acc, sample) => {
acc.r += sample.r;
acc.g += sample.g;
acc.b += sample.b;
acc.luma += sample.luma;
return acc;
},
{ b: 0, g: 0, luma: 0, r: 0 }
);
return {
b: totals.b / kept.length,
g: totals.g / kept.length,
luma: totals.luma / kept.length,
r: totals.r / kept.length,
};
}
function dilateScaleAdjustStructureMask(params: {
height: number;
mask: Uint8Array;
radius: number;
width: number;
}) {
const { height, mask, radius, width } = params;
if (radius <= 0) return mask;
const output = new Uint8Array(mask.length);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (mask[y * width + x] === 0) continue;
const left = Math.max(0, x - radius);
const right = Math.min(width - 1, x + radius);
const top = Math.max(0, y - radius);
const bottom = Math.min(height - 1, y + radius);
for (let yy = top; yy <= bottom; yy += 1) {
for (let xx = left; xx <= right; xx += 1) {
output[yy * width + xx] = 255;
}
}
}
}
return output;
}
function buildScaleAdjustStructureInitialMask(params: {
box: { left: number; top: number; width: number; height: number };
raw: Buffer;
width: number;
}) {
const { box, raw, width } = params;
const background = estimateScaleAdjustBoxBackground(params);
const mask = new Uint8Array(box.width * box.height);
for (let localY = 0; localY < box.height; localY += 1) {
const y = box.top + localY;
for (let localX = 0; localX < box.width; localX += 1) {
if (localX >= box.width * 0.9) continue;
const x = box.left + localX;
const rawIndex = getScaleAdjustRawPixelIndex(width, x, y);
const r = raw[rawIndex] || 0;
const g = raw[rawIndex + 1] || 0;
const b = raw[rawIndex + 2] || 0;
const { luma, saturation } = getScaleAdjustPixelStats(r, g, b);
if (isScaleAdjustLikelySkinPixel(r, g, b)) continue;
if (isScaleAdjustLikelyWhiteFabricPixel(r, g, b, luma, saturation)) {
continue;
}
const colorDistance = Math.sqrt(
(r - background.r) ** 2 +
(g - background.g) ** 2 +
(b - background.b) ** 2
);
const darkStructure = luma < Math.min(136, background.luma - 38);
const saturatedStructure =
saturation > 0.16 && luma < 218 && colorDistance > 38;
const woodOrCaneStructure =
r > 46 &&
g > 26 &&
b < 132 &&
r >= g * 0.9 &&
g >= b * 1.02 &&
saturation > 0.18 &&
luma < 218;
if (darkStructure || saturatedStructure || woodOrCaneStructure) {
mask[localY * box.width + localX] = 255;
}
}
}
return dilateScaleAdjustStructureMask({
height: box.height,
mask,
radius: Math.max(1, Math.round(Math.min(box.width, box.height) * 0.004)),
width: box.width,
});
}
function filterScaleAdjustStructureComponents(params: {
box: { width: number; height: number };
mask: Uint8Array;
}) {
const { box, mask } = params;
const width = box.width;
const height = box.height;
const visited = new Uint8Array(mask.length);
const output = new Uint8Array(mask.length);
const queueX: number[] = [];
const queueY: number[] = [];
const minComponentArea = Math.max(
10,
Math.round(width * height * SCALE_ADJUST_STRUCTURE_LOCK_COMPONENT_MIN_RATIO)
);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const startIndex = y * width + x;
if (mask[startIndex] === 0 || visited[startIndex]) continue;
queueX.length = 0;
queueY.length = 0;
queueX.push(x);
queueY.push(y);
visited[startIndex] = 1;
let head = 0;
let area = 0;
let minX = x;
let minY = y;
let maxX = x;
let maxY = y;
while (head < queueX.length) {
const cx = queueX[head] || 0;
const cy = queueY[head] || 0;
head += 1;
area += 1;
minX = Math.min(minX, cx);
minY = Math.min(minY, cy);
maxX = Math.max(maxX, cx);
maxY = Math.max(maxY, cy);
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue;
const nx = cx + dx;
const ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
const nextIndex = ny * width + nx;
if (mask[nextIndex] === 0 || visited[nextIndex]) continue;
visited[nextIndex] = 1;
queueX.push(nx);
queueY.push(ny);
}
}
}
const componentWidth = maxX - minX + 1;
const componentHeight = maxY - minY + 1;
const componentBoxArea = Math.max(1, componentWidth * componentHeight);
const componentDensity = area / componentBoxArea;
const centerXRatio = (minX + componentWidth / 2) / width;
const touchesRight = maxX >= width * 0.88;
const edgeSliver = touchesRight && componentWidth < width * 0.18;
const elongated =
componentHeight > componentWidth * 2.4 ||
componentWidth > componentHeight * 2.4;
const likelyStructure =
area >= minComponentArea ||
(elongated && area >= Math.max(8, minComponentArea * 0.4));
const centeredEnough = centerXRatio >= 0.04 && centerXRatio <= 0.88;
const fillSurfaceComponent =
componentWidth >= width * 0.14 &&
componentHeight >= height * 0.045 &&
componentWidth <= width * 0.82 &&
componentHeight <= height * 0.42 &&
componentDensity >= 0.035 &&
componentDensity <= 0.72;
if (likelyStructure && centeredEnough && !edgeSliver) {
if (fillSurfaceComponent) {
for (let fillY = minY; fillY <= maxY; fillY += 1) {
for (let fillX = minX; fillX <= maxX; fillX += 1) {
output[fillY * width + fillX] = 255;
}
}
} else {
for (let index = 0; index < queueX.length; index += 1) {
output[(queueY[index] || 0) * width + (queueX[index] || 0)] = 255;
}
}
}
}
}
return dilateScaleAdjustStructureMask({
height,
mask: output,
radius: Math.max(1, Math.round(Math.min(width, height) * 0.006)),
width,
});
}
function solidifyScaleAdjustStructureMask(params: {
box: { width: number; height: number };
mask: Uint8Array;
}) {
const { box, mask } = params;
const width = box.width;
const height = box.height;
const bridgeGap = Math.max(2, Math.round(Math.min(width, height) * 0.006));
const output = dilateScaleAdjustStructureMask({
height,
mask,
radius: Math.max(1, Math.round(Math.min(width, height) * 0.004)),
width,
});
for (let y = 0; y < height; y += 1) {
let x = 0;
while (x < width) {
while (x < width && output[y * width + x] !== 0) x += 1;
const gapStart = x;
while (x < width && output[y * width + x] === 0) x += 1;
const gapEnd = x - 1;
const gapLength = gapEnd - gapStart + 1;
if (
gapLength > 0 &&
gapLength <= bridgeGap &&
gapStart > 0 &&
gapEnd < width - 1 &&
output[y * width + gapStart - 1] !== 0 &&
output[y * width + gapEnd + 1] !== 0
) {
for (let fillX = gapStart; fillX <= gapEnd; fillX += 1) {
output[y * width + fillX] = 255;
}
}
}
}
for (let x = 0; x < width; x += 1) {
let y = 0;
while (y < height) {
while (y < height && output[y * width + x] !== 0) y += 1;
const gapStart = y;
while (y < height && output[y * width + x] === 0) y += 1;
const gapEnd = y - 1;
const gapLength = gapEnd - gapStart + 1;
if (
gapLength > 0 &&
gapLength <= bridgeGap &&
gapStart > 0 &&
gapEnd < height - 1 &&
output[(gapStart - 1) * width + x] !== 0 &&
output[(gapEnd + 1) * width + x] !== 0
) {
for (let fillY = gapStart; fillY <= gapEnd; fillY += 1) {
output[fillY * width + x] = 255;
}
}
}
}
const panelRowThreshold = Math.max(8, Math.round(width * 0.055));
const panelMinHeight = Math.max(12, Math.round(height * 0.035));
const panelMaxGap = Math.max(1, Math.round(height * 0.006));
let panelStart = -1;
let panelGap = 0;
const getRowMarkedCount = (rowY: number) => {
let count = 0;
for (let rowX = 0; rowX < width; rowX += 1) {
if (output[rowY * width + rowX] !== 0) count += 1;
}
return count;
};
const fillPanelBand = (startY: number, endY: number) => {
const bandHeight = endY - startY + 1;
if (bandHeight < panelMinHeight) return;
let minX = width;
let maxX = -1;
let marked = 0;
for (let bandY = startY; bandY <= endY; bandY += 1) {
for (let bandX = 0; bandX < width; bandX += 1) {
if (output[bandY * width + bandX] === 0) continue;
minX = Math.min(minX, bandX);
maxX = Math.max(maxX, bandX);
marked += 1;
}
}
if (maxX < minX) return;
const span = maxX - minX + 1;
const density = marked / Math.max(1, span * bandHeight);
if (
span < width * 0.16 ||
span > width * 0.86 ||
density < 0.025 ||
density > 0.78
) {
return;
}
for (let fillY = startY; fillY <= endY; fillY += 1) {
for (let fillX = minX; fillX <= maxX; fillX += 1) {
output[fillY * width + fillX] = 255;
}
}
};
for (let y = 0; y < height; y += 1) {
const hasPanelRow = getRowMarkedCount(y) >= panelRowThreshold;
if (hasPanelRow) {
if (panelStart < 0) panelStart = y;
panelGap = 0;
continue;
}
if (panelStart < 0) continue;
panelGap += 1;
if (panelGap <= panelMaxGap) continue;
fillPanelBand(panelStart, y - panelGap);
panelStart = -1;
panelGap = 0;
}
if (panelStart >= 0) {
fillPanelBand(panelStart, height - 1 - panelGap);
}
return dilateScaleAdjustStructureMask({
height,
mask: output,
radius: Math.max(1, Math.round(Math.min(width, height) * 0.004)),
width,
});
}
function getScaleAdjustMaskBoundingBox(params: {
height: number;
mask: Uint8Array;
width: number;
}) {
const { height, mask, width } = params;
let left = width;
let top = height;
let right = -1;
let bottom = -1;
let count = 0;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (mask[y * width + x] === 0) continue;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x);
bottom = Math.max(bottom, y);
count += 1;
}
}
if (right < left || bottom < top) return null;
return {
count,
left,
top,
width: right - left + 1,
height: bottom - top + 1,
};
}
function extractScaleAdjustMaskBuffer(params: {
box: { left: number; top: number; width: number; height: number };
mask: Uint8Array;
maskWidth: number;
}) {
const { box, mask, maskWidth } = params;
const output = Buffer.alloc(box.width * box.height);
for (let y = 0; y < box.height; y += 1) {
const sourceStart = (box.top + y) * maskWidth + box.left;
const targetStart = y * box.width;
for (let x = 0; x < box.width; x += 1) {
output[targetStart + x] = mask[sourceStart + x] || 0;
}
}
return output;
}
async function buildScaleAdjustMaskWashPatch(params: {
background: { b: number; g: number; r: number };
box: { width: number; height: number };
maskBuffer: Buffer;
}) {
const { background, box, maskBuffer } = params;
const alpha = await sharp(maskBuffer, {
raw: { channels: 1, height: box.height, width: box.width },
})
.blur(Math.max(1.2, Math.min(box.width, box.height) * 0.018))
.raw()
.toBuffer();
const rgba = Buffer.alloc(box.width * box.height * 4);
for (let index = 0; index < box.width * box.height; index += 1) {
const offset = index * 4;
rgba[offset] = Math.round(background.r);
rgba[offset + 1] = Math.round(background.g);
rgba[offset + 2] = Math.round(background.b);
rgba[offset + 3] = Math.round((alpha[index] || 0) * 0.9);
}
return sharp(rgba, {
raw: { channels: 4, height: box.height, width: box.width },
})
.png({ compressionLevel: 6 })
.toBuffer();
}
async function buildScaleAdjustStructureLockedComposite(params: {
placement: ReturnType<typeof getScaleAdjustVisualGuidePlacement>;
raw: Buffer;
sourceBuffer: Buffer;
sourceWidth: number;
}) {
const { placement, raw, sourceBuffer, sourceWidth } = params;
const initialMask = buildScaleAdjustStructureInitialMask({
box: placement.sourceBox,
raw,
width: sourceWidth,
});
const mask = filterScaleAdjustStructureComponents({
box: placement.sourceBox,
mask: initialMask,
});
const structureBbox = getScaleAdjustMaskBoundingBox({
height: placement.sourceBox.height,
mask,
width: placement.sourceBox.width,
});
if (!structureBbox) return null;
const maskRatio = structureBbox.count / Math.max(1, placement.sourceBox.width * placement.sourceBox.height);
if (maskRatio < SCALE_ADJUST_STRUCTURE_LOCK_MIN_MASK_RATIO) return null;
const subjectMask = solidifyScaleAdjustStructureMask({
box: placement.sourceBox,
mask,
});
const bbox = getScaleAdjustMaskBoundingBox({
height: placement.sourceBox.height,
mask: subjectMask,
width: placement.sourceBox.width,
});
if (!bbox) return null;
const sourceSubjectBox = {
left: placement.sourceBox.left + bbox.left,
top: placement.sourceBox.top + bbox.top,
width: bbox.width,
height: bbox.height,
};
const sourceSubjectMask = extractScaleAdjustMaskBuffer({
box: bbox,
mask: subjectMask,
maskWidth: placement.sourceBox.width,
});
const background = estimateScaleAdjustBoxBackground({
box: placement.sourceBox,
raw,
width: sourceWidth,
});
const sourceWashMask = dilateScaleAdjustStructureMask({
height: placement.sourceBox.height,
mask: subjectMask,
radius: Math.max(
2,
Math.round(Math.min(placement.sourceBox.width, placement.sourceBox.height) * 0.012)
),
width: placement.sourceBox.width,
});
const sourceWashPatch = await buildScaleAdjustMaskWashPatch({
background,
box: placement.sourceBox,
maskBuffer: Buffer.from(sourceWashMask),
});
const subjectCrop = await sharp(sourceBuffer, { failOn: "none" })
.extract(sourceSubjectBox)
.removeAlpha()
.png({ compressionLevel: 6 })
.toBuffer();
const subjectAlpha = await sharp(sourceSubjectMask, {
raw: { channels: 1, height: bbox.height, width: bbox.width },
})
.blur(Math.max(0.7, Math.min(bbox.width, bbox.height) * 0.006))
.png({ compressionLevel: 6 })
.toBuffer();
const widthFactor =
placement.targetBox.width / Math.max(1, placement.sourceFootprintBox.width);
const heightFactor =
placement.targetBox.height / Math.max(1, placement.sourceFootprintBox.height);
const structureScale = Math.max(
0.05,
Math.min(
3.5,
Math.sqrt(
Math.max(0.0025, widthFactor) * Math.max(0.0025, heightFactor)
)
)
);
const placedWidth = Math.max(1, Math.round(bbox.width * structureScale));
const placedHeight = Math.max(1, Math.round(bbox.height * structureScale));
const sourceCenterX = sourceSubjectBox.left + sourceSubjectBox.width / 2;
const sourceCenterY = sourceSubjectBox.top + sourceSubjectBox.height / 2;
const targetCenterX =
placement.targetBox.left +
((sourceCenterX - placement.sourceFootprintBox.left) /
Math.max(1, placement.sourceFootprintBox.width)) *
placement.targetBox.width;
const targetCenterY =
placement.targetBox.top +
((sourceCenterY - placement.sourceFootprintBox.top) /
Math.max(1, placement.sourceFootprintBox.height)) *
placement.targetBox.height;
const structureOverlay = await sharp(subjectCrop, { failOn: "none" })
.joinChannel(subjectAlpha)
.resize(placedWidth, placedHeight, {
fit: "fill",
kernel: sharp.kernel.lanczos3,
})
.png({ compressionLevel: 6 })
.toBuffer();
return {
overlay: {
input: structureOverlay,
left: Math.round(targetCenterX - placedWidth / 2),
top: Math.round(targetCenterY - placedHeight / 2),
},
wash: {
input: sourceWashPatch,
left: placement.sourceBox.left,
top: placement.sourceBox.top,
},
};
}
function getScaleAdjustEditMaskBoxes(params: {
regions: NormalizedLocalEditRegion[];
scalePercent?: ScaleAdjustPercent | null;
@ -1192,9 +1810,23 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
sourceBuffer: Buffer;
sourceHeight: number;
sourceWidth: number;
structureLockedDraft?: boolean;
}) {
const { regions, scalePercent, sourceBuffer, sourceHeight, sourceWidth } =
params;
const {
regions,
scalePercent,
sourceBuffer,
sourceHeight,
sourceWidth,
structureLockedDraft = false,
} = params;
const sourceRaw = structureLockedDraft
? await sharp(sourceBuffer, { failOn: "none" })
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer()
: null;
const sourceFootprintBoxes: Array<{
left: number;
top: number;
@ -1204,6 +1836,11 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
const placements: Array<ReturnType<typeof getScaleAdjustVisualGuidePlacement>> =
[];
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
const subjectWashComposites: Array<{
input: Buffer;
left: number;
top: number;
}> = [];
for (const region of regions) {
const placement = getScaleAdjustVisualGuidePlacement({
@ -1212,8 +1849,31 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
scalePercent,
width: sourceWidth,
});
sourceFootprintBoxes.push(placement.sourceFootprintBox);
placements.push(placement);
const targetWidthFactor =
placement.targetBox.width / Math.max(1, placement.sourceFootprintBox.width);
const targetHeightFactor =
placement.targetBox.height / Math.max(1, placement.sourceFootprintBox.height);
const shouldUseStructureLock =
structureLockedDraft &&
sourceRaw &&
(targetWidthFactor > 1.08 || targetHeightFactor > 1.08);
const structureLockedComposite = shouldUseStructureLock
? await buildScaleAdjustStructureLockedComposite({
placement,
raw: sourceRaw,
sourceBuffer,
sourceWidth,
})
: null;
if (structureLockedComposite) {
subjectWashComposites.push(structureLockedComposite.wash);
composites.push(structureLockedComposite.overlay);
continue;
}
sourceFootprintBoxes.push(placement.sourceFootprintBox);
const resizedCropBuffer = await sharp(sourceBuffer, { failOn: "none" })
.extract(placement.sourceBox)
@ -1256,21 +1916,28 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
]
: [];
const targetShadowComposite = placements.length
? [
{
input: buildScaleAdjustTargetFootprintShadowSvg({
height: sourceHeight,
placements,
width: sourceWidth,
}),
left: 0,
top: 0,
},
]
? structureLockedDraft
? []
: [
{
input: buildScaleAdjustTargetFootprintShadowSvg({
height: sourceHeight,
placements,
width: sourceWidth,
}),
left: 0,
top: 0,
},
]
: [];
return sharp(sourceBuffer, { failOn: "none" })
.composite([...washComposite, ...targetShadowComposite, ...composites])
.composite([
...washComposite,
...subjectWashComposites,
...targetShadowComposite,
...composites,
])
.png({ compressionLevel: 6 })
.toBuffer();
}
@ -1286,12 +1953,14 @@ async function createScaleAdjustPatchReference(params: {
params;
const workspaceRect = getScaleAdjustPatchWorkspaceRect(regions, scalePercent);
const workspacePixelRect = rectToPixels(workspaceRect, sourceWidth, sourceHeight);
const structureLockedDraft = hasScaleAdjustEnlargeTarget(regions, scalePercent);
const targetDraftBuffer = await buildScaleAdjustVisualLayoutGuideBuffer({
regions,
scalePercent,
sourceBuffer,
sourceHeight,
sourceWidth,
structureLockedDraft,
});
const [sourceCropBuffer, targetDraftCropBuffer] = await Promise.all([
sharp(sourceBuffer, { failOn: "none" })
@ -1317,6 +1986,7 @@ async function createScaleAdjustPatchReference(params: {
return {
sourceCropUrl: sourceCropUpload.url,
structureLockedDraft,
targetDraftUrl: targetDraftUpload.url,
workspacePixelRect,
workspaceRect,
@ -2093,14 +2763,19 @@ function buildScaleAdjustPatchPrompt(
) {
const targetDraftPrimary = options.targetDraftPrimary === true;
const includeSourceReference = options.includeSourceReference !== false;
const structureLockedDraft = patchReference.structureLockedDraft === true;
const sourceInputIndex = targetDraftPrimary && includeSourceReference ? 2 : 1;
const draftInputIndex = targetDraftPrimary ? 1 : 2;
const cropReferenceStartIndex =
targetDraftPrimary && !includeSourceReference ? 2 : 3;
const firstInputRole = targetDraftPrimary
? includeSourceReference
? "Input image 1 is the rough edited target-layout crop. It is the hard layout target: the selected furniture size and center in the final output must match this draft, but the rough wash, ghosted old footprint, placeholder shadow, blur, and patch artifacts must be cleaned."
: "Input image 1 is the rough edited target-layout crop and the only visual reference. It is the hard layout target: the selected furniture size and center in the final output must match this draft. Clean away the rough wash, ghosted old footprint, placeholder shadow, blur, and patch artifacts."
? structureLockedDraft
? includeSourceReference
? "Input image 1 is a rough product-structure-locked target crop. The selected furniture/product was moved and scaled using source-photo pixels, so its final size, position, rods, panels, weave, wheels, material, and perspective are the hard target. Repair rough background holes, edge halos, blur, occlusion, contact shadows, and surface artifacts without redesigning the product. Input image 2 is only the clean background/person reference, never the size reference."
: "Input image 1 is a rough product-structure-locked target crop and the only visual reference. The selected furniture/product was moved and scaled using source-photo pixels, so its final size, position, rods, panels, weave, wheels, material, and perspective are the hard target. Repair rough background holes, edge halos, blur, occlusion, contact shadows, and surface artifacts without redesigning the product."
: includeSourceReference
? "Input image 1 is the rough edited target-layout crop. It is the hard layout target: the selected furniture size and center in the final output must match this draft, but the rough wash, ghosted old footprint, placeholder shadow, blur, and patch artifacts must be cleaned."
: "Input image 1 is the rough edited target-layout crop and the only visual reference. It is the hard layout target: the selected furniture size and center in the final output must match this draft. Clean away the rough wash, ghosted old footprint, placeholder shadow, blur, and patch artifacts."
: "Input image 1 is the exact crop from the original photo and is the locked crop base.";
const secondInputRole = targetDraftPrimary
? includeSourceReference
@ -2147,6 +2822,19 @@ function buildScaleAdjustPatchPrompt(
return `Region ${region.index}: current search area inside input image ${sourceInputIndex} crop is x=${sourceInPatch.left}, y=${sourceInPatch.top}, w=${sourceInPatch.width}, h=${sourceInPatch.height}; final visual footprint inside input image ${draftInputIndex} / output crop is x=${targetInPatch.left}, y=${targetInPatch.top}, w=${targetInPatch.width}, h=${targetInPatch.height}; requested scale width=${widthFactor.toFixed(2)}, height=${heightFactor.toFixed(2)}. User request: ${region.prompt || action}. Main action: ${action}. The final object must be much closer to the final footprint than to the current search area.${cropNote}`;
})
.join("\n");
const chineseHardConstraint = (() => {
if (!targetDraftPrimary) {
return "中文硬约束:只输出输入图1同一裁切范围的干净局部照片;输入图2是目标大小/位置草稿,必须按它的目标尺寸和目标位置输出,不是要保留旧尺寸。目标家具必须相对原图产生明显比例变化,非目标背景和裁切边缘要稳定自然。";
}
if (structureLockedDraft) {
return includeSourceReference
? "中文硬约束:输入图1已经用原图产品像素把目标家具/产品放到目标尺寸和目标位置,产品结构、杆件、面板、编织纹理、轮子、材质和透视都要按输入图1保留。输入图2只能作为干净背景、人物、光影参考,绝对不能把产品恢复成输入图2里的旧尺寸。不能重新设计或替换产品,但要清理产品表面的草稿黑斑、破洞、脏点、灰块和藤编断裂,让藤编面板连续干净、轮子只出现在底部。"
: "中文硬约束:输入图1已经用原图产品像素把目标家具/产品放到目标尺寸和目标位置,产品结构、杆件、面板、编织纹理、轮子、材质和透视都要按输入图1保留。不能重新设计或替换产品,但要清理产品表面的草稿黑斑、破洞、脏点、灰块和藤编断裂,让藤编面板连续干净、轮子只出现在底部。";
}
return includeSourceReference
? "中文硬约束:输入图1是目标大小/位置草稿,必须按它的目标尺寸和目标位置输出;输入图2只是原始质感、人物和背景参考,绝对不要把家具恢复成输入图2里的旧尺寸。只输出同一裁切范围的干净局部照片,去掉草稿中的泛白、残影和拼接感。"
: "中文硬约束:输入图1是唯一视觉参考和目标大小/位置草稿,必须按它的目标尺寸和目标位置输出。不要找回旧尺寸家具。只输出同一裁切范围的干净局部照片,去掉草稿中的泛白、残影和拼接感。";
})();
return [
"Local furniture scale edit inside a cropped workspace.",
@ -2157,15 +2845,16 @@ function buildScaleAdjustPatchPrompt(
"Only edit the selected movable furniture subject. Keep the crop borders, wall, window frame, floor, rug, sofa, lamps, decor, lighting, camera perspective, and all non-target furniture stable.",
"If people, faces, arms, hands, bodies, hair, or worn clothing appear in the crop, they are not furniture. Preserve the human scale, identity, pose, face, hand shape, and clothing position as much as possible; do not enlarge, shrink, duplicate, or redraw the person as part of the furniture scale edit.",
"When the final footprint is smaller, the selected furniture must clearly shrink; erase the old larger furniture footprint outside the final footprint and replace it with natural background/floor/rug/wall/shadow that matches the original crop.",
"When the final footprint is larger, enlarge the selected furniture itself to match the target draft while keeping nearby people and hands natural. If a hand touches or holds an item on the furniture, keep the hand anatomically plausible and use the original crop as the person-preservation reference.",
structureLockedDraft
? "When the final footprint is larger, the target draft already places the same furniture/product at the required larger size. Do not redesign the product geometry; keep the locked rods, panels, weave, wheels, material, and perspective while cleaning background gaps, boundary blending, rough surface artifacts, natural occlusion, and contact shadows."
: "When the final footprint is larger, enlarge the selected furniture itself to match the target draft while keeping nearby people and hands natural. If a hand touches or holds an item on the furniture, keep the hand anatomically plausible and use the original crop as the person-preservation reference.",
"Keep the same furniture identity, material, perspective, object details, contact shadows, and natural aspect ratio. Do not stretch, squash, duplicate, or replace it with a different item.",
"Hard failure condition: if the selected furniture remains near its original old size, the answer is wrong. The output must visibly change the furniture footprint to match the target draft footprint.",
structureLockedDraft
? "Remove every rough draft artifact from input image 1: gray fill patches, beige wash, flat painted areas, jagged mask holes, broken woven-panel cells, black speckled stains, dark blotches on cane panels, rectangular crop edges, and ghost fragments. Woven panels must look like continuous clean cane weave; wheels belong only under the bottom frame, not as black stains inside the woven panel. Rods, wheels, and hooks must remain the same product structure, with natural material texture."
: "",
"Do not output boxes, lines, labels, coordinate marks, UI marks, text, arrows, overlays, rough layout artifacts, rectangular patch edges, or a full-room image.",
targetDraftPrimary
? includeSourceReference
? "中文硬约束:输入图1是目标大小/位置草稿,必须按它的目标尺寸和目标位置输出;输入图2只是原始质感、人物和背景参考,绝对不要把家具恢复成输入图2里的旧尺寸。只输出同一裁切范围的干净局部照片,去掉草稿中的泛白、残影和拼接感。"
: "中文硬约束:输入图1是唯一视觉参考和目标大小/位置草稿,必须按它的目标尺寸和目标位置输出。不要找回旧尺寸家具。只输出同一裁切范围的干净局部照片,去掉草稿中的泛白、残影和拼接感。"
: "中文硬约束:只输出输入图1同一裁切范围的干净局部照片;输入图2是目标大小/位置草稿,必须按它的目标尺寸和目标位置输出,不是要保留旧尺寸。目标家具必须相对原图产生明显比例变化,非目标背景和裁切边缘要稳定自然。",
chineseHardConstraint,
"",
"Region instructions:",
regionLines,
@ -2354,11 +3043,15 @@ export async function POST(request: NextRequest) {
!useProviderTextOnlyCoordinatePrompt;
const useScaleAdjustMarkedPhotoGuide =
useScaleAdjustCoordinatePrompt && !useScaleAdjustVisualLayoutGuide;
const useScaleAdjustStructureLockedPatchWorkflow =
isScaleAdjustOperation && hasScaleAdjustEnlargeTarget(regions, scalePercent);
const useScaleAdjustEditMask =
isScaleAdjustOperation &&
!useScaleAdjustStructureLockedPatchWorkflow &&
isScaleAdjustMaskCapableModel(resolvedScaleAdjustModelEntry);
const useScaleAdjustPatchWorkflow =
isScaleAdjustOperation && !useScaleAdjustEditMask;
isScaleAdjustOperation &&
(useScaleAdjustStructureLockedPatchWorkflow || !useScaleAdjustEditMask);
const sendScaleAdjustAnnotatedReferenceToModel =
isScaleAdjustOperation &&
!useScaleAdjustPatchWorkflow &&
@ -2460,7 +3153,8 @@ export async function POST(request: NextRequest) {
})
: [];
const useScaleAdjustPatchTargetDraftPrimary = Boolean(scaleAdjustPatchReference);
const includeScaleAdjustPatchSourceReference = false;
const includeScaleAdjustPatchSourceReference =
scaleAdjustPatchReference?.structureLockedDraft === true;
const prompt = isScaleAdjustOperation
? scaleAdjustPatchReference
? buildScaleAdjustPatchPrompt(
@ -2509,7 +3203,7 @@ export async function POST(request: NextRequest) {
const scaleAdjustTextOnlyNegativePrompt =
"artificial graphic overlay, visible coordinate aid, visible line, visible label, UI element, annotation text, full image redesign, full-frame zoom, crop, pan, pasted crop, hard patch edge, color mismatch, changed sofa, changed lamp, changed wall, changed rug, duplicate furniture, stretched furniture, distorted furniture";
const scaleAdjustPatchNegativePrompt =
"full room image, changed crop border, artificial graphic overlay, visible coordinate aid, visible line, visible label, UI element, annotation text, guide mark, pasted crop, hard patch edge, color mismatch, duplicate furniture, unchanged target furniture, stretched furniture, distorted furniture";
"full room image, changed crop border, artificial graphic overlay, visible coordinate aid, visible line, visible label, UI element, annotation text, guide mark, pasted crop, hard patch edge, color mismatch, gray draft patch, beige wash patch, flat painted area, broken woven panel, jagged mask hole, black speckled stain, dark blotch on cane panel, dirty woven panel, wheel inside woven panel, duplicate furniture, unchanged target furniture, changed locked product, redrawn product interior, different furniture, stretched furniture, distorted furniture";
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
@ -2575,11 +3269,16 @@ export async function POST(request: NextRequest) {
: {}),
}
: undefined;
const resolvedRequestImageSize = scaleAdjustPatchReference
? "1K"
: typeof body.imageSize === "string" && body.imageSize.trim()
const requestedImageSize =
typeof body.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: "2K";
const resolvedRequestImageSize =
scaleAdjustPatchReference?.structureLockedDraft === true
? requestedImageSize
: scaleAdjustPatchReference
? "1K"
: requestedImageSize;
const imageRequest: CanvasGenerateImageRequest = {
allowFailover: isScaleAdjustOperation ? false : undefined,
aspectRatio: scaleAdjustPatchReference
@ -2627,6 +3326,8 @@ export async function POST(request: NextRequest) {
requestedModel: requestedModel || null,
resolvedAspectRatio,
scaleAdjustPatchWorkflowApplied: Boolean(scaleAdjustPatchReference),
scaleAdjustPatchStructureLockedDraftApplied:
scaleAdjustPatchReference?.structureLockedDraft === true,
scaleAdjustPatchReferenceOrder:
scaleAdjustPatchReference && useScaleAdjustPatchTargetDraftPrimary
? includeScaleAdjustPatchSourceReference
@ -2690,7 +3391,9 @@ export async function POST(request: NextRequest) {
.length,
scaleAdjustPromptStrategy: isScaleAdjustOperation
? scaleAdjustPatchReference
? "ai-patch-workspace-draft-v70-one-shot"
? scaleAdjustPatchReference.structureLockedDraft
? "ai-patch-workspace-structure-locked-draft-v85-one-shot"
: "ai-patch-workspace-draft-v70-one-shot"
: useFalScaleAdjustCoordinatePrompt
? "fal-safe-marked-photo-box-guide-no-old-crop-v44-one-shot"
: useScaleAdjustVisualLayoutGuide