Your Name 18a3495d2c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
无限画布新增图片文字编辑工作流
2026-02-07 21:48:49 +08:00

161 lines
5.2 KiB
TypeScript

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().min(1),
});
const requestSchema = z.object({
imageUrl: z.string().min(1, "图片地址不能为空"),
items: z.array(textItemSchema).min(1, "至少需要一条文字修改"),
});
function pickVolcanoModel(models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>) {
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 }>) {
const replacementLines = replacements
.map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`)
.join("\n");
return [
"请基于输入图片执行精确文字替换。",
"硬性要求:",
"1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。",
"2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。",
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
"替换清单:",
replacementLines,
"请返回一张最终完成的图片。",
].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 replacements = parsed.data.items
.map((item) => ({
id: item.id,
originalText: item.originalText.trim(),
editedText: item.editedText.trim(),
}))
.filter((item) => item.originalText && item.editedText && item.originalText !== item.editedText);
if (replacements.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);
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 },
);
}
}