204 lines
6.3 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|