1321 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
import { auth } from "@/app/(auth)/auth";
import { 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 { 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 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 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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 || entry.model.supportsVision === false) {
return false;
}
const label = `${entry.model.displayName || ""} ${entry.model.modelId || ""}`
.toLowerCase()
.replace(/\s+/g, "");
return label.includes("详情专线");
}
function resolveScaleAdjustPreferredModelId(params: {
configuredModelId: string;
requestedModelId: string;
models: AiModelWithProviderForSelection[];
}) {
const requestedModel = findModelSelectionEntry(
params.models,
params.requestedModelId
);
if (isScaleAdjustDedicatedDetailModel(requestedModel)) {
return requestedModel?.model.id || params.requestedModelId;
}
const configuredModel = findModelSelectionEntry(
params.models,
params.configuredModelId
);
if (isScaleAdjustDedicatedDetailModel(configuredModel)) {
return configuredModel?.model.id || params.configuredModelId;
}
const autoDetailModel = params.models.find(isScaleAdjustDedicatedDetailModel);
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 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 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 cropBox = 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 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(3, Math.round(minSide * 0.0048));
const targetColor = "#00d4ff";
const sourceColor = "#ff3b30";
const connectorColor = "#14c8ff";
const dash = Math.max(12, Math.round(strokeWidth * 2.8));
const gap = Math.max(8, Math.round(strokeWidth * 1.8));
const connectorWidth = Math.max(2, Math.round(strokeWidth * 0.74));
const markerSize = Math.max(8, Math.round(strokeWidth * 3.1));
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 badgeSize = Math.max(22, Math.round(minSide * 0.022));
const badgeX = clamp(
source.left + Math.round(badgeSize * 0.45),
badgeSize / 2 + 4,
width - badgeSize / 2 - 4
);
const badgeY = clamp(
source.top + Math.round(badgeSize * 0.45),
badgeSize / 2 + 4,
height - badgeSize / 2 - 4
);
const fontSize = Math.max(13, Math.round(badgeSize * 0.52));
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 sourceCorners = [
{ x: source.left, y: source.top },
{ x: sourceRight, y: source.top },
{ x: sourceRight, y: sourceBottom },
{ x: source.left, y: sourceBottom },
];
const targetCorners = [
{ x: target.left, y: target.top },
{ x: targetRight, y: target.top },
{ x: targetRight, y: targetBottom },
{ x: target.left, y: targetBottom },
];
const connectorSvg = sourceCorners
.map((corner, cornerIndex) => {
const targetCorner = targetCorners[cornerIndex];
const distance = Math.hypot(targetCorner.x - corner.x, targetCorner.y - corner.y);
if (distance < Math.max(8, strokeWidth * 2.2)) return "";
const dx = (targetCorner.x - corner.x) / distance;
const dy = (targetCorner.y - corner.y) / distance;
const startInset = Math.min(distance * 0.16, Math.max(5, strokeWidth * 1.8));
const endInset = Math.min(distance * 0.18, Math.max(8, strokeWidth * 2.8));
const x1 = corner.x + dx * startInset;
const y1 = corner.y + dy * startInset;
const x2 = targetCorner.x - dx * endInset;
const y2 = targetCorner.y - dy * endInset;
return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${connectorColor}" stroke-width="${connectorWidth}" stroke-linecap="round" opacity="0.86" marker-end="url(#scale-adjust-arrow)" />`;
})
.join("");
const cornerHandleSize = Math.max(7, Math.round(strokeWidth * 2.2));
const sourceCornerSvg = sourceCorners
.map(
(corner) =>
`<circle cx="${corner.x}" cy="${corner.y}" r="${cornerHandleSize * 0.48}" fill="#ffffff" stroke="${sourceColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.7))}" />`
)
.join("");
const targetCornerSvg = targetCorners
.map(
(corner) =>
`<rect x="${corner.x - cornerHandleSize / 2}" y="${corner.y - cornerHandleSize / 2}" width="${cornerHandleSize}" height="${cornerHandleSize}" rx="${Math.max(1, Math.round(cornerHandleSize * 0.18))}" fill="#ffffff" stroke="${targetColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.7))}" />`
)
.join("");
return `
${connectorSvg}
<rect x="${source.left}" y="${source.top}" width="${source.width}" height="${source.height}" rx="${radius}" fill="${sourceColor}" opacity="0.025" stroke="${sourceColor}" stroke-width="${strokeWidth}" />
<rect x="${target.left}" y="${target.top}" width="${target.width}" height="${target.height}" rx="${radius}" fill="${targetColor}" opacity="0.035" stroke="${targetColor}" stroke-width="${Math.max(3, Math.round(strokeWidth * 1.05))}" stroke-dasharray="${dash} ${gap}" />
${sourceCornerSvg}
${targetCornerSvg}
<circle cx="${badgeX}" cy="${badgeY}" r="${badgeSize / 2}" fill="${sourceColor}" opacity="0.92" stroke="#ffffff" stroke-width="${Math.max(1.5, Math.round(strokeWidth * 0.36))}" />
<text x="${badgeX}" y="${badgeY + fontSize * 0.36}" text-anchor="middle" fill="#ffffff" font-size="${fontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="800">${region.index}</text>
`;
})
.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-arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="${markerSize}" markerHeight="${markerSize}" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="${connectorColor}" opacity="0.92" />
</marker>
</defs>
${regionSvg}
</svg>
`);
}
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 targetPositionNote = moved
? "青色虚线框同时指定用户希望的最终目标大小和目标位置,目标主体调整后的视觉中心应移动到青色虚线框中心附近。"
: "青色虚线框指定用户希望的最终目标大小,目标主体调整后应围绕当前主体位置自然放置。";
const cropNote =
cropIndex >= 0
? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例`
: "";
return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立调整的商品/物体主体。红色实线框只是“寻找主体”的范围,不是最终边界,也不是要缩放或贴回的矩形补丁。${targetPositionNote}请直接读取输入图片2中青色虚线框相对红色实线框的真实大小和位置关系,并按这个框线关系调整主体;四条蓝色箭头从红框四角指向青框四角,表示主体外接轮廓从当前位置迁移到目标大小和目标位置的方向。不要把框线换算成文字百分比来执行。目标虚线框不是要求主体填满矩形或变成矩形比例,请以主体自身轮廓、原始宽高比和视觉中心为准,整体等比调整主体大小和位置。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果红框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
})
.join("\n");
const cropLines = cropReferences
.map(
(reference, index) =>
`输入图片${index + 3}:区域 ${reference.regionIndex} 的原图干净局部裁切参考图,只用于确认要缩放的主体身份、轮廓和宽高比,不能当成输出底图,也不能把裁切图整体贴回。`
)
.join("\n");
return [
"这是一项局部主体比例 inpaint/edit 任务,不是整图缩放、整图裁切或重新构图任务。",
sourceSizeLine,
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。除目标主体尺寸变化与必要背景补全外,输入图片1中的文字、Logo、装饰、排版、背景、光线、构图边界和所有未选区域必须保持不变。",
"输入图片2是白底选区参考图,只用于说明输入图片1上的红色实线框、青色目标虚线框和四角箭头关系;它不包含场景内容,不是输出底图,不得学习或复刻它的框线、箭头、编号、角点、半透明遮罩或 UI 标记。",
cropLines
? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}`
: "",
"输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制调整后的空间。青色虚线框表示用户手画的比例调整后目标大小和目标位置。四条蓝色箭头从红框四角指向青框四角:箭头向外表示放大,箭头向内表示缩小,箭头整体偏移表示移动。最终画面不得出现这些框线、箭头或角点,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。",
"如果系统或模型只能读取一张参考图,必须优先以输入图片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中识别红色实线框范围内最主要、可独立调整的主体物体,再只对这个“主体物体”做局部比例调整。调整后的主体大小和位置必须以输入图片2中对应青色目标框线为准;如果输出看起来和原图一样,就是失败。",
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");
}
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 displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent });
const annotationSvg = isScaleAdjustOperation
? buildScaleAdjustAnnotationSvg({
height: sourceHeight,
regions,
scalePercent,
width: sourceWidth,
})
: buildAnnotationSvg({
height: sourceHeight,
hidePromptText: false,
regions,
width: sourceWidth,
});
const annotatedBase = isScaleAdjustOperation
? sharp({
create: {
background: "#ffffff",
channels: 3,
height: sourceHeight,
width: sourceWidth,
},
})
: sharp(sourceBuffer, { failOn: "none" });
const annotatedBuffer = await annotatedBase
.composite([{ input: annotationSvg, left: 0, top: 0 }])
.png({ compressionLevel: 6 })
.toBuffer();
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile(
annotatedFilename,
toArrayBuffer(annotatedBuffer)
);
const scaleAdjustCropReferences = isScaleAdjustOperation
? await createScaleAdjustCropReferences({
regions,
sourceBuffer,
sourceHeight,
sourceWidth,
})
: [];
const prompt =
isScaleAdjustOperation
? buildScaleAdjustPrompt(
regions,
scalePercent,
scaleAdjustCropReferences,
{ width: sourceWidth, height: sourceHeight }
)
: buildLocalEditPrompt(regions);
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
const scaleAdjustCropReferenceImageUrls = scaleAdjustCropReferences.map(
(reference) => reference.url
);
const referenceImages = isScaleAdjustOperation
? [
sourceImageUrl,
annotatedUpload.url,
...scaleAdjustCropReferenceImageUrls,
...manualRegionReferenceImageUrls,
]
: [sourceImageUrl, ...manualRegionReferenceImageUrls];
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,
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 scaleAdjustCompositeRegions = isScaleAdjustOperation
? buildScaleAdjustCompositeRegions(regions, scalePercent)
: [];
const imageRequest: CanvasGenerateImageRequest = {
aspectRatio: resolvedAspectRatio,
billingFeature: "repaint",
debugLabel:
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
debugMeta: {
annotatedReferenceUrl: annotatedUpload.url,
annotatedReferenceSentToModel: isScaleAdjustOperation,
annotatedReferenceType: isScaleAdjustOperation
? "white-source-target-arrow-guide"
: "source-overlay",
cleanSourceReferenceIndex: 1,
configuredDetailModelId: configuredDetailModel || null,
detailFixedModelApplied: Boolean(detailFixedModel),
detailFixedModelId: detailFixedModel || null,
displayPrompt,
operation,
requestedModel: requestedModel || null,
resolvedAspectRatio,
resolvedModel: resolvedModel || null,
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
regionReferenceImageCount:
manualRegionReferenceImageUrls.length +
scaleAdjustCropReferenceImageUrls.length,
regionCount: regions.length,
scalePercent,
scaleAdjustCropReferenceCount: scaleAdjustCropReferenceImageUrls.length,
scaleAdjustProtectionMode: hasScaleAdjustTargetRects
? "target-rect"
: "scale-percent",
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
.length,
scaleAdjustPromptStrategy: isScaleAdjustOperation
? "locked-plate-local-subject-target-box-arrows-v5"
: undefined,
scaleAdjustPostCompositeApplied: false,
scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation,
sourceHeight,
sourceNodeId: body.sourceNodeId || null,
sourceWidth,
},
displayPrompt,
imageCount: 1,
imageSize:
typeof body.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: "2K",
model: resolvedModel || undefined,
negativePrompt:
isScaleAdjustOperation
? "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、箭头、连线、角点、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、整张图缩放、全图放大、全图裁切、全图平移、整体重绘、删除文字、清理文字、改写文字、文字模糊、Logo消失、主体拉伸、主体压扁、主体变窄、主体变高但变窄、横向挤压、纵向拉伸、透视变形、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、背景错位、窗框错位、文字错位、地毯纹理错位、非主体背景重绘、主体未缩放、整块框选区域缩放"
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、动物截断、缺头、缺尾巴、缺四肢、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
prompt,
promptExtend: false,
referenceImages,
scaleAdjustVerification: isScaleAdjustOperation
? {
protectedRegions: scaleAdjustCompositeRegions,
sourceImageUrl,
regions: regions.map((region) => region.rect),
...(typeof scalePercent === "number"
? { scalePercent: scalePercent as ScaleAdjustPercent }
: {}),
...(hasScaleAdjustTargetRects
? {
targetRegions: regions
.map((region) => region.targetRect)
.filter(
(
rect
): rect is NonNullable<NormalizedLocalEditRegion["targetRect"]> =>
Boolean(rect)
),
}
: {}),
}
: undefined,
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,
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 }
);
}
}