diff --git a/app/api/canvas/text-detect/route.ts b/app/api/canvas/text-detect/route.ts new file mode 100644 index 0000000..2e686e6 --- /dev/null +++ b/app/api/canvas/text-detect/route.ts @@ -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>) { + 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; + 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 }, + ); + } +} diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts new file mode 100644 index 0000000..7b35297 --- /dev/null +++ b/app/api/canvas/text-edit/route.ts @@ -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>) { + 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 }, + ); + } +} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 5080089..8f18187 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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( + () => normalizeTextEditItems(data.textEditItems), + ); + const [textEditError, setTextEditError] = useState(null); + const [isDetectingText, setIsDetectingText] = useState(false); + const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); // 文件输入的 ref const fileInputRef = useRef(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) => { @@ -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) { 详情替换 + handleAction('text-edit')}> + + 编辑文字 + @@ -995,6 +1260,22 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { 场景融合 + + {/* 文字编辑 */} + + + + + 编辑文字 + {/* 角度编辑 */} @@ -1095,6 +1376,96 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { , document.body )} + + {/* 文字编辑面板(显示在图片节点旁边) */} + {isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal( +
e.stopPropagation()} + > +
+
+ + 编辑文字 +
+ +
+ +
+

+ 自动调用 Qwen3VL 识别图片文字。确认后将调用火山生图模型替换文字,并尽可能保持原字体形状。 +

+ + {isDetectingText && ( +
+ + 正在识别图片文字... +
+ )} + + {textEditError && !isDetectingText && ( +
+ {textEditError} +
+ )} + + {!isDetectingText && textEditItems.length === 0 && !textEditError && ( +
+ 未识别到可编辑文字,可点击“重新识别”再试一次。 +
+ )} + + {textEditItems.map((item, index) => ( +
+
+ 文本 {index + 1} + {typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`} +
+
+ 原文:{item.originalText} +
+ handleTextItemChange(item.id, e.target.value)} + placeholder="输入替换后的文字" + /> +
+ ))} +
+ +
+ + +
+
, + document.body + )} {/* 图片标注器 */} {annotatorOpen && data.src && ( diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 1179056..9005862 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -8,6 +8,18 @@ interface ApiResponse { 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> { + 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> { + 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: '文字替换失败' }; + } + }, }; // 视频生成服务 diff --git a/types/canvas/index.ts b/types/canvas/index.ts index 03448b3..6e35d25 100644 --- a/types/canvas/index.ts +++ b/types/canvas/index.ts @@ -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; resultUrl?: string;