Your Name 927514ad6b
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
文字编辑支持删除条目并传递到生图
2026-02-07 21:53:37 +08:00

204 lines
6.3 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().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<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 }>,
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 },
);
}
}