import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; import { createHuankeGemini } from "@/lib/ai/providers/huanke-gemini"; import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image"; import { createBflImage } from "@/lib/ai/providers/bfl-image"; import { createRuxaImage } from "@/lib/ai/providers/ruxa-image"; import { createFalImage } from "@/lib/ai/providers/fal-image"; import { createOpenAIImage } from "@/lib/ai/providers/openai-image"; import { createStepfunImage } from "@/lib/ai/providers/stepfun-image"; import { createMidjourney } from "@/lib/ai/providers/midjourney"; import { createRunway } from "@/lib/ai/providers/runway"; import { createKrea } from "@/lib/ai/providers/krea"; import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision"; import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification"; 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, "至少需要一条文字修改"), }); const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([ "gemini", "grsai-gpt-image", "huanke-gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "midjourney", "runway", "krea", "volcano-vision", ]); function pickTextEditModel(models: Awaited>) { const candidates = models .filter( (item) => isImageGenerationModelDescriptor({ providerType: item.provider.type, modelId: item.model.modelId, capabilities: item.model.capabilities, supportsImageGen: item.model.supportsImageGen, }) && item.model.isEnabled && item.provider.isEnabled && SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type), ) .sort((a, b) => { const aScore = (a.model.isDefaultTextEditModel ? 100 : 0) + (a.model.isDefault ? 10 : 0) + (a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (a.model.displayName?.includes("火山") ? 3 : 0); const bScore = (b.model.isDefaultTextEditModel ? 100 : 0) + (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 createTextEditProvider( provider: Awaited>[number]["provider"], ) { const baseURL = provider.baseUrl; const apiKey = provider.apiKey; switch (provider.type) { case "gemini": case "grsai-gpt-image": return createGeminiImage({ baseURL, apiKey }); case "huanke-gemini": return createHuankeGemini({ baseURL, apiKey }); case "alibaba-image": return createAlibabaImage({ baseURL, apiKey }); case "bfl-image": return createBflImage({ baseURL, apiKey }); case "ruxa-image": return createRuxaImage({ baseURL, apiKey }); case "fal-image": return createFalImage({ baseURL, apiKey }); case "openai-image": return createOpenAIImage({ baseURL, apiKey }); case "stepfun-image": return createStepfunImage({ baseURL, apiKey }); case "midjourney": return createMidjourney({ baseURL, apiKey }); case "runway": return createRunway({ baseURL, apiKey }); case "krea": return createKrea({ baseURL, apiKey }); case "volcano-vision": return createVolcanoVision({ baseURL, apiKey }); default: throw new Error(`当前文字编辑暂不支持服务商类型:${provider.type}`); } } 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 = pickTextEditModel(models); if (!selected) { return NextResponse.json( { success: false, error: "未配置可用的文字编辑生图模型,请在系统设置-AI 模型中启用一个支持图片生成的模型并设为默认文字编辑生图模型", }, { status: 500 }, ); } console.info("[Canvas Text Edit] selected model:", { providerType: selected.provider.type, providerName: selected.provider.name, modelId: selected.model.modelId, displayName: selected.model.displayName, isDefault: selected.model.isDefault, isDefaultTextEditModel: selected.model.isDefaultTextEditModel, }); const provider = createTextEditProvider(selected.provider); 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 }, ); } }