无限画布新增图片文字编辑工作流

This commit is contained in:
Your Name 2026-02-07 21:48:49 +08:00
parent ab95548b2c
commit 18a3495d2c
5 changed files with 781 additions and 3 deletions

View File

@ -0,0 +1,189 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { generateText } from "ai";
import { auth } from "@/app/(auth)/auth";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
export const runtime = "nodejs";
export const maxDuration = 60;
const requestSchema = z.object({
imageUrl: z.string().min(1, "图片地址不能为空"),
});
interface OcrItem {
id: string;
text: string;
confidence?: number;
}
function pickVisionModel(models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>) {
const normalized = models
.filter((item) => item.model.supportsVision)
.sort((a, b) => {
const aScore =
(a.model.modelId?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
(a.model.displayName?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
(a.provider.type === "alibaba" ? 20 : 0) +
(a.model.isDefault ? 5 : 0);
const bScore =
(b.model.modelId?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
(b.model.displayName?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
(b.provider.type === "alibaba" ? 20 : 0) +
(b.model.isDefault ? 5 : 0);
return bScore - aScore;
});
return normalized[0];
}
function parseJsonText(raw: string): any | null {
if (!raw) return null;
const trimmed = raw.trim();
try {
return JSON.parse(trimmed);
} catch {
// continue
}
const markdownJsonMatch = trimmed.match(/```json\s*([\s\S]*?)\s*```/i);
if (markdownJsonMatch?.[1]) {
try {
return JSON.parse(markdownJsonMatch[1]);
} catch {
// continue
}
}
const objectMatch = trimmed.match(/\{[\s\S]*\}/);
if (objectMatch?.[0]) {
try {
return JSON.parse(objectMatch[0]);
} catch {
// continue
}
}
return null;
}
function sanitizeItems(items: unknown[]): OcrItem[] {
const result: OcrItem[] = [];
for (let index = 0; index < items.length; index += 1) {
const item = items[index] as Record<string, unknown>;
const rawText = typeof item?.text === "string" ? item.text : "";
const text = rawText.replace(/\s+/g, " ").trim();
if (!text) continue;
const confidenceValue =
typeof item?.confidence === "number" && Number.isFinite(item.confidence)
? Math.min(Math.max(item.confidence, 0), 1)
: undefined;
result.push({
id: String(item?.id || index + 1),
text,
confidence: confidenceValue,
});
}
return result.slice(0, 50);
}
function fallbackItemsFromRaw(raw: string): OcrItem[] {
const lines = raw
.split("\n")
.map((line) => line.replace(/^[-*\d.\s]+/, "").trim())
.filter(Boolean)
.slice(0, 20);
return lines.map((text, index) => ({
id: String(index + 1),
text,
}));
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = await request.json();
const parsed = requestSchema.safeParse(body);
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 models = await getEnabledAiModelsWithProvider();
const selected = pickVisionModel(models);
if (!selected) {
return NextResponse.json({ success: false, error: "未配置可用的视觉模型" }, { status: 500 });
}
const model = await getLanguageModelById(selected.model.id);
if (!model) {
return NextResponse.json({ success: false, error: "视觉模型加载失败" }, { status: 500 });
}
const prompt = [
"你是图片文字识别助手。",
"请识别图片中可见文字并按阅读顺序输出 JSON。",
"只返回 JSON,不要额外说明。",
"格式:{\"items\":[{\"id\":\"1\",\"text\":\"示例\",\"confidence\":0.95}]}",
"如果没有识别到文字,返回 {\"items\":[]}。",
].join("\n");
const { text } = await generateText({
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image", image: imageUrl },
],
},
],
temperature: 0.1,
});
const parsedJson = parseJsonText(text || "");
let items: OcrItem[] = [];
if (parsedJson && Array.isArray(parsedJson.items)) {
items = sanitizeItems(parsedJson.items);
}
if (items.length === 0 && text) {
items = fallbackItemsFromRaw(text);
}
return NextResponse.json({
success: true,
data: {
items,
model: selected.model.displayName,
},
});
} catch (error) {
console.error("[Canvas Text Detect] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "文字识别失败",
},
{ status: 500 },
);
}
}

View File

@ -0,0 +1,160 @@
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 },
);
}
}

View File

@ -2,7 +2,7 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X } from "lucide-react";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -24,7 +24,8 @@ import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/comp
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { imageGenerationService } from "@/services/canvas";
import type { CanvasNode, ImageNodeData } from "@/types/canvas";
import type { CanvasTextEditPayloadItem } from "@/services/canvas";
import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas";
import { cn } from "@/lib/utils";
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
@ -39,6 +40,26 @@ interface PickedMarker {
index: number;
}
const normalizeTextEditItems = (items: ImageTextEditItem[] | undefined): ImageTextEditItem[] => {
if (!items || items.length === 0) return [];
return items
.map((item, index) => {
const originalText = (item.originalText || "").trim();
if (!originalText) return null;
return {
id: item.id || String(index + 1),
originalText,
editedText: (item.editedText || item.originalText || "").trim(),
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
};
const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url);
/**
* 在图片上绘制标记点,返回带标记的图片 base64
*/
@ -139,6 +160,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
const [textEditItems, setTextEditItems] = useState<ImageTextEditItem[]>(
() => normalizeTextEditItems(data.textEditItems),
);
const [textEditError, setTextEditError] = useState<string | null>(null);
const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null);
@ -170,6 +198,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}
}, [isThisNodePicking]);
useEffect(() => {
if (!textEditPanelOpen) {
setTextEditItems(normalizeTextEditItems(data.textEditItems));
setTextEditError(null);
}
}, [data.textEditItems, textEditPanelOpen]);
useEffect(() => {
if (!isSelected) {
setTextEditPanelOpen(false);
}
}, [isSelected]);
// 🆕 处理图片点击标注
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
if (!isThisNodePicking || isPickerLoading || !data.src) return;
@ -253,6 +294,221 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
return { top: 0, left: 0 };
};
const getTextPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 360;
const spacing = 12;
const maxTop = Math.max(12, window.innerHeight - 460);
let left = rect.right + spacing;
if (left + panelWidth > window.innerWidth - 12) {
left = Math.max(12, rect.left - panelWidth - spacing);
}
const top = Math.min(Math.max(12, rect.top), maxTop);
return { top, left };
};
const detectTextItems = useCallback(
async (forceRefresh: boolean = false) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isDetectingText) {
return;
}
if (!forceRefresh && data.textEditSource === data.src) {
const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0) {
setTextEditItems(cached);
setTextEditError(null);
return;
}
}
setIsDetectingText(true);
setTextEditError(null);
try {
const result = await imageGenerationService.detectCanvasText(data.src);
if (!result.success) {
throw new Error(result.error || "文字识别失败");
}
const detectedItems: ImageTextEditItem[] = (result.data?.items || [])
.map((item, index) => {
const text = (item.text || "").trim();
if (!text) return null;
return {
id: item.id || String(index + 1),
originalText: text,
editedText: text,
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
setTextEditItems(detectedItems);
updateNode(node.id, {
data: {
...data,
textEditItems: detectedItems,
textEditSource: data.src,
textEditUpdatedAt: new Date().toISOString(),
},
});
if (detectedItems.length === 0) {
toast.info("未识别到可编辑文字");
} else {
toast.success(`已识别 ${detectedItems.length} 段文字`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "文字识别失败";
setTextEditError(message);
toast.error(message);
} finally {
setIsDetectingText(false);
}
},
[data, isDetectingText, node.id, updateNode],
);
const handleOpenTextEdit = useCallback(async () => {
setTextEditPanelOpen(true);
setTextEditError(null);
const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0 && data.textEditSource === data.src) {
setTextEditItems(cached);
return;
}
await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
const handleTextItemChange = (id: string, value: string) => {
setTextEditItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, editedText: value } : item)),
);
};
const handleApplyTextEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
const changedItems: CanvasTextEditPayloadItem[] = textEditItems
.map((item) => ({
id: item.id,
originalText: item.originalText.trim(),
editedText: item.editedText.trim(),
}))
.filter(
(item) =>
item.originalText.length > 0 &&
item.editedText.length > 0 &&
item.originalText !== item.editedText,
);
if (changedItems.length === 0) {
toast.info("请先修改至少一段文字");
return;
}
if (isApplyingTextEdit) {
return;
}
setIsApplyingTextEdit(true);
setIsProcessing(true);
setTextEditError(null);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: "文字替换处理中...",
originalSrc: data.src,
},
connections: [node.id],
});
try {
const result = await imageGenerationService.editCanvasText({
imageUrl: data.src,
items: changedItems,
});
if (!result.success || !result.data?.originalUrl) {
throw new Error(result.error || "文字替换失败");
}
updateNode(newNodeId, {
data: {
src: result.data.originalUrl,
status: "completed",
prompt: "文字替换结果",
originalSrc: data.src,
model: result.data.model,
},
});
updateNode(node.id, {
data: {
...data,
textEditItems,
textEditSource: data.src,
textEditUpdatedAt: new Date().toISOString(),
},
});
setTextEditPanelOpen(false);
toast.success("文字替换已完成");
} catch (error) {
const message = error instanceof Error ? error.message : "文字替换失败";
setTextEditError(message);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "文字替换失败",
originalSrc: data.src,
},
});
toast.error(message);
} finally {
setIsApplyingTextEdit(false);
setIsProcessing(false);
}
}, [addNode, data, isApplyingTextEdit, node.id, node.position.x, node.position.y, node.size.height, node.size.width, textEditItems, updateNode]);
// 触发文件选择
const handleSelectFile = (e: React.MouseEvent) => {
e.preventDefault();
@ -327,6 +583,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
'angle': '角度编辑',
'scene-fusion': '详情替换',
'annotate': '图片标注',
'text-edit': '编辑文字',
};
return names[action] || action;
};
@ -359,6 +616,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
setAnnotatorOpen(true);
return;
}
if (action === 'text-edit') {
await handleOpenTextEdit();
return;
}
const actionName = getActionName(action);
@ -784,6 +1045,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Layers className="h-4 w-4 mr-2" />
详情替换
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('text-edit')}>
<Type className="h-4 w-4 mr-2" />
编辑文字
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
@ -996,6 +1261,22 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<TooltipContent side="bottom" className="text-xs">场景融合</TooltipContent>
</Tooltip>
{/* 文字编辑 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('text-edit')}
>
<Type className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">编辑文字</TooltipContent>
</Tooltip>
{/* 角度编辑 */}
<Tooltip>
<TooltipTrigger asChild>
@ -1096,6 +1377,96 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
document.body
)}
{/* 文字编辑面板(显示在图片节点旁边) */}
{isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] w-[360px] rounded-xl border border-border bg-background/95 backdrop-blur shadow-2xl"
style={{
top: getTextPanelPosition().top,
left: getTextPanelPosition().left,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
<div className="flex items-center gap-2">
<Type className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">编辑文字</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setTextEditPanelOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="px-4 py-3 space-y-3 max-h-[360px] overflow-y-auto">
<p className="text-xs text-muted-foreground">
自动调用 Qwen3VL 识别图片文字。确认后将调用火山生图模型替换文字,并尽可能保持原字体形状。
</p>
{isDetectingText && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
正在识别图片文字...
</div>
)}
{textEditError && !isDetectingText && (
<div className="rounded-md bg-destructive/10 text-destructive text-xs px-3 py-2">
{textEditError}
</div>
)}
{!isDetectingText && textEditItems.length === 0 && !textEditError && (
<div className="rounded-md bg-muted/50 text-muted-foreground text-xs px-3 py-3">
未识别到可编辑文字,可点击“重新识别”再试一次。
</div>
)}
{textEditItems.map((item, index) => (
<div key={item.id} className="rounded-lg border border-border/70 p-3 space-y-2">
<div className="text-[11px] text-muted-foreground">
文本 {index + 1}
{typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
原文:{item.originalText}
</div>
<Input
value={item.editedText}
onChange={(e) => handleTextItemChange(item.id, e.target.value)}
placeholder="输入替换后的文字"
/>
</div>
))}
</div>
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-border/70">
<Button
variant="outline"
size="sm"
disabled={isDetectingText || isApplyingTextEdit}
onClick={() => detectTextItems(true)}
>
{isDetectingText ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
重新识别
</Button>
<Button
size="sm"
disabled={isDetectingText || isApplyingTextEdit || textEditItems.length === 0}
onClick={handleApplyTextEdit}
>
{isApplyingTextEdit ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}
确认替换
</Button>
</div>
</div>,
document.body
)}
{/* 图片标注器 */}
{annotatorOpen && data.src && (
<ImageAnnotator

View File

@ -8,6 +8,18 @@ interface ApiResponse<T = any> {
error?: string;
}
export interface CanvasTextDetectItem {
id: string;
text: string;
confidence?: number;
}
export interface CanvasTextEditPayloadItem {
id: string;
originalText: string;
editedText: string;
}
// 画布服务
export const canvasService = {
// 获取画布列表
@ -330,6 +342,41 @@ export const imageGenerationService = {
return { success: false, error: '场景裂变失败' };
}
},
// 识别图片中的文字(OCR)
async detectCanvasText(imageUrl: string): Promise<ApiResponse<{ items: CanvasTextDetectItem[]; model?: string }>> {
try {
const response = await fetch('/api/canvas/text-detect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl }),
});
const data = await response.json();
return data;
} catch (error) {
console.error('[Canvas] 文字识别失败:', error);
return { success: false, error: '文字识别失败' };
}
},
// 应用文字替换并生成新图
async editCanvasText(params: {
imageUrl: string;
items: CanvasTextEditPayloadItem[];
}): Promise<ApiResponse<{ thumbnailUrl: string; originalUrl: string; model?: string }>> {
try {
const response = await fetch('/api/canvas/text-edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (error) {
console.error('[Canvas] 文字替换失败:', error);
return { success: false, error: '文字替换失败' };
}
},
};
// 视频生成服务

View File

@ -30,6 +30,14 @@ export interface MessageNodeData {
attachments?: Attachment[];
}
// 图片文字编辑项
export interface ImageTextEditItem {
id: string;
originalText: string;
editedText: string;
confidence?: number;
}
// 图片节点数据
export interface ImageNodeData {
src: string;
@ -41,6 +49,9 @@ export interface ImageNodeData {
originalSrc?: string;
editHistory?: ImageEditAction[];
hasAnnotations?: boolean; // 🆕 标记图片是否有用户标注
textEditItems?: ImageTextEditItem[]; // OCR 识别到的可编辑文字
textEditSource?: string; // OCR 结果对应的图片 URL
textEditUpdatedAt?: string; // OCR 更新时间
}
// 视频节点数据
@ -124,7 +135,7 @@ export interface Attachment {
// 图片编辑操作
export interface ImageEditAction {
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate';
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate' | 'text-edit';
timestamp: Date;
params?: Record<string, any>;
resultUrl?: string;