无限画布新增图片文字编辑工作流
This commit is contained in:
parent
ab95548b2c
commit
18a3495d2c
189
app/api/canvas/text-detect/route.ts
Normal file
189
app/api/canvas/text-detect/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
160
app/api/canvas/text-edit/route.ts
Normal file
160
app/api/canvas/text-edit/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -169,6 +197,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
setCurrentClick(null);
|
||||
}
|
||||
}, [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>) => {
|
||||
@ -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" />
|
||||
@ -995,6 +1260,22 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
</TooltipTrigger>
|
||||
<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>
|
||||
@ -1095,6 +1376,96 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
</TooltipProvider>,
|
||||
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 && (
|
||||
|
||||
@ -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: '文字替换失败' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 视频生成服务
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user