1049 lines
37 KiB
TypeScript
1049 lines
37 KiB
TypeScript
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 { hasScaleAdjustPositionMoved } from "@/lib/canvas/scale-adjust-geometry";
|
||
import {
|
||
buildScaleAdjustProductLockAssets,
|
||
type CanvasScaleAdjustIntentRegion,
|
||
} from "@/lib/canvas/scale-adjust-product-lock";
|
||
import {
|
||
buildSemanticScaleAdjustCleanupPrompt,
|
||
buildSemanticScaleAdjustCompositeRegions,
|
||
buildSemanticScaleAdjustExtractionPrompt,
|
||
} from "@/lib/canvas/scale-adjust-semantic";
|
||
import {
|
||
getPointsSystemConfig,
|
||
} 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 MIN_REGION_EDGE_PX = 24;
|
||
const SCALE_ADJUST_OPERATION = "scale-adjust";
|
||
const SCALE_ADJUST_REPAIR_MODEL_REF = "e03d3341-cecd-425e-b88b-9350cbbce18f";
|
||
const SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF =
|
||
"5198c20f-b4cc-402c-bc73-eeabacd086b4";
|
||
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
|
||
const MIN_SCALE_ADJUST_PERCENT = -80;
|
||
const MAX_SCALE_ADJUST_PERCENT = 200;
|
||
type ScaleAdjustPercent = 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 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 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 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");
|
||
}
|
||
|
||
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 pointsConfig = await getPointsSystemConfig();
|
||
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 });
|
||
}
|
||
|
||
if (
|
||
operation === SCALE_ADJUST_OPERATION &&
|
||
(hasScaleAdjustTargetRects || scalePercent !== null)
|
||
) {
|
||
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 requestedImageSize =
|
||
typeof body.imageSize === "string" && body.imageSize.trim()
|
||
? body.imageSize.trim()
|
||
: "2K";
|
||
const scaleAdjustIntentRegions: CanvasScaleAdjustIntentRegion[] = regions.map(
|
||
(region) => ({
|
||
index: region.index,
|
||
rect: region.rect,
|
||
targetRect: getScaleAdjustTargetRect(region, scalePercent),
|
||
})
|
||
);
|
||
const hasPositionMove = scaleAdjustIntentRegions.some((region) =>
|
||
hasScaleAdjustPositionMoved(region.rect, region.targetRect)
|
||
);
|
||
const productLockAssets = await buildScaleAdjustProductLockAssets({
|
||
protectPeople: hasPositionMove,
|
||
regions: scaleAdjustIntentRegions,
|
||
sourceBuffer,
|
||
sourceHeight,
|
||
sourceImageUrl,
|
||
sourceWidth,
|
||
userId: session.user.id,
|
||
});
|
||
const useSemanticScaleAdjust =
|
||
!hasPositionMove && productLockAssets.semanticFallbackSuggested;
|
||
const semanticCompositeRegions = useSemanticScaleAdjust
|
||
? buildSemanticScaleAdjustCompositeRegions(scaleAdjustIntentRegions)
|
||
: [];
|
||
const assetSuffix = `${Date.now()}-${Math.random()
|
||
.toString(36)
|
||
.slice(2, 8)}`;
|
||
const [
|
||
modelMaskUpload,
|
||
overlayUpload,
|
||
repairMaskUpload,
|
||
semanticSourceCropUploads,
|
||
sourceSubjectMaskUpload,
|
||
] = await Promise.all([
|
||
uploadFile(
|
||
`canvas-scale-adjust-mask-${assetSuffix}.png`,
|
||
toArrayBuffer(productLockAssets.modelMaskBuffer)
|
||
),
|
||
uploadFile(
|
||
`canvas-scale-adjust-overlay-${assetSuffix}.png`,
|
||
toArrayBuffer(productLockAssets.overlayBuffer)
|
||
),
|
||
uploadFile(
|
||
`canvas-scale-adjust-repair-mask-${assetSuffix}.png`,
|
||
toArrayBuffer(productLockAssets.repairMaskBuffer)
|
||
),
|
||
useSemanticScaleAdjust
|
||
? Promise.all(
|
||
productLockAssets.semanticSourceCropBuffers.map(
|
||
(buffer, index) =>
|
||
uploadFile(
|
||
`canvas-scale-adjust-semantic-source-${assetSuffix}-${index + 1}.png`,
|
||
toArrayBuffer(buffer)
|
||
)
|
||
)
|
||
)
|
||
: Promise.resolve([]),
|
||
hasPositionMove || useSemanticScaleAdjust
|
||
? uploadFile(
|
||
`canvas-scale-adjust-subject-mask-${assetSuffix}.png`,
|
||
toArrayBuffer(productLockAssets.sourceSubjectMaskBuffer)
|
||
)
|
||
: Promise.resolve(null),
|
||
]);
|
||
const backgroundPlateUpload = productLockAssets.backgroundPlateBuffer
|
||
? await uploadFile(
|
||
`canvas-scale-adjust-background-${assetSuffix}.png`,
|
||
toArrayBuffer(productLockAssets.backgroundPlateBuffer)
|
||
)
|
||
: null;
|
||
const prompt = useSemanticScaleAdjust
|
||
? buildSemanticScaleAdjustExtractionPrompt()
|
||
: hasPositionMove
|
||
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
|
||
: "移除掩膜中的完整产品并自然还原背景。";
|
||
const providerImageParams = useSemanticScaleAdjust
|
||
? {
|
||
quality: "high",
|
||
}
|
||
: hasPositionMove
|
||
? {
|
||
maskImageUrl: modelMaskUpload.url,
|
||
quality: "medium",
|
||
}
|
||
: {
|
||
objectRemovalMaskImageUrl: modelMaskUpload.url,
|
||
objectRemovalMode: "premium",
|
||
};
|
||
const productLockComposite = {
|
||
...(backgroundPlateUpload
|
||
? { backgroundPlateImageUrl: backgroundPlateUpload.url }
|
||
: {}),
|
||
overlayImageUrl: overlayUpload.url,
|
||
...(hasPositionMove
|
||
? {
|
||
repairMaskImageUrl: repairMaskUpload.url,
|
||
sourceImageUrl,
|
||
}
|
||
: {}),
|
||
...(hasPositionMove && sourceSubjectMaskUpload
|
||
? {
|
||
regions: scaleAdjustIntentRegions.map((region) => ({
|
||
source: region.rect,
|
||
target: region.targetRect,
|
||
})),
|
||
shadowSourceImageUrl: sourceImageUrl,
|
||
sourceSubjectMaskImageUrl: sourceSubjectMaskUpload.url,
|
||
}
|
||
: {}),
|
||
sourceHeight,
|
||
sourceWidth,
|
||
};
|
||
const scaleAdjustVerification = {
|
||
allowOutsideChangedWhenChangedInside: false,
|
||
deterministicProductOverlayApplied: true,
|
||
oldFootprintMaskImageUrl: useSemanticScaleAdjust
|
||
? sourceSubjectMaskUpload?.url
|
||
: hasPositionMove
|
||
? sourceSubjectMaskUpload?.url
|
||
: repairMaskUpload.url,
|
||
regions: scaleAdjustIntentRegions.map((region) => region.rect),
|
||
rejectFailedCandidates: true,
|
||
rejectNoEffectiveChangeOnly: false,
|
||
rejectOldFootprintOnly: true,
|
||
sourceImageUrl,
|
||
targetRegions: scaleAdjustIntentRegions.map((region) => region.targetRect),
|
||
};
|
||
const imageRequest: CanvasGenerateImageRequest = {
|
||
allowFailover: false,
|
||
aspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
|
||
billingFeature: "repaint",
|
||
debugLabel: "canvas-scale-adjust-product-lock",
|
||
debugMeta: {
|
||
displayPrompt,
|
||
operation,
|
||
requestedModel: requestedModel || null,
|
||
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
|
||
resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||
scaleAdjustProductLockMaskEditApplied: true,
|
||
scaleAdjustPersonExclusionApplied:
|
||
productLockAssets.personExclusionApplied,
|
||
scaleAdjustPromptStrategy: useSemanticScaleAdjust
|
||
? "gpt-extract-product-lock-v2"
|
||
: hasPositionMove
|
||
? "gpt-mask-background-rebuild-product-lock-v1"
|
||
: "finegrain-erase-product-lock-v1",
|
||
scaleAdjustProviderMaskImageUrl: useSemanticScaleAdjust
|
||
? null
|
||
: modelMaskUpload.url,
|
||
scaleAdjustSemanticFallbackRegionIndexes:
|
||
productLockAssets.semanticFallbackRegionIndexes,
|
||
scaleAdjustSemanticFallbackSuggested:
|
||
productLockAssets.semanticFallbackSuggested,
|
||
scaleAdjustSequentialCleanupRetry: !useSemanticScaleAdjust,
|
||
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
|
||
.length,
|
||
scalePercent,
|
||
sourceHeight,
|
||
sourceNodeId: body.sourceNodeId || null,
|
||
sourceWidth,
|
||
},
|
||
displayPrompt,
|
||
imageCount: 1,
|
||
imageSize: requestedImageSize,
|
||
model: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||
negativePrompt:
|
||
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
|
||
prompt,
|
||
promptExtend: false,
|
||
providerImageParams,
|
||
referenceImages: [sourceImageUrl],
|
||
...(useSemanticScaleAdjust
|
||
? {
|
||
localEditComposite: {
|
||
expandRegions: false,
|
||
featherMaxPx: 28,
|
||
featherMinPx: 12,
|
||
featherRatio: 0.012,
|
||
maskMode: "region" as const,
|
||
regions: semanticCompositeRegions,
|
||
sourceImageUrl,
|
||
},
|
||
}
|
||
: {}),
|
||
...(useSemanticScaleAdjust &&
|
||
semanticSourceCropUploads.length === scaleAdjustIntentRegions.length
|
||
? {
|
||
semanticScaleAdjustPreparation: {
|
||
cleanupImageSize: "2K",
|
||
cleanupModel: SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF,
|
||
cleanupPrompt: buildSemanticScaleAdjustCleanupPrompt(),
|
||
extractionPrompt: buildSemanticScaleAdjustExtractionPrompt(),
|
||
regions: scaleAdjustIntentRegions.map((region, index) => ({
|
||
source: region.rect,
|
||
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
|
||
target: region.targetRect,
|
||
})),
|
||
relightModel: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||
sourceImageUrl,
|
||
},
|
||
}
|
||
: {}),
|
||
retryVariants: useSemanticScaleAdjust ? [] : [
|
||
{
|
||
debugMeta: {
|
||
scaleAdjustRetryReason: "old-footprint-remnant-detected",
|
||
scaleAdjustRetryStrategy: "sequential-finegrain-cleanup",
|
||
},
|
||
label: "finegrain-cleanup-pass",
|
||
prompt,
|
||
providerImageParams,
|
||
referenceImages: [sourceImageUrl],
|
||
scaleAdjustProductLockComposite: productLockComposite,
|
||
scaleAdjustVerification: {
|
||
...scaleAdjustVerification,
|
||
rejectNoEffectiveChangeOnly: true,
|
||
rejectOldFootprintOnly: false,
|
||
},
|
||
},
|
||
],
|
||
...(useSemanticScaleAdjust
|
||
? {}
|
||
: { scaleAdjustProductLockComposite: productLockComposite }),
|
||
...(hasPositionMove
|
||
? {
|
||
scaleAdjustModelRelight: {
|
||
model: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||
overlayImageUrl: overlayUpload.url,
|
||
quality: "medium" as const,
|
||
regions: scaleAdjustIntentRegions.map((region) => ({
|
||
target: region.targetRect,
|
||
})),
|
||
sourceHeight,
|
||
sourceWidth,
|
||
},
|
||
}
|
||
: {}),
|
||
scaleAdjustVerification,
|
||
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: `比例调整消耗积分(${task.id})`,
|
||
});
|
||
} catch (pointsError) {
|
||
console.error("[Points] Failed to consume scale adjust points:", pointsError);
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
annotatedReferenceUrl: overlayUpload.url,
|
||
aspectRatio: resolvedAspectRatio,
|
||
displayPrompt,
|
||
prompt: displayPrompt,
|
||
regions,
|
||
sourceHeight,
|
||
sourceWidth,
|
||
taskId: task.id,
|
||
status: currentTask?.status || task.status,
|
||
queueStatus: currentTask?.queueStatus,
|
||
});
|
||
}
|
||
|
||
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 annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
||
.composite([
|
||
{
|
||
input: buildAnnotationSvg({
|
||
height: sourceHeight,
|
||
hidePromptText: false,
|
||
regions,
|
||
width: sourceWidth,
|
||
}),
|
||
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 prompt = buildLocalEditPrompt(regions);
|
||
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
|
||
region.referenceImages.map((reference) => reference.url)
|
||
);
|
||
const referenceImages = [sourceImageUrl, ...manualRegionReferenceImageUrls];
|
||
const requestedImageSize =
|
||
typeof body.imageSize === "string" && body.imageSize.trim()
|
||
? body.imageSize.trim()
|
||
: "2K";
|
||
const imageRequest: CanvasGenerateImageRequest = {
|
||
aspectRatio: resolvedAspectRatio,
|
||
billingFeature: "repaint",
|
||
debugLabel: "canvas-local-edit",
|
||
debugMeta: {
|
||
annotatedReferenceUrl: annotatedUpload.url,
|
||
annotatedReferenceType: "source-overlay",
|
||
displayPrompt,
|
||
operation,
|
||
requestedModel: requestedModel || null,
|
||
resolvedAspectRatio,
|
||
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
|
||
regionReferenceImageCount: referenceImages.length,
|
||
regionCount: regions.length,
|
||
sourceHeight,
|
||
sourceNodeId: body.sourceNodeId || null,
|
||
sourceWidth,
|
||
},
|
||
displayPrompt,
|
||
imageCount: 1,
|
||
imageSize: requestedImageSize,
|
||
model: requestedModel || undefined,
|
||
negativePrompt:
|
||
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
|
||
prompt,
|
||
promptExtend: false,
|
||
referenceImages,
|
||
localEditComposite: {
|
||
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 }
|
||
);
|
||
}
|
||
}
|