680 lines
26 KiB
TypeScript
680 lines
26 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 { 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;
|
||
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;
|
||
};
|
||
pixelRect: {
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
};
|
||
|
||
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_PERCENT_OPTIONS = [-30, -20, -10, 10, 20, 30] as const;
|
||
type ScaleAdjustPercent = (typeof SCALE_ADJUST_PERCENT_OPTIONS)[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 ((SCALE_ADJUST_PERCENT_OPTIONS as readonly number[]).includes(value)) {
|
||
return value as ScaleAdjustPercent;
|
||
}
|
||
throw new Error("请选择有效的比例调整幅度");
|
||
}
|
||
|
||
function getScaleAdjustLabel(scalePercent: ScaleAdjustPercent) {
|
||
return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`;
|
||
}
|
||
|
||
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 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 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);
|
||
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,
|
||
pixelRect,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildAnnotationSvg(params: {
|
||
width: number;
|
||
height: number;
|
||
regions: NormalizedLocalEditRegion[];
|
||
}) {
|
||
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 = `区域${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}` : "",
|
||
"",
|
||
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
|
||
"未被列为编辑区域的画面必须逐像素保持原图不变,不允许重绘、改色、补光、换材质、移动物体、改变清晰度或改变背景细节。",
|
||
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
|
||
"如果某个区域提示词与原图空间冲突,以自然融入原图为最高优先级。",
|
||
].join("\n");
|
||
}
|
||
|
||
function buildScaleAdjustPrompt(
|
||
regions: NormalizedLocalEditRegion[],
|
||
scalePercent: ScaleAdjustPercent
|
||
) {
|
||
const scaleFactor = Math.round((1 + scalePercent / 100) * 100) / 100;
|
||
const actionLabel = getScaleAdjustLabel(scalePercent);
|
||
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;
|
||
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):该矩形只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标视觉尺寸为原主体的 ${Math.round(scaleFactor * 100)}%。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
|
||
})
|
||
.join("\n");
|
||
|
||
return [
|
||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
||
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。",
|
||
"最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
|
||
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
|
||
"",
|
||
`任务:对用户框选的主体物体做“比例调整”,统一执行${actionLabel}。`,
|
||
regionLines,
|
||
"",
|
||
"关键要求:",
|
||
"1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
|
||
"2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
|
||
"3. 缩小时,目标主体应围绕原位置视觉中心自然变小;主体缩小后露出的区域必须根据周围真实背景、阴影、遮挡、纹理和透视进行无缝补全。",
|
||
"4. 放大时,目标主体应围绕原位置视觉中心自然变大;边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。",
|
||
"5. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
|
||
"6. 未受比例调整影响的其它画面区域必须尽量保持原图不变,不允许整体重绘、改色、补光、移动物体或改变背景细节。",
|
||
"7. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 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) {
|
||
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 && scalePercent !== null
|
||
? `比例调整:${getScaleAdjustLabel(scalePercent)}`
|
||
: undefined,
|
||
requirePrompt: operation !== SCALE_ADJUST_OPERATION,
|
||
});
|
||
} catch (validationError) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error:
|
||
validationError instanceof Error
|
||
? validationError.message
|
||
: "局部编辑区域参数无效",
|
||
},
|
||
{ 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 });
|
||
}
|
||
|
||
const annotationSvg = buildAnnotationSvg({
|
||
height: sourceHeight,
|
||
regions,
|
||
width: sourceWidth,
|
||
});
|
||
const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
||
.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 prompt =
|
||
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
|
||
? buildScaleAdjustPrompt(regions, scalePercent)
|
||
: buildLocalEditPrompt(regions);
|
||
const isScaleAdjustOperation =
|
||
operation === SCALE_ADJUST_OPERATION && scalePercent !== null;
|
||
const regionReferenceImageUrls = regions.flatMap((region) =>
|
||
region.referenceImages.map((reference) => reference.url)
|
||
);
|
||
const resolvedAspectRatio = resolveLocalEditAspectRatio(
|
||
body.aspectRatio,
|
||
sourceWidth,
|
||
sourceHeight
|
||
);
|
||
|
||
const imageRequest: CanvasGenerateImageRequest = {
|
||
aspectRatio: resolvedAspectRatio,
|
||
billingFeature: "repaint",
|
||
debugLabel:
|
||
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
|
||
debugMeta: {
|
||
annotatedReferenceUrl: annotatedUpload.url,
|
||
annotatedReferenceSentToModel: false,
|
||
operation,
|
||
resolvedAspectRatio,
|
||
regionReferenceImageCount: regionReferenceImageUrls.length,
|
||
regionCount: regions.length,
|
||
scalePercent,
|
||
scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation,
|
||
sourceHeight,
|
||
sourceNodeId: body.sourceNodeId || null,
|
||
sourceWidth,
|
||
},
|
||
imageCount: 1,
|
||
imageSize:
|
||
typeof body.imageSize === "string" && body.imageSize.trim()
|
||
? body.imageSize.trim()
|
||
: "2K",
|
||
model:
|
||
typeof body.model === "string" && body.model.trim()
|
||
? body.model.trim()
|
||
: undefined,
|
||
negativePrompt:
|
||
isScaleAdjustOperation
|
||
? "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、主体未缩放、整块框选区域缩放"
|
||
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
||
prompt,
|
||
promptExtend: false,
|
||
referenceImages: [
|
||
sourceImageUrl,
|
||
...regionReferenceImageUrls,
|
||
],
|
||
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,
|
||
prompt,
|
||
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 }
|
||
);
|
||
}
|
||
}
|