From 5b0d75a7387e8cc609953b816d7365392b823588 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 16 Sep 2026 18:56:03 +0800 Subject: [PATCH] fix(video-canvas): persist edits and record live provider regression results --- .../canvas/LiblibVideoCanvasReplica.tsx | 145 ++++++++++-------- docs/video-canvas-parity-2026-09-16.md | 4 +- docs/video-canvas-real-test-2026-09-16.md | 109 +++++++++++++ hooks/use-video-canvas-persistence.ts | 81 ++++++++++ lib/ai/providers/vip98k.ts | 10 +- lib/video-canvas/save-queue.ts | 60 ++++++++ playwright.helpers.config.ts | 1 + tests/routes/video-canvas-save-queue.test.ts | 59 +++++++ 8 files changed, 403 insertions(+), 66 deletions(-) create mode 100644 docs/video-canvas-real-test-2026-09-16.md create mode 100644 hooks/use-video-canvas-persistence.ts create mode 100644 lib/video-canvas/save-queue.ts create mode 100644 tests/routes/video-canvas-save-queue.test.ts diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 00ba13273..be90f96e3 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -92,6 +92,7 @@ import { videoGenerationService, workflowAnalysisService, } from "@/services/canvas"; +import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence"; import type { CanvasInitialProject } from "@/types/canvas"; import { getVideoCanvasFunctionBillingCopy, @@ -4780,7 +4781,9 @@ export function LiblibVideoCanvasReplica({ const [historyListDownloadPendingId, setHistoryListDownloadPendingId] = useState(null); const [historyWatermarkRulesOpen, setHistoryWatermarkRulesOpen] = useState(false); const [isProjectLoading, setIsProjectLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); + const initialProjectRef = useRef(initialProject); + initialProjectRef.current = initialProject; + const [loadedProject, setLoadedProject] = useState<{ id: string; revision: string } | null>(null); const [, setShareEnabled] = useState(false); const [publishPublic, setPublishPublic] = useState(false); const [canvasCommand, setCanvasCommand] = useState(null); @@ -4878,88 +4881,72 @@ export function LiblibVideoCanvasReplica({ useEffect(() => { let active = true; - const hydrateProject = (project: { - canvasData?: unknown; - title?: string | null; - }) => { + const hydrateProject = (project: { canvasData?: unknown; title?: string | null }) => { const title = normalizeCanvasTitle(project.title); + const restored = restoreNodes(project.canvasData); + const data = project.canvasData as { viewport?: typeof DEFAULT_VIEWPORT; selectedNodeIds?: string[] } | undefined; setProjectTitle(title); setDraftTitle(title); - setNodes(restoreNodes(project.canvasData)); + setNodes(restored); setEdges(restoreEdges(project.canvasData)); + const storedViewport = data?.viewport; + setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite) + ? { x: storedViewport.x, y: storedViewport.y, zoom: clampCanvasZoom(storedViewport.zoom) } + : DEFAULT_VIEWPORT); + setSelectedNodeId(restored.find(node => data?.selectedNodeIds?.includes(node.id))?.id || null); }; async function loadProject() { setIsProjectLoading(true); - const hasInitialProject = initialProject?.id === canvasId; + setLoadedProject(null); try { - if (hasInitialProject) { - hydrateProject(initialProject); - } - - const result = await canvasService.getProject(canvasId); + if (initialProjectRef.current?.id === canvasId) hydrateProject(initialProjectRef.current); + const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}`); + let result = await response.json(); if (!active) return; - if (result.success && result.data) { - hydrateProject(result.data); - } else if (!hasInitialProject) { - setProjectTitle("未命名"); - setDraftTitle("未命名"); - const seedResult = await canvasService.saveCanvasState( - canvasId, - { - nodes: [], - edges: [], - selectedNodeIds: [], - viewport: DEFAULT_VIEWPORT, - tool: "select", - }, - "video" - ); - if (!seedResult.success) { - console.warn("Failed to seed blank video canvas", seedResult.error); - } + if (response.status === 404) { + // Only a confirmed missing project may be seeded; a failed load must never overwrite it. + const created = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "未命名", workspaceType: "video", canvasData: { + nodes: [], edges: [], selectedNodeIds: [], viewport: DEFAULT_VIEWPORT, tool: "select", + } }), + }); + result = await created.json(); + if (!created.ok) throw new Error(result.error || "创建画布失败"); + } else if (!response.ok) { + throw new Error(result.error || "加载画布失败"); } + if (!active) return; + if (!result.success || !result.data?.updatedAt) throw new Error(result.error || "加载画布失败"); + hydrateProject(result.data); + setLoadedProject({ id: canvasId, revision: String(result.data.updatedAt) }); } catch (error) { - console.warn("Failed to load video canvas project", error); + if (active) toast.error(error instanceof Error ? error.message : "加载画布失败,请刷新重试"); } finally { - setIsProjectLoading(false); + if (active) setIsProjectLoading(false); } } - void loadProject(); - return () => { - active = false; - }; - }, [canvasId, initialProject]); + return () => { active = false; }; + }, [canvasId]); - const saveCanvas = useCallback(async () => { - setIsSaving(true); - const result = await canvasService.saveCanvasState( - canvasId, - { - nodes: serializeNodes(nodes) as any, - edges: serializeEdges(edges) as any, - selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], - viewport, - tool: "select", - }, - "video" - ); - if (result.success) { - toast.success("已保存"); - } else { - toast.error(result.error || "保存失败"); - } - setIsSaving(false); - }, [canvasId, edges, nodes, selectedNodeId, viewport]); + const canvasSnapshot = useMemo(() => JSON.stringify({ + title: projectTitle, + canvasData: { + nodes: serializeNodes(nodes), edges: serializeEdges(edges), + selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], viewport, tool: "select", + }, + }), [nodes, edges, selectedNodeId, viewport, projectTitle]); + const persistence = useVideoCanvasPersistence(canvasId, loadedProject, canvasSnapshot); - const commitTitle = useCallback(async () => { + const commitTitle = useCallback(() => { const nextTitle = draftTitle.trim() || "未命名"; setProjectTitle(nextTitle); setDraftTitle(nextTitle); setIsTitleEditing(false); - await canvasService.updateProject(canvasId, { title: nextTitle }, "video"); - }, [canvasId, draftTitle]); + }, [draftTitle]); const addNodes = useCallback((nextNodes: CloneNode[], nextEdges: CloneEdge[]) => { setNodes((current) => [...current, ...nextNodes]); @@ -10579,7 +10566,7 @@ export function LiblibVideoCanvasReplica({ return (
{ + const targetId = selectedNode.id; + setNodes(current => current.map(node => node.id === targetId ? { + ...node, + draftValues: { ...node.draftValues, content: prompt ? [prompt] : [], + params: { ...getNodeParams(node), prompt, model } }, + } : node)); + }} onSubmit={(prompt, model) => { setNodes((current) => current.map((node) => @@ -11141,6 +11136,9 @@ export function LiblibVideoCanvasReplica({ ) : null} toggleTopPanel("account")} @@ -11524,6 +11522,9 @@ export function LiblibVideoCanvasReplica({ } function TopBar({ + saveStatus, + saveMessage, + onSave, draftTitle, isTitleEditing, onAccount, @@ -11536,6 +11537,9 @@ function TopBar({ siteLogoDark, siteName, }: { + saveStatus: "saved" | "pending" | "saving" | "error" | "unavailable"; + saveMessage?: string; + onSave: () => void; draftTitle: string; isTitleEditing: boolean; onAccount: () => void; @@ -11585,6 +11589,15 @@ function TopBar({
+