import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision"; export const runtime = "nodejs"; export const maxDuration = 120; const textItemSchema = z.object({ id: z.string().min(1), originalText: z.string().min(1), editedText: z.string().optional(), action: z.enum(["replace", "delete"]).optional(), }); const requestSchema = z.object({ imageUrl: z.string().min(1, "图片地址不能为空"), items: z.array(textItemSchema).min(1, "至少需要一条文字修改"), }); function pickVolcanoModel(models: Awaited>) { const candidates = models .filter((item) => item.model.supportsImageGen && item.provider.type === "volcano-vision") .sort((a, b) => { const aScore = (a.model.isDefault ? 10 : 0) + (a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (a.model.displayName?.includes("火山") ? 3 : 0); const bScore = (b.model.isDefault ? 10 : 0) + (b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (b.model.displayName?.includes("火山") ? 3 : 0); return bScore - aScore; }); return candidates[0]; } function parseImageFromMarkdown(markdown: string): { originalUrl: string; thumbnailUrl: string } { const strictMatch = markdown.match(/!\[thumb\|\|\|([^|]+)\|\|\|[^\]]*\]\(([^)]+)\)/); if (strictMatch) { return { originalUrl: strictMatch[1], thumbnailUrl: strictMatch[2], }; } const simpleMatch = markdown.match(/!\[[^\]]*\]\(([^)]+)\)/); if (simpleMatch) { return { originalUrl: simpleMatch[1], thumbnailUrl: simpleMatch[1], }; } throw new Error("未解析到生成图片"); } function buildTextEditPrompt( replacements: Array<{ originalText: string; editedText: string }>, deletions: Array<{ originalText: string }>, ) { const sections: string[] = [ "请基于输入图片执行精确文字替换。", "硬性要求:", "1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。", "2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。", "3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。", "4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。", ]; if (replacements.length > 0) { const replacementLines = replacements .map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`) .join("\n"); sections.push("替换清单:", replacementLines); } if (deletions.length > 0) { const deletionLines = deletions .map((item, index) => `${index + 1}. 删除文字: ${item.originalText}`) .join("\n"); sections.push( "删除清单:", deletionLines, "删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹。", ); } sections.push("请返回一张最终完成的图片。"); return sections.join("\n"); } export async function POST(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const json = await request.json(); const parsed = requestSchema.safeParse(json); if (!parsed.success) { return NextResponse.json({ success: false, error: parsed.error.errors[0]?.message || "参数错误" }, { status: 400 }); } const imageUrl = parsed.data.imageUrl.trim(); if (!/^https?:\/\//i.test(imageUrl)) { return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 }); } const normalizedItems = parsed.data.items .map((item) => ({ id: item.id, originalText: item.originalText.trim(), editedText: (item.editedText || "").trim(), action: item.action, })); const replacements = normalizedItems .filter( (item) => item.originalText && item.editedText && item.action !== "delete" && item.originalText !== item.editedText, ) .map((item) => ({ id: item.id, originalText: item.originalText, editedText: item.editedText, })); const deletions = normalizedItems .filter( (item) => item.originalText && (item.action === "delete" || item.editedText.length === 0), ) .map((item) => ({ id: item.id, originalText: item.originalText, })); if (replacements.length === 0 && deletions.length === 0) { return NextResponse.json({ success: false, error: "未检测到有效的文字改动或删除" }, { status: 400 }); } const models = await getEnabledAiModelsWithProvider(); const selected = pickVolcanoModel(models); if (!selected) { return NextResponse.json({ success: false, error: "未配置可用的火山生图模型" }, { status: 500 }); } const provider = createVolcanoVision({ baseURL: selected.provider.baseUrl, apiKey: selected.provider.apiKey, }); const prompt = buildTextEditPrompt(replacements, deletions); const result = await provider.chat(selected.model.modelId).doGenerate({ messages: [ { role: "user", content: [ { type: "image_url", image_url: { url: imageUrl } }, { type: "text", text: prompt }, ], }, ], imageParams: { imageCount: 1, }, }); if (!result?.text) { throw new Error("模型未返回结果"); } const { originalUrl, thumbnailUrl } = parseImageFromMarkdown(result.text); return NextResponse.json({ success: true, data: { originalUrl, thumbnailUrl, model: selected.model.displayName, }, }); } catch (error) { console.error("[Canvas Text Edit] Error:", error); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "文字替换失败", }, { status: 500 }, ); } }