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; }; type LocalEditRequest = { sourceImageUrl?: string; sourceNodeId?: string; regions?: LocalEditRegionInput[]; model?: string; imageSize?: string; aspectRatio?: string; }; type NormalizedLocalEditRegion = { id: string; index: number; color: string; colorName: string; prompt: string; 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 MIN_REGION_EDGE_PX = 24; const DEFAULT_REGION_COLORS = [ "#ff3b30", "#1677ff", "#00c853", "#ff9500", "#af52de", "#00bcd4", "#ff2d55", "#8d6e63", ]; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } 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, "'"); } 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 getColorName(hex: string) { const normalized = hex.toLowerCase(); const names: Record = { "#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>, 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 ): 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(); if (!prompt) { 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: prompt.slice(0, 300), 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) => `${escapeXml(line)}` ) .join(""); return ` ${region.index} ${textLines} `; }) .join(""); return Buffer.from(` ${regionSvg} `); } function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) { const regionLines = regions .map((region) => `区域 ${region.index}(${region.colorName},编号 ${region.index}):${region.prompt}`) .join("\n"); return [ "输入图片1是一张带彩色标注框和框下提示词的图片编辑参考图;输入图片2是同一张原始干净图,用于保持画面细节与未编辑区域。", "彩色框、编号、提示词气泡只是编辑指令,不是最终画面内容。", "", "请严格根据每个区域下方的提示词,只在对应彩色框附近完成局部编辑:", regionLines, "", "最终输出必须去除所有彩色框、编号、提示词气泡和标注文字。", "未被彩色框标注的区域必须尽量保持原图不变。", "新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。", "如果某个区域提示词与原图空间冲突,以自然融入原图为最高优先级。", ].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 }); } let regions: NormalizedLocalEditRegion[]; try { regions = normalizeRegions(body.regions, sourceWidth, sourceHeight); } 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: "image_repaint", modelId: "canvas-local-edit", note: "局部编辑消耗积分", 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 = buildLocalEditPrompt(regions); const imageRequest: CanvasGenerateImageRequest = { aspectRatio: typeof body.aspectRatio === "string" && body.aspectRatio.trim() ? body.aspectRatio.trim() : "auto", billingFeature: "repaint", debugLabel: "canvas-local-edit", debugMeta: { annotatedReferenceUrl: annotatedUpload.url, regionCount: regions.length, sourceNodeId: body.sourceNodeId || null, }, 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, prompt, promptExtend: false, referenceImages: [annotatedUpload.url, sourceImageUrl], ownerKey: session.user.id, }; const task = await createCanvasImageTask({ request: imageRequest as unknown as Record, 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 local edit points:", pointsError); } } return NextResponse.json({ success: true, annotatedReferenceUrl: annotatedUpload.url, prompt, regions, 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 } ); } }