5179 lines
216 KiB
TypeScript
5179 lines
216 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import sharp from "sharp";
|
||
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import {
|
||
executeRmbgJob,
|
||
fetchImageBufferFromUrl,
|
||
} from "@/lib/ai/rmbg/service";
|
||
import {
|
||
createCanvasImageTask,
|
||
enqueueCanvasImageTask,
|
||
getCanvasImageTaskForUser,
|
||
} from "@/lib/canvas/image-task-queue";
|
||
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
||
import {
|
||
getEnabledAiModelsWithProvider,
|
||
getPointsSystemConfig,
|
||
getSiteSettings,
|
||
} from "@/lib/db/queries";
|
||
import {
|
||
modelLacksReferenceImageInputSupport,
|
||
} from "@/lib/ai/reference-image-capability";
|
||
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
|
||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||
import { uploadFile } from "@/lib/storage/service";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 120;
|
||
|
||
type LocalEditRect = {
|
||
x?: number;
|
||
y?: number;
|
||
width?: number;
|
||
height?: number;
|
||
};
|
||
|
||
type LocalEditRegionInput = {
|
||
id?: string;
|
||
index?: number;
|
||
color?: string;
|
||
prompt?: string;
|
||
rect?: LocalEditRect;
|
||
targetRect?: LocalEditRect;
|
||
referenceImages?: Array<{
|
||
id?: string;
|
||
url?: string;
|
||
name?: string;
|
||
source?: string;
|
||
}>;
|
||
};
|
||
|
||
type LocalEditRequest = {
|
||
sourceImageUrl?: string;
|
||
sourceNodeId?: string;
|
||
regions?: LocalEditRegionInput[];
|
||
model?: string;
|
||
imageSize?: string;
|
||
aspectRatio?: string;
|
||
operation?: string;
|
||
scalePercent?: number;
|
||
};
|
||
|
||
type NormalizedLocalEditRegion = {
|
||
id: string;
|
||
index: number;
|
||
color: string;
|
||
colorName: string;
|
||
prompt: string;
|
||
referenceImages: Array<{
|
||
id: string;
|
||
url: string;
|
||
name?: string;
|
||
source: "upload" | "canvas";
|
||
}>;
|
||
rect: {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
targetRect?: {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
} | null;
|
||
pixelRect: {
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
targetPixelRect?: {
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
} | null;
|
||
};
|
||
|
||
const MAX_LOCAL_EDIT_REGIONS = 8;
|
||
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
|
||
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_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_LAYOUT_GUIDE_WASH_OPACITY = 0.46;
|
||
const SCALE_ADJUST_STRUCTURE_LOCK_MIN_MASK_RATIO = 0.006;
|
||
const SCALE_ADJUST_STRUCTURE_LOCK_COMPONENT_MIN_RATIO = 0.00018;
|
||
const SCALE_ADJUST_RMBG_MIN_ALPHA_RATIO = 0.012;
|
||
const SCALE_ADJUST_RMBG_MAX_SOLID_ALPHA_RATIO = 0.92;
|
||
const SCALE_ADJUST_RMBG_ALPHA_CLEAN_THRESHOLD = 42;
|
||
const MIN_REGION_EDGE_PX = 24;
|
||
const SCALE_ADJUST_OPERATION = "scale-adjust";
|
||
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
|
||
const MIN_SCALE_ADJUST_PERCENT = -80;
|
||
const MAX_SCALE_ADJUST_PERCENT = 200;
|
||
type ScaleAdjustPercent = number;
|
||
type ScaleAdjustCropReference = {
|
||
regionIndex: number;
|
||
url: string;
|
||
};
|
||
type ScaleAdjustPatchReference = {
|
||
compositeClipMaskUrl?: string | null;
|
||
sourceCropUrl: string;
|
||
structureLockedDraft?: boolean;
|
||
targetDraftFullUrl?: string | null;
|
||
targetDraftUrl: string;
|
||
workspaceRect: NormalizedLocalEditRegion["rect"];
|
||
workspacePixelRect: {
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
};
|
||
type ScaleAdjustStructureLockedComposite = {
|
||
compositeClip?: { input: Buffer; left: number; top: number };
|
||
method?: "pixel-mask" | "rect-crop-guide" | "rmbg-cutout";
|
||
overlay: { input: Buffer; left: number; top: number };
|
||
targetClip?: { input: Buffer; left: number; top: number };
|
||
wash: { input: Buffer; left: number; top: number };
|
||
};
|
||
type AiModelWithProviderForSelection = Awaited<
|
||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||
>[number];
|
||
const DEFAULT_REGION_COLORS = [
|
||
"#ff3b30",
|
||
"#1677ff",
|
||
"#00c853",
|
||
"#ff9500",
|
||
"#af52de",
|
||
"#00bcd4",
|
||
"#ff2d55",
|
||
"#8d6e63",
|
||
];
|
||
const SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS = [
|
||
"1:1",
|
||
"1:4",
|
||
"1:8",
|
||
"2:3",
|
||
"3:2",
|
||
"3:4",
|
||
"4:1",
|
||
"4:3",
|
||
"4:5",
|
||
"5:4",
|
||
"8:1",
|
||
"9:16",
|
||
"16:9",
|
||
"21:9",
|
||
] as const;
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.min(max, Math.max(min, value));
|
||
}
|
||
|
||
function parseAspectRatioValue(ratio: string) {
|
||
const [width, height] = ratio.split(":").map((part) => Number(part));
|
||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||
return 1;
|
||
}
|
||
return width / height;
|
||
}
|
||
|
||
function inferClosestAspectRatio(width: number, height: number) {
|
||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||
return "1:1";
|
||
}
|
||
const sourceRatio = width / height;
|
||
let closest: (typeof SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS)[number] = "1:1";
|
||
let closestDiff = Number.POSITIVE_INFINITY;
|
||
for (const candidate of SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS) {
|
||
const diff = Math.abs(parseAspectRatioValue(candidate) - sourceRatio);
|
||
if (diff < closestDiff) {
|
||
closest = candidate;
|
||
closestDiff = diff;
|
||
}
|
||
}
|
||
return closest;
|
||
}
|
||
|
||
function resolveLocalEditAspectRatio(input: unknown, sourceWidth: number, sourceHeight: number) {
|
||
const requested = String(input || "").trim();
|
||
if (
|
||
requested &&
|
||
requested.toLowerCase() !== "auto" &&
|
||
(SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS as readonly string[]).includes(requested)
|
||
) {
|
||
return requested;
|
||
}
|
||
return inferClosestAspectRatio(sourceWidth, sourceHeight);
|
||
}
|
||
|
||
function toArrayBuffer(buffer: Buffer) {
|
||
return buffer.buffer.slice(
|
||
buffer.byteOffset,
|
||
buffer.byteOffset + buffer.byteLength
|
||
) as ArrayBuffer;
|
||
}
|
||
|
||
function escapeXml(value: string) {
|
||
return value
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function normalizeHexColor(input: unknown, fallback: string) {
|
||
const raw = String(input || "").trim();
|
||
const expanded = raw.replace(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i, "#$1$1$2$2$3$3");
|
||
return /^#[0-9a-f]{6}$/i.test(expanded) ? expanded.toLowerCase() : fallback;
|
||
}
|
||
|
||
function findModelSelectionEntry(
|
||
models: AiModelWithProviderForSelection[],
|
||
modelRef: string
|
||
) {
|
||
const normalizedRef = String(modelRef || "").trim();
|
||
if (!normalizedRef) return null;
|
||
return (
|
||
models.find(
|
||
(entry) =>
|
||
entry.model.id === normalizedRef || entry.model.modelId === normalizedRef
|
||
) || null
|
||
);
|
||
}
|
||
|
||
function isScaleAdjustDedicatedDetailModel(
|
||
entry: AiModelWithProviderForSelection | null | undefined
|
||
) {
|
||
if (
|
||
!entry?.model?.supportsImageGen ||
|
||
modelLacksReferenceImageInputSupport(entry.model, entry.provider)
|
||
) {
|
||
return false;
|
||
}
|
||
const label = `${entry.model.displayName || ""} ${entry.model.modelId || ""}`
|
||
.toLowerCase()
|
||
.replace(/\s+/g, "");
|
||
return label.includes("详情专线");
|
||
}
|
||
|
||
function normalizeScaleAdjustModelText(input: unknown) {
|
||
return String(input || "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.normalize("NFKC")
|
||
.replace(/[「」『』【】[\]()(){}]/g, " ")
|
||
.replace(/[_/|::,,.。+]+/g, "-")
|
||
.replace(/\s+/g, " ");
|
||
}
|
||
|
||
function resolveScaleAdjustModelFamilyFromText(input: unknown) {
|
||
const normalized = normalizeScaleAdjustModelText(input);
|
||
const compact = normalized.replace(/[^a-z0-9]+/g, "");
|
||
if (!normalized && !compact) return "";
|
||
if (
|
||
normalized.includes("gemini-3.1-flash-image-preview") ||
|
||
normalized.includes("nano-banana-2") ||
|
||
compact.includes("nanobanana2")
|
||
) {
|
||
return "nano-banana-2";
|
||
}
|
||
if (
|
||
normalized.includes("gemini-3-pro-image-preview") ||
|
||
normalized.includes("nano-banana-pro") ||
|
||
compact.includes("nanobananapro")
|
||
) {
|
||
return "nano-banana-pro";
|
||
}
|
||
if (normalized.includes("gpt-image-2") || compact.includes("gptimage2")) {
|
||
return "gpt-image-2";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function getScaleAdjustModelFamily(
|
||
entry: AiModelWithProviderForSelection | null | undefined
|
||
) {
|
||
if (!entry) return "";
|
||
return (
|
||
resolveScaleAdjustModelFamilyFromText(entry.model.modelId) ||
|
||
resolveScaleAdjustModelFamilyFromText(entry.model.displayName) ||
|
||
resolveScaleAdjustModelFamilyFromText(entry.model.id)
|
||
);
|
||
}
|
||
|
||
function isScaleAdjustMaskCapableModel(
|
||
entry: AiModelWithProviderForSelection | null | undefined
|
||
) {
|
||
if (!entry || getScaleAdjustModelFamily(entry) !== "gpt-image-2") {
|
||
return false;
|
||
}
|
||
if (entry.provider.type === "openai-image") return true;
|
||
if (entry.provider.type === "fal-image") {
|
||
return isFalGptImage2EditModelId(entry.model.modelId || "");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isScaleAdjustCoordinateGuideModel(
|
||
entry: AiModelWithProviderForSelection | null | undefined
|
||
) {
|
||
return getScaleAdjustModelFamily(entry) === "nano-banana-2";
|
||
}
|
||
|
||
function isScaleAdjustFalNanoBananaEditModel(
|
||
entry: AiModelWithProviderForSelection | null | undefined
|
||
) {
|
||
if (
|
||
!entry ||
|
||
entry.provider.type !== "fal-image" ||
|
||
getScaleAdjustModelFamily(entry) !== "nano-banana-2"
|
||
) {
|
||
return false;
|
||
}
|
||
const label = `${entry.model.displayName || ""} ${entry.model.modelId || ""}`
|
||
.toLowerCase()
|
||
.replace(/\s+/g, " ");
|
||
return label.includes("/edit") || /\bedit\b/.test(label);
|
||
}
|
||
|
||
function isScaleAdjustHealthyModel(
|
||
entry: AiModelWithProviderForSelection | null | undefined
|
||
) {
|
||
return !entry?.model?.healthStatus || entry.model.healthStatus === "healthy";
|
||
}
|
||
|
||
function getScaleAdjustProviderPriority(providerType: string) {
|
||
switch (providerType) {
|
||
case "ruxa-image":
|
||
return 10;
|
||
case "snapgen-image":
|
||
return 20;
|
||
case "krea":
|
||
return 30;
|
||
case "gemini":
|
||
case "grsai-gpt-image":
|
||
return 40;
|
||
case "alibaba-image":
|
||
case "bfl-image":
|
||
case "openai-image":
|
||
case "stepfun-image":
|
||
case "volcano-vision":
|
||
return 60;
|
||
case "fal-image":
|
||
return 1000;
|
||
default:
|
||
return 80;
|
||
}
|
||
}
|
||
|
||
function getScaleAdjustModelHealthPenalty(
|
||
healthStatus: string | null | undefined
|
||
) {
|
||
if (healthStatus === "degraded") return 25;
|
||
if (healthStatus === "auto_disabled") return 200;
|
||
return 0;
|
||
}
|
||
|
||
function getScaleAdjustCandidatePriority(entry: AiModelWithProviderForSelection) {
|
||
return (
|
||
getScaleAdjustProviderPriority(entry.provider.type) +
|
||
getScaleAdjustModelHealthPenalty(entry.model.healthStatus)
|
||
);
|
||
}
|
||
|
||
function isScaleAdjustUsableImageEditModel(
|
||
entry: AiModelWithProviderForSelection | null | undefined
|
||
) {
|
||
return Boolean(
|
||
entry?.model?.supportsImageGen &&
|
||
!modelLacksReferenceImageInputSupport(entry.model, entry.provider)
|
||
);
|
||
}
|
||
|
||
function compareScaleAdjustProviderCandidates(
|
||
left: AiModelWithProviderForSelection,
|
||
right: AiModelWithProviderForSelection,
|
||
preferredModelRef: string
|
||
) {
|
||
const leftPriority = getScaleAdjustCandidatePriority(left);
|
||
const rightPriority = getScaleAdjustCandidatePriority(right);
|
||
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
||
|
||
const leftDedicated = isScaleAdjustDedicatedDetailModel(left);
|
||
const rightDedicated = isScaleAdjustDedicatedDetailModel(right);
|
||
if (leftDedicated !== rightDedicated) return leftDedicated ? -1 : 1;
|
||
|
||
const leftExact =
|
||
left.model.id === preferredModelRef || left.model.modelId === preferredModelRef;
|
||
const rightExact =
|
||
right.model.id === preferredModelRef || right.model.modelId === preferredModelRef;
|
||
if (leftExact !== rightExact) return leftExact ? -1 : 1;
|
||
|
||
return 0;
|
||
}
|
||
|
||
function resolveScaleAdjustPreferredModelId(params: {
|
||
configuredModelId: string;
|
||
honorRequestedModelBeforeGpt?: boolean;
|
||
preferFalNanoBananaEdit?: boolean;
|
||
preferMaskCapableGpt?: boolean;
|
||
preferProviderTypes?: string[];
|
||
requestedModelId: string;
|
||
models: AiModelWithProviderForSelection[];
|
||
}) {
|
||
const requestedModel = findModelSelectionEntry(
|
||
params.models,
|
||
params.requestedModelId
|
||
);
|
||
const configuredModel = findModelSelectionEntry(
|
||
params.models,
|
||
params.configuredModelId
|
||
);
|
||
if (
|
||
params.honorRequestedModelBeforeGpt &&
|
||
requestedModel &&
|
||
isScaleAdjustUsableImageEditModel(requestedModel)
|
||
) {
|
||
return requestedModel.model.id || params.requestedModelId;
|
||
}
|
||
|
||
const gptImage2Candidates = params.models
|
||
.filter((entry) => isScaleAdjustUsableImageEditModel(entry))
|
||
.filter((entry) => getScaleAdjustModelFamily(entry) === "gpt-image-2")
|
||
.sort((left, right) =>
|
||
compareScaleAdjustProviderCandidates(
|
||
left,
|
||
right,
|
||
params.requestedModelId || params.configuredModelId
|
||
)
|
||
);
|
||
if (params.preferMaskCapableGpt) {
|
||
const maskCapableGptImage2 = gptImage2Candidates
|
||
.filter(isScaleAdjustMaskCapableModel)
|
||
.filter(isScaleAdjustHealthyModel);
|
||
if (maskCapableGptImage2[0]) {
|
||
return maskCapableGptImage2[0].model.id;
|
||
}
|
||
}
|
||
for (const providerType of params.preferProviderTypes || []) {
|
||
const providerCandidate = gptImage2Candidates.find(
|
||
(entry) =>
|
||
entry.provider.type === providerType && isScaleAdjustHealthyModel(entry)
|
||
);
|
||
if (providerCandidate) {
|
||
return providerCandidate.model.id;
|
||
}
|
||
}
|
||
if (params.preferFalNanoBananaEdit) {
|
||
const falEditCandidate = params.models
|
||
.filter((entry) => isScaleAdjustUsableImageEditModel(entry))
|
||
.filter(isScaleAdjustFalNanoBananaEditModel)
|
||
.filter(isScaleAdjustHealthyModel)
|
||
.sort((left, right) =>
|
||
compareScaleAdjustProviderCandidates(
|
||
left,
|
||
right,
|
||
params.requestedModelId || params.configuredModelId
|
||
)
|
||
)[0];
|
||
if (falEditCandidate) {
|
||
return falEditCandidate.model.id;
|
||
}
|
||
}
|
||
if (gptImage2Candidates[0]) {
|
||
return gptImage2Candidates[0].model.id;
|
||
}
|
||
|
||
if (
|
||
isScaleAdjustUsableImageEditModel(requestedModel) &&
|
||
requestedModel &&
|
||
getScaleAdjustModelFamily(requestedModel) !== "nano-banana-2" &&
|
||
requestedModel.provider.type !== "alibaba-image"
|
||
) {
|
||
return requestedModel.model.id || params.requestedModelId;
|
||
}
|
||
|
||
const autoDetailModel = params.models.find(isScaleAdjustDedicatedDetailModel);
|
||
const preferredModel =
|
||
(isScaleAdjustDedicatedDetailModel(requestedModel) ? requestedModel : null) ||
|
||
(isScaleAdjustDedicatedDetailModel(configuredModel) ? configuredModel : null) ||
|
||
autoDetailModel ||
|
||
configuredModel ||
|
||
requestedModel ||
|
||
null;
|
||
const preferredModelRef =
|
||
preferredModel?.model.id ||
|
||
preferredModel?.model.modelId ||
|
||
params.configuredModelId ||
|
||
params.requestedModelId;
|
||
const preferredFamily =
|
||
getScaleAdjustModelFamily(preferredModel) ||
|
||
resolveScaleAdjustModelFamilyFromText(params.configuredModelId) ||
|
||
resolveScaleAdjustModelFamilyFromText(params.requestedModelId);
|
||
|
||
if (preferredFamily) {
|
||
const sameFamilyCandidates = params.models
|
||
.filter((entry) => isScaleAdjustUsableImageEditModel(entry))
|
||
.filter((entry) => getScaleAdjustModelFamily(entry) === preferredFamily)
|
||
.sort((left, right) =>
|
||
compareScaleAdjustProviderCandidates(left, right, preferredModelRef)
|
||
);
|
||
if (sameFamilyCandidates[0]) {
|
||
return sameFamilyCandidates[0].model.id;
|
||
}
|
||
}
|
||
|
||
if (isScaleAdjustDedicatedDetailModel(requestedModel)) {
|
||
return requestedModel?.model.id || params.requestedModelId;
|
||
}
|
||
|
||
if (isScaleAdjustDedicatedDetailModel(configuredModel)) {
|
||
return configuredModel?.model.id || params.configuredModelId;
|
||
}
|
||
|
||
if (autoDetailModel) {
|
||
return autoDetailModel.model.id;
|
||
}
|
||
|
||
return params.configuredModelId || params.requestedModelId;
|
||
}
|
||
|
||
function normalizeHttpImageUrl(input: unknown) {
|
||
const raw = String(input || "").trim();
|
||
if (!/^https?:\/\//i.test(raw)) return "";
|
||
return raw;
|
||
}
|
||
|
||
function normalizeRegionReferenceImages(
|
||
region: LocalEditRegionInput,
|
||
itemIndex: number
|
||
): NormalizedLocalEditRegion["referenceImages"] {
|
||
if (!Array.isArray(region.referenceImages)) return [];
|
||
return region.referenceImages
|
||
.map((reference, referenceIndex) => {
|
||
const url = normalizeHttpImageUrl(reference?.url);
|
||
if (!url) return null;
|
||
const name =
|
||
typeof reference?.name === "string" && reference.name.trim()
|
||
? reference.name.trim().slice(0, 80)
|
||
: "";
|
||
return {
|
||
id:
|
||
typeof reference?.id === "string" && reference.id.trim()
|
||
? reference.id.trim()
|
||
: `region-${itemIndex + 1}-reference-${referenceIndex + 1}`,
|
||
url,
|
||
...(name ? { name } : {}),
|
||
source: reference?.source === "canvas" ? "canvas" as const : "upload" as const,
|
||
};
|
||
})
|
||
.filter((item): item is NormalizedLocalEditRegion["referenceImages"][number] =>
|
||
Boolean(item)
|
||
)
|
||
.slice(0, MAX_REFERENCE_IMAGES_PER_REGION);
|
||
}
|
||
|
||
function resolveLocalEditOperation(input: unknown) {
|
||
return String(input || "").trim() === SCALE_ADJUST_OPERATION
|
||
? SCALE_ADJUST_OPERATION
|
||
: "local-edit";
|
||
}
|
||
|
||
function normalizeScaleAdjustPercent(input: unknown): ScaleAdjustPercent {
|
||
const value = Math.round(Number(input));
|
||
if (
|
||
Number.isFinite(value) &&
|
||
value !== 0 &&
|
||
value >= MIN_SCALE_ADJUST_PERCENT &&
|
||
value <= MAX_SCALE_ADJUST_PERCENT
|
||
) {
|
||
return value;
|
||
}
|
||
throw new Error("请选择有效的比例调整幅度");
|
||
}
|
||
|
||
function buildLocalEditDisplayPrompt(params: {
|
||
operation: LocalEditOperation;
|
||
scalePercent: ScaleAdjustPercent | null;
|
||
}) {
|
||
if (params.operation === SCALE_ADJUST_OPERATION) {
|
||
return "比例调整:按目标框调整";
|
||
}
|
||
return "局部编辑处理";
|
||
}
|
||
|
||
function getColorName(hex: string) {
|
||
const normalized = hex.toLowerCase();
|
||
const names: Record<string, string> = {
|
||
"#ff3b30": "红色",
|
||
"#1677ff": "蓝色",
|
||
"#00c853": "绿色",
|
||
"#ff9500": "橙色",
|
||
"#af52de": "紫色",
|
||
"#00bcd4": "青色",
|
||
"#ff2d55": "玫红色",
|
||
"#8d6e63": "棕色",
|
||
};
|
||
return names[normalized] || `${normalized} 色`;
|
||
}
|
||
|
||
function normalizeRect(input: LocalEditRect | undefined) {
|
||
if (!input) return null;
|
||
const x = Number(input.x);
|
||
const y = Number(input.y);
|
||
const width = Number(input.width);
|
||
const height = Number(input.height);
|
||
if (![x, y, width, height].every(Number.isFinite)) return null;
|
||
const left = clamp(Math.min(x, x + width), 0, 1);
|
||
const top = clamp(Math.min(y, y + height), 0, 1);
|
||
const right = clamp(Math.max(x, x + width), 0, 1);
|
||
const bottom = clamp(Math.max(y, y + height), 0, 1);
|
||
const normalizedWidth = clamp(right - left, 0, 1 - left);
|
||
const normalizedHeight = clamp(bottom - top, 0, 1 - top);
|
||
if (normalizedWidth <= 0 || normalizedHeight <= 0) return null;
|
||
return {
|
||
x: left,
|
||
y: top,
|
||
width: normalizedWidth,
|
||
height: normalizedHeight,
|
||
};
|
||
}
|
||
|
||
function rectToPixels(
|
||
rect: NonNullable<ReturnType<typeof normalizeRect>>,
|
||
sourceWidth: number,
|
||
sourceHeight: number
|
||
) {
|
||
const left = clamp(Math.round(rect.x * sourceWidth), 0, Math.max(0, sourceWidth - 1));
|
||
const top = clamp(Math.round(rect.y * sourceHeight), 0, Math.max(0, sourceHeight - 1));
|
||
const width = clamp(Math.round(rect.width * sourceWidth), 1, sourceWidth - left);
|
||
const height = clamp(Math.round(rect.height * sourceHeight), 1, sourceHeight - top);
|
||
return { left, top, width, height };
|
||
}
|
||
|
||
function scaleRectAroundCenter(
|
||
rect: NormalizedLocalEditRegion["rect"],
|
||
scalePercent: ScaleAdjustPercent
|
||
) {
|
||
const requestedScale = Math.max(0.1, 1 + scalePercent / 100);
|
||
const maxUniformScale = Math.max(
|
||
0.1,
|
||
Math.min(
|
||
rect.width > 0 ? 1 / rect.width : requestedScale,
|
||
rect.height > 0 ? 1 / rect.height : requestedScale
|
||
)
|
||
);
|
||
const scale = Math.min(requestedScale, maxUniformScale);
|
||
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 getScaleAdjustTargetRect(
|
||
region: NormalizedLocalEditRegion,
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
if (region.targetRect) return region.targetRect;
|
||
if (typeof scalePercent === "number" && Number.isFinite(scalePercent)) {
|
||
return scaleRectAroundCenter(region.rect, scalePercent);
|
||
}
|
||
return region.rect;
|
||
}
|
||
|
||
function hasScaleAdjustEnlargeTarget(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
return regions.some((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
return widthFactor > 1.08 || heightFactor > 1.08;
|
||
});
|
||
}
|
||
|
||
function hasScaleAdjustShrinkTarget(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
return regions.some((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
return widthFactor < 0.92 || heightFactor < 0.92;
|
||
});
|
||
}
|
||
|
||
function hasScaleAdjustStructureTarget(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
return regions.some((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
return (
|
||
widthFactor > 1.08 ||
|
||
heightFactor > 1.08 ||
|
||
widthFactor < 0.92 ||
|
||
heightFactor < 0.92
|
||
);
|
||
});
|
||
}
|
||
|
||
function expandRectWithMargins(
|
||
rect: NormalizedLocalEditRegion["rect"],
|
||
marginX: number,
|
||
marginY: number
|
||
) {
|
||
const left = clamp(rect.x - marginX, 0, 1);
|
||
const top = clamp(rect.y - marginY, 0, 1);
|
||
const right = clamp(rect.x + rect.width + marginX, left + 0.001, 1);
|
||
const bottom = clamp(rect.y + rect.height + marginY, top + 0.001, 1);
|
||
|
||
return {
|
||
x: left,
|
||
y: top,
|
||
width: Math.max(0.001, right - left),
|
||
height: Math.max(0.001, bottom - top),
|
||
};
|
||
}
|
||
|
||
function getRectUnion(
|
||
leftRect: NormalizedLocalEditRegion["rect"],
|
||
rightRect: NormalizedLocalEditRegion["rect"]
|
||
) {
|
||
const left = Math.min(leftRect.x, rightRect.x);
|
||
const top = Math.min(leftRect.y, rightRect.y);
|
||
const right = Math.max(
|
||
leftRect.x + leftRect.width,
|
||
rightRect.x + rightRect.width
|
||
);
|
||
const bottom = Math.max(
|
||
leftRect.y + leftRect.height,
|
||
rightRect.y + rightRect.height
|
||
);
|
||
return {
|
||
x: clamp(left, 0, 1),
|
||
y: clamp(top, 0, 1),
|
||
width: Math.max(0.001, clamp(right, 0, 1) - clamp(left, 0, 1)),
|
||
height: Math.max(0.001, clamp(bottom, 0, 1) - clamp(top, 0, 1)),
|
||
};
|
||
}
|
||
|
||
function rectsDifferMeaningfully(
|
||
leftRect: NormalizedLocalEditRegion["rect"],
|
||
rightRect: NormalizedLocalEditRegion["rect"],
|
||
epsilon = 0.0005
|
||
) {
|
||
return (
|
||
Math.abs(leftRect.x - rightRect.x) > epsilon ||
|
||
Math.abs(leftRect.y - rightRect.y) > epsilon ||
|
||
Math.abs(leftRect.width - rightRect.width) > epsilon ||
|
||
Math.abs(leftRect.height - rightRect.height) > epsilon
|
||
);
|
||
}
|
||
|
||
function getScaleAdjustWorkspaceRect(
|
||
sourceRect: NormalizedLocalEditRegion["rect"],
|
||
targetRect: NormalizedLocalEditRegion["rect"],
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
const sourceWidth = Math.max(0.0001, sourceRect.width);
|
||
const sourceHeight = Math.max(0.0001, sourceRect.height);
|
||
const widthDeltaRatio = Math.abs(targetRect.width / sourceWidth - 1);
|
||
const heightDeltaRatio = Math.abs(targetRect.height / sourceHeight - 1);
|
||
const areaDeltaRatio = Math.abs(
|
||
Math.sqrt(
|
||
Math.max(0.0001, targetRect.width * targetRect.height) /
|
||
Math.max(0.0001, sourceWidth * sourceHeight)
|
||
) - 1
|
||
);
|
||
const sourceCenterX = sourceRect.x + sourceRect.width / 2;
|
||
const sourceCenterY = sourceRect.y + sourceRect.height / 2;
|
||
const targetCenterX = targetRect.x + targetRect.width / 2;
|
||
const targetCenterY = targetRect.y + targetRect.height / 2;
|
||
const movementRatioX = Math.abs(targetCenterX - sourceCenterX) / sourceWidth;
|
||
const movementRatioY = Math.abs(targetCenterY - sourceCenterY) / sourceHeight;
|
||
const explicitChangeRatio = Math.max(
|
||
widthDeltaRatio,
|
||
heightDeltaRatio,
|
||
areaDeltaRatio,
|
||
movementRatioX,
|
||
movementRatioY
|
||
);
|
||
const fallbackScaleRatio =
|
||
typeof scalePercent === "number" && Number.isFinite(scalePercent)
|
||
? Math.min(1, Math.abs(scalePercent) / 100)
|
||
: 0;
|
||
const changeRatio = Math.max(explicitChangeRatio, fallbackScaleRatio);
|
||
const unionRect = getRectUnion(sourceRect, targetRect);
|
||
if (changeRatio <= 0 && !rectsDifferMeaningfully(unionRect, targetRect)) {
|
||
return targetRect;
|
||
}
|
||
|
||
const marginX = Math.min(
|
||
0.16,
|
||
Math.max(
|
||
0.018,
|
||
unionRect.width * 0.04,
|
||
sourceRect.width * changeRatio * 0.24
|
||
)
|
||
);
|
||
const marginY = Math.min(
|
||
0.16,
|
||
Math.max(
|
||
0.018,
|
||
unionRect.height * 0.04,
|
||
sourceRect.height * changeRatio * 0.24
|
||
)
|
||
);
|
||
|
||
return expandRectWithMargins(unionRect, marginX, marginY);
|
||
}
|
||
|
||
function buildScaleAdjustCompositeRegions(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
return regions.flatMap((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const workspaceRect = getScaleAdjustWorkspaceRect(
|
||
region.rect,
|
||
targetRect,
|
||
scalePercent
|
||
);
|
||
return rectsDifferMeaningfully(workspaceRect, targetRect) &&
|
||
rectsDifferMeaningfully(workspaceRect, region.rect)
|
||
? [region.rect, targetRect, workspaceRect]
|
||
: [region.rect, targetRect];
|
||
});
|
||
}
|
||
|
||
function buildScaleAdjustPatchCompositeRegions(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
return regions.flatMap((region) => [
|
||
region.rect,
|
||
getScaleAdjustTargetRect(region, scalePercent),
|
||
]);
|
||
}
|
||
|
||
function buildScaleAdjustProtectedChangeRegions(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
return regions.flatMap((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const unionRect = getRectUnion(region.rect, targetRect);
|
||
const marginX = Math.min(
|
||
0.045,
|
||
Math.max(0.012, unionRect.width * 0.08)
|
||
);
|
||
const marginY = Math.min(
|
||
0.045,
|
||
Math.max(0.012, unionRect.height * 0.08)
|
||
);
|
||
|
||
return [
|
||
expandRectWithMargins(region.rect, marginX, marginY),
|
||
expandRectWithMargins(targetRect, marginX, marginY),
|
||
];
|
||
});
|
||
}
|
||
|
||
function getScaleAdjustPatchWorkspaceRect(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent?: ScaleAdjustPercent | null
|
||
) {
|
||
const workspaceRects = regions.map((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const baseWorkspace = getScaleAdjustWorkspaceRect(
|
||
region.rect,
|
||
targetRect,
|
||
scalePercent
|
||
);
|
||
const marginX = Math.min(
|
||
SCALE_ADJUST_PATCH_WORKSPACE_MAX_MARGIN,
|
||
Math.max(
|
||
SCALE_ADJUST_PATCH_WORKSPACE_MIN_MARGIN,
|
||
baseWorkspace.width * 0.22,
|
||
region.rect.width * 0.18
|
||
)
|
||
);
|
||
const marginY = Math.min(
|
||
SCALE_ADJUST_PATCH_WORKSPACE_MAX_MARGIN,
|
||
Math.max(
|
||
SCALE_ADJUST_PATCH_WORKSPACE_MIN_MARGIN,
|
||
baseWorkspace.height * 0.22,
|
||
region.rect.height * 0.18
|
||
)
|
||
);
|
||
return expandRectWithMargins(baseWorkspace, marginX, marginY);
|
||
});
|
||
|
||
return workspaceRects.reduce((combined, rect) => getRectUnion(combined, rect));
|
||
}
|
||
|
||
function expandPixelRectForCrop(params: {
|
||
height: number;
|
||
pixelRect: NormalizedLocalEditRegion["pixelRect"];
|
||
width: number;
|
||
}) {
|
||
const { height, pixelRect, width } = params;
|
||
const padX = Math.round(pixelRect.width * SCALE_ADJUST_CROP_PADDING_RATIO);
|
||
const padY = Math.round(pixelRect.height * SCALE_ADJUST_CROP_PADDING_RATIO);
|
||
const left = clamp(pixelRect.left - padX, 0, Math.max(0, width - 1));
|
||
const top = clamp(pixelRect.top - padY, 0, Math.max(0, height - 1));
|
||
const right = clamp(
|
||
pixelRect.left + pixelRect.width + padX,
|
||
left + 1,
|
||
width
|
||
);
|
||
const bottom = clamp(
|
||
pixelRect.top + pixelRect.height + padY,
|
||
top + 1,
|
||
height
|
||
);
|
||
|
||
return {
|
||
left,
|
||
top,
|
||
width: Math.max(1, right - left),
|
||
height: Math.max(1, bottom - top),
|
||
};
|
||
}
|
||
|
||
async function createScaleAdjustCropReferences(params: {
|
||
regions: NormalizedLocalEditRegion[];
|
||
sourceBuffer: Buffer;
|
||
sourceHeight: number;
|
||
sourceWidth: number;
|
||
}): Promise<ScaleAdjustCropReference[]> {
|
||
const references: ScaleAdjustCropReference[] = [];
|
||
const regionsForCrop = params.regions.slice(0, MAX_SCALE_ADJUST_CROP_REFERENCES);
|
||
|
||
for (const region of regionsForCrop) {
|
||
const focusBox = getScaleAdjustFocusedSourceBox({
|
||
region,
|
||
sourceBox: region.pixelRect,
|
||
});
|
||
const cropBox = getScaleAdjustFocusDirection(region)
|
||
? focusBox
|
||
: expandPixelRectForCrop({
|
||
height: params.sourceHeight,
|
||
pixelRect: region.pixelRect,
|
||
width: params.sourceWidth,
|
||
});
|
||
const cropBuffer = await sharp(params.sourceBuffer, { failOn: "none" })
|
||
.extract(cropBox)
|
||
.resize({
|
||
fit: "inside",
|
||
height: SCALE_ADJUST_CROP_MAX_EDGE,
|
||
withoutEnlargement: true,
|
||
width: SCALE_ADJUST_CROP_MAX_EDGE,
|
||
})
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const upload = await uploadFile(
|
||
`canvas-scale-adjust-crop-r${region.index}-${Date.now()}-${Math.random()
|
||
.toString(36)
|
||
.slice(2, 8)}.png`,
|
||
toArrayBuffer(cropBuffer)
|
||
);
|
||
references.push({
|
||
regionIndex: region.index,
|
||
url: upload.url,
|
||
});
|
||
}
|
||
|
||
return references;
|
||
}
|
||
|
||
function getScaleAdjustVisualGuidePlacement(params: {
|
||
height: number;
|
||
region: NormalizedLocalEditRegion;
|
||
scalePercent?: ScaleAdjustPercent | null;
|
||
width: number;
|
||
}) {
|
||
const { height, region, scalePercent, width } = params;
|
||
const source = region.pixelRect;
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const target = region.targetPixelRect || rectToPixels(targetRect, width, height);
|
||
const sourceLeft = clamp(source.left, 0, Math.max(0, width - 1));
|
||
const sourceTop = clamp(source.top, 0, Math.max(0, height - 1));
|
||
const sourceRight = clamp(
|
||
source.left + source.width,
|
||
sourceLeft + 1,
|
||
width
|
||
);
|
||
const sourceBottom = clamp(
|
||
source.top + source.height,
|
||
sourceTop + 1,
|
||
height
|
||
);
|
||
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(
|
||
0.05,
|
||
Math.min(
|
||
Number.isFinite(widthScale) && widthScale > 0 ? widthScale : 1,
|
||
Number.isFinite(heightScale) && heightScale > 0 ? heightScale : 1
|
||
)
|
||
);
|
||
const placedWidth = clamp(
|
||
Math.round(sourceWidth * uniformScale),
|
||
1,
|
||
Math.max(1, width)
|
||
);
|
||
const placedHeight = clamp(
|
||
Math.round(sourceHeight * uniformScale),
|
||
1,
|
||
Math.max(1, height)
|
||
);
|
||
const targetCenterX = target.left + target.width / 2;
|
||
const targetCenterY = target.top + target.height / 2;
|
||
const placedLeft = clamp(
|
||
Math.round(targetCenterX - placedWidth / 2),
|
||
0,
|
||
Math.max(0, width - placedWidth)
|
||
);
|
||
const placedTop = clamp(
|
||
Math.round(targetCenterY - placedHeight / 2),
|
||
0,
|
||
Math.max(0, height - placedHeight)
|
||
);
|
||
|
||
return {
|
||
placedHeight: Math.max(1, Math.round(placedHeight)),
|
||
placedLeft: Math.max(0, Math.round(placedLeft)),
|
||
placedTop: Math.max(0, Math.round(placedTop)),
|
||
placedWidth: Math.max(1, Math.round(placedWidth)),
|
||
sourceBox: {
|
||
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, "");
|
||
const protectedDirectionalPerson =
|
||
/(?:不要|别|勿|不改变|不能改变|不动|保持|保留|保护|非目标|do not|don't|dont|keep|preserve|without changing).{0,28}(?:left|right|左|右).{0,28}(?:person|people|human|woman|man|model|face|body|人物|人像|人|模特|女人|男人|脸|身体)/.test(
|
||
text
|
||
) ||
|
||
/(?:left|right|左|右).{0,16}(?:person|people|human|woman|man|model|face|body|人物|人像|人|模特|女人|男人|脸|身体).{0,28}(?:不要|别|勿|不改变|不能改变|不动|保持|保留|保护|非目标|do not|don't|dont|keep|preserve|without changing)/.test(
|
||
text
|
||
) ||
|
||
/(?:不要|别|勿|不改变|不能改变|不动|保持|保留|保护|非目标).{0,16}(?:左侧|右侧|左边|右边|左方|右方).{0,16}(?:人物|人像|人|模特|女人|男人|脸|身体)/.test(
|
||
compact
|
||
);
|
||
|
||
if (protectedDirectionalPerson) return "";
|
||
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)));
|
||
const inset = Math.min(
|
||
Math.floor(Math.min(width, height) / 8),
|
||
Math.max(1, Math.round(blur * 0.7))
|
||
);
|
||
const rectWidth = Math.max(1, width - inset * 2);
|
||
const rectHeight = Math.max(1, height - inset * 2);
|
||
|
||
return Buffer.from(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||
<defs>
|
||
<filter id="soft-edge" x="-60%" y="-60%" width="220%" height="220%">
|
||
<feGaussianBlur stdDeviation="${blur}" />
|
||
</filter>
|
||
</defs>
|
||
<rect x="${inset}" y="${inset}" width="${rectWidth}" height="${rectHeight}" rx="${Math.max(1, blur)}" ry="${Math.max(1, blur)}" fill="#fff" filter="url(#soft-edge)" />
|
||
</svg>`
|
||
);
|
||
}
|
||
|
||
function formatScaleAdjustWashColor(
|
||
background?: { b: number; g: number; r: number } | null
|
||
) {
|
||
if (!background) return SCALE_ADJUST_LAYOUT_GUIDE_WASH_COLOR;
|
||
const r = clamp(Math.round(background.r), 24, 242);
|
||
const g = clamp(Math.round(background.g), 24, 242);
|
||
const b = clamp(Math.round(background.b), 24, 242);
|
||
return `rgb(${r}, ${g}, ${b})`;
|
||
}
|
||
|
||
function buildScaleAdjustSourceFootprintWashSvg(params: {
|
||
boxes: Array<{
|
||
background?: { b: number; g: number; r: number } | null;
|
||
innerOpacity?: number;
|
||
left: number;
|
||
opacity?: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
}>;
|
||
height: number;
|
||
width: number;
|
||
}) {
|
||
const { boxes, height, width } = params;
|
||
const defs = boxes
|
||
.map((box, index) => {
|
||
const blur = Math.max(
|
||
14,
|
||
Math.min(72, Math.round(Math.min(box.width, box.height) * 0.14))
|
||
);
|
||
return `<filter id="soft-wash-${index}" x="-45%" y="-45%" width="190%" height="190%">
|
||
<feGaussianBlur stdDeviation="${blur}" />
|
||
</filter>`;
|
||
})
|
||
.join("");
|
||
const rects = boxes
|
||
.map((box, index) => {
|
||
const blur = Math.max(
|
||
14,
|
||
Math.min(72, Math.round(Math.min(box.width, box.height) * 0.14))
|
||
);
|
||
const pad = Math.max(blur, Math.round(Math.min(box.width, box.height) * 0.055));
|
||
const left = Math.max(0, box.left - blur);
|
||
const top = Math.max(0, box.top - blur);
|
||
const right = Math.min(width, box.left + box.width + blur);
|
||
const bottom = Math.min(height, box.top + box.height + blur);
|
||
const rectWidth = Math.max(1, right - left);
|
||
const rectHeight = Math.max(1, bottom - top);
|
||
const radius = Math.max(
|
||
8,
|
||
Math.min(Math.round(blur * 1.8), Math.round(rectWidth / 3), Math.round(rectHeight / 3))
|
||
);
|
||
const innerLeft = Math.max(0, box.left + pad);
|
||
const innerTop = Math.max(0, box.top + pad);
|
||
const innerRight = Math.min(width, box.left + box.width - pad);
|
||
const innerBottom = Math.min(height, box.top + box.height - pad);
|
||
const fill = formatScaleAdjustWashColor(box.background);
|
||
const opacity =
|
||
typeof box.opacity === "number"
|
||
? clamp(box.opacity, 0, 1)
|
||
: SCALE_ADJUST_LAYOUT_GUIDE_WASH_OPACITY;
|
||
const innerOpacity =
|
||
typeof box.innerOpacity === "number"
|
||
? clamp(box.innerOpacity, 0, 1)
|
||
: 0.18;
|
||
const inner =
|
||
innerRight > innerLeft + 4 && innerBottom > innerTop + 4
|
||
? `<rect x="${innerLeft}" y="${innerTop}" width="${innerRight - innerLeft}" height="${innerBottom - innerTop}" rx="${Math.max(4, Math.round(radius * 0.65))}" ry="${Math.max(4, Math.round(radius * 0.65))}" fill="${fill}" fill-opacity="${innerOpacity}" filter="url(#soft-wash-${index})" />`
|
||
: "";
|
||
return `<rect x="${left}" y="${top}" width="${rectWidth}" height="${rectHeight}" rx="${radius}" ry="${radius}" fill="${fill}" fill-opacity="${opacity}" filter="url(#soft-wash-${index})" />${inner}`;
|
||
})
|
||
.join("");
|
||
|
||
return Buffer.from(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||
<defs>
|
||
${defs}
|
||
</defs>
|
||
${rects}
|
||
</svg>`
|
||
);
|
||
}
|
||
|
||
function buildScaleAdjustTargetFootprintShadowSvg(params: {
|
||
height: number;
|
||
placements: Array<ReturnType<typeof getScaleAdjustVisualGuidePlacement>>;
|
||
width: number;
|
||
}) {
|
||
const { height, placements, width } = params;
|
||
const minSide = Math.max(1, Math.min(width, height));
|
||
const blur = Math.max(4, Math.min(18, Math.round(minSide * 0.014)));
|
||
const rects = placements
|
||
.map((placement) => {
|
||
const pad = Math.max(2, Math.round(blur * 0.45));
|
||
const left = clamp(
|
||
placement.placedLeft - pad,
|
||
0,
|
||
Math.max(0, width - 1)
|
||
);
|
||
const top = clamp(
|
||
placement.placedTop - pad,
|
||
0,
|
||
Math.max(0, height - 1)
|
||
);
|
||
const right = clamp(
|
||
placement.placedLeft + placement.placedWidth + pad,
|
||
left + 1,
|
||
width
|
||
);
|
||
const bottom = clamp(
|
||
placement.placedTop + placement.placedHeight + pad,
|
||
top + 1,
|
||
height
|
||
);
|
||
const radius = Math.max(4, Math.round(blur * 1.2));
|
||
return `<rect x="${left}" y="${top}" width="${Math.max(1, right - left)}" height="${Math.max(1, bottom - top)}" rx="${radius}" ry="${radius}" fill="#111827" fill-opacity="0.13" filter="url(#target-shadow)" />`;
|
||
})
|
||
.join("");
|
||
|
||
return Buffer.from(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||
<defs>
|
||
<filter id="target-shadow" x="-30%" y="-30%" width="160%" height="160%">
|
||
<feGaussianBlur stdDeviation="${blur}" />
|
||
</filter>
|
||
</defs>
|
||
${rects}
|
||
</svg>`
|
||
);
|
||
}
|
||
|
||
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 isScaleAdjustPersonLikePrompt(prompt: string) {
|
||
const text = normalizeScaleAdjustModelText(prompt);
|
||
return /person|people|human|woman|man|girl|boy|model|face|body|hand|arm|leg|clothing|人物|人像|真人|模特|女人|男人|女孩|男孩|小孩|脸|身体|手|胳膊|腿|衣服/.test(
|
||
text
|
||
);
|
||
}
|
||
|
||
function isScaleAdjustFurnitureProductLikePrompt(prompt: string) {
|
||
const text = normalizeScaleAdjustModelText(prompt);
|
||
return /furniture|product|object|chair|sofa|table|desk|cabinet|shelf|rack|lamp|cart|wheel|wood|woven|cane|rattan|panel|商品|产品|物体|家具|家居|椅|沙发|茶几|桌|柜|架|衣帽架|置物架|收纳|灯|车|轮|木|藤编|编织|面板|杆|挂包|帽子|伞/.test(
|
||
text
|
||
);
|
||
}
|
||
|
||
type ScaleAdjustProtectedPersonSide = "auto" | "left" | "right";
|
||
|
||
function getScaleAdjustProtectedPersonSide(
|
||
prompt: string
|
||
): ScaleAdjustProtectedPersonSide | null {
|
||
const text = normalizeScaleAdjustModelText(prompt);
|
||
if (
|
||
!/person|people|human|woman|man|model|girl|boy|face|body|人物|人像|人|模特|女人|男人|女孩|男孩|脸|身体/.test(
|
||
text
|
||
)
|
||
) {
|
||
return null;
|
||
}
|
||
if (/right|右侧|右边|右方|右/.test(text)) return "right";
|
||
if (/left|左侧|左边|左方|左/.test(text)) return "left";
|
||
return "auto";
|
||
}
|
||
|
||
function hasScaleAdjustPersonPixelSignal(params: {
|
||
box: { left: number; top: number; width: number; height: number };
|
||
raw: Buffer;
|
||
width: number;
|
||
}) {
|
||
const { box, raw, width } = params;
|
||
let sampled = 0;
|
||
let skin = 0;
|
||
let whiteFabric = 0;
|
||
const step = Math.max(1, Math.floor(Math.min(box.width, box.height) / 260));
|
||
|
||
for (let localY = 0; localY < box.height; localY += step) {
|
||
const y = box.top + localY;
|
||
for (let localX = 0; localX < box.width; localX += step) {
|
||
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);
|
||
sampled += 1;
|
||
if (isScaleAdjustLikelySkinPixel(r, g, b)) {
|
||
skin += 1;
|
||
} else if (
|
||
isScaleAdjustLikelyWhiteFabricPixel(r, g, b, luma, saturation) &&
|
||
luma < 242
|
||
) {
|
||
whiteFabric += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (sampled <= 0) return false;
|
||
const skinRatio = skin / sampled;
|
||
const fabricRatio = whiteFabric / sampled;
|
||
return skin >= 18 && skinRatio >= 0.0025 && fabricRatio >= 0.035;
|
||
}
|
||
|
||
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 };
|
||
includePersonPixels?: boolean;
|
||
raw: Buffer;
|
||
width: number;
|
||
}) {
|
||
const { box, includePersonPixels = false, 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) {
|
||
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);
|
||
const likelySkin = isScaleAdjustLikelySkinPixel(r, g, b);
|
||
const likelyWhiteFabric = isScaleAdjustLikelyWhiteFabricPixel(
|
||
r,
|
||
g,
|
||
b,
|
||
luma,
|
||
saturation
|
||
);
|
||
const colorDistance = Math.sqrt(
|
||
(r - background.r) ** 2 +
|
||
(g - background.g) ** 2 +
|
||
(b - background.b) ** 2
|
||
);
|
||
|
||
if (includePersonPixels && likelySkin) {
|
||
mask[localY * box.width + localX] = 255;
|
||
continue;
|
||
}
|
||
if (
|
||
includePersonPixels &&
|
||
likelyWhiteFabric &&
|
||
luma < 246 &&
|
||
(colorDistance > 12 || luma < background.luma - 4)
|
||
) {
|
||
mask[localY * box.width + localX] = 255;
|
||
continue;
|
||
}
|
||
if (likelySkin) continue;
|
||
if (likelyWhiteFabric) {
|
||
continue;
|
||
}
|
||
|
||
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 buildScaleAdjustProtectedPersonSeedMask(params: {
|
||
box: { left: number; top: number; width: number; height: number };
|
||
raw: Buffer;
|
||
side: ScaleAdjustProtectedPersonSide;
|
||
width: number;
|
||
}) {
|
||
const { box, raw, side, width } = params;
|
||
const seed = new Uint8Array(box.width * box.height);
|
||
let seedCount = 0;
|
||
const sideAllowsX = (localX: number) => {
|
||
if (side === "right") return localX >= box.width * 0.52;
|
||
if (side === "left") return localX <= box.width * 0.48;
|
||
return true;
|
||
};
|
||
|
||
for (let localY = 0; localY < box.height; localY += 1) {
|
||
const y = box.top + localY;
|
||
for (let localX = 0; localX < box.width; localX += 1) {
|
||
if (!sideAllowsX(localX)) 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);
|
||
const likelySkin = isScaleAdjustLikelySkinPixel(r, g, b);
|
||
const likelyWhiteFabric =
|
||
isScaleAdjustLikelyWhiteFabricPixel(r, g, b, luma, saturation) &&
|
||
luma < 246 &&
|
||
saturation < 0.22;
|
||
if (!likelySkin && !likelyWhiteFabric) continue;
|
||
seed[localY * box.width + localX] = 255;
|
||
seedCount += 1;
|
||
}
|
||
}
|
||
|
||
const seedRatio = seedCount / Math.max(1, box.width * box.height);
|
||
if (seedCount < 24 || seedRatio < 0.0006) return null;
|
||
|
||
return dilateScaleAdjustStructureMask({
|
||
height: box.height,
|
||
mask: seed,
|
||
radius: Math.max(4, Math.round(Math.min(box.width, box.height) * 0.032)),
|
||
width: box.width,
|
||
});
|
||
}
|
||
|
||
function filterScaleAdjustStructureComponents(params: {
|
||
box: { width: number; height: number };
|
||
mask: Uint8Array;
|
||
protectedPersonMask?: Uint8Array | null;
|
||
protectedPersonSide?: ScaleAdjustProtectedPersonSide | null;
|
||
}) {
|
||
const { box, mask, protectedPersonMask = null, protectedPersonSide = null } =
|
||
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;
|
||
let protectedPersonOverlap = 0;
|
||
|
||
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);
|
||
if (protectedPersonMask?.[cy * width + cx]) {
|
||
protectedPersonOverlap += 1;
|
||
}
|
||
|
||
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 centerYRatio = (minY + componentHeight / 2) / height;
|
||
const touchesTop = minY <= height * 0.04;
|
||
const touchesRight = maxX >= width * 0.88;
|
||
const touchesLeft = minX <= width * 0.12;
|
||
const edgeSliver = touchesRight && componentWidth < width * 0.18;
|
||
const touchesProtectedPersonSide =
|
||
protectedPersonSide === "right"
|
||
? maxX >= width * 0.68
|
||
: protectedPersonSide === "left"
|
||
? minX <= width * 0.32
|
||
: protectedPersonSide === "auto"
|
||
? touchesLeft || touchesRight
|
||
: false;
|
||
const shouldClipProtectedPersonSide =
|
||
Boolean(protectedPersonMask) &&
|
||
protectedPersonOverlap > 0 &&
|
||
touchesProtectedPersonSide;
|
||
const allowSurfaceFill = !protectedPersonSide;
|
||
const isProtectedPersonSafetySidePixel = (
|
||
pixelX: number,
|
||
pixelY: number
|
||
) => {
|
||
if (protectedPersonSide === "right") {
|
||
return (
|
||
(pixelX >= width * 0.7 && pixelY >= height * 0.38) ||
|
||
(pixelX >= width * 0.62 && pixelY >= height * 0.56)
|
||
);
|
||
}
|
||
if (protectedPersonSide === "left") {
|
||
return (
|
||
(pixelX <= width * 0.3 && pixelY >= height * 0.38) ||
|
||
(pixelX <= width * 0.38 && pixelY >= height * 0.56)
|
||
);
|
||
}
|
||
if (protectedPersonSide === "auto") {
|
||
return (
|
||
(touchesRight &&
|
||
pixelX >= width * 0.72 &&
|
||
pixelY >= height * 0.42) ||
|
||
(touchesLeft &&
|
||
pixelX <= width * 0.28 &&
|
||
pixelY >= height * 0.42)
|
||
);
|
||
}
|
||
return false;
|
||
};
|
||
const isProtectedPersonPixel = (pixelX: number, pixelY: number) => {
|
||
const pixelIndex = pixelY * width + pixelX;
|
||
if (protectedPersonMask?.[pixelIndex]) return true;
|
||
if (isProtectedPersonSafetySidePixel(pixelX, pixelY)) return true;
|
||
if (!shouldClipProtectedPersonSide) return false;
|
||
if (protectedPersonSide === "right") return pixelX >= width * 0.78;
|
||
if (protectedPersonSide === "left") return pixelX <= width * 0.22;
|
||
if (protectedPersonSide === "auto") {
|
||
return (
|
||
(touchesRight && pixelX >= width * 0.82) ||
|
||
(touchesLeft && pixelX <= width * 0.18)
|
||
);
|
||
}
|
||
return false;
|
||
};
|
||
const broadTopBackground =
|
||
touchesTop &&
|
||
centerYRatio <= 0.45 &&
|
||
componentWidth >= width * 0.42 &&
|
||
componentHeight >= height * 0.18 &&
|
||
componentDensity >= 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 &&
|
||
!broadTopBackground
|
||
) {
|
||
if (fillSurfaceComponent && allowSurfaceFill) {
|
||
for (let fillY = minY; fillY <= maxY; fillY += 1) {
|
||
for (let fillX = minX; fillX <= maxX; fillX += 1) {
|
||
if (isProtectedPersonPixel(fillX, fillY)) continue;
|
||
output[fillY * width + fillX] = 255;
|
||
}
|
||
}
|
||
} else {
|
||
for (let index = 0; index < queueX.length; index += 1) {
|
||
const pixelX = queueX[index] || 0;
|
||
const pixelY = queueY[index] || 0;
|
||
if (isProtectedPersonPixel(pixelX, pixelY)) continue;
|
||
output[pixelY * width + pixelX] = 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 buildScaleAdjustCompositeClipMaskPatch(params: {
|
||
box: { width: number; height: number };
|
||
maskBuffer: Buffer | Uint8Array;
|
||
}) {
|
||
const { box, maskBuffer } = params;
|
||
const alpha = await sharp(Buffer.from(maskBuffer), {
|
||
raw: { channels: 1, height: box.height, width: box.width },
|
||
})
|
||
.blur(Math.max(0.8, Math.min(box.width, box.height) * 0.006))
|
||
.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] = 255;
|
||
rgba[offset + 1] = 255;
|
||
rgba[offset + 2] = 255;
|
||
rgba[offset + 3] = alpha[index] || 0;
|
||
}
|
||
return sharp(rgba, {
|
||
raw: { channels: 4, height: box.height, width: box.width },
|
||
})
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
}
|
||
|
||
async function buildScaleAdjustOverlayClipMaskPatch(imageBuffer: Buffer) {
|
||
const metadata = await sharp(imageBuffer, { failOn: "none" }).metadata();
|
||
const width = Math.max(1, Math.round(Number(metadata.width || 0)));
|
||
const height = Math.max(1, Math.round(Number(metadata.height || 0)));
|
||
const alpha = await sharp(imageBuffer, { failOn: "none" })
|
||
.ensureAlpha()
|
||
.extractChannel(3)
|
||
.blur(Math.max(0.8, Math.min(width, height) * 0.006))
|
||
.raw()
|
||
.toBuffer();
|
||
const rgba = Buffer.alloc(width * height * 4);
|
||
for (let index = 0; index < width * height; index += 1) {
|
||
const offset = index * 4;
|
||
const value = alpha[index] || 0;
|
||
rgba[offset] = 255;
|
||
rgba[offset + 1] = 255;
|
||
rgba[offset + 2] = 255;
|
||
rgba[offset + 3] = value < 6 ? 0 : Math.min(255, Math.round(value * 1.35));
|
||
}
|
||
return sharp(rgba, {
|
||
raw: { channels: 4, height, width },
|
||
})
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
}
|
||
|
||
async function buildScaleAdjustCompositeClipMaskBuffer(params: {
|
||
height: number;
|
||
sourceMaskComposites: Array<{ input: Buffer; left: number; top: number }>;
|
||
targetMaskComposites: Array<{ input: Buffer; left: number; top: number }>;
|
||
width: number;
|
||
}) {
|
||
const { height, sourceMaskComposites, targetMaskComposites, width } = params;
|
||
if (sourceMaskComposites.length === 0 && targetMaskComposites.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return sharp({
|
||
create: {
|
||
background: { alpha: 0, b: 255, g: 255, r: 255 },
|
||
channels: 4,
|
||
height,
|
||
width,
|
||
},
|
||
})
|
||
.composite([...sourceMaskComposites, ...targetMaskComposites])
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
}
|
||
|
||
function getScaleAdjustAlphaBoundingBox(params: {
|
||
height: number;
|
||
rawRgba: Buffer;
|
||
threshold?: number;
|
||
width: number;
|
||
}) {
|
||
const { height, rawRgba, threshold = 18, 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) {
|
||
const alpha = rawRgba[(y * width + x) * 4 + 3] || 0;
|
||
if (alpha < threshold) 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 thresholdScaleAdjustAlphaMask(params: {
|
||
maskBuffer: Buffer | Uint8Array;
|
||
threshold?: number;
|
||
}) {
|
||
const { maskBuffer, threshold = 96 } = params;
|
||
const output = new Uint8Array(maskBuffer.length);
|
||
for (let index = 0; index < maskBuffer.length; index += 1) {
|
||
output[index] = (maskBuffer[index] || 0) >= threshold ? 255 : 0;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
async function applyScaleAdjustAlphaMaskToRgba(params: {
|
||
alphaMask: Uint8Array;
|
||
height: number;
|
||
rawRgba: Buffer;
|
||
width: number;
|
||
}) {
|
||
const { alphaMask, height, rawRgba, width } = params;
|
||
const blur = Math.max(0.45, Math.min(width, height) * 0.0035);
|
||
const softenedAlpha = await sharp(Buffer.from(alphaMask), {
|
||
raw: { channels: 1, height, width },
|
||
})
|
||
.blur(blur)
|
||
.raw()
|
||
.toBuffer();
|
||
const output = Buffer.from(rawRgba);
|
||
const pixelCount = Math.min(alphaMask.length, Math.floor(output.length / 4));
|
||
for (let index = 0; index < pixelCount; index += 1) {
|
||
output[index * 4 + 3] = softenedAlpha[index] || 0;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function getScaleAdjustAlphaBandingStats(params: {
|
||
height: number;
|
||
maskBuffer: Buffer | Uint8Array;
|
||
threshold?: number;
|
||
width: number;
|
||
}) {
|
||
const { height, maskBuffer, threshold = 24, width } = params;
|
||
const mask = thresholdScaleAdjustAlphaMask({ maskBuffer, threshold });
|
||
const bbox = getScaleAdjustMaskBoundingBox({ height, mask, width });
|
||
if (!bbox) {
|
||
return {
|
||
activeRowRatio: 0,
|
||
bboxHeightRatio: 0,
|
||
bboxWidthRatio: 0,
|
||
detected: false,
|
||
transitionRatio: 0,
|
||
wideRowRatio: 0,
|
||
};
|
||
}
|
||
|
||
let activeRows = 0;
|
||
let transitions = 0;
|
||
let wideRows = 0;
|
||
let previousCoverage: number | null = null;
|
||
for (let y = bbox.top; y < bbox.top + bbox.height; y += 1) {
|
||
let rowCount = 0;
|
||
for (let x = bbox.left; x < bbox.left + bbox.width; x += 1) {
|
||
if (mask[y * width + x] !== 0) rowCount += 1;
|
||
}
|
||
const coverage = rowCount / Math.max(1, bbox.width);
|
||
if (coverage >= 0.08) activeRows += 1;
|
||
if (coverage >= 0.45) wideRows += 1;
|
||
if (
|
||
previousCoverage !== null &&
|
||
Math.abs(coverage - previousCoverage) >= 0.28
|
||
) {
|
||
transitions += 1;
|
||
}
|
||
previousCoverage = coverage;
|
||
}
|
||
|
||
const bboxHeightRatio = bbox.height / Math.max(1, height);
|
||
const bboxWidthRatio = bbox.width / Math.max(1, width);
|
||
const activeRowRatio = activeRows / Math.max(1, bbox.height);
|
||
const transitionRatio = transitions / Math.max(1, bbox.height - 1);
|
||
const wideRowRatio = wideRows / Math.max(1, bbox.height);
|
||
const detected =
|
||
(bboxWidthRatio >= 0.38 &&
|
||
bboxHeightRatio >= 0.22 &&
|
||
wideRowRatio >= 0.18 &&
|
||
transitionRatio >= 0.12) ||
|
||
(bboxWidthRatio >= 0.55 &&
|
||
activeRowRatio >= 0.45 &&
|
||
transitionRatio >= 0.09);
|
||
|
||
return {
|
||
activeRowRatio: Number(activeRowRatio.toFixed(3)),
|
||
bboxHeightRatio: Number(bboxHeightRatio.toFixed(3)),
|
||
bboxWidthRatio: Number(bboxWidthRatio.toFixed(3)),
|
||
detected,
|
||
transitionRatio: Number(transitionRatio.toFixed(3)),
|
||
wideRowRatio: Number(wideRowRatio.toFixed(3)),
|
||
};
|
||
}
|
||
|
||
async function cleanScaleAdjustAlphaChannel(params: {
|
||
height: number;
|
||
referenceRawRgb?: Buffer | null;
|
||
rawRgba: Buffer;
|
||
threshold?: number;
|
||
width: number;
|
||
}) {
|
||
const {
|
||
height,
|
||
referenceRawRgb = null,
|
||
rawRgba,
|
||
threshold = SCALE_ADJUST_RMBG_ALPHA_CLEAN_THRESHOLD,
|
||
width,
|
||
} = params;
|
||
const pixelCount = Math.floor(rawRgba.length / 4);
|
||
const cleanedRaw = Buffer.from(rawRgba);
|
||
const cleanedAlpha = Buffer.alloc(pixelCount);
|
||
const range = Math.max(1, 255 - threshold);
|
||
const hasReference =
|
||
referenceRawRgb && referenceRawRgb.length >= pixelCount * 3;
|
||
|
||
for (let index = 0; index < pixelCount; index += 1) {
|
||
const colorIndex = index * 4;
|
||
const alphaIndex = index * 4 + 3;
|
||
const sourceAlpha = rawRgba[alphaIndex] || 0;
|
||
let matteArtifact = false;
|
||
if (hasReference && sourceAlpha >= threshold) {
|
||
const r = rawRgba[colorIndex] || 0;
|
||
const g = rawRgba[colorIndex + 1] || 0;
|
||
const b = rawRgba[colorIndex + 2] || 0;
|
||
const luma = getScaleAdjustPixelLuma(r, g, b);
|
||
const channelSpread = Math.max(r, g, b) - Math.min(r, g, b);
|
||
const refIndex = index * 3;
|
||
const refLuma = getScaleAdjustPixelLuma(
|
||
referenceRawRgb?.[refIndex] || 0,
|
||
referenceRawRgb?.[refIndex + 1] || 0,
|
||
referenceRawRgb?.[refIndex + 2] || 0
|
||
);
|
||
matteArtifact = luma <= 18 && channelSpread <= 18 && refLuma >= 42;
|
||
}
|
||
const nextAlpha =
|
||
sourceAlpha < threshold || matteArtifact
|
||
? 0
|
||
: Math.max(0, Math.min(255, Math.round(((sourceAlpha - threshold) / range) * 255)));
|
||
cleanedRaw[alphaIndex] = nextAlpha;
|
||
cleanedAlpha[index] = nextAlpha;
|
||
}
|
||
|
||
return { cleanedAlpha, cleanedRaw };
|
||
}
|
||
|
||
async function resizeScaleAdjustAlphaMask(params: {
|
||
height: number;
|
||
maskBuffer: Buffer;
|
||
targetHeight: number;
|
||
targetWidth: number;
|
||
width: number;
|
||
}) {
|
||
const { height, maskBuffer, targetHeight, targetWidth, width } = params;
|
||
const { data, info } = await sharp(maskBuffer, {
|
||
raw: { channels: 1, height, width },
|
||
})
|
||
.resize(targetWidth, targetHeight, {
|
||
background: { alpha: 1, b: 0, g: 0, r: 0 },
|
||
fit: "contain",
|
||
kernel: sharp.kernel.lanczos3,
|
||
})
|
||
.greyscale()
|
||
.raw()
|
||
.toBuffer({ resolveWithObject: true });
|
||
if (info.channels === 1) return Buffer.from(data);
|
||
|
||
const output = Buffer.alloc(targetWidth * targetHeight);
|
||
for (let index = 0; index < targetWidth * targetHeight; index += 1) {
|
||
output[index] = data[index * info.channels] || 0;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
async function buildScaleAdjustRmbgStructureLockedComposite(params: {
|
||
placement: ReturnType<typeof getScaleAdjustVisualGuidePlacement>;
|
||
raw: Buffer;
|
||
sourceBuffer: Buffer;
|
||
sourceImageUrl?: string;
|
||
sourceWidth: number;
|
||
userId?: string;
|
||
}): Promise<ScaleAdjustStructureLockedComposite | null> {
|
||
const {
|
||
placement,
|
||
raw,
|
||
sourceBuffer,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
userId,
|
||
} = params;
|
||
if (!userId) return null;
|
||
|
||
try {
|
||
const sourceCropBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
||
.extract(placement.sourceBox)
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const cutoutResult = await executeRmbgJob({
|
||
userId,
|
||
imageBase64: sourceCropBuffer.toString("base64"),
|
||
imageBuffer: sourceCropBuffer,
|
||
prompt: "比例调整主体抠图:只保留框选的目标人物/家具/商品主体,输出透明背景 PNG",
|
||
skipGallerySave: true,
|
||
sourceImageUrl,
|
||
});
|
||
const cutoutBuffer = await fetchImageBufferFromUrl(cutoutResult.original_url);
|
||
const normalizedCutout = await sharp(cutoutBuffer, { failOn: "none" })
|
||
.rotate()
|
||
.ensureAlpha()
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const cutoutMetadata = await sharp(normalizedCutout, { failOn: "none" }).metadata();
|
||
const cutoutWidth = Math.round(Number(cutoutMetadata.width || 0));
|
||
const cutoutHeight = Math.round(Number(cutoutMetadata.height || 0));
|
||
if (cutoutWidth <= 0 || cutoutHeight <= 0) return null;
|
||
const sameSizeCutout =
|
||
Math.abs(cutoutWidth - placement.sourceBox.width) <= 2 &&
|
||
Math.abs(cutoutHeight - placement.sourceBox.height) <= 2;
|
||
const sourceCropRaw = sameSizeCutout
|
||
? await sharp(sourceCropBuffer, { failOn: "none" })
|
||
.removeAlpha()
|
||
.toColorspace("srgb")
|
||
.raw()
|
||
.toBuffer()
|
||
: null;
|
||
|
||
const cutoutRaw = await sharp(normalizedCutout, { failOn: "none" })
|
||
.ensureAlpha()
|
||
.raw()
|
||
.toBuffer();
|
||
const { cleanedAlpha, cleanedRaw } = await cleanScaleAdjustAlphaChannel({
|
||
height: cutoutHeight,
|
||
referenceRawRgb: sourceCropRaw,
|
||
rawRgba: cutoutRaw,
|
||
width: cutoutWidth,
|
||
});
|
||
const highConfidenceCutoutMask = thresholdScaleAdjustAlphaMask({
|
||
maskBuffer: cleanedAlpha,
|
||
threshold: 96,
|
||
});
|
||
const highConfidenceCutoutBbox = getScaleAdjustMaskBoundingBox({
|
||
height: cutoutHeight,
|
||
mask: highConfidenceCutoutMask,
|
||
width: cutoutWidth,
|
||
});
|
||
if (!highConfidenceCutoutBbox) return null;
|
||
const refinedCutoutRaw = await applyScaleAdjustAlphaMaskToRgba({
|
||
alphaMask: highConfidenceCutoutMask,
|
||
height: cutoutHeight,
|
||
rawRgba: cleanedRaw,
|
||
width: cutoutWidth,
|
||
});
|
||
const cleanedCutout = await sharp(refinedCutoutRaw, {
|
||
raw: { channels: 4, height: cutoutHeight, width: cutoutWidth },
|
||
})
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const alphaBbox = getScaleAdjustAlphaBoundingBox({
|
||
height: cutoutHeight,
|
||
rawRgba: refinedCutoutRaw,
|
||
width: cutoutWidth,
|
||
});
|
||
if (!alphaBbox) return null;
|
||
|
||
const alphaRatio = alphaBbox.count / Math.max(1, cutoutWidth * cutoutHeight);
|
||
const bboxCoversCanvas =
|
||
alphaBbox.width >= cutoutWidth * 0.97 &&
|
||
alphaBbox.height >= cutoutHeight * 0.97;
|
||
if (
|
||
alphaRatio < SCALE_ADJUST_RMBG_MIN_ALPHA_RATIO ||
|
||
(alphaRatio > SCALE_ADJUST_RMBG_MAX_SOLID_ALPHA_RATIO && bboxCoversCanvas)
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const fullAlphaMask = Buffer.from(highConfidenceCutoutMask);
|
||
const sourceMaskBuffer = sameSizeCutout
|
||
? fullAlphaMask
|
||
: await resizeScaleAdjustAlphaMask({
|
||
height: cutoutHeight,
|
||
maskBuffer: fullAlphaMask,
|
||
targetHeight: placement.sourceBox.height,
|
||
targetWidth: placement.sourceBox.width,
|
||
width: cutoutWidth,
|
||
});
|
||
const alphaBandingStats = getScaleAdjustAlphaBandingStats({
|
||
height: placement.sourceBox.height,
|
||
maskBuffer: sourceMaskBuffer,
|
||
width: placement.sourceBox.width,
|
||
});
|
||
if (alphaBandingStats.detected) {
|
||
console.warn(
|
||
"[Canvas Local Edit] Scale adjust RMBG alpha banding detected; fallback to pixel mask:",
|
||
alphaBandingStats
|
||
);
|
||
return null;
|
||
}
|
||
const background = estimateScaleAdjustBoxBackground({
|
||
box: placement.sourceBox,
|
||
raw,
|
||
width: sourceWidth,
|
||
});
|
||
const highConfidenceSourceMask = thresholdScaleAdjustAlphaMask({
|
||
maskBuffer: sourceMaskBuffer,
|
||
threshold: 96,
|
||
});
|
||
const filteredSourceMask = filterScaleAdjustStructureComponents({
|
||
box: placement.sourceBox,
|
||
mask: highConfidenceSourceMask,
|
||
});
|
||
const filteredSourceBbox = getScaleAdjustMaskBoundingBox({
|
||
height: placement.sourceBox.height,
|
||
mask: filteredSourceMask,
|
||
width: placement.sourceBox.width,
|
||
});
|
||
const sourceWashBaseMask = filteredSourceBbox
|
||
? solidifyScaleAdjustStructureMask({
|
||
box: placement.sourceBox,
|
||
mask: filteredSourceMask,
|
||
})
|
||
: highConfidenceSourceMask;
|
||
const sourceWashMask = dilateScaleAdjustStructureMask({
|
||
height: placement.sourceBox.height,
|
||
mask: sourceWashBaseMask,
|
||
radius: Math.max(
|
||
2,
|
||
Math.round(
|
||
Math.min(placement.sourceBox.width, placement.sourceBox.height) * 0.014
|
||
)
|
||
),
|
||
width: placement.sourceBox.width,
|
||
});
|
||
const sourceCompositeClipMask = dilateScaleAdjustStructureMask({
|
||
height: placement.sourceBox.height,
|
||
mask: filteredSourceBbox ? filteredSourceMask : highConfidenceSourceMask,
|
||
radius: Math.max(
|
||
1,
|
||
Math.round(
|
||
Math.min(placement.sourceBox.width, placement.sourceBox.height) * 0.003
|
||
)
|
||
),
|
||
width: placement.sourceBox.width,
|
||
});
|
||
const sourceWashPatch = await buildScaleAdjustMaskWashPatch({
|
||
background,
|
||
box: placement.sourceBox,
|
||
maskBuffer: Buffer.from(sourceWashMask),
|
||
});
|
||
const compositeClipMaskPatch = await buildScaleAdjustCompositeClipMaskPatch({
|
||
box: placement.sourceBox,
|
||
maskBuffer: sourceCompositeClipMask,
|
||
});
|
||
|
||
const subjectCutout = await sharp(cleanedCutout, { failOn: "none" })
|
||
.extract(alphaBbox)
|
||
.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)
|
||
)
|
||
)
|
||
);
|
||
let placedWidth = Math.max(1, Math.round(alphaBbox.width * structureScale));
|
||
let placedHeight = Math.max(1, Math.round(alphaBbox.height * structureScale));
|
||
if (!sameSizeCutout) {
|
||
const maxPlacedWidth = Math.max(1, Math.round(placement.targetBox.width * 1.06));
|
||
const maxPlacedHeight = Math.max(1, Math.round(placement.targetBox.height * 1.06));
|
||
const fitRatio = Math.min(
|
||
1,
|
||
maxPlacedWidth / Math.max(1, placedWidth),
|
||
maxPlacedHeight / Math.max(1, placedHeight)
|
||
);
|
||
placedWidth = Math.max(1, Math.round(placedWidth * fitRatio));
|
||
placedHeight = Math.max(1, Math.round(placedHeight * fitRatio));
|
||
}
|
||
|
||
const sourceSubjectBox = sameSizeCutout
|
||
? {
|
||
left: placement.sourceBox.left + alphaBbox.left,
|
||
top: placement.sourceBox.top + alphaBbox.top,
|
||
width: alphaBbox.width,
|
||
height: alphaBbox.height,
|
||
}
|
||
: null;
|
||
const sourceCenterX = sourceSubjectBox
|
||
? sourceSubjectBox.left + sourceSubjectBox.width / 2
|
||
: placement.sourceBox.left + placement.sourceBox.width / 2;
|
||
const sourceCenterY = sourceSubjectBox
|
||
? sourceSubjectBox.top + sourceSubjectBox.height / 2
|
||
: placement.sourceBox.top + placement.sourceBox.height / 2;
|
||
const targetCenterX = sourceSubjectBox
|
||
? placement.targetBox.left +
|
||
((sourceCenterX - placement.sourceFootprintBox.left) /
|
||
Math.max(1, placement.sourceFootprintBox.width)) *
|
||
placement.targetBox.width
|
||
: placement.targetBox.left + placement.targetBox.width / 2;
|
||
const targetCenterY = sourceSubjectBox
|
||
? placement.targetBox.top +
|
||
((sourceCenterY - placement.sourceFootprintBox.top) /
|
||
Math.max(1, placement.sourceFootprintBox.height)) *
|
||
placement.targetBox.height
|
||
: placement.targetBox.top + placement.targetBox.height / 2;
|
||
const structureOverlay = await sharp(subjectCutout, { failOn: "none" })
|
||
.resize(placedWidth, placedHeight, {
|
||
fit: "fill",
|
||
kernel: sharp.kernel.lanczos3,
|
||
})
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const targetClipMaskPatch =
|
||
await buildScaleAdjustOverlayClipMaskPatch(structureOverlay);
|
||
|
||
console.info("[Canvas Local Edit] Scale adjust RMBG structure draft applied:", {
|
||
alphaRatio: Number(alphaRatio.toFixed(3)),
|
||
cutoutHeight,
|
||
cutoutWidth,
|
||
placedHeight,
|
||
placedWidth,
|
||
sameSizeCutout,
|
||
alphaBandingStats,
|
||
sourceHeight: placement.sourceBox.height,
|
||
sourceWidth: placement.sourceBox.width,
|
||
});
|
||
|
||
return {
|
||
compositeClip: {
|
||
input: compositeClipMaskPatch,
|
||
left: placement.sourceBox.left,
|
||
top: placement.sourceBox.top,
|
||
},
|
||
method: "rmbg-cutout",
|
||
overlay: {
|
||
input: structureOverlay,
|
||
left: Math.round(targetCenterX - placedWidth / 2),
|
||
top: Math.round(targetCenterY - placedHeight / 2),
|
||
},
|
||
targetClip: {
|
||
input: targetClipMaskPatch,
|
||
left: Math.round(targetCenterX - placedWidth / 2),
|
||
top: Math.round(targetCenterY - placedHeight / 2),
|
||
},
|
||
wash: {
|
||
input: sourceWashPatch,
|
||
left: placement.sourceBox.left,
|
||
top: placement.sourceBox.top,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
console.warn("[Canvas Local Edit] Scale adjust RMBG structure draft failed; fallback to pixel mask:", {
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function validateScaleAdjustProtectedPersonMask(params: {
|
||
box: { width: number; height: number };
|
||
mask: Uint8Array | null;
|
||
}) {
|
||
const { box, mask } = params;
|
||
if (!mask) return null;
|
||
const maskBox = getScaleAdjustMaskBoundingBox({
|
||
height: box.height,
|
||
mask,
|
||
width: box.width,
|
||
});
|
||
if (!maskBox) return null;
|
||
|
||
const maskRatio = maskBox.count / Math.max(1, box.width * box.height);
|
||
const fullSideStripe =
|
||
maskBox.height >= box.height * 0.92 && maskBox.width >= box.width * 0.28;
|
||
if (maskRatio > 0.26 || fullSideStripe) {
|
||
console.warn(
|
||
"[Canvas Local Edit] Scale adjust protected person mask rejected as broad background:",
|
||
{
|
||
heightRatio: Number((maskBox.height / Math.max(1, box.height)).toFixed(3)),
|
||
maskRatio: Number(maskRatio.toFixed(3)),
|
||
widthRatio: Number((maskBox.width / Math.max(1, box.width)).toFixed(3)),
|
||
}
|
||
);
|
||
return null;
|
||
}
|
||
|
||
return mask;
|
||
}
|
||
|
||
function suppressScaleAdjustProtectedFurnitureDarkAlpha(params: {
|
||
alphaMask: Buffer;
|
||
bbox: { left: number; top: number; width: number; height: number };
|
||
protectedPersonSide: ScaleAdjustProtectedPersonSide | null;
|
||
rawRgb: Buffer;
|
||
sourceBox: { width: number; height: number };
|
||
}) {
|
||
const { alphaMask, bbox, protectedPersonSide, rawRgb, sourceBox } = params;
|
||
if (!protectedPersonSide) return alphaMask;
|
||
|
||
const output = Buffer.from(alphaMask);
|
||
const isProtectedSideArea = (sourceX: number, sourceY: number) => {
|
||
if (protectedPersonSide === "right") {
|
||
return (
|
||
(sourceX >= sourceBox.width * 0.72 && sourceY >= sourceBox.height * 0.22) ||
|
||
(sourceX >= sourceBox.width * 0.58 && sourceY >= sourceBox.height * 0.68)
|
||
);
|
||
}
|
||
if (protectedPersonSide === "left") {
|
||
return (
|
||
(sourceX <= sourceBox.width * 0.28 && sourceY >= sourceBox.height * 0.22) ||
|
||
(sourceX <= sourceBox.width * 0.42 && sourceY >= sourceBox.height * 0.68)
|
||
);
|
||
}
|
||
return sourceY >= sourceBox.height * 0.3;
|
||
};
|
||
const isFarProtectedSideArea = (sourceX: number, sourceY: number) => {
|
||
if (protectedPersonSide === "right") {
|
||
return sourceX >= sourceBox.width * 0.84 && sourceY >= sourceBox.height * 0.3;
|
||
}
|
||
if (protectedPersonSide === "left") {
|
||
return sourceX <= sourceBox.width * 0.16 && sourceY >= sourceBox.height * 0.3;
|
||
}
|
||
return sourceY >= sourceBox.height * 0.58;
|
||
};
|
||
|
||
let clearedPixels = 0;
|
||
let markedPixels = 0;
|
||
for (let localY = 0; localY < bbox.height; localY += 1) {
|
||
for (let localX = 0; localX < bbox.width; localX += 1) {
|
||
const alphaIndex = localY * bbox.width + localX;
|
||
const alpha = output[alphaIndex] || 0;
|
||
if (alpha === 0) continue;
|
||
markedPixels += 1;
|
||
|
||
const colorIndex = alphaIndex * 3;
|
||
const r = rawRgb[colorIndex] || 0;
|
||
const g = rawRgb[colorIndex + 1] || 0;
|
||
const b = rawRgb[colorIndex + 2] || 0;
|
||
const { luma, saturation } = getScaleAdjustPixelStats(r, g, b);
|
||
const sourceX = bbox.left + localX;
|
||
const sourceY = bbox.top + localY;
|
||
const inProtectedSideArea = isProtectedSideArea(sourceX, sourceY);
|
||
const farProtectedSideArea = isFarProtectedSideArea(sourceX, sourceY);
|
||
const likelyHumanPixel =
|
||
isScaleAdjustLikelySkinPixel(r, g, b) ||
|
||
(isScaleAdjustLikelyWhiteFabricPixel(r, g, b, luma, saturation) &&
|
||
luma < 246 &&
|
||
saturation < 0.24);
|
||
const neutralDark =
|
||
luma < 150 &&
|
||
(saturation < 0.55 || (r < 110 && g < 110 && b < 110));
|
||
const lowContrastDark = luma < 132 && saturation < 0.32;
|
||
const shouldClear =
|
||
(inProtectedSideArea && likelyHumanPixel) ||
|
||
(farProtectedSideArea &&
|
||
(likelyHumanPixel ||
|
||
(neutralDark && luma < 72) ||
|
||
(lowContrastDark && saturation < 0.18)));
|
||
if (!shouldClear) continue;
|
||
|
||
output[alphaIndex] = 0;
|
||
clearedPixels += 1;
|
||
}
|
||
}
|
||
|
||
if (markedPixels > 0) {
|
||
console.info(
|
||
"[Canvas Local Edit] Scale adjust protected furniture dark alpha cleared:",
|
||
{
|
||
clearedRatio: Number((clearedPixels / markedPixels).toFixed(3)),
|
||
clearedPixels,
|
||
protectedPersonSide,
|
||
totalMarkedPixels: markedPixels,
|
||
}
|
||
);
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
async function buildScaleAdjustStructureLockedComposite(params: {
|
||
includePersonPixels?: boolean;
|
||
placement: ReturnType<typeof getScaleAdjustVisualGuidePlacement>;
|
||
preferPixelStructure?: boolean;
|
||
protectedPersonSide?: ScaleAdjustProtectedPersonSide | null;
|
||
raw: Buffer;
|
||
sourceBuffer: Buffer;
|
||
sourceImageUrl?: string;
|
||
sourceWidth: number;
|
||
userId?: string;
|
||
}): Promise<ScaleAdjustStructureLockedComposite | null> {
|
||
const {
|
||
includePersonPixels = false,
|
||
placement,
|
||
preferPixelStructure = false,
|
||
protectedPersonSide = null,
|
||
raw,
|
||
sourceBuffer,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
userId,
|
||
} = params;
|
||
// The current RMBG provider can bake horizontal matte stripes into furniture cutouts.
|
||
const allowRmbgStructureLock =
|
||
!preferPixelStructure &&
|
||
(process.env.CANVAS_SCALE_ADJUST_ENABLE_RMBG_STRUCTURE === "1" ||
|
||
(includePersonPixels &&
|
||
process.env.CANVAS_SCALE_ADJUST_DISABLE_PERSON_RMBG !== "1"));
|
||
const rmbgComposite = !allowRmbgStructureLock
|
||
? null
|
||
: await buildScaleAdjustRmbgStructureLockedComposite({
|
||
placement,
|
||
raw,
|
||
sourceBuffer,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
userId,
|
||
});
|
||
if (rmbgComposite) return rmbgComposite;
|
||
if (includePersonPixels) return null;
|
||
|
||
const initialMask = buildScaleAdjustStructureInitialMask({
|
||
box: placement.sourceBox,
|
||
includePersonPixels,
|
||
raw,
|
||
width: sourceWidth,
|
||
});
|
||
const protectedPersonMask =
|
||
preferPixelStructure && protectedPersonSide
|
||
? validateScaleAdjustProtectedPersonMask({
|
||
box: placement.sourceBox,
|
||
mask: buildScaleAdjustProtectedPersonSeedMask({
|
||
box: placement.sourceBox,
|
||
raw,
|
||
side: protectedPersonSide,
|
||
width: sourceWidth,
|
||
}),
|
||
})
|
||
: null;
|
||
const mask = filterScaleAdjustStructureComponents({
|
||
box: placement.sourceBox,
|
||
mask: initialMask,
|
||
protectedPersonMask,
|
||
protectedPersonSide,
|
||
});
|
||
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 = protectedPersonSide
|
||
? dilateScaleAdjustStructureMask({
|
||
height: placement.sourceBox.height,
|
||
mask,
|
||
radius: Math.max(
|
||
1,
|
||
Math.round(
|
||
Math.min(placement.sourceBox.width, placement.sourceBox.height) *
|
||
0.004
|
||
)
|
||
),
|
||
width: placement.sourceBox.width,
|
||
})
|
||
: 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: protectedPersonSide
|
||
? dilateScaleAdjustStructureMask({
|
||
height: placement.sourceBox.height,
|
||
mask,
|
||
radius: Math.max(
|
||
3,
|
||
Math.round(
|
||
Math.min(placement.sourceBox.width, placement.sourceBox.height) *
|
||
0.034
|
||
)
|
||
),
|
||
width: placement.sourceBox.width,
|
||
})
|
||
: subjectMask,
|
||
radius: Math.max(
|
||
2,
|
||
Math.round(
|
||
Math.min(placement.sourceBox.width, placement.sourceBox.height) *
|
||
(protectedPersonSide ? 0.02 : 0.012)
|
||
)
|
||
),
|
||
width: placement.sourceBox.width,
|
||
});
|
||
const sourceCompositeClipMask = dilateScaleAdjustStructureMask({
|
||
height: placement.sourceBox.height,
|
||
mask,
|
||
radius: Math.max(
|
||
1,
|
||
Math.round(Math.min(placement.sourceBox.width, placement.sourceBox.height) * 0.003)
|
||
),
|
||
width: placement.sourceBox.width,
|
||
});
|
||
const sourceWashPatch = await buildScaleAdjustMaskWashPatch({
|
||
background,
|
||
box: placement.sourceBox,
|
||
maskBuffer: Buffer.from(sourceWashMask),
|
||
});
|
||
const compositeClipMaskPatch = await buildScaleAdjustCompositeClipMaskPatch({
|
||
box: placement.sourceBox,
|
||
maskBuffer: sourceCompositeClipMask,
|
||
});
|
||
const subjectCrop = await sharp(sourceBuffer, { failOn: "none" })
|
||
.extract(sourceSubjectBox)
|
||
.removeAlpha()
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const sourceSubjectRaw = await sharp(sourceBuffer, { failOn: "none" })
|
||
.extract(sourceSubjectBox)
|
||
.removeAlpha()
|
||
.toColorspace("srgb")
|
||
.raw()
|
||
.toBuffer();
|
||
const alphaMaskForSubject =
|
||
preferPixelStructure && protectedPersonSide
|
||
? suppressScaleAdjustProtectedFurnitureDarkAlpha({
|
||
alphaMask: Buffer.from(sourceSubjectMask),
|
||
bbox,
|
||
protectedPersonSide,
|
||
rawRgb: sourceSubjectRaw,
|
||
sourceBox: placement.sourceBox,
|
||
})
|
||
: sourceSubjectMask;
|
||
const subjectAlpha = await sharp(alphaMaskForSubject, {
|
||
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 useRectCropTargetGuide = preferPixelStructure && Boolean(protectedPersonSide);
|
||
const structureOverlay = useRectCropTargetGuide
|
||
? await sharp(sourceBuffer, { failOn: "none" })
|
||
.extract(placement.sourceBox)
|
||
.resize(placement.placedWidth, placement.placedHeight, {
|
||
fit: "fill",
|
||
kernel: sharp.kernel.lanczos3,
|
||
})
|
||
.ensureAlpha()
|
||
.composite([
|
||
{
|
||
input: buildScaleAdjustPatchMaskSvg({
|
||
height: placement.placedHeight,
|
||
width: placement.placedWidth,
|
||
}),
|
||
blend: "dest-in",
|
||
},
|
||
])
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer()
|
||
: await sharp(subjectCrop, { failOn: "none" })
|
||
.joinChannel(subjectAlpha)
|
||
.resize(placedWidth, placedHeight, {
|
||
fit: "fill",
|
||
kernel: sharp.kernel.lanczos3,
|
||
})
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const targetClipMaskPatch =
|
||
await buildScaleAdjustOverlayClipMaskPatch(structureOverlay);
|
||
const overlayLeft = useRectCropTargetGuide
|
||
? placement.placedLeft
|
||
: Math.round(targetCenterX - placedWidth / 2);
|
||
const overlayTop = useRectCropTargetGuide
|
||
? placement.placedTop
|
||
: Math.round(targetCenterY - placedHeight / 2);
|
||
|
||
return {
|
||
compositeClip: {
|
||
input: compositeClipMaskPatch,
|
||
left: placement.sourceBox.left,
|
||
top: placement.sourceBox.top,
|
||
},
|
||
method: useRectCropTargetGuide ? "rect-crop-guide" : "pixel-mask",
|
||
overlay: {
|
||
input: structureOverlay,
|
||
left: overlayLeft,
|
||
top: overlayTop,
|
||
},
|
||
targetClip: {
|
||
input: targetClipMaskPatch,
|
||
left: overlayLeft,
|
||
top: overlayTop,
|
||
},
|
||
wash: {
|
||
input: sourceWashPatch,
|
||
left: placement.sourceBox.left,
|
||
top: placement.sourceBox.top,
|
||
},
|
||
};
|
||
}
|
||
|
||
function getScaleAdjustEditMaskBoxes(params: {
|
||
regions: NormalizedLocalEditRegion[];
|
||
scalePercent?: ScaleAdjustPercent | null;
|
||
sourceHeight: number;
|
||
sourceWidth: number;
|
||
}) {
|
||
const { regions, scalePercent, sourceHeight, sourceWidth } = params;
|
||
return regions.map((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const workspaceRect = getScaleAdjustWorkspaceRect(
|
||
region.rect,
|
||
targetRect,
|
||
scalePercent
|
||
);
|
||
return rectToPixels(workspaceRect, sourceWidth, sourceHeight);
|
||
});
|
||
}
|
||
|
||
async function buildScaleAdjustEditMaskBuffer(params: {
|
||
regions: NormalizedLocalEditRegion[];
|
||
scalePercent?: ScaleAdjustPercent | null;
|
||
sourceHeight: number;
|
||
sourceWidth: number;
|
||
}) {
|
||
const { regions, scalePercent, sourceHeight, sourceWidth } = params;
|
||
const boxes = getScaleAdjustEditMaskBoxes({
|
||
regions,
|
||
scalePercent,
|
||
sourceHeight,
|
||
sourceWidth,
|
||
});
|
||
const minSide = Math.max(1, Math.min(sourceWidth, sourceHeight));
|
||
const blur = Math.max(5, Math.min(28, Math.round(minSide * 0.018)));
|
||
const pad = Math.max(2, Math.round(blur * 0.55));
|
||
const rects = boxes
|
||
.map((box) => {
|
||
const left = clamp(box.left - pad, 0, Math.max(0, sourceWidth - 1));
|
||
const top = clamp(box.top - pad, 0, Math.max(0, sourceHeight - 1));
|
||
const right = clamp(
|
||
box.left + box.width + pad,
|
||
left + 1,
|
||
sourceWidth
|
||
);
|
||
const bottom = clamp(
|
||
box.top + box.height + pad,
|
||
top + 1,
|
||
sourceHeight
|
||
);
|
||
return `<rect x="${left}" y="${top}" width="${Math.max(1, right - left)}" height="${Math.max(1, bottom - top)}" rx="${Math.max(4, Math.round(blur * 0.8))}" ry="${Math.max(4, Math.round(blur * 0.8))}" fill="#ffffff" filter="url(#soft-edit-mask)" />`;
|
||
})
|
||
.join("");
|
||
const editMaskSvg = Buffer.from(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${sourceWidth}" height="${sourceHeight}" viewBox="0 0 ${sourceWidth} ${sourceHeight}">
|
||
<defs>
|
||
<filter id="soft-edit-mask" x="-20%" y="-20%" width="140%" height="140%">
|
||
<feGaussianBlur stdDeviation="${blur}" />
|
||
</filter>
|
||
</defs>
|
||
<rect x="0" y="0" width="${sourceWidth}" height="${sourceHeight}" fill="#000000" />
|
||
${rects}
|
||
</svg>`
|
||
);
|
||
const editAlpha = await sharp(editMaskSvg, { failOn: "none" })
|
||
.resize(sourceWidth, sourceHeight, { fit: "fill" })
|
||
.greyscale()
|
||
.raw()
|
||
.toBuffer();
|
||
const rgba = Buffer.alloc(sourceWidth * sourceHeight * 4);
|
||
for (let index = 0; index < sourceWidth * sourceHeight; index += 1) {
|
||
const offset = index * 4;
|
||
const editable = editAlpha[index] || 0;
|
||
rgba[offset] = 0;
|
||
rgba[offset + 1] = 0;
|
||
rgba[offset + 2] = 0;
|
||
rgba[offset + 3] = clamp(255 - editable, 0, 255);
|
||
}
|
||
return sharp(rgba, {
|
||
raw: { channels: 4, height: sourceHeight, width: sourceWidth },
|
||
})
|
||
.png({ compressionLevel: 9 })
|
||
.toBuffer();
|
||
}
|
||
|
||
async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
|
||
regions: NormalizedLocalEditRegion[];
|
||
scalePercent?: ScaleAdjustPercent | null;
|
||
sourceBuffer: Buffer;
|
||
sourceHeight: number;
|
||
sourceImageUrl?: string;
|
||
sourceWidth: number;
|
||
structureLockAppliedRef?: { value: boolean };
|
||
structureLockedDraft?: boolean;
|
||
userId?: string;
|
||
}) {
|
||
const {
|
||
regions,
|
||
scalePercent,
|
||
sourceBuffer,
|
||
sourceHeight,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
structureLockAppliedRef,
|
||
structureLockedDraft = false,
|
||
userId,
|
||
} = params;
|
||
const sourceRaw =
|
||
structureLockedDraft || regions.length > 0
|
||
? await sharp(sourceBuffer, { failOn: "none" })
|
||
.removeAlpha()
|
||
.toColorspace("srgb")
|
||
.raw()
|
||
.toBuffer()
|
||
: null;
|
||
const sourceFootprintBoxes: Array<{
|
||
background?: { b: number; g: number; r: number } | null;
|
||
innerOpacity?: number;
|
||
left: number;
|
||
opacity?: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
}> = [];
|
||
const placements: Array<ReturnType<typeof getScaleAdjustVisualGuidePlacement>> =
|
||
[];
|
||
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
|
||
const subjectWashComposites: Array<{
|
||
input: Buffer;
|
||
left: number;
|
||
top: number;
|
||
}> = [];
|
||
const compositeClipComposites: Array<{
|
||
input: Buffer;
|
||
left: number;
|
||
top: number;
|
||
}> = [];
|
||
const targetClipComposites: Array<{
|
||
input: Buffer;
|
||
left: number;
|
||
top: number;
|
||
}> = [];
|
||
|
||
for (const region of regions) {
|
||
const placement = getScaleAdjustVisualGuidePlacement({
|
||
height: sourceHeight,
|
||
region,
|
||
scalePercent,
|
||
width: sourceWidth,
|
||
});
|
||
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 ||
|
||
targetWidthFactor < 0.92 ||
|
||
targetHeightFactor < 0.92);
|
||
const preferPixelStructure = isScaleAdjustFurnitureProductLikePrompt(
|
||
region.prompt
|
||
);
|
||
const protectedPersonSide = preferPixelStructure
|
||
? getScaleAdjustProtectedPersonSide(region.prompt)
|
||
: null;
|
||
const hasPersonPixels =
|
||
!preferPixelStructure &&
|
||
!!sourceRaw &&
|
||
hasScaleAdjustPersonPixelSignal({
|
||
box: placement.sourceBox,
|
||
raw: sourceRaw,
|
||
width: sourceWidth,
|
||
});
|
||
const includePersonPixels =
|
||
!preferPixelStructure &&
|
||
(isScaleAdjustPersonLikePrompt(region.prompt) || hasPersonPixels);
|
||
const structureLockedComposite = shouldUseStructureLock
|
||
? await buildScaleAdjustStructureLockedComposite({
|
||
includePersonPixels,
|
||
placement,
|
||
preferPixelStructure,
|
||
protectedPersonSide,
|
||
raw: sourceRaw,
|
||
sourceBuffer,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
userId,
|
||
})
|
||
: null;
|
||
|
||
if (structureLockedComposite) {
|
||
if (structureLockAppliedRef) {
|
||
structureLockAppliedRef.value = true;
|
||
}
|
||
subjectWashComposites.push(structureLockedComposite.wash);
|
||
if (structureLockedComposite.compositeClip) {
|
||
compositeClipComposites.push(structureLockedComposite.compositeClip);
|
||
}
|
||
if (structureLockedComposite.targetClip) {
|
||
targetClipComposites.push(structureLockedComposite.targetClip);
|
||
}
|
||
composites.push(structureLockedComposite.overlay);
|
||
continue;
|
||
}
|
||
|
||
const shouldUseShrinkCutoutDraft =
|
||
process.env.CANVAS_SCALE_ADJUST_ENABLE_RMBG_SHRINK_CUTOUT === "1" &&
|
||
!structureLockedDraft &&
|
||
sourceRaw &&
|
||
userId &&
|
||
(targetWidthFactor < 0.92 || targetHeightFactor < 0.92);
|
||
const shrinkCutoutComposite = shouldUseShrinkCutoutDraft
|
||
? await buildScaleAdjustRmbgStructureLockedComposite({
|
||
placement,
|
||
raw: sourceRaw,
|
||
sourceBuffer,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
userId,
|
||
})
|
||
: null;
|
||
|
||
if (shrinkCutoutComposite) {
|
||
subjectWashComposites.push(shrinkCutoutComposite.wash);
|
||
if (shrinkCutoutComposite.compositeClip) {
|
||
compositeClipComposites.push(shrinkCutoutComposite.compositeClip);
|
||
}
|
||
if (shrinkCutoutComposite.targetClip) {
|
||
targetClipComposites.push(shrinkCutoutComposite.targetClip);
|
||
}
|
||
composites.push(shrinkCutoutComposite.overlay);
|
||
continue;
|
||
}
|
||
|
||
sourceFootprintBoxes.push({
|
||
...placement.sourceFootprintBox,
|
||
background: sourceRaw
|
||
? estimateScaleAdjustBoxBackground({
|
||
box: placement.sourceFootprintBox,
|
||
raw: sourceRaw,
|
||
width: sourceWidth,
|
||
})
|
||
: null,
|
||
});
|
||
|
||
const resizedCropBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
||
.extract(placement.sourceBox)
|
||
.resize(placement.placedWidth, placement.placedHeight, {
|
||
fit: "fill",
|
||
})
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const cropBuffer = await sharp(resizedCropBuffer, { failOn: "none" })
|
||
.ensureAlpha()
|
||
.composite([
|
||
{
|
||
input: buildScaleAdjustPatchMaskSvg({
|
||
height: placement.placedHeight,
|
||
width: placement.placedWidth,
|
||
}),
|
||
blend: "dest-in",
|
||
},
|
||
])
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
composites.push({
|
||
input: cropBuffer,
|
||
left: placement.placedLeft,
|
||
top: placement.placedTop,
|
||
});
|
||
}
|
||
|
||
const washComposite = sourceFootprintBoxes.length
|
||
? [
|
||
{
|
||
input: buildScaleAdjustSourceFootprintWashSvg({
|
||
boxes: sourceFootprintBoxes,
|
||
height: sourceHeight,
|
||
width: sourceWidth,
|
||
}),
|
||
left: 0,
|
||
top: 0,
|
||
},
|
||
]
|
||
: [];
|
||
const targetShadowComposite = placements.length
|
||
? structureLockedDraft
|
||
? []
|
||
: [
|
||
{
|
||
input: buildScaleAdjustTargetFootprintShadowSvg({
|
||
height: sourceHeight,
|
||
placements,
|
||
width: sourceWidth,
|
||
}),
|
||
left: 0,
|
||
top: 0,
|
||
},
|
||
]
|
||
: [];
|
||
|
||
const buffer = await sharp(sourceBuffer, { failOn: "none" })
|
||
.composite([
|
||
...washComposite,
|
||
...subjectWashComposites,
|
||
...targetShadowComposite,
|
||
...composites,
|
||
])
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
const compositeClipMaskBuffer =
|
||
compositeClipComposites.length || targetClipComposites.length
|
||
? await buildScaleAdjustCompositeClipMaskBuffer({
|
||
height: sourceHeight,
|
||
sourceMaskComposites: compositeClipComposites,
|
||
targetMaskComposites: targetClipComposites,
|
||
width: sourceWidth,
|
||
})
|
||
: null;
|
||
|
||
return { buffer, compositeClipMaskBuffer };
|
||
}
|
||
|
||
async function createScaleAdjustPatchReference(params: {
|
||
fullFrameReferenceOnly?: boolean;
|
||
regions: NormalizedLocalEditRegion[];
|
||
scalePercent?: ScaleAdjustPercent | null;
|
||
sourceBuffer: Buffer;
|
||
sourceHeight: number;
|
||
sourceImageUrl?: string;
|
||
sourceWidth: number;
|
||
userId?: string;
|
||
}): Promise<ScaleAdjustPatchReference> {
|
||
const {
|
||
regions,
|
||
scalePercent,
|
||
sourceBuffer,
|
||
sourceHeight,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
userId,
|
||
} = params;
|
||
const workspaceRect = getScaleAdjustPatchWorkspaceRect(regions, scalePercent);
|
||
const workspacePixelRect = rectToPixels(workspaceRect, sourceWidth, sourceHeight);
|
||
const shouldUseStructureLockedDraft = hasScaleAdjustStructureTarget(
|
||
regions,
|
||
scalePercent
|
||
);
|
||
const structureLockAppliedRef = { value: false };
|
||
const targetDraftReference = await buildScaleAdjustVisualLayoutGuideBuffer({
|
||
regions,
|
||
scalePercent,
|
||
sourceBuffer,
|
||
sourceHeight,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
structureLockAppliedRef,
|
||
structureLockedDraft: shouldUseStructureLockedDraft,
|
||
userId,
|
||
});
|
||
const targetDraftBuffer = targetDraftReference.buffer;
|
||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
if (params.fullFrameReferenceOnly && structureLockAppliedRef.value) {
|
||
const [targetDraftFullUpload, compositeClipMaskUpload] = await Promise.all([
|
||
uploadFile(
|
||
`canvas-scale-adjust-full-target-draft-${suffix}.png`,
|
||
toArrayBuffer(targetDraftBuffer)
|
||
),
|
||
targetDraftReference.compositeClipMaskBuffer
|
||
? uploadFile(
|
||
`canvas-scale-adjust-composite-clip-mask-${suffix}.png`,
|
||
toArrayBuffer(targetDraftReference.compositeClipMaskBuffer)
|
||
)
|
||
: Promise.resolve(null),
|
||
]);
|
||
|
||
return {
|
||
compositeClipMaskUrl: compositeClipMaskUpload?.url || null,
|
||
sourceCropUrl: sourceImageUrl || targetDraftFullUpload.url,
|
||
structureLockedDraft: structureLockAppliedRef.value,
|
||
targetDraftFullUrl: targetDraftFullUpload.url,
|
||
targetDraftUrl: targetDraftFullUpload.url,
|
||
workspacePixelRect,
|
||
workspaceRect,
|
||
};
|
||
}
|
||
|
||
const [sourceCropBuffer, targetDraftCropBuffer] = await Promise.all([
|
||
sharp(sourceBuffer, { failOn: "none" })
|
||
.extract(workspacePixelRect)
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer(),
|
||
sharp(targetDraftBuffer, { failOn: "none" })
|
||
.extract(workspacePixelRect)
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer(),
|
||
]);
|
||
const [
|
||
sourceCropUpload,
|
||
targetDraftUpload,
|
||
targetDraftFullUpload,
|
||
compositeClipMaskUpload,
|
||
] = await Promise.all([
|
||
uploadFile(
|
||
`canvas-scale-adjust-workspace-source-${suffix}.png`,
|
||
toArrayBuffer(sourceCropBuffer)
|
||
),
|
||
uploadFile(
|
||
`canvas-scale-adjust-workspace-target-draft-${suffix}.png`,
|
||
toArrayBuffer(targetDraftCropBuffer)
|
||
),
|
||
uploadFile(
|
||
`canvas-scale-adjust-full-target-draft-${suffix}.png`,
|
||
toArrayBuffer(targetDraftBuffer)
|
||
),
|
||
targetDraftReference.compositeClipMaskBuffer
|
||
? uploadFile(
|
||
`canvas-scale-adjust-composite-clip-mask-${suffix}.png`,
|
||
toArrayBuffer(targetDraftReference.compositeClipMaskBuffer)
|
||
)
|
||
: Promise.resolve(null),
|
||
]);
|
||
|
||
return {
|
||
compositeClipMaskUrl: compositeClipMaskUpload?.url || null,
|
||
sourceCropUrl: sourceCropUpload.url,
|
||
structureLockedDraft: structureLockAppliedRef.value,
|
||
targetDraftFullUrl: targetDraftFullUpload.url,
|
||
targetDraftUrl: targetDraftUpload.url,
|
||
workspacePixelRect,
|
||
workspaceRect,
|
||
};
|
||
}
|
||
|
||
function wrapText(text: string, maxChars: number, maxLines = 3) {
|
||
const compact = text.replace(/\s+/g, " ").trim();
|
||
if (!compact) return [];
|
||
const lines: string[] = [];
|
||
let remaining = compact;
|
||
while (remaining.length > 0 && lines.length < maxLines) {
|
||
if (remaining.length <= maxChars) {
|
||
lines.push(remaining);
|
||
break;
|
||
}
|
||
lines.push(remaining.slice(0, maxChars));
|
||
remaining = remaining.slice(maxChars);
|
||
}
|
||
if (remaining.length > 0 && lines.length >= maxLines) {
|
||
lines[lines.length - 1] = `${lines[lines.length - 1].slice(0, Math.max(1, maxChars - 1))}…`;
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
function normalizeRegions(
|
||
rawRegions: LocalEditRegionInput[] | undefined,
|
||
sourceWidth: number,
|
||
sourceHeight: number,
|
||
options: { requirePrompt?: boolean; fallbackPrompt?: string } = {}
|
||
): NormalizedLocalEditRegion[] {
|
||
if (!Array.isArray(rawRegions) || rawRegions.length === 0) {
|
||
throw new Error("请先框选至少一个局部编辑区域");
|
||
}
|
||
if (rawRegions.length > MAX_LOCAL_EDIT_REGIONS) {
|
||
throw new Error(`局部编辑最多支持 ${MAX_LOCAL_EDIT_REGIONS} 个区域`);
|
||
}
|
||
|
||
return rawRegions.map((region, itemIndex) => {
|
||
const rect = normalizeRect(region.rect);
|
||
if (!rect) {
|
||
throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`);
|
||
}
|
||
const targetRect = normalizeRect(region.targetRect);
|
||
const prompt = String(region.prompt || "").trim();
|
||
const resolvedPrompt = prompt || String(options.fallbackPrompt || "").trim();
|
||
if (options.requirePrompt !== false && !resolvedPrompt) {
|
||
throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`);
|
||
}
|
||
const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight);
|
||
const targetPixelRect = targetRect
|
||
? rectToPixels(targetRect, sourceWidth, sourceHeight)
|
||
: null;
|
||
if (pixelRect.width < MIN_REGION_EDGE_PX || pixelRect.height < MIN_REGION_EDGE_PX) {
|
||
throw new Error(`区域 ${itemIndex + 1} 过小,请重新框选更大的范围`);
|
||
}
|
||
const color = normalizeHexColor(
|
||
region.color,
|
||
DEFAULT_REGION_COLORS[itemIndex % DEFAULT_REGION_COLORS.length]
|
||
);
|
||
return {
|
||
id: String(region.id || `region-${itemIndex + 1}`),
|
||
index: itemIndex + 1,
|
||
color,
|
||
colorName: getColorName(color),
|
||
prompt: resolvedPrompt.slice(0, 300),
|
||
referenceImages: normalizeRegionReferenceImages(region, itemIndex),
|
||
rect,
|
||
targetRect,
|
||
pixelRect,
|
||
targetPixelRect,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildAnnotationSvg(params: {
|
||
width: number;
|
||
height: number;
|
||
regions: NormalizedLocalEditRegion[];
|
||
hidePromptText?: boolean;
|
||
}) {
|
||
const { width, height, regions } = params;
|
||
const minSide = Math.max(1, Math.min(width, height));
|
||
const strokeWidth = Math.max(4, Math.round(minSide * 0.006));
|
||
const fontSize = Math.max(24, Math.round(minSide * 0.026));
|
||
const smallFontSize = Math.max(18, Math.round(fontSize * 0.72));
|
||
const padX = Math.round(fontSize * 0.55);
|
||
const padY = Math.round(fontSize * 0.34);
|
||
const gap = Math.round(fontSize * 0.34);
|
||
const radius = Math.round(fontSize * 0.5);
|
||
|
||
const regionSvg = regions
|
||
.map((region) => {
|
||
const { left, top, width: rectWidth, height: rectHeight } = region.pixelRect;
|
||
const labelText = params.hidePromptText
|
||
? `区域${region.index} 目标主体`
|
||
: `区域${region.index} ${region.prompt}`;
|
||
const maxChars = Math.max(8, Math.min(24, Math.floor((width * 0.42) / Math.max(1, fontSize * 0.58))));
|
||
const lines = wrapText(labelText, maxChars, 3);
|
||
const labelWidth = Math.min(
|
||
Math.max(
|
||
rectWidth,
|
||
...lines.map((line) => Math.ceil(line.length * fontSize * 0.62) + padX * 2)
|
||
),
|
||
Math.max(180, width - 16)
|
||
);
|
||
const labelHeight = lines.length * Math.round(fontSize * 1.24) + padY * 2;
|
||
const preferBelow = top + rectHeight + gap + labelHeight <= height - 8;
|
||
const labelX = clamp(left, 8, Math.max(8, width - labelWidth - 8));
|
||
const labelY = preferBelow
|
||
? top + rectHeight + gap
|
||
: clamp(top - gap - labelHeight, 8, Math.max(8, height - labelHeight - 8));
|
||
const badgeSize = Math.max(34, Math.round(fontSize * 1.35));
|
||
const badgeCx = clamp(left, badgeSize / 2 + 6, width - badgeSize / 2 - 6);
|
||
const badgeCy = clamp(top, badgeSize / 2 + 6, height - badgeSize / 2 - 6);
|
||
const textLines = lines
|
||
.map(
|
||
(line, lineIndex) =>
|
||
`<text x="${labelX + padX}" y="${labelY + padY + fontSize + lineIndex * Math.round(fontSize * 1.24)}" fill="#ffffff" font-size="${fontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="700">${escapeXml(line)}</text>`
|
||
)
|
||
.join("");
|
||
|
||
return `
|
||
<rect x="${left}" y="${top}" width="${rectWidth}" height="${rectHeight}" rx="${radius}" fill="none" stroke="${region.color}" stroke-width="${strokeWidth}" />
|
||
<rect x="${left}" y="${top}" width="${rectWidth}" height="${rectHeight}" rx="${radius}" fill="${region.color}" opacity="0.08" />
|
||
<circle cx="${badgeCx}" cy="${badgeCy}" r="${badgeSize / 2}" fill="${region.color}" stroke="#ffffff" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.45))}" />
|
||
<text x="${badgeCx}" y="${badgeCy + smallFontSize * 0.38}" text-anchor="middle" fill="#ffffff" font-size="${smallFontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="800">${region.index}</text>
|
||
<rect x="${labelX}" y="${labelY}" width="${labelWidth}" height="${labelHeight}" rx="${radius}" fill="#000000" opacity="0.78" stroke="${region.color}" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.5))}" />
|
||
${textLines}
|
||
`;
|
||
})
|
||
.join("");
|
||
|
||
return Buffer.from(`
|
||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||
${regionSvg}
|
||
</svg>
|
||
`);
|
||
}
|
||
|
||
function buildScaleAdjustAnnotationSvg(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(2, Math.round(minSide * 0.0034));
|
||
const fontSize = Math.max(16, Math.round(minSide * 0.018));
|
||
const labelPadX = Math.round(fontSize * 0.45);
|
||
const labelPadY = Math.round(fontSize * 0.24);
|
||
const targetColor = "#111827";
|
||
const sourceColor = "#9ca3af";
|
||
const helperColor = "#4b5563";
|
||
const label = (text: string, x: number, y: number, color: string) => {
|
||
const labelWidth = Math.round(text.length * fontSize * 0.62 + labelPadX * 2);
|
||
const labelHeight = Math.round(fontSize * 1.2 + labelPadY * 2);
|
||
const left = clamp(x, 6, Math.max(6, width - labelWidth - 6));
|
||
const top = clamp(y, 6, Math.max(6, height - labelHeight - 6));
|
||
return `
|
||
<rect x="${left}" y="${top}" width="${labelWidth}" height="${labelHeight}" rx="${Math.round(labelHeight * 0.24)}" fill="${color}" opacity="0.9" />
|
||
<text x="${left + labelPadX}" y="${top + labelPadY + fontSize}" fill="#ffffff" font-size="${fontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="800">${escapeXml(text)}</text>
|
||
`;
|
||
};
|
||
|
||
const regionSvg = regions
|
||
.map((region) => {
|
||
const source = region.pixelRect;
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const target =
|
||
region.targetPixelRect || rectToPixels(targetRect, width, height);
|
||
const radius = Math.max(8, Math.round(strokeWidth * 2));
|
||
const sourceCenterX = source.left + source.width / 2;
|
||
const sourceCenterY = source.top + source.height / 2;
|
||
const targetCenterX = target.left + target.width / 2;
|
||
const targetCenterY = target.top + target.height / 2;
|
||
const widthFactor = source.width > 0 ? target.width / source.width : 1;
|
||
const heightFactor = source.height > 0 ? target.height / source.height : 1;
|
||
const isShrink = widthFactor < 0.92 || heightFactor < 0.92;
|
||
const isEnlarge = widthFactor > 1.08 || heightFactor > 1.08;
|
||
const actionLabel = isShrink ? "SHRINK" : isEnlarge ? "ENLARGE" : "MOVE";
|
||
const scaleLabel = `${actionLabel} ${widthFactor.toFixed(2)}W ${heightFactor.toFixed(2)}H`;
|
||
const instructionLabel = isShrink
|
||
? "OLD OUTSIDE -> BG"
|
||
: isEnlarge
|
||
? "TARGET = FINAL FOOTPRINT"
|
||
: "FINAL SUBJECT CENTER";
|
||
const centerMoved =
|
||
Math.hypot(targetCenterX - sourceCenterX, targetCenterY - sourceCenterY) >
|
||
Math.max(8, minSide * 0.012);
|
||
|
||
return `
|
||
<rect x="${source.left}" y="${source.top}" width="${source.width}" height="${source.height}" rx="${radius}" fill="${sourceColor}" opacity="0.08" />
|
||
<rect x="${target.left}" y="${target.top}" width="${target.width}" height="${target.height}" rx="${radius}" fill="${targetColor}" opacity="0.08" />
|
||
<rect x="${source.left}" y="${source.top}" width="${source.width}" height="${source.height}" rx="${radius}" fill="none" stroke="${sourceColor}" stroke-width="${strokeWidth}" opacity="0.82" />
|
||
<rect x="${target.left}" y="${target.top}" width="${target.width}" height="${target.height}" rx="${radius}" fill="none" stroke="${targetColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 1.08))}" opacity="0.88" />
|
||
<circle cx="${sourceCenterX}" cy="${sourceCenterY}" r="${Math.max(3, Math.round(strokeWidth * 1.2))}" fill="${sourceColor}" opacity="0.88" />
|
||
<circle cx="${targetCenterX}" cy="${targetCenterY}" r="${Math.max(4, Math.round(strokeWidth * 1.45))}" fill="${targetColor}" opacity="0.92" />
|
||
${
|
||
centerMoved
|
||
? `<line x1="${sourceCenterX}" y1="${sourceCenterY}" x2="${targetCenterX}" y2="${targetCenterY}" stroke="${helperColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.78))}" stroke-linecap="round" marker-end="url(#scale-adjust-arrowhead)" opacity="0.72" />`
|
||
: ""
|
||
}
|
||
${label(`OLD ${region.index}`, source.left + strokeWidth, source.top + strokeWidth, sourceColor)}
|
||
${label(`TARGET ${region.index}`, target.left + strokeWidth, target.top + strokeWidth, targetColor)}
|
||
${label(scaleLabel, target.left + strokeWidth, target.top + target.height + strokeWidth * 2, helperColor)}
|
||
${label(instructionLabel, source.left + strokeWidth, source.top + source.height + strokeWidth * 2, helperColor)}
|
||
`;
|
||
})
|
||
.join("");
|
||
|
||
return Buffer.from(`
|
||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||
<rect x="0" y="0" width="${width}" height="${height}" fill="#f5f7fb" />
|
||
<defs>
|
||
<marker id="scale-adjust-arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth">
|
||
<path d="M 0 0 L 10 5 L 0 10 z" fill="${helperColor}" />
|
||
</marker>
|
||
</defs>
|
||
<rect x="${Math.max(1, strokeWidth / 2)}" y="${Math.max(1, strokeWidth / 2)}" width="${Math.max(1, width - strokeWidth)}" height="${Math.max(1, height - strokeWidth)}" fill="none" stroke="#d9dee8" stroke-width="${Math.max(1, Math.round(strokeWidth * 0.55))}" />
|
||
${regionSvg}
|
||
</svg>
|
||
`);
|
||
}
|
||
|
||
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 `
|
||
<rect x="${left}" y="${top}" width="${labelWidth}" height="${labelHeight}" rx="${Math.round(labelHeight * 0.22)}" fill="${color}" opacity="0.92" />
|
||
<text x="${left + padX}" y="${top + Math.round(labelHeight * 0.68)}" fill="#ffffff" font-size="${fontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="800">${escapeXml(text)}</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 `
|
||
<rect x="${sourceBox.left}" y="${sourceBox.top}" width="${sourceBox.width}" height="${sourceBox.height}" rx="${radius}" fill="#e5e7eb" opacity="0.72" stroke="${sourceColor}" stroke-width="${strokeWidth}" />
|
||
<rect x="${targetBox.left}" y="${targetBox.top}" width="${targetBox.width}" height="${targetBox.height}" rx="${radius}" fill="#d1d5db" opacity="0.82" stroke="${targetColor}" stroke-width="${Math.max(3, Math.round(strokeWidth * 1.25))}" />
|
||
<circle cx="${sourceCenterX}" cy="${sourceCenterY}" r="${Math.max(4, Math.round(strokeWidth * 1.25))}" fill="${sourceColor}" />
|
||
<circle cx="${targetCenterX}" cy="${targetCenterY}" r="${Math.max(5, Math.round(strokeWidth * 1.55))}" fill="${targetColor}" />
|
||
${
|
||
centerMoved
|
||
? `<line x1="${sourceCenterX}" y1="${sourceCenterY}" x2="${targetCenterX}" y2="${targetCenterY}" stroke="${helperColor}" stroke-width="${Math.max(3, Math.round(strokeWidth * 0.9))}" stroke-linecap="round" marker-end="url(#scale-adjust-zoom-arrow)" opacity="0.78" />`
|
||
: ""
|
||
}
|
||
${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 -> BG", sourceBox.left, sourceBox.top + sourceBox.height + strokeWidth * 4 + labelHeight, helperColor)}
|
||
`;
|
||
})
|
||
.join("");
|
||
|
||
return Buffer.from(`
|
||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<marker id="scale-adjust-zoom-arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth">
|
||
<path d="M 0 0 L 10 5 L 0 10 z" fill="${helperColor}" />
|
||
</marker>
|
||
</defs>
|
||
<rect x="0" y="0" width="${width}" height="${height}" fill="#f5f7fb" />
|
||
<rect x="${panelLeft}" y="${panelTop}" width="${panelWidth}" height="${panelHeight}" rx="${Math.round(minSide * 0.018)}" fill="#ffffff" stroke="#cbd5e1" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.65))}" />
|
||
<text x="${panelLeft}" y="${Math.max(fontSize + 8, panelTop - Math.round(fontSize * 0.45))}" fill="#111827" font-size="${smallFontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="800">LOCAL SCALE MAP - USE PROMPT PIXELS FOR ABSOLUTE POSITION</text>
|
||
${regionSvg}
|
||
</svg>
|
||
`);
|
||
}
|
||
|
||
function buildScaleAdjustMarkedPhotoGuideSvg(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.005));
|
||
const currentColor = "#00b8d4";
|
||
const finalColor = "#2563eb";
|
||
|
||
const regionSvg = regions
|
||
.map((region) => {
|
||
const source = region.pixelRect;
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const target =
|
||
region.targetPixelRect || rectToPixels(targetRect, width, height);
|
||
const radius = Math.max(8, Math.round(strokeWidth * 2));
|
||
return `
|
||
<rect x="${source.left}" y="${source.top}" width="${source.width}" height="${source.height}" rx="${radius}" fill="${currentColor}" opacity="0.08" />
|
||
<rect x="${target.left}" y="${target.top}" width="${target.width}" height="${target.height}" rx="${radius}" fill="${finalColor}" opacity="0.14" />
|
||
<rect x="${source.left}" y="${source.top}" width="${source.width}" height="${source.height}" rx="${radius}" fill="none" stroke="${currentColor}" stroke-width="${strokeWidth}" stroke-dasharray="${strokeWidth * 2} ${strokeWidth * 1.4}" opacity="0.95" />
|
||
<rect x="${target.left}" y="${target.top}" width="${target.width}" height="${target.height}" rx="${radius}" fill="none" stroke="${finalColor}" stroke-width="${Math.max(3, Math.round(strokeWidth * 1.25))}" opacity="0.98" />
|
||
`;
|
||
})
|
||
.join("");
|
||
|
||
return Buffer.from(`
|
||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||
<rect x="0" y="0" width="${width}" height="${height}" fill="#f8fafc" opacity="0.24" />
|
||
${regionSvg}
|
||
</svg>
|
||
`);
|
||
}
|
||
|
||
async function buildScaleAdjustMarkedPhotoGuideBuffer(params: {
|
||
sourceBuffer: Buffer;
|
||
width: number;
|
||
height: number;
|
||
regions: NormalizedLocalEditRegion[];
|
||
scalePercent?: ScaleAdjustPercent | null;
|
||
}) {
|
||
const { sourceBuffer, width, height, regions, scalePercent } = params;
|
||
return sharp(sourceBuffer, { failOn: "none" })
|
||
.rotate()
|
||
.resize(width, height, { fit: "cover", position: "center" })
|
||
.modulate({ brightness: 1.04, saturation: 0.45 })
|
||
.composite([
|
||
{
|
||
input: buildScaleAdjustMarkedPhotoGuideSvg({
|
||
height,
|
||
regions,
|
||
scalePercent,
|
||
width,
|
||
}),
|
||
left: 0,
|
||
top: 0,
|
||
},
|
||
])
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
}
|
||
|
||
function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
|
||
const regionLines = regions
|
||
.map((region) => {
|
||
const left = Math.round(region.rect.x * 1000) / 10;
|
||
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 referenceNote =
|
||
region.referenceImages.length > 0
|
||
? `;该区域有 ${region.referenceImages.length} 张专属参考图,依次为后续输入图片中的“区域 ${region.index} 参考图”,只可用于该区域`
|
||
: "";
|
||
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%${referenceNote}):${region.prompt}`;
|
||
})
|
||
.join("\n");
|
||
const referenceLines = regions
|
||
.flatMap((region) =>
|
||
region.referenceImages.map(
|
||
(reference, referenceIndex) =>
|
||
`区域 ${region.index} 参考图 ${referenceIndex + 1}:${reference.name || "未命名参考图"},只能影响区域 ${region.index}`
|
||
)
|
||
)
|
||
.join("\n");
|
||
|
||
return [
|
||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
||
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。",
|
||
"输入图片2及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
|
||
"最终结果必须保持输入图片1的原始画幅比例和构图边界,不要把画面压扁、拉伸、加白边或改成方图。",
|
||
"请不要凭空生成任何彩色框、蓝框、绿框、红框、矩形边框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
|
||
"",
|
||
"请严格根据每个区域下方的提示词,只在对应坐标区域附近完成局部编辑:",
|
||
regionLines,
|
||
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
|
||
"",
|
||
"新增主体完整性要求:如果区域提示词要求新增动物、人物、商品或其它完整主体,必须把该主体完整放在框选区域的安全内边距内,四周至少预留 8%-12% 缓冲空间;不要让头、身体、四肢、尾巴、产品边缘或阴影贴到选区边界,更不能被边界截断。空间不足时请主动缩小新增主体,而不是裁切主体。",
|
||
"边缘融合要求:编辑区域与未编辑区域的地面、墙面、桌面、椅脚、阴影、纹理和透视必须连续自然,不得出现错位、断层、硬边、矩形补丁、局部贴片或不同清晰度的边界。",
|
||
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
|
||
"未被列为编辑区域的画面必须逐像素保持原图不变,不允许重绘、改色、补光、换材质、移动物体、改变清晰度或改变背景细节。",
|
||
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
|
||
"如果某个区域提示词与原图空间冲突,以自然融入原图为最高优先级。",
|
||
].join("\n");
|
||
}
|
||
|
||
function buildScaleAdjustPrompt(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent: ScaleAdjustPercent | null | undefined,
|
||
cropReferences: ScaleAdjustCropReference[] = [],
|
||
sourceSize?: { width: number; height: number }
|
||
) {
|
||
const sourceSizeLine = sourceSize
|
||
? `最终输出必须保持与输入图片1完全相同的画幅和像素尺寸:${sourceSize.width} x ${sourceSize.height} px。`
|
||
: "最终输出必须保持与输入图片1完全相同的画幅和像素尺寸。";
|
||
const regionLines = regions
|
||
.map((region) => {
|
||
const cropIndex = cropReferences.findIndex(
|
||
(reference) => reference.regionIndex === region.index
|
||
);
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const sourceCenterX = region.rect.x + region.rect.width / 2;
|
||
const sourceCenterY = region.rect.y + region.rect.height / 2;
|
||
const targetCenterX = targetRect.x + targetRect.width / 2;
|
||
const targetCenterY = targetRect.y + targetRect.height / 2;
|
||
const moved =
|
||
Math.hypot(targetCenterX - sourceCenterX, targetCenterY - sourceCenterY) >
|
||
0.01;
|
||
const targetPixelRect =
|
||
region.targetPixelRect ||
|
||
(sourceSize
|
||
? rectToPixels(targetRect, sourceSize.width, sourceSize.height)
|
||
: null);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
const sourcePixelLine = `源框像素坐标:x=${region.pixelRect.left}, y=${region.pixelRect.top}, w=${region.pixelRect.width}, h=${region.pixelRect.height}`;
|
||
const targetPixelLine = targetPixelRect
|
||
? `目标框像素坐标:x=${targetPixelRect.left}, y=${targetPixelRect.top}, w=${targetPixelRect.width}, h=${targetPixelRect.height}`
|
||
: `目标框相对坐标:x=${Math.round(targetRect.x * 1000) / 10}%, y=${Math.round(targetRect.y * 1000) / 10}%, w=${Math.round(targetRect.width * 1000) / 10}%, h=${Math.round(targetRect.height * 1000) / 10}%`;
|
||
const scaleLine = `目标视觉占位约为原主体宽度的 ${widthFactor.toFixed(2)} 倍、高度的 ${heightFactor.toFixed(2)} 倍;执行时必须以等比主体缩放为准,不要拉伸主体去硬贴目标框。`;
|
||
const targetPositionNote = moved
|
||
? "目标框同时指定用户希望的最终目标大小和目标位置,目标主体调整后的视觉中心应移动到目标框中心附近。"
|
||
: "目标框指定用户希望的最终目标大小,目标主体调整后应围绕当前主体位置自然放置。";
|
||
const cropNote =
|
||
cropIndex >= 0
|
||
? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例`
|
||
: "";
|
||
return `区域 ${region.index}${cropNote}:请在输入图片1中找到源框范围内最主要、可独立调整的商品/物体主体。${sourcePixelLine};${targetPixelLine};${scaleLine}${targetPositionNote}源框只是“寻找主体”的范围,不是最终边界,也不是要缩放或贴回的矩形补丁。目标框只是主体调整后的视觉占位参考,不要求主体填满矩形,更不能改变主体自身宽高比、透视或形体。请像真实图片编辑一样先保留锁定的原始画布,再只把这个主体整体等比变大/变小/移动到目标视觉占位;主体缩放后露出或遮挡的极小背景区域需要自然重建。严禁把输入图片1整张图、输入图片2目标布局参考图、任意裁切图、源框矩形区域或目标框矩形区域当成图片块整体缩放、平移、复制、裁切或贴回。如果源框中只覆盖到主体的一部分,请根据局部裁切参考和可见结构推断该主体的完整可见轮廓,只调整主体,不调整矩形框内背景。`;
|
||
})
|
||
.join("\n");
|
||
const cropLines = cropReferences
|
||
.map(
|
||
(reference, index) =>
|
||
`输入图片${index + 3}:区域 ${reference.regionIndex} 的原图干净局部裁切参考图,只用于确认要缩放的主体身份、轮廓和宽高比,不能当成输出底图,也不能把裁切图整体贴回。`
|
||
)
|
||
.join("\n");
|
||
|
||
return [
|
||
"这是一项局部主体比例 inpaint/edit 任务,不是整图缩放、整图裁切或重新构图任务。",
|
||
sourceSizeLine,
|
||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。除目标主体尺寸变化与必要背景补全外,输入图片1中的文字、Logo、装饰、排版、背景、光线、构图边界和所有未选区域必须保持不变。",
|
||
"输入图片2是一张与输入图片1尺寸和画幅完全相同的完整画面编辑意图图,不是输出底图。它保留原图完整画幅作为稳定参考,并把源框旧主体占位强淡化为“这里的原尺寸主体需要被目标尺寸替换并自然修复背景”的指令;同时把同一目标主体粗略缩放后放在用户希望的最终位置和大小。请只读取目标主体的最终视觉占位、中心位置和原画幅关系;输入图片2里被淡化的旧主体不能表示最终还要保留原尺寸主体。不得学习、复刻或输出淡化痕迹、阴影占位、矩形裁切边缘、拼接痕迹或参考图粗糙画质。",
|
||
cropLines
|
||
? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}`
|
||
: "",
|
||
"输入图片2中的完整画面编辑意图是强约束:淡化的 OLD 占位表示旧尺寸主体要被移除/修复,清晰的 TARGET 占位表示最终主体大小和中心位置;整张图不应缩放、裁切或平移。最终输出必须回到输入图片1的真实照片环境中完成自然编辑,不得直接粘贴 input2 的矩形图块;主体不需要填满目标矩形,必须保持主体自身原始宽高比和透视形体。",
|
||
"如果系统或模型只能读取一张参考图,必须优先以输入图片1为输出基底,不得清理、改写或删除输入图片1中框选区域外的任何文字和图形。",
|
||
"系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片、蒙版边界,也不是放大后的空间上限,更不是要整体缩放的矩形图层。",
|
||
"最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度;严禁整张图整体缩放、裁切、平移、重新排版或重新生成。",
|
||
"Highest priority rule: do NOT zoom, crop, resize, reframe, pan, or regenerate the full canvas. Treat image 1 as a locked background plate; edit only the selected subject and the smallest necessary background around its old and new footprint.",
|
||
"请不要生成任何彩色框、矩形边框、箭头、连线、角点、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
|
||
"请同时使用下方每个区域的源框/目标框像素坐标和输入图片2的目标布局参考来理解目标占位。坐标和参考图只用于指导主体位置和大小,不是要求把某个矩形图块缩放后贴回。",
|
||
"",
|
||
"任务:先在输入图片1中识别源框范围内最主要、可独立调整的主体物体,再只对这个“主体物体”做局部比例调整。调整后的主体大小和位置必须接近目标框表示的视觉占位;不接受看起来和原图几乎相同的输出。",
|
||
regionLines,
|
||
"",
|
||
"关键要求:",
|
||
"1. 只调整目标主体物体本身的整体视觉比例,不得保留原尺寸;主体轮廓宽度和高度必须同步等比变化,宽高比必须与原主体一致,不能为了贴合目标框而拉宽、压窄、拉高或压扁。",
|
||
"2. 严禁把整个矩形框选区域、输入图片1整张图或任意裁切图当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
|
||
"2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;画面中的标题、Logo、页面文字、边框、窗框、背景家具或非目标主体必须作为像素锚点保持原位置和原比例,不能跟着目标主体变化。",
|
||
"3. 如果输入图片2中的目标框明显小于源框,目标主体应自然变小,主体缩小后露出的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。",
|
||
"4. 如果输入图片2中的目标框明显大于源框,目标主体应自然变大;允许主体自然超出用户手画的原始源框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。",
|
||
"5. 如果目标框中心与源框中心不同,允许主体同步移动到目标框中心附近;如果主体靠近画布边界导致目标空间不足,请用能完整放入画面的最大统一倍率整体调整,绝不能为了塞进画面而只压缩宽度或只拉伸高度。",
|
||
"6. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
|
||
"6.1 比例调整后的主体边缘必须和周围环境自然融合,选区边界不能被肉眼看出;不得出现一整块矩形区域清晰度、颜色、光照、纹理或透视突然变化。",
|
||
"6.2 框选区域通常会包含主体周围的背景留白,背景留白不是目标主体。请锁定这些非主体背景的窗框、文字、地毯纹理、墙面、桌面边缘和透视线,只重建主体缩放后被遮挡或露出的极小必要区域;不要把框选矩形当作整块背景重新生成。",
|
||
"6.3 如果框选范围内没有清晰、完整、可独立缩放的主体物体,请保持该区域背景结构稳定,不要为了制造变化而重绘窗框、文字、墙面、地面或其它非主体元素。",
|
||
"7. 未受比例调整影响的其它画面区域必须保持原图不变,尤其是框选区域外的文字、Logo、标题、尺寸标注、装饰条和页面排版不得被清理、改写、模糊或重绘。",
|
||
"8. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
|
||
"9. 输出前自检:如果目标主体大小与原图相比没有明显变化,或者主体宽高比发生非等比变形,必须重新生成;禁止返回看起来和原图相同或被拉伸的结果。",
|
||
].join("\n");
|
||
}
|
||
|
||
function buildScaleAdjustCoordinatePrompt(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent: ScaleAdjustPercent | null | undefined,
|
||
sourceSize: { width: number; height: number },
|
||
cropReferences: ScaleAdjustCropReference[] = [],
|
||
options: {
|
||
falSafe?: boolean;
|
||
markedPhotoGuide?: boolean;
|
||
visualLayoutGuide?: boolean;
|
||
} = {}
|
||
) {
|
||
const regionLines = regions
|
||
.map((region) => {
|
||
const cropIndex = cropReferences.findIndex(
|
||
(reference) => reference.regionIndex === region.index
|
||
);
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const targetPixelRect =
|
||
region.targetPixelRect ||
|
||
rectToPixels(targetRect, sourceSize.width, sourceSize.height);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
const sourceRight = region.pixelRect.left + region.pixelRect.width;
|
||
const sourceBottom = region.pixelRect.top + region.pixelRect.height;
|
||
const targetRight = targetPixelRect.left + targetPixelRect.width;
|
||
const targetBottom = targetPixelRect.top + targetPixelRect.height;
|
||
const regionUserInstruction = region.prompt.trim();
|
||
const userInstructionLine = regionUserInstruction
|
||
? ` User instruction for this region: ${regionUserInstruction}.`
|
||
: "";
|
||
const isShrink = widthFactor < 0.92 || heightFactor < 0.92;
|
||
const isEnlarge = widthFactor > 1.08 || heightFactor > 1.08;
|
||
const scaleDirection =
|
||
isShrink
|
||
? "make the selected subject clearly smaller"
|
||
: isEnlarge
|
||
? "make the selected subject clearly larger"
|
||
: "move/fit the selected subject to the target box";
|
||
const footprintRule = isShrink
|
||
? "After shrinking, the original OLD footprint outside the TARGET box must be cleared and rebuilt as natural background/floor/rug/wall; the selected subject must not still touch the OLD box edges."
|
||
: isEnlarge
|
||
? "After enlarging, the same selected subject should visibly occupy more of the TARGET footprint, with natural shadows and occlusion; do not just move it or stretch one axis."
|
||
: "Keep the subject centered on the TARGET box without changing the full canvas.";
|
||
|
||
const cropNote =
|
||
cropIndex >= 0
|
||
? options.falSafe
|
||
? ` Input image ${cropIndex + 3} is an extra identity and silhouette reference.`
|
||
: ` Input image ${cropIndex + 3} is a clean crop reference for this same target subject identity and silhouette; use it only to recognize the subject, never paste it, and do not preserve its old size.`
|
||
: "";
|
||
if (options.falSafe) {
|
||
const actionPhrase =
|
||
isShrink
|
||
? "resize the selected subject proportionally smaller and place it inside the final footprint"
|
||
: isEnlarge
|
||
? "resize the same selected subject proportionally larger toward the final footprint"
|
||
: "place the same selected subject on the final footprint";
|
||
const blendPhrase =
|
||
isShrink
|
||
? "Blend the newly visible floor, rug, wall, window area, shadow, and background around it naturally."
|
||
: isEnlarge
|
||
? "Blend edges, contact shadows, and occlusion with the surrounding room naturally."
|
||
: "Keep the surrounding room stable.";
|
||
return `Region ${region.index}: current target-subject search box in source photo left=${region.pixelRect.left}, top=${region.pixelRect.top}, right=${sourceRight}, bottom=${sourceBottom}, width=${region.pixelRect.width}, height=${region.pixelRect.height}; requested final footprint left=${targetPixelRect.left}, top=${targetPixelRect.top}, right=${targetRight}, bottom=${targetBottom}, width=${targetPixelRect.width}, height=${targetPixelRect.height}; scale factor width=${widthFactor.toFixed(2)}, height=${heightFactor.toFixed(2)}. Edit direction: ${actionPhrase}. ${blendPhrase}${cropNote}`;
|
||
}
|
||
return `Region ${region.index}: OLD search box in input 1 left=${region.pixelRect.left}, top=${region.pixelRect.top}, right=${sourceRight}, bottom=${sourceBottom}, width=${region.pixelRect.width}, height=${region.pixelRect.height}; TARGET final visual footprint left=${targetPixelRect.left}, top=${targetPixelRect.top}, right=${targetRight}, bottom=${targetBottom}, width=${targetPixelRect.width}, height=${targetPixelRect.height}; target scale is ${widthFactor.toFixed(2)}x width and ${heightFactor.toFixed(2)}x height. Main action: ${scaleDirection}.${userInstructionLine} The final selected-subject silhouette must clearly leave the OLD footprint edges and align much closer to TARGET than OLD; returning an almost unchanged target subject footprint is not acceptable. ${footprintRule}${cropNote}`;
|
||
})
|
||
.join("\n");
|
||
const cropLines = cropReferences
|
||
.map(
|
||
(reference, index) =>
|
||
`Input image ${index + 3}: clean crop for Region ${reference.regionIndex}; identity/shape reference only, not a patch or layer.`
|
||
)
|
||
.join("\n");
|
||
|
||
if (options.falSafe) {
|
||
return [
|
||
"Interior selected-subject scale photo edit.",
|
||
`Return one realistic photo with the same camera frame and pixel size as input image 1: ${sourceSize.width} x ${sourceSize.height} px.`,
|
||
"Input image 1 is the source photo. Input image 2 is a marked copy of the source photo for location guidance only and should not appear in the final photo.",
|
||
"In the guide, the cyan dashed box marks the existing selected target subject area and the blue solid box marks the requested final footprint.",
|
||
"Resize only the selected target subject indicated by each current box. The target may be a person/model/child/body part, furniture, product, or another object. Keep the camera, room layout, walls, windows, floor, rug, sofa, lamp, text, background, and all non-target subjects stable.",
|
||
"Preserve the target identity, person anatomy/face/clothing or product material/structure, perspective, contact shadows, and natural aspect ratio/proportions.",
|
||
"For a smaller final footprint, show the selected subject smaller and blend newly visible floor, rug, wall, window area, shadow, and background around it naturally. For a larger final footprint, scale the same selected subject proportionally with natural edges and contact shadows.",
|
||
"Return a clean photo without guide marks, boxes, arrows, overlays, patch edges, color mismatch, or full-frame zoom.",
|
||
cropLines ? `${cropLines}` : "",
|
||
"",
|
||
"Region instructions:",
|
||
regionLines,
|
||
].join("\n");
|
||
}
|
||
|
||
const coordinateGuideLine = options.visualLayoutGuide
|
||
? "输入图片2是一张完整画面的粗略目标布局草稿,不是输出底图。它把 OLD 源框旧主体占位洗淡,表示旧尺寸主体必须从原尺寸位置移除并自然修复背景;同时把目标主体粗略缩放/移动到 TARGET 最终占位附近,这个缩小后的位置和尺寸是最终结果必须匹配的几何目标。请把 input 2 当成最终构图/比例的强约束:如果 input 2 中目标主体比 input 1 小,最终输出也必须明显变小,不能保留 input 1 的原尺寸主体。不要复制 input 2 的淡化痕迹、粗糙拼接、局部透明边、矩形补丁、亮度变化或参考图画质;最终必须以 input 1 的真实照片为唯一画面基底重新完成无缝图片编辑。"
|
||
: options.markedPhotoGuide
|
||
? "输入图片2是一张原图浅化后的完整画面位置参考图,不是输出底图。青色虚线框表示当前主体搜索框(对应下方 OLD),蓝色实线框表示目标最终占位(对应下方 TARGET)。请用输入图片2理解主体在真实室内画面中的位置关系,但最终图不能出现任何框线、色块、标注或参考图的浅化效果。"
|
||
: "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 selected subject; the TARGET box/label gives the required final footprint, center, and scale factor. If the map says OLD OUTSIDE -> BG, rebuild the old full-size subject area outside TARGET as natural background in the final photo. Read the boxes, labels, arrows, and scale text only as instructions; never output any map graphics.";
|
||
|
||
return [
|
||
"Local selected-subject scale edit. Use input image 1 as the clean original photo and the locked camera frame.",
|
||
"One-shot cost constraint: produce the requested edit in this generation without relying on later retries or alternate candidates; an unchanged result is not acceptable.",
|
||
"中文硬约束:只调整 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.`,
|
||
coordinateGuideLine,
|
||
cropLines ? `${cropLines}` : "",
|
||
"",
|
||
"Task:",
|
||
"Find the main selected target subject inside each OLD box in input image 1. The target can be a person/model/child/body part, furniture, product, or another movable object. Resize and, if needed, move that subject only so its final visible footprint matches the TARGET box shown in input image 2 and the numeric TARGET coordinates below.",
|
||
"Do a real subject-scale edit, not a full image zoom. The camera, room layout, windows, wall decor, text, floor, rug, and all non-target subjects must remain stable.",
|
||
"If an OLD box contains more than one object/person, choose the dominant target subject inside that box, usually the largest or most central selected item/person. Keep adjacent lamps, plants, side tables, pillows, wall decor, windows, text, background objects, and non-target people at their original size unless they are clearly part of the same target subject.",
|
||
"Keep the target identity, person face/anatomy/clothing or product material/structure, perspective, contact shadows, and natural aspect ratio/proportions from input image 1. Do not create a second copy or extra parts. Do not preserve the original target size just because the scene looks realistic; the user explicitly wants a visible scale change.",
|
||
"For beds, sofas, tables, chairs, cabinets, benches, people, models, children, hands, and other large visible subjects, still perform the scale edit on that selected subject. Do not treat the selected subject as locked background.",
|
||
"If the target is smaller, the selected subject must visibly become smaller and be mostly contained by the TARGET footprint. Rebuild the OLD area outside the TARGET footprint as newly exposed floor, rug, wall, bedding, shadows, or background. If the target is larger, enlarge the same selected subject naturally without stretching it.",
|
||
"缩小时请优先让目标主体明显缩小,而不是为了保守而保持原样。不接受原尺寸保留、只轻微变形、或只改变整张图构图。",
|
||
"Do not keep the selected subject at its original footprint. The edited subject should fit the TARGET rectangle much better than the OLD rectangle.",
|
||
"",
|
||
"Region requirements:",
|
||
regionLines,
|
||
"",
|
||
"Success check: compare the final selected-subject footprint with input image 1 and the neutral coordinate map in input image 2. The selected subject size must clearly change from the original and align much closer to TARGET than OLD, while the rest of the photo stays stable.",
|
||
"Do not paste rectangular image areas. Do not output coordinate-map lines, labels, boxes, arrows, scale text, muted wash, overlays, seams, hard patch edges, haze, color mismatch, full-canvas zoom, crop, pan, or redesign.",
|
||
"Return only the clean edited photo.",
|
||
].join("\n");
|
||
}
|
||
|
||
function buildScaleAdjustCoordinateSystemPrompt() {
|
||
return [
|
||
"You are an image editing model for local selected-subject scale adjustment.",
|
||
"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 an unchanged selected target subject and full-canvas zoom.",
|
||
"Generate a clean realistic final photo by editing the selected target subject itself and naturally rebuilding only the tiny surrounding background that becomes visible.",
|
||
"The target subject can be a person/model/child/body part, furniture, product, or another movable object. Prefer a clear visible size change over a conservative unchanged result; an unchanged target subject is not acceptable.",
|
||
"When shrinking, rebuild the old full-size target-subject footprint outside the target footprint as natural background. Large selected subjects are editable and must not remain locked at original size.",
|
||
"Do not paste, composite, clone, crop, resize, or copy rectangular image patches from any input.",
|
||
"Never output guide artifacts from the edit map: no faded wash, placeholder shadow, overlay patch, rough crop rectangle, lines, labels, blocks, seams, rectangular borders, arrows, masks, UI marks, or annotations.",
|
||
"Keep the camera frame, lighting, wall, floor, rug, windows, background furniture, text, logos, and all non-target regions stable.",
|
||
].join("\n");
|
||
}
|
||
|
||
function buildScaleAdjustTextOnlyPrompt(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent: ScaleAdjustPercent | null | undefined,
|
||
cropReferences: ScaleAdjustCropReference[],
|
||
sourceSize: { width: number; height: number }
|
||
) {
|
||
const cropLines = cropReferences
|
||
.map(
|
||
(reference, index) =>
|
||
`Input image ${index + 2}: clean crop reference for Region ${reference.regionIndex}; use it only to identify the selected target subject and its natural aspect ratio/proportions, never paste the crop into the output.`
|
||
)
|
||
.join("\n");
|
||
const regionLines = regions
|
||
.map((region) => {
|
||
const cropIndex = cropReferences.findIndex(
|
||
(reference) => reference.regionIndex === region.index
|
||
);
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const targetPixelRect =
|
||
region.targetPixelRect ||
|
||
rectToPixels(targetRect, sourceSize.width, sourceSize.height);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
const sourceRight = region.pixelRect.left + region.pixelRect.width;
|
||
const sourceBottom = region.pixelRect.top + region.pixelRect.height;
|
||
const targetRight = targetPixelRect.left + targetPixelRect.width;
|
||
const targetBottom = targetPixelRect.top + targetPixelRect.height;
|
||
const cropNote =
|
||
cropIndex >= 0
|
||
? ` Input image ${cropIndex + 2} is the clean crop reference for this target-subject identity and silhouette.`
|
||
: "";
|
||
return `Region ${region.index}: hidden coordinate set A current area x0=${region.pixelRect.left}, y0=${region.pixelRect.top}, x1=${sourceRight}, y1=${sourceBottom}, width=${region.pixelRect.width}, height=${region.pixelRect.height}; hidden coordinate set B final size/location x0=${targetPixelRect.left}, y0=${targetPixelRect.top}, x1=${targetRight}, y1=${targetBottom}, width=${targetPixelRect.width}, height=${targetPixelRect.height}; final visual scale width=${widthFactor.toFixed(2)}, height=${heightFactor.toFixed(2)}. User request: ${region.prompt || "make the same selected target subject match set B"}.${cropNote}`;
|
||
})
|
||
.join("\n");
|
||
|
||
return [
|
||
"Local selected-subject scale edit. Input image 1 is the locked source photo and the only final canvas base.",
|
||
`Return one clean realistic photo with exactly the same camera frame and pixel size as input image 1: ${sourceSize.width} x ${sourceSize.height} px.`,
|
||
cropLines ? cropLines : "",
|
||
"The final photo must contain only natural scene content. Never add artificial graphic overlays, visible coordinate aids, labels, lines, UI elements, or text.",
|
||
"Only edit the main selected target subject inside each current pixel area. The target can be a person/model/child/body part, furniture, product, or another object. Keep all other non-target subjects, furniture, sofa, lamps, wall, windows, rug, floor, decor, lighting, and camera frame stable.",
|
||
"Use the numeric pixel sets below as hidden instructions only; they are not visual content and must not be drawn.",
|
||
"When set B is smaller than set A, make the same selected target subject visibly smaller and naturally rebuild the newly visible floor, rug, wall, shadow, or background around it. Do not leave the old full-size subject silhouette.",
|
||
"Keep the target identity, person anatomy/face/clothing or product material/structure, perspective, contact shadows, and natural aspect ratio/proportions. Do not stretch, squash, duplicate, or paste a rectangular crop.",
|
||
"中文硬约束:输入图1是锁定底图,只能调整 A 像素范围里的主要目标主体,目标可以是人物、模特、小孩、手臂身体、商品或家具,让它变成 B 像素范围对应的大小和位置。A/B 坐标只是隐藏指令,最终照片里绝对不能画出任何线条、框、文字、标注或 UI 元素。",
|
||
"",
|
||
"Region instructions:",
|
||
regionLines,
|
||
"",
|
||
"Success check before returning: the selected target subject must be clearly closer to set B than to set A, while the rest of the photo remains visually unchanged and clean.",
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function toPatchRelativePixelBox(
|
||
box: { left: number; top: number; width: number; height: number },
|
||
workspace: ScaleAdjustPatchReference["workspacePixelRect"]
|
||
) {
|
||
const left = clamp(box.left - workspace.left, 0, Math.max(0, workspace.width - 1));
|
||
const top = clamp(box.top - workspace.top, 0, Math.max(0, workspace.height - 1));
|
||
const right = clamp(
|
||
box.left + box.width - workspace.left,
|
||
left + 1,
|
||
workspace.width
|
||
);
|
||
const bottom = clamp(
|
||
box.top + box.height - workspace.top,
|
||
top + 1,
|
||
workspace.height
|
||
);
|
||
|
||
return {
|
||
left,
|
||
top,
|
||
width: Math.max(1, right - left),
|
||
height: Math.max(1, bottom - top),
|
||
};
|
||
}
|
||
|
||
function buildScaleAdjustPatchPrompt(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent: ScaleAdjustPercent | null | undefined,
|
||
patchReference: ScaleAdjustPatchReference,
|
||
sourceSize: { width: number; height: number },
|
||
cropReferences: ScaleAdjustCropReference[] = [],
|
||
options: {
|
||
falSafe?: boolean;
|
||
includeSourceReference?: boolean;
|
||
targetDraftPrimary?: boolean;
|
||
} = {}
|
||
) {
|
||
const targetDraftPrimary = options.targetDraftPrimary === true;
|
||
const includeSourceReference = options.includeSourceReference !== false;
|
||
const falSafe = options.falSafe === true;
|
||
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
|
||
? structureLockedDraft
|
||
? includeSourceReference
|
||
? "Input image 1 is a rough subject-structure-locked target crop. The selected target subject was moved and scaled using source-photo pixels, so its final size, position, silhouette, material/skin/clothing, perspective, and visible structure are the hard target. If the subject is a person, keep the same person identity, face, body proportions, pose, hair, hands, and clothing while cleaning rough edges. If the subject is furniture/product, keep rods, panels, weave, wheels, material, and product structure. Repair rough background holes, edge halos, blur, occlusion, contact shadows, and surface artifacts without redesigning the subject. Input image 2 is only the clean background and non-target reference, never the old size reference."
|
||
: "Input image 1 is a rough subject-structure-locked target crop and the only visual reference. The selected target subject was moved and scaled using source-photo pixels, so its final size, position, silhouette, material/skin/clothing, perspective, and visible structure are the hard target. If the subject is a person, keep the same person identity, face, body proportions, pose, hair, hands, and clothing while cleaning rough edges. If the subject is furniture/product, keep rods, panels, weave, wheels, material, and product structure. Repair rough background holes, edge halos, blur, occlusion, contact shadows, and surface artifacts without redesigning the subject."
|
||
: includeSourceReference
|
||
? "Input image 1 is the rough edited target-layout crop. It is the hard layout target: the selected subject size and center in the final output must match this draft, but the rough wash, ghosted old footprint, pale wipe, placeholder shadow, blur, and patch artifacts are only edit instructions and must be cleaned. Never reproduce the pale wipe or any rectangular haze as photo content."
|
||
: "Input image 1 is the rough edited target-layout crop and the only visual reference. It is the hard layout target: the selected subject size and center in the final output must match this draft. Clean away the rough wash, ghosted old footprint, pale wipe, placeholder shadow, blur, and patch artifacts. Never reproduce the pale wipe or any rectangular haze as photo content."
|
||
: "Input image 1 is the exact crop from the original photo and is the locked crop base.";
|
||
const secondInputRole = targetDraftPrimary
|
||
? includeSourceReference
|
||
? "Input image 2 is the original clean crop. Use it for stable crop framing, background texture, lighting, perspective, target identity, and non-target person/object preservation. Do not restore the selected subject to its old size from this image."
|
||
: ""
|
||
: "Input image 2 is a rough target-layout draft of the same crop. It is the hard layout target for the selected subject size and position. Use it to understand the final footprint and newly visible background area, then remove any rough seams, washed areas, placeholder shadows, blur, or patch artifacts from that draft.";
|
||
const cropLines = cropReferences
|
||
.map(
|
||
(reference, index) =>
|
||
`Input image ${index + cropReferenceStartIndex}: clean identity crop for Region ${reference.regionIndex}; use it only to recognize the target subject shape, identity, anatomy/materials, and natural aspect ratio, not its old size.`
|
||
)
|
||
.join("\n");
|
||
const regionLines = regions
|
||
.map((region) => {
|
||
const cropIndex = cropReferences.findIndex(
|
||
(reference) => reference.regionIndex === region.index
|
||
);
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const targetPixelRect =
|
||
region.targetPixelRect ||
|
||
rectToPixels(targetRect, sourceSize.width, sourceSize.height);
|
||
const sourceInPatch = toPatchRelativePixelBox(
|
||
region.pixelRect,
|
||
patchReference.workspacePixelRect
|
||
);
|
||
const targetInPatch = toPatchRelativePixelBox(
|
||
targetPixelRect,
|
||
patchReference.workspacePixelRect
|
||
);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
const action =
|
||
widthFactor < 0.92 || heightFactor < 0.92
|
||
? "make the same selected subject visibly smaller"
|
||
: widthFactor > 1.08 || heightFactor > 1.08
|
||
? "make the same selected subject visibly larger"
|
||
: "move the same selected subject to the final footprint";
|
||
const cropNote =
|
||
cropIndex >= 0
|
||
? ` Input image ${cropIndex + cropReferenceStartIndex} is the clean identity reference for this exact target subject.`
|
||
: "";
|
||
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}. Treat the main selected subject inside the search area as the target; it may be a person, model, child, body part, furniture, product, or other object. The final subject must be much closer to the final footprint than to the current search area.${cropNote}`;
|
||
})
|
||
.join("\n");
|
||
const falSafeRegionLines = regions
|
||
.map((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const targetPixelRect =
|
||
region.targetPixelRect ||
|
||
rectToPixels(targetRect, sourceSize.width, sourceSize.height);
|
||
const sourceInPatch = toPatchRelativePixelBox(
|
||
region.pixelRect,
|
||
patchReference.workspacePixelRect
|
||
);
|
||
const targetInPatch = toPatchRelativePixelBox(
|
||
targetPixelRect,
|
||
patchReference.workspacePixelRect
|
||
);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
const action =
|
||
widthFactor < 0.92 || heightFactor < 0.92
|
||
? "make the same selected subject smaller"
|
||
: widthFactor > 1.08 || heightFactor > 1.08
|
||
? "make the same selected subject larger"
|
||
: "move the same selected subject to the final footprint";
|
||
return `Region ${region.index}: original search area x=${sourceInPatch.left}, y=${sourceInPatch.top}, w=${sourceInPatch.width}, h=${sourceInPatch.height}; final target footprint x=${targetInPatch.left}, y=${targetInPatch.top}, w=${targetInPatch.width}, h=${targetInPatch.height}; scale width=${widthFactor.toFixed(2)}, height=${heightFactor.toFixed(2)}. ${region.prompt || action}`;
|
||
})
|
||
.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是唯一视觉参考和目标大小/位置草稿,必须按它的目标尺寸和目标位置输出。不要找回旧尺寸目标主体。草稿里的淡色擦除层/雾块只是提示旧位置需要补背景,不是画面内容,最终不能出现矩形雾块、白块、残影或拼接感。";
|
||
})();
|
||
|
||
if (falSafe) {
|
||
return [
|
||
"Clean local image edit for a cropped workspace.",
|
||
`Return one clean realistic crop with the same framing and aspect as input image 1. This crop maps to full-photo pixels left=${patchReference.workspacePixelRect.left}, top=${patchReference.workspacePixelRect.top}, width=${patchReference.workspacePixelRect.width}, height=${patchReference.workspacePixelRect.height}.`,
|
||
"Input image 1 is the rough target draft. The selected subject is already placed at the requested final size and position; keep that final footprint.",
|
||
structureLockedDraft
|
||
? "Structure lock: treat the selected subject pixels in input image 1 as the locked product/person layer. AI work is limited to background completion, boundary blending, occlusion, lighting, contact shadows, and removing rough draft artifacts. Do not repaint the product/person interior."
|
||
: "",
|
||
includeSourceReference
|
||
? "Input image 2 is the original clean crop for background texture, lighting, perspective, and non-target objects only. Do not restore the selected subject to its old size."
|
||
: "",
|
||
"Use AI editing only to clean and blend the draft: remove halos, mask holes, rough edges, ghost fragments, haze blocks, dirty spots, and bad contact shadows.",
|
||
"Keep the same selected subject identity, product structure, material, perspective, and natural proportions. Do not replace, stretch, duplicate, or redesign it.",
|
||
"Rebuild newly visible floor, rug, wall, sofa edge, shadow, or background naturally where the old larger footprint was removed.",
|
||
"Do not output a full-room image. Do not zoom, crop, pan, or resize the whole canvas. Do not draw boxes, lines, labels, arrows, UI marks, overlays, text, seams, or rectangular patch edges.",
|
||
"中文硬约束:输入图1已经把目标主体放到最终大小和位置;只清理融合局部裁切,不要恢复旧尺寸,不要整图缩放,不要输出标注框、矩形雾块、贴片边、残影或全房间图。",
|
||
"",
|
||
"Region instructions:",
|
||
falSafeRegionLines,
|
||
"",
|
||
"Success check: the selected subject must stay much closer to the final target footprint than to the original search area, and the crop must look like one natural photograph.",
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
|
||
return [
|
||
"Local selected-subject scale edit inside a cropped workspace.",
|
||
`Return exactly one clean realistic crop with the same framing and aspect as input image 1. The crop represents full-photo pixels left=${patchReference.workspacePixelRect.left}, top=${patchReference.workspacePixelRect.top}, width=${patchReference.workspacePixelRect.width}, height=${patchReference.workspacePixelRect.height}.`,
|
||
firstInputRole,
|
||
secondInputRole,
|
||
cropLines,
|
||
structureLockedDraft
|
||
? "Structure lock: treat the selected subject pixels in input image 1 as the locked product/person layer. AI work is limited to background completion, boundary blending, occlusion, lighting, contact shadows, and removing rough draft artifacts. Do not repaint or redesign the target subject interior."
|
||
: "",
|
||
"Only edit the selected target subject inside the search area. The target subject can be a person/model/child/body part, furniture, product, or another movable object. Keep the crop borders, wall, window frame, floor, rug, sofa, lamps, decor, lighting, camera perspective, and all non-target subjects stable.",
|
||
"If the selected target is a person, scale the whole visible person/body coherently to the final footprint while preserving identity, face, pose, anatomy, hand shape, hair, clothing, and natural human proportions. If a person appears outside the selected target, preserve that non-target person without resizing or redrawing them.",
|
||
"When the final footprint is smaller, the selected subject must clearly shrink; erase the old larger subject footprint outside the final footprint and replace it with natural background/floor/rug/wall/shadow that matches the original crop.",
|
||
"If input image 1 still shows any faint, transparent, blurred, or ghosted old-size copy of the selected subject outside the final footprint, treat it as an erase marker only. Do not keep it. The only selected target subject that may remain is the smaller clean subject inside the final footprint.",
|
||
structureLockedDraft
|
||
? "When the final footprint is larger, the target draft already places the same selected subject at the required larger size. Do not redesign the subject geometry/anatomy; keep the locked person identity/anatomy/clothing or product structure/material/perspective while cleaning background gaps, boundary blending, rough surface artifacts, natural occlusion, and contact shadows."
|
||
: "When the final footprint is larger, enlarge the selected subject itself to match the target draft while keeping nearby non-target people and hands natural. If the target is a person, preserve anatomy and identity instead of inventing a new person.",
|
||
"Keep the same target identity, perspective, visible details, contact shadows, and natural aspect ratio/proportions. Do not stretch, squash, duplicate, or replace it with a different subject.",
|
||
"Hard failure condition: if the selected target subject remains near its original old size, the answer is wrong. The output must visibly change the selected subject 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, black speckled stains, dark blotches, rectangular crop edges, and ghost fragments. Human skin, face, hands, hair, and clothing must look continuous and natural when the target is a person. Woven panels, wheels, rods, hooks, and product surfaces must remain continuous and clean when the target is furniture/product."
|
||
: "",
|
||
"Do not output boxes, lines, labels, coordinate marks, UI marks, text, arrows, overlays, rough layout artifacts, rectangular patch edges, or a full-room image.",
|
||
chineseHardConstraint,
|
||
"",
|
||
"Region instructions:",
|
||
regionLines,
|
||
"",
|
||
"Success check before returning: the selected target subject footprint must be clearly closer to the final footprint than the current search area, and the crop must look like one natural photograph.",
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function buildScaleAdjustFullFramePatchPrompt(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent: ScaleAdjustPercent | null | undefined,
|
||
sourceSize: { width: number; height: number }
|
||
) {
|
||
const regionLines = regions
|
||
.map((region) => {
|
||
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||
const targetPixelRect =
|
||
region.targetPixelRect ||
|
||
rectToPixels(targetRect, sourceSize.width, sourceSize.height);
|
||
const widthFactor =
|
||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||
const heightFactor =
|
||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||
const action =
|
||
widthFactor < 0.92 || heightFactor < 0.92
|
||
? "make the same selected furniture/product visibly smaller"
|
||
: widthFactor > 1.08 || heightFactor > 1.08
|
||
? "make the same selected furniture/product visibly larger"
|
||
: "move the same selected furniture/product to the final footprint";
|
||
return `Region ${region.index}: original selected search area in input image 1 is x=${region.pixelRect.left}, y=${region.pixelRect.top}, w=${region.pixelRect.width}, h=${region.pixelRect.height}; final target footprint shown by input image 2 is x=${targetPixelRect.left}, y=${targetPixelRect.top}, w=${targetPixelRect.width}, h=${targetPixelRect.height}; requested scale width=${widthFactor.toFixed(2)}, height=${heightFactor.toFixed(2)}. User request: ${region.prompt || action}. Main action: ${action}.`;
|
||
})
|
||
.join("\n");
|
||
|
||
return [
|
||
"Full-frame local image edit for furniture/product scale adjustment.",
|
||
"Return exactly one clean realistic full image with the same framing, camera, aspect ratio, and canvas extent as input image 1. Do not crop, zoom, pan, or resize the whole photo.",
|
||
"Input image 1 is the original clean full photo and the locked background plate for the final output. Preserve its framing, lighting, perspective, non-target people, non-target objects, and untouched areas.",
|
||
"Input image 2 is a full-photo target draft guide. It only shows where the selected furniture/product should end up and how large its final visual footprint should be. Treat the target draft size, center, silhouette, product structure, material, perspective, wheels, rods, panels, hooks, weave, and visible details as the hard target instruction.",
|
||
"The selected furniture/product in input image 1 is still at the old size and old position. Do not restore, keep, or prefer that old footprint. Use input image 1 for clean pixels and identity, then move/scale only the selected furniture/product to match input image 2.",
|
||
"AI work is limited to moving/scaling the locked selected furniture/product, natural background completion, boundary blending, occlusion, and contact shadows. Do not repaint, redesign, replace, stretch, duplicate, or distort the selected furniture/product interior.",
|
||
"The target draft can contain translucent pasted pixels, faint old-footprint washes, clipped fragments, suppressed dark details, partial mask silhouettes, or rectangular crop-guide edges. These are draft artifacts only. Use input image 1 to recover clean product details at the target size, but do not copy any dark blob, black hole, ghost fragment, transparent residue, pasted silhouette, crop rectangle, or old-size placement as final photo content.",
|
||
"If the target footprint intersects a non-target person, preserve that person as the foreground occluder exactly as in the original photo: do not move, cover, deform, redraw, or change their face, body, clothes, hands, legs, feet, or pose. Blend the furniture/product behind the person naturally where needed.",
|
||
"Erase the old larger furniture/product footprint outside the final target footprint and rebuild newly visible wall, curtain, floor, rug, shadow, and background naturally. Do not leave ghost copies, old-size remnants, rectangular haze, pale wash blocks, patch edges, black holes, dirty speckles, or visible seams.",
|
||
"Keep all non-target content stable: person identity and anatomy, sofa, window, curtain, cabinet, lamp, wall art, floor, rug, lighting, textural grain, and global color must remain consistent.",
|
||
"Do not output boxes, masks, labels, coordinate marks, UI marks, arrows, guide overlays, crop borders, or any annotation artifacts.",
|
||
"中文硬约束:输入图1是原始干净图,也是最终输出的锁定底板;输入图2只是完整画面的目标草稿,用来说明目标家具/商品的最终大小和最终位置。绝对不能把输入图1里的旧尺寸家具保留下来,也不能把输入图2的贴片痕迹当成最终内容。只移动/等比缩放目标家具并做背景补全、遮挡融合、阴影和边缘清理。草稿里的半透明贴片、暗色残块、黑洞、旧尺寸残影、矩形裁切边都只是错误遮罩提示,最终必须删除。非目标人物必须保持原样,不能被目标家具盖住、变形、换脸、改姿势或改腿脚。最终必须是一张完整自然照片,不能有矩形雾块、贴片边、黑洞、残影、标注框。",
|
||
"",
|
||
"Region instructions:",
|
||
regionLines,
|
||
"",
|
||
"Success check before returning: the selected furniture/product footprint is clearly closer to the final target footprint in input image 1 than to the original search area, all non-target people remain unchanged, and the full image looks like one natural photograph.",
|
||
].join("\n");
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
try {
|
||
const session = await auth();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
||
}
|
||
|
||
const body = (await request.json()) as LocalEditRequest;
|
||
const rawSourceImageUrl = String(body.sourceImageUrl || "").trim();
|
||
if (!rawSourceImageUrl) {
|
||
return NextResponse.json({ success: false, error: "缺少 sourceImageUrl 参数" }, { status: 400 });
|
||
}
|
||
|
||
const sourceImageUrl = new URL(rawSourceImageUrl, request.url).toString();
|
||
const sourceBuffer = await sharp(await fetchImageBufferFromUrl(sourceImageUrl), {
|
||
failOn: "none",
|
||
})
|
||
.rotate()
|
||
.toBuffer();
|
||
const sourceMetadata = await sharp(sourceBuffer, { failOn: "none" }).metadata();
|
||
const sourceWidth = Number(sourceMetadata.width || 0);
|
||
const sourceHeight = Number(sourceMetadata.height || 0);
|
||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||
return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 });
|
||
}
|
||
|
||
const operation = resolveLocalEditOperation(body.operation);
|
||
let scalePercent: ScaleAdjustPercent | null = null;
|
||
if (operation === SCALE_ADJUST_OPERATION) {
|
||
const hasRequestedScalePercent =
|
||
body.scalePercent !== undefined && body.scalePercent !== null;
|
||
if (hasRequestedScalePercent) {
|
||
try {
|
||
scalePercent = normalizeScaleAdjustPercent(body.scalePercent);
|
||
} catch (validationError) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error:
|
||
validationError instanceof Error
|
||
? validationError.message
|
||
: "比例调整参数无效",
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let regions: NormalizedLocalEditRegion[];
|
||
try {
|
||
regions = normalizeRegions(body.regions, sourceWidth, sourceHeight, {
|
||
fallbackPrompt:
|
||
operation === SCALE_ADJUST_OPERATION
|
||
? "比例调整:按目标框调整"
|
||
: undefined,
|
||
requirePrompt: operation !== SCALE_ADJUST_OPERATION,
|
||
});
|
||
} catch (validationError) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error:
|
||
validationError instanceof Error
|
||
? validationError.message
|
||
: "局部编辑区域参数无效",
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
const hasScaleAdjustTargetRects =
|
||
operation === SCALE_ADJUST_OPERATION &&
|
||
regions.length > 0 &&
|
||
regions.every((region) => Boolean(region.targetRect));
|
||
if (
|
||
operation === SCALE_ADJUST_OPERATION &&
|
||
!hasScaleAdjustTargetRects &&
|
||
scalePercent === null
|
||
) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: "请为每个区域提供有效的目标框 targetRect,或提供比例调整幅度 scalePercent",
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const shouldUseScaleAdjustDetailModel =
|
||
operation === SCALE_ADJUST_OPERATION &&
|
||
(hasScaleAdjustTargetRects || scalePercent !== null);
|
||
const [pointsConfig, siteSettingsForModel, scaleAdjustModelCandidates] =
|
||
await Promise.all([
|
||
getPointsSystemConfig(),
|
||
getSiteSettings().catch(() => null),
|
||
shouldUseScaleAdjustDetailModel
|
||
? getEnabledAiModelsWithProvider(false, true).catch((error) => {
|
||
console.warn(
|
||
"[Canvas Local Edit] Failed to load scale adjust model candidates:",
|
||
error
|
||
);
|
||
return [] as AiModelWithProviderForSelection[];
|
||
})
|
||
: Promise.resolve([] as AiModelWithProviderForSelection[]),
|
||
]);
|
||
const pointsCharge = await prepareFeaturePointsCharge({
|
||
userId: session.user.id,
|
||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||
reason: operation === SCALE_ADJUST_OPERATION ? "image_scale_adjust" : "image_repaint",
|
||
modelId:
|
||
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
|
||
note: operation === SCALE_ADJUST_OPERATION ? "比例调整消耗积分" : "局部编辑消耗积分",
|
||
pointsConfig,
|
||
});
|
||
if (!pointsCharge.ok) {
|
||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||
}
|
||
|
||
const isScaleAdjustOperation =
|
||
operation === SCALE_ADJUST_OPERATION &&
|
||
(hasScaleAdjustTargetRects || scalePercent !== null);
|
||
const hasScaleAdjustEnlarge = isScaleAdjustOperation
|
||
? hasScaleAdjustEnlargeTarget(regions, scalePercent)
|
||
: false;
|
||
const hasScaleAdjustShrink = isScaleAdjustOperation
|
||
? hasScaleAdjustShrinkTarget(regions, scalePercent)
|
||
: false;
|
||
const preferScaleAdjustMaskCapableModel =
|
||
hasScaleAdjustShrink && !hasScaleAdjustEnlarge;
|
||
const preferScaleAdjustFalNanoBananaEdit =
|
||
hasScaleAdjustShrink && !hasScaleAdjustEnlarge;
|
||
const preferScaleAdjustProviderTypes =
|
||
hasScaleAdjustShrink && !hasScaleAdjustEnlarge ? ["grsai-gpt-image"] : [];
|
||
const displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent });
|
||
const resolvedAspectRatio = resolveLocalEditAspectRatio(
|
||
body.aspectRatio,
|
||
sourceWidth,
|
||
sourceHeight
|
||
);
|
||
const requestedModel =
|
||
typeof body.model === "string" && body.model.trim()
|
||
? body.model.trim()
|
||
: undefined;
|
||
const configuredDetailModel =
|
||
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
|
||
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
|
||
: "";
|
||
const detailFixedModel = isScaleAdjustOperation
|
||
? resolveScaleAdjustPreferredModelId({
|
||
configuredModelId: configuredDetailModel,
|
||
honorRequestedModelBeforeGpt: hasScaleAdjustEnlarge,
|
||
preferFalNanoBananaEdit: preferScaleAdjustFalNanoBananaEdit,
|
||
preferMaskCapableGpt: preferScaleAdjustMaskCapableModel,
|
||
preferProviderTypes: preferScaleAdjustProviderTypes,
|
||
requestedModelId: requestedModel || "",
|
||
models: scaleAdjustModelCandidates,
|
||
})
|
||
: "";
|
||
if (
|
||
isScaleAdjustOperation &&
|
||
detailFixedModel &&
|
||
detailFixedModel !== configuredDetailModel
|
||
) {
|
||
console.info("[Canvas Local Edit] Override scale adjust model to detail line:", {
|
||
configuredDetailModel: configuredDetailModel || null,
|
||
requestedModel: requestedModel || null,
|
||
resolvedModel: detailFixedModel,
|
||
});
|
||
}
|
||
const resolvedModel = detailFixedModel || requestedModel;
|
||
const resolvedScaleAdjustModelEntry = isScaleAdjustOperation
|
||
? findModelSelectionEntry(scaleAdjustModelCandidates, resolvedModel || "")
|
||
: null;
|
||
const useScaleAdjustCoordinateGuide =
|
||
isScaleAdjustOperation &&
|
||
isScaleAdjustCoordinateGuideModel(resolvedScaleAdjustModelEntry);
|
||
const scaleAdjustModelFamily = getScaleAdjustModelFamily(
|
||
resolvedScaleAdjustModelEntry
|
||
);
|
||
const useProviderTextOnlyCoordinatePrompt =
|
||
isScaleAdjustOperation &&
|
||
resolvedScaleAdjustModelEntry?.provider?.type === "alibaba-image";
|
||
const useScaleAdjustCoordinatePrompt = useScaleAdjustCoordinateGuide;
|
||
const useFalScaleAdjustCoordinatePrompt =
|
||
useScaleAdjustCoordinatePrompt &&
|
||
resolvedScaleAdjustModelEntry?.provider?.type === "fal-image";
|
||
const useScaleAdjustVisualLayoutGuide =
|
||
useScaleAdjustCoordinatePrompt &&
|
||
!useFalScaleAdjustCoordinatePrompt &&
|
||
!useProviderTextOnlyCoordinatePrompt;
|
||
const useScaleAdjustMarkedPhotoGuide =
|
||
useScaleAdjustCoordinatePrompt && !useScaleAdjustVisualLayoutGuide;
|
||
const isScaleAdjustFalNanoBananaEdit =
|
||
isScaleAdjustOperation &&
|
||
isScaleAdjustFalNanoBananaEditModel(resolvedScaleAdjustModelEntry);
|
||
const useScaleAdjustFullFrameFalEdit =
|
||
isScaleAdjustFalNanoBananaEdit && !hasScaleAdjustShrink;
|
||
const useScaleAdjustStructureLockedPatchWorkflow =
|
||
isScaleAdjustOperation &&
|
||
!useScaleAdjustFullFrameFalEdit &&
|
||
hasScaleAdjustStructureTarget(regions, scalePercent) &&
|
||
(hasScaleAdjustShrink ||
|
||
!preferScaleAdjustMaskCapableModel ||
|
||
!isScaleAdjustMaskCapableModel(resolvedScaleAdjustModelEntry));
|
||
const useScaleAdjustEditMask =
|
||
isScaleAdjustOperation &&
|
||
!useScaleAdjustStructureLockedPatchWorkflow &&
|
||
isScaleAdjustMaskCapableModel(resolvedScaleAdjustModelEntry);
|
||
const useScaleAdjustPatchWorkflow =
|
||
isScaleAdjustOperation &&
|
||
!useScaleAdjustFullFrameFalEdit &&
|
||
(useScaleAdjustStructureLockedPatchWorkflow || !useScaleAdjustEditMask);
|
||
const sendScaleAdjustAnnotatedReferenceToModel =
|
||
isScaleAdjustOperation &&
|
||
!useScaleAdjustPatchWorkflow &&
|
||
!useProviderTextOnlyCoordinatePrompt;
|
||
const shouldUploadAnnotatedReference =
|
||
!isScaleAdjustOperation || sendScaleAdjustAnnotatedReferenceToModel;
|
||
const annotatedBuffer = shouldUploadAnnotatedReference
|
||
? isScaleAdjustOperation
|
||
? useScaleAdjustCoordinatePrompt
|
||
? useScaleAdjustVisualLayoutGuide
|
||
? (
|
||
await buildScaleAdjustVisualLayoutGuideBuffer({
|
||
regions,
|
||
scalePercent,
|
||
sourceBuffer,
|
||
sourceHeight,
|
||
sourceWidth,
|
||
})
|
||
).buffer
|
||
: useScaleAdjustMarkedPhotoGuide
|
||
? await buildScaleAdjustMarkedPhotoGuideBuffer({
|
||
height: sourceHeight,
|
||
regions,
|
||
scalePercent,
|
||
sourceBuffer,
|
||
width: sourceWidth,
|
||
})
|
||
: await sharp(
|
||
buildScaleAdjustZoomedCoordinateSvg({
|
||
height: sourceHeight,
|
||
regions,
|
||
scalePercent,
|
||
width: sourceWidth,
|
||
}),
|
||
{ failOn: "none" }
|
||
)
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer()
|
||
: (
|
||
await buildScaleAdjustVisualLayoutGuideBuffer({
|
||
regions,
|
||
scalePercent,
|
||
sourceBuffer,
|
||
sourceHeight,
|
||
sourceWidth,
|
||
})
|
||
).buffer
|
||
: await sharp(sourceBuffer, { failOn: "none" })
|
||
.composite([
|
||
{
|
||
input: buildAnnotationSvg({
|
||
height: sourceHeight,
|
||
hidePromptText: false,
|
||
regions,
|
||
width: sourceWidth,
|
||
}),
|
||
left: 0,
|
||
top: 0,
|
||
},
|
||
])
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer()
|
||
: null;
|
||
const annotatedFilename = `${isScaleAdjustOperation ? "canvas-scale-adjust-layout-guide" : "canvas-local-edit"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
|
||
const annotatedUpload = annotatedBuffer
|
||
? await uploadFile(annotatedFilename, toArrayBuffer(annotatedBuffer))
|
||
: null;
|
||
const scaleAdjustEditMaskUpload = useScaleAdjustEditMask
|
||
? await uploadFile(
|
||
`canvas-scale-adjust-edit-mask-${Date.now()}-${Math.random()
|
||
.toString(36)
|
||
.slice(2, 8)}.png`,
|
||
toArrayBuffer(
|
||
await buildScaleAdjustEditMaskBuffer({
|
||
regions,
|
||
scalePercent,
|
||
sourceHeight,
|
||
sourceWidth,
|
||
})
|
||
)
|
||
)
|
||
: null;
|
||
let scaleAdjustPatchReference: ScaleAdjustPatchReference | null = null;
|
||
let scaleAdjustPatchReferenceFailed = false;
|
||
if (useScaleAdjustPatchWorkflow) {
|
||
try {
|
||
scaleAdjustPatchReference = await createScaleAdjustPatchReference({
|
||
regions,
|
||
scalePercent,
|
||
sourceBuffer,
|
||
sourceHeight,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
fullFrameReferenceOnly:
|
||
useScaleAdjustStructureLockedPatchWorkflow &&
|
||
regions.some((region) =>
|
||
isScaleAdjustFurnitureProductLikePrompt(region.prompt)
|
||
),
|
||
userId: session.user.id,
|
||
});
|
||
} catch (error) {
|
||
scaleAdjustPatchReferenceFailed = true;
|
||
console.warn(
|
||
"[Canvas Local Edit] Scale adjust patch reference failed; falling back to source-only coordinate prompt:",
|
||
{
|
||
error: error instanceof Error ? error.message : String(error),
|
||
}
|
||
);
|
||
}
|
||
}
|
||
const shouldCreateScaleAdjustCropReferences =
|
||
isScaleAdjustOperation &&
|
||
!scaleAdjustPatchReferenceFailed &&
|
||
(!scaleAdjustPatchReference &&
|
||
(useScaleAdjustVisualLayoutGuide ||
|
||
!useScaleAdjustCoordinatePrompt ||
|
||
useProviderTextOnlyCoordinatePrompt ||
|
||
(useScaleAdjustMarkedPhotoGuide && !useFalScaleAdjustCoordinatePrompt)));
|
||
const scaleAdjustCropReferences: ScaleAdjustCropReference[] =
|
||
shouldCreateScaleAdjustCropReferences
|
||
? await createScaleAdjustCropReferences({
|
||
regions,
|
||
sourceBuffer,
|
||
sourceHeight,
|
||
sourceWidth,
|
||
})
|
||
: [];
|
||
const useScaleAdjustFullFrameStructurePatch =
|
||
Boolean(scaleAdjustPatchReference?.structureLockedDraft) &&
|
||
Boolean(scaleAdjustPatchReference?.targetDraftFullUrl) &&
|
||
regions.some((region) =>
|
||
isScaleAdjustFurnitureProductLikePrompt(region.prompt)
|
||
);
|
||
const useScaleAdjustPatchTargetDraftPrimary = Boolean(scaleAdjustPatchReference);
|
||
const includeScaleAdjustPatchSourceReference =
|
||
Boolean(scaleAdjustPatchReference);
|
||
const useFalScaleAdjustPatchPrompt =
|
||
Boolean(scaleAdjustPatchReference) &&
|
||
!useScaleAdjustFullFrameStructurePatch &&
|
||
resolvedScaleAdjustModelEntry?.provider?.type === "fal-image";
|
||
const prompt = isScaleAdjustOperation
|
||
? scaleAdjustPatchReference
|
||
? useScaleAdjustFullFrameStructurePatch
|
||
? buildScaleAdjustFullFramePatchPrompt(
|
||
regions,
|
||
scalePercent,
|
||
{ width: sourceWidth, height: sourceHeight }
|
||
)
|
||
: buildScaleAdjustPatchPrompt(
|
||
regions,
|
||
scalePercent,
|
||
scaleAdjustPatchReference,
|
||
{ width: sourceWidth, height: sourceHeight },
|
||
scaleAdjustCropReferences,
|
||
{
|
||
falSafe: useFalScaleAdjustPatchPrompt,
|
||
includeSourceReference: includeScaleAdjustPatchSourceReference,
|
||
targetDraftPrimary: useScaleAdjustPatchTargetDraftPrimary,
|
||
}
|
||
)
|
||
: useProviderTextOnlyCoordinatePrompt
|
||
? buildScaleAdjustTextOnlyPrompt(
|
||
regions,
|
||
scalePercent,
|
||
scaleAdjustCropReferences,
|
||
{ width: sourceWidth, height: sourceHeight }
|
||
)
|
||
: useScaleAdjustCoordinatePrompt
|
||
? buildScaleAdjustCoordinatePrompt(
|
||
regions,
|
||
scalePercent,
|
||
{
|
||
width: sourceWidth,
|
||
height: sourceHeight,
|
||
},
|
||
scaleAdjustCropReferences,
|
||
{
|
||
falSafe: useFalScaleAdjustCoordinatePrompt,
|
||
markedPhotoGuide: useScaleAdjustMarkedPhotoGuide,
|
||
visualLayoutGuide: useScaleAdjustVisualLayoutGuide,
|
||
}
|
||
)
|
||
: buildScaleAdjustPrompt(
|
||
regions,
|
||
scalePercent,
|
||
scaleAdjustCropReferences,
|
||
{ width: sourceWidth, height: sourceHeight }
|
||
)
|
||
: buildLocalEditPrompt(regions);
|
||
const scaleAdjustCoordinateNegativePrompt = useFalScaleAdjustCoordinatePrompt
|
||
? "guide marks, labels, boxes, arrows, overlays, patch edges, color mismatch, full-frame zoom, unchanged target subject, duplicate subject, duplicate furniture, duplicate person, stretched furniture, distorted furniture, deformed person, changed face, extra limbs"
|
||
: "visible annotation, guide overlay, translucent overlay, target placement overlay, faded wash, placeholder shadow, text label, arrows, boxes, rectangle border, UI marks, pasted patch, rough layout patch, hard crop edge, seam, hard rectangular edge, color mismatch, different-resolution area, whole image zoom, whole image crop, whole image pan, whole image redesign, unchanged target subject, old-size target subject, unchanged target person, old-size target person, unchanged target furniture, old-size target furniture, stretched subject, squashed subject, distorted subject, stretched furniture, squashed furniture, duplicate subject, duplicate person, duplicate furniture, second copy, changed face, deformed person, broken anatomy, extra limbs, extra fingers, extra legs, extra rails, side panels, shelves, cabinets, lattice panels";
|
||
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 subject, duplicate person, duplicate furniture, changed face, deformed person, extra limbs, stretched subject, 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, rectangular haze, white haze block, pale wipe block, color mismatch, gray draft patch, beige wash patch, flat painted area, jagged mask hole, black speckled stain, dark blotch, duplicate subject, duplicate person, duplicate furniture, unchanged target subject, unchanged target person, unchanged target furniture, old-size target subject, changed target identity, changed face, deformed person, broken anatomy, extra limbs, extra fingers, changed locked product, redrawn product interior, different furniture, broken woven panel, dirty woven panel, wheel inside woven panel, stretched subject, stretched furniture, distorted furniture";
|
||
const scaleAdjustFalPatchNegativePrompt =
|
||
"full room image, boxes, labels, UI marks, arrows, text, overlays, seams, rectangular patch edge, haze block, ghost copy, old-size subject, unchanged subject, duplicate subject, stretched subject, distorted product, changed product structure";
|
||
const scaleAdjustFullFramePatchNegativePrompt =
|
||
"boxes, labels, UI marks, arrows, text, overlays, seams, rectangular patch edge, haze block, ghost copy, old-size subject, unchanged subject, duplicate subject, stretched subject, distorted product, changed product structure, changed person, changed face, deformed person, extra limbs, changed hands, changed feet, whole-image zoom, crop, pan";
|
||
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
|
||
region.referenceImages.map((reference) => reference.url)
|
||
);
|
||
const scaleAdjustCropReferenceImageUrls = scaleAdjustCropReferences.map(
|
||
(reference) => reference.url
|
||
);
|
||
const referenceImages = isScaleAdjustOperation
|
||
? scaleAdjustPatchReference
|
||
? useScaleAdjustFullFrameStructurePatch
|
||
? [
|
||
sourceImageUrl,
|
||
scaleAdjustPatchReference.targetDraftFullUrl as string,
|
||
...scaleAdjustCropReferenceImageUrls,
|
||
...manualRegionReferenceImageUrls,
|
||
]
|
||
: useScaleAdjustPatchTargetDraftPrimary
|
||
? [
|
||
scaleAdjustPatchReference.targetDraftUrl,
|
||
...(includeScaleAdjustPatchSourceReference
|
||
? [scaleAdjustPatchReference.sourceCropUrl]
|
||
: []),
|
||
...scaleAdjustCropReferenceImageUrls,
|
||
...manualRegionReferenceImageUrls,
|
||
]
|
||
: [
|
||
scaleAdjustPatchReference.sourceCropUrl,
|
||
scaleAdjustPatchReference.targetDraftUrl,
|
||
...scaleAdjustCropReferenceImageUrls,
|
||
...manualRegionReferenceImageUrls,
|
||
]
|
||
: [
|
||
sourceImageUrl,
|
||
...(sendScaleAdjustAnnotatedReferenceToModel && annotatedUpload?.url
|
||
? [annotatedUpload.url]
|
||
: []),
|
||
...scaleAdjustCropReferenceImageUrls,
|
||
...manualRegionReferenceImageUrls,
|
||
]
|
||
: [sourceImageUrl, ...manualRegionReferenceImageUrls];
|
||
const scaleAdjustCompositeRegions = isScaleAdjustOperation
|
||
? buildScaleAdjustCompositeRegions(regions, scalePercent)
|
||
: [];
|
||
const scaleAdjustPatchCompositeRegions = isScaleAdjustOperation
|
||
? buildScaleAdjustPatchCompositeRegions(regions, scalePercent)
|
||
: [];
|
||
const scaleAdjustProtectedChangeRegions =
|
||
isScaleAdjustOperation && scaleAdjustPatchReference
|
||
? buildScaleAdjustProtectedChangeRegions(regions, scalePercent)
|
||
: [];
|
||
const scaleAdjustProtectedRegions =
|
||
isScaleAdjustOperation && scaleAdjustPatchReference
|
||
? scaleAdjustProtectedChangeRegions
|
||
: scaleAdjustCompositeRegions;
|
||
const scaleAdjustVerificationConfig = isScaleAdjustOperation
|
||
? {
|
||
allowOutsideChangedWhenChangedInside: false,
|
||
allowDeterministicFallback: false,
|
||
protectedRegions: scaleAdjustProtectedRegions,
|
||
rejectFailedCandidates: true,
|
||
sourceImageUrl,
|
||
regions: regions.map((region) => region.rect),
|
||
regionPrompts: regions.map((region) => region.prompt),
|
||
...(typeof scalePercent === "number"
|
||
? { scalePercent: scalePercent as ScaleAdjustPercent }
|
||
: {}),
|
||
...(hasScaleAdjustTargetRects
|
||
? {
|
||
targetRegions: regions
|
||
.map((region) => region.targetRect)
|
||
.filter(
|
||
(
|
||
rect
|
||
): rect is NonNullable<NormalizedLocalEditRegion["targetRect"]> =>
|
||
Boolean(rect)
|
||
),
|
||
}
|
||
: {}),
|
||
}
|
||
: undefined;
|
||
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 ? true : undefined,
|
||
aspectRatio: scaleAdjustPatchReference && !useScaleAdjustFullFrameStructurePatch
|
||
? inferClosestAspectRatio(
|
||
scaleAdjustPatchReference.workspacePixelRect.width,
|
||
scaleAdjustPatchReference.workspacePixelRect.height
|
||
)
|
||
: resolvedAspectRatio,
|
||
billingFeature: "repaint",
|
||
debugLabel:
|
||
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
|
||
debugMeta: {
|
||
annotatedReferenceUrl: annotatedUpload?.url || null,
|
||
annotatedReferenceSentToModel: sendScaleAdjustAnnotatedReferenceToModel,
|
||
annotatedReferenceType: isScaleAdjustOperation
|
||
? useScaleAdjustFullFrameStructurePatch
|
||
? "scale-adjust-full-frame-structure-target-draft"
|
||
: useProviderTextOnlyCoordinatePrompt
|
||
? "scale-adjust-text-coordinate-only-debug-map"
|
||
: useScaleAdjustCoordinatePrompt
|
||
? useScaleAdjustVisualLayoutGuide
|
||
? "scale-adjust-visual-layout-guide"
|
||
: useScaleAdjustMarkedPhotoGuide
|
||
? "scale-adjust-marked-photo-guide"
|
||
: "scale-adjust-neutral-coordinate-map"
|
||
: "scale-adjust-full-frame-semantic-edit-map"
|
||
: "source-overlay",
|
||
scaleAdjustEditMaskApplied: Boolean(scaleAdjustEditMaskUpload),
|
||
scaleAdjustEditMaskUrl: scaleAdjustEditMaskUpload?.url || null,
|
||
scaleAdjustEditMaskProviderType:
|
||
useScaleAdjustEditMask
|
||
? resolvedScaleAdjustModelEntry?.provider?.type || null
|
||
: null,
|
||
cleanSourceReferenceIndex:
|
||
scaleAdjustPatchReference && useScaleAdjustFullFrameStructurePatch
|
||
? 1
|
||
: scaleAdjustPatchReference &&
|
||
useScaleAdjustPatchTargetDraftPrimary &&
|
||
!includeScaleAdjustPatchSourceReference
|
||
? null
|
||
: scaleAdjustPatchReference && useScaleAdjustPatchTargetDraftPrimary
|
||
? 2
|
||
: 1,
|
||
configuredDetailModelId: configuredDetailModel || null,
|
||
detailFixedModelApplied: Boolean(detailFixedModel),
|
||
detailFixedModelId: detailFixedModel || null,
|
||
displayPrompt,
|
||
operation,
|
||
requestedModel: requestedModel || null,
|
||
resolvedAspectRatio,
|
||
scaleAdjustPatchWorkflowApplied: Boolean(scaleAdjustPatchReference),
|
||
scaleAdjustPatchReferenceFailed,
|
||
scaleAdjustPatchForcedForShrink:
|
||
Boolean(scaleAdjustPatchReference) && hasScaleAdjustShrink,
|
||
scaleAdjustPatchStructureLockedDraftApplied:
|
||
scaleAdjustPatchReference?.structureLockedDraft === true,
|
||
scaleAdjustFullFrameStructurePatchApplied:
|
||
useScaleAdjustFullFrameStructurePatch,
|
||
scaleAdjustFullFrameFalEditApplied: useScaleAdjustFullFrameFalEdit,
|
||
scaleAdjustFullFrameFalEditDisabledForShrink:
|
||
isScaleAdjustFalNanoBananaEdit && hasScaleAdjustShrink,
|
||
scaleAdjustPatchReferenceOrder:
|
||
scaleAdjustPatchReference && useScaleAdjustFullFrameStructurePatch
|
||
? "full-source-full-target-draft"
|
||
: scaleAdjustPatchReference && useScaleAdjustPatchTargetDraftPrimary
|
||
? includeScaleAdjustPatchSourceReference
|
||
? "target-draft-source-identity"
|
||
: "target-draft-identity"
|
||
: scaleAdjustPatchReference
|
||
? "source-target-draft-identity"
|
||
: null,
|
||
scaleAdjustPatchSourceCropUrl:
|
||
scaleAdjustPatchReference?.sourceCropUrl || null,
|
||
scaleAdjustPatchTargetDraftUrl:
|
||
scaleAdjustPatchReference?.targetDraftUrl || null,
|
||
scaleAdjustPatchTargetDraftFullUrl:
|
||
scaleAdjustPatchReference?.targetDraftFullUrl || null,
|
||
scaleAdjustPatchCompositeClipMaskUrl:
|
||
scaleAdjustPatchReference?.compositeClipMaskUrl || null,
|
||
scaleAdjustPatchWorkspacePixelRect:
|
||
scaleAdjustPatchReference?.workspacePixelRect || null,
|
||
scaleAdjustPatchWorkspaceRect:
|
||
scaleAdjustPatchReference?.workspaceRect || null,
|
||
resolvedModel: resolvedModel || null,
|
||
resolvedScaleAdjustProviderType:
|
||
resolvedScaleAdjustModelEntry?.provider?.type || null,
|
||
scaleAdjustModelFamily: scaleAdjustModelFamily || null,
|
||
scaleAdjustCoordinateGuideApplied: useScaleAdjustCoordinateGuide,
|
||
scaleAdjustCoordinatePromptApplied:
|
||
useScaleAdjustCoordinatePrompt || useProviderTextOnlyCoordinatePrompt,
|
||
scaleAdjustTextOnlyCoordinatePromptApplied:
|
||
useProviderTextOnlyCoordinatePrompt,
|
||
scaleAdjustNeutralCoordinateMapApplied:
|
||
isScaleAdjustOperation &&
|
||
useScaleAdjustCoordinatePrompt &&
|
||
!useScaleAdjustMarkedPhotoGuide &&
|
||
!useScaleAdjustVisualLayoutGuide,
|
||
scaleAdjustMarkedPhotoGuideApplied: useScaleAdjustMarkedPhotoGuide,
|
||
scaleAdjustFalMarkedPhotoGuideApplied: useFalScaleAdjustCoordinatePrompt,
|
||
scaleAdjustVisualLayoutGuideApplied:
|
||
isScaleAdjustOperation &&
|
||
!useProviderTextOnlyCoordinatePrompt &&
|
||
(!useScaleAdjustCoordinatePrompt || useScaleAdjustVisualLayoutGuide),
|
||
falScaleAdjustCoordinateGuideApplied:
|
||
useScaleAdjustCoordinateGuide &&
|
||
resolvedScaleAdjustModelEntry?.provider?.type === "fal-image",
|
||
falScaleAdjustSafePromptApplied: useFalScaleAdjustCoordinatePrompt,
|
||
falScaleAdjustPatchSafePromptApplied: useFalScaleAdjustPatchPrompt,
|
||
scaleAdjustProviderFallbackPolicy: isScaleAdjustOperation
|
||
? "single-output-provider-error-fallback-same-family-fal-last"
|
||
: undefined,
|
||
scaleAdjustQualityFailoverPolicy: isScaleAdjustOperation
|
||
? "disabled-reject-unchanged-ai-candidate"
|
||
: undefined,
|
||
scaleAdjustPreferSameNameProviderFailover:
|
||
isScaleAdjustOperation,
|
||
scaleAdjustStrictSameFamilyFailover:
|
||
isScaleAdjustOperation,
|
||
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
|
||
regionReferenceImageCount:
|
||
referenceImages.length,
|
||
regionCount: regions.length,
|
||
scalePercent,
|
||
scaleAdjustCropReferenceCount: scaleAdjustCropReferenceImageUrls.length,
|
||
scaleAdjustProtectionMode: hasScaleAdjustTargetRects
|
||
? "target-rect"
|
||
: "scale-percent",
|
||
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
|
||
.length,
|
||
scaleAdjustPromptStrategy: isScaleAdjustOperation
|
||
? scaleAdjustPatchReference
|
||
? useScaleAdjustFullFrameStructurePatch
|
||
? "full-frame-source-locked-target-guide-v8-one-shot"
|
||
: scaleAdjustPatchReference.structureLockedDraft
|
||
? "ai-patch-workspace-structure-locked-draft-v87-subject-alpha-clipped-one-shot"
|
||
: "ai-patch-workspace-draft-v75-soft-wipe-erase-ghost-one-shot"
|
||
: scaleAdjustPatchReferenceFailed
|
||
? "source-only-coordinate-fallback-after-reference-upload-failure-v1-one-shot"
|
||
: useScaleAdjustEditMask
|
||
? "mask-edit-gpt-image-2-scale-shrink-v74-one-shot"
|
||
: useFalScaleAdjustCoordinatePrompt
|
||
? "fal-safe-marked-photo-box-guide-no-old-crop-v44-one-shot"
|
||
: useScaleAdjustVisualLayoutGuide
|
||
? "same-name-focused-layout-crop-guide-v54-one-shot"
|
||
: useProviderTextOnlyCoordinatePrompt
|
||
? "provider-text-coordinate-crop-v61-one-shot"
|
||
: useScaleAdjustMarkedPhotoGuide
|
||
? "same-name-marked-photo-box-guide-no-old-crop-v45-one-shot"
|
||
: "locked-plate-semantic-target-map-no-old-crop-v15"
|
||
: undefined,
|
||
scaleAdjustPostCompositeApplied:
|
||
Boolean(scaleAdjustPatchReference) &&
|
||
!useScaleAdjustFullFrameStructurePatch,
|
||
scaleAdjustUsesFullFrameOutput:
|
||
isScaleAdjustOperation &&
|
||
(!scaleAdjustPatchReference || useScaleAdjustFullFrameStructurePatch),
|
||
sourceHeight,
|
||
sourceNodeId: body.sourceNodeId || null,
|
||
sourceWidth,
|
||
},
|
||
displayPrompt,
|
||
falSafetyTolerance: useScaleAdjustCoordinateGuide ? "6" : undefined,
|
||
falSystemPrompt:
|
||
useScaleAdjustCoordinatePrompt
|
||
? buildScaleAdjustCoordinateSystemPrompt()
|
||
: undefined,
|
||
falThinkingLevel: useScaleAdjustCoordinateGuide ? "high" : undefined,
|
||
imageCount: 1,
|
||
imageSize: resolvedRequestImageSize,
|
||
model: resolvedModel || undefined,
|
||
negativePrompt:
|
||
isScaleAdjustOperation
|
||
? scaleAdjustPatchReference
|
||
? useScaleAdjustFullFrameStructurePatch
|
||
? scaleAdjustFullFramePatchNegativePrompt
|
||
: useFalScaleAdjustPatchPrompt
|
||
? scaleAdjustFalPatchNegativePrompt
|
||
: scaleAdjustPatchNegativePrompt
|
||
: useProviderTextOnlyCoordinatePrompt
|
||
? scaleAdjustTextOnlyNegativePrompt
|
||
: useScaleAdjustCoordinatePrompt
|
||
? scaleAdjustCoordinateNegativePrompt
|
||
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、箭头、连线、角点、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、蓝色色块、色条、灰底坐标图、目标占位叠图、淡化痕迹、选区边框、白边、画幅压扁、画面拉伸、整张图缩放、全图放大、全图裁切、全图平移、文字位置变化、文字内容变化、文字模糊、Logo位置变化、主体拉伸、主体压扁、主体变窄、主体变高但变窄、横向挤压、纵向拉伸、透视变形、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、背景错位、窗框错位、文字错位、地毯纹理错位、非主体背景重绘、主体未缩放、整块框选区域缩放"
|
||
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、动物截断、缺头、缺尾巴、缺四肢、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
|
||
prompt,
|
||
promptExtend: false,
|
||
providerImageParams: scaleAdjustEditMaskUpload
|
||
? { maskImageUrl: scaleAdjustEditMaskUpload.url }
|
||
: undefined,
|
||
referenceImages,
|
||
scaleAdjustPatchComposite: scaleAdjustPatchReference && !useScaleAdjustFullFrameStructurePatch
|
||
? {
|
||
diffAlphaCutoff:
|
||
scaleAdjustPatchReference.structureLockedDraft ? 24 : 8,
|
||
diffAlphaMultiplier:
|
||
scaleAdjustPatchReference.structureLockedDraft ? 2.25 : 3,
|
||
diffCompareBlurPx:
|
||
scaleAdjustPatchReference.structureLockedDraft ? 1.2 : 0.8,
|
||
diffHighThreshold:
|
||
scaleAdjustPatchReference.structureLockedDraft ? 72 : 42,
|
||
diffLowThreshold:
|
||
scaleAdjustPatchReference.structureLockedDraft ? 26 : 14,
|
||
featherMaxPx:
|
||
scaleAdjustPatchReference.structureLockedDraft ? 36 : 42,
|
||
featherMinPx: 10,
|
||
featherRatio: 0.018,
|
||
maskMode: "diff",
|
||
regions: scaleAdjustPatchCompositeRegions,
|
||
sourceImageUrl,
|
||
clipMaskImageUrl:
|
||
scaleAdjustPatchReference.compositeClipMaskUrl || undefined,
|
||
workspaceRegion: scaleAdjustPatchReference.workspaceRect,
|
||
}
|
||
: undefined,
|
||
scaleAdjustVerification: scaleAdjustVerificationConfig,
|
||
localEditComposite: isScaleAdjustOperation
|
||
? undefined
|
||
: {
|
||
sourceImageUrl,
|
||
regions: regions.map((region) => region.rect),
|
||
},
|
||
ownerKey: session.user.id,
|
||
};
|
||
|
||
const task = await createCanvasImageTask({
|
||
request: imageRequest as unknown as Record<string, unknown>,
|
||
userId: session.user.id,
|
||
});
|
||
await enqueueCanvasImageTask(task.id);
|
||
const currentTask = await getCanvasImageTaskForUser({
|
||
taskId: task.id,
|
||
userId: session.user.id,
|
||
});
|
||
|
||
if (pointsCharge.chargeable) {
|
||
try {
|
||
await pointsCharge.consume({
|
||
note:
|
||
operation === SCALE_ADJUST_OPERATION
|
||
? `比例调整消耗积分(${task.id})`
|
||
: `局部编辑消耗积分(${task.id})`,
|
||
});
|
||
} catch (pointsError) {
|
||
console.error("[Points] Failed to consume local edit points:", pointsError);
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
annotatedReferenceUrl: annotatedUpload?.url || null,
|
||
aspectRatio: resolvedAspectRatio,
|
||
displayPrompt,
|
||
prompt: displayPrompt,
|
||
regions,
|
||
sourceHeight,
|
||
sourceWidth,
|
||
taskId: task.id,
|
||
status: currentTask?.status || task.status,
|
||
queueStatus: currentTask?.queueStatus,
|
||
});
|
||
} catch (error) {
|
||
console.error("[Canvas Local Edit] Error:", error);
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: error instanceof Error ? error.message : "局部编辑提交失败",
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|