feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力

This commit is contained in:
ddcat666
2026-10-11 22:19:20 +08:00
parent 6dedc5973c
commit 5f359f2b1d
63 changed files with 3235 additions and 6193 deletions

View File

@@ -1,45 +0,0 @@
import { Button, Modal } from "antd";
import { Check, Star } from "lucide-react";
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
import { producedModelLabel } from "@/lib/canvas/produced-model";
import { canvasThemes } from "@/lib/canvas-theme";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimary, onFocus }: { open: boolean; versions: CanvasNodeData[]; onClose: () => void; onSetPrimary: (nodeId: string) => void; onFocus: (nodeId: string) => void }) {
const theme = canvasThemes[useActiveTheme()];
const config = useEffectiveConfig();
const modalWidth = Math.min(1180, Math.max(440, 112 + versions.length * 340));
return (
<Modal title="版本对比" open={open} footer={null} width={modalWidth} centered onCancel={onClose} styles={{ body: { overflow: "hidden" } }}>
<div className="thin-scrollbar grid max-h-[70vh] grid-flow-col auto-cols-[328px] gap-3 overflow-x-auto pb-2">
{versions.map((node) => {
const producedModelText = producedModelLabel(config, node.metadata?.producedModel);
return (
<article key={node.id} className="overflow-hidden rounded-[var(--r-lg)] border" style={{ borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, background: theme.node.panel, boxShadow: node.metadata?.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : undefined }}>
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-full)] border text-[var(--node-badge-fs)] font-semibold leading-none" style={{ background: node.metadata?.versionPrimary ? theme.accent.primarySoft : theme.toolbar.itemHover, borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, color: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.text }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.accent.primary }}><Check className="size-3" />主版本</span> : null}
</div>
<button type="button" className="block h-52 w-full overflow-hidden" style={{ background: theme.node.fill }} onClick={() => onFocus(node.id)}>
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video ? <CanvasVideoPreviewImage node={node} alt={node.title || "版本视频"} className="size-full object-contain" loading="lazy" decoding="async" fallback={<span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>暂无视频首帧</span>} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
</button>
<div className="space-y-2 p-3 text-[var(--fs-label)]">
{producedModelText ? <Info label="产出模型" value={producedModelText} /> : null}
<Info label="尺寸" value={node.metadata?.size || "默认尺寸"} />
<div><div className="whitespace-nowrap opacity-45">提示词</div><div className="mt-1 line-clamp-5 whitespace-pre-wrap break-words leading-5">{node.metadata?.composerContent || node.metadata?.prompt || "未填写"}</div></div>
<Button block size="small" className="whitespace-nowrap" type={node.metadata?.versionPrimary ? "default" : "primary"} disabled={node.metadata?.versionPrimary} icon={<Star className="size-3.5" />} onClick={() => onSetPrimary(node.id)}>{node.metadata?.versionPrimary ? "当前主版本" : "设为主版本"}</Button>
</div>
</article>
);
})}
</div>
</Modal>
);
}
function Info({ label, value }: { label: string; value: string }) {
return <div className="flex min-w-0 items-center gap-3"><span className="w-16 shrink-0 whitespace-nowrap opacity-45">{label}</span><span className="min-w-0 flex-1 truncate text-right font-medium" title={value}>{value}</span></div>;
}

View File

@@ -24,7 +24,7 @@ import { type PrevisObject, type PrevisLight, type PrevisShot, type PrevisHumano
import { resolvePrevisPlacementAnchor, resolvePrevisPlacement, type PrevisGroundPoint } from "@/lib/canvas/previs/previs-placement";
import { uploadMediaFile } from "@/services/file-storage";
import { saveRemoteUserDataNow, localSavedRemotePendingMessage } from "@/services/user-data-sync";
import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, RotateCcw, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react";
import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react";
import { PrevisCanvasDock } from "./previs-canvas-dock";
import { Dropdown } from "antd";
import { nanoid } from "nanoid";
@@ -45,7 +45,7 @@ import { buildPrevisPathKeyframes } from "@/lib/canvas/previs/previs-path";
import { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder";
import { shouldReinitializePrevisSession, isPrevisOutputSnapshotCurrent } from "@/lib/canvas/previs/previs-session";
import { isPrevisPreviewRequestForWorkbench, previsPreviewRequestKey, type PrevisPreviewRequest } from "@/lib/canvas/previs/previs-preview";
import { describePrevisSaveStatus, shouldOfferPrevisDraftRecovery, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring";
import { describePrevisSaveStatus, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring";
import { usePrevisSaveCoordinator } from "@/components/canvas/previs/use-previs-save-coordinator";
import "./canvas-previs-workbench.css";
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
@@ -79,7 +79,7 @@ export function CanvasPrevisWorkbench({
onDeleteImageNode: (nodeId: string) => void;
onFlush?: () => void | Promise<void>;
}) {
const { message, modal } = App.useApp();
const { message } = App.useApp();
const theme = canvasThemes[useActiveTheme()];
const effectiveCanvasId = useMemo(() => {
const explicit = canvasId?.trim();
@@ -183,11 +183,7 @@ export function CanvasPrevisWorkbench({
}, [onChange, onFlush]);
const closingRef = useRef(false);
const recoveryPromptedRef = useRef<string | null>(null);
const [retrying, setRetrying] = useState(false);
const handledPreviewRequestsRef = useRef(new Set<string>());
// 按 scene.id 持有唯一 coordinator:flush 先把 request.scene 写回项目,再等持久化完成。
const saveController = usePrevisSaveCoordinator({
sceneId: scene?.id ?? null,
initialScene: scene,
@@ -198,25 +194,6 @@ export function CanvasPrevisWorkbench({
saveControllerRef.current = saveController;
const saveIndicator = describePrevisSaveStatus(saveController.progress);
const retrySave = async () => {
setRetrying(true);
try {
if (await saveController.retry()) {
recordPrevisDiagnostic("PREVIS_SAVE_RETRY_RECOVERED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, userInitiated: true });
message.success("已保存到项目");
return;
}
// 只有真的存在合法本地候选才敢说草稿已保留。
const draftStored = Boolean(saveController.restoreCandidate());
recordPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, draftStored, userInitiated: true });
if (!draftStored) recordPrevisDiagnostic("PREVIS_SAVE_DRAFT_UNAVAILABLE", { sceneId: draftRef.current?.id, revision: saveController.progress.revision });
if (draftStored) message.error("远端保存失败,本地草稿已保留,可稍后重试");
else message.error("远端和本地都未保存,请不要关闭预演台并继续重试");
} finally {
setRetrying(false);
}
};
const writeDraft = useCallback((next: PrevisScene | null) => {
draftRef.current = next;
setDraft(next);
@@ -232,7 +209,7 @@ export function CanvasPrevisWorkbench({
onChangeRef.current(current);
}, []);
/** 真实 canonical 提交:先交给 coordinator(本地草稿 + 远端保存),再镜像到项目。 */
/** 真实 canonical 提交:先交给 coordinator,再镜像到项目。 */
const commitDraft = useCallback(() => {
const current = draftRef.current;
if (!current || initializedSceneIdRef.current !== current.id) return;
@@ -282,40 +259,6 @@ export function CanvasPrevisWorkbench({
message.info("Agent 已更新预演场景");
}, [message, open, scene, setPlayhead, setSelectedBone, setSelectedLightId, setSelectedObjectId, writeDraft]);
// 打开会话时检查合法本地恢复候选:同一场景只提示一次,恢复/放弃都必须有明确结果。
useEffect(() => {
if (!open || !scene) return;
if (recoveryPromptedRef.current === scene.id) return;
recoveryPromptedRef.current = scene.id;
const controller = saveControllerRef.current;
const candidate = controller?.restoreCandidate() ?? null;
if (!controller || !candidate) return;
if (!shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene: scene })) return;
modal.confirm({
title: "发现未保存的本地草稿",
content: `这个镜头存在一份比项目更新的本地草稿(修订 ${candidate.revision})。恢复后会立即写回项目并保存。`,
okText: "恢复草稿",
cancelText: "放弃草稿",
closable: false,
mask: { closable: false },
keyboard: false,
onOk: () => {
if (!controller.restoreDraft(candidate)) {
message.error("草稿恢复失败,已保留当前场景");
return;
}
writeDraft(candidate.scene);
message.success("已恢复本地草稿并写回项目");
},
onCancel: () => {
if (controller.discardDraft()) message.success("已放弃本地草稿");
else message.error("草稿删除失败,下次打开可能仍会提示");
},
});
}, [message, modal, open, scene, writeDraft]);
const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null;
const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null;
useEffect(() => {
@@ -522,31 +465,9 @@ export function CanvasPrevisWorkbench({
onClose();
return;
}
if (decision.kind === "blocked") {
recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision, draftStored: saveController.progress.draftStored });
message.error(decision.message);
closingRef.current = false;
return;
}
// 确认框存续期间保持上锁,否则重复点击会叠出多个弹窗。
modal.confirm({
title: "远端保存失败",
content: decision.message,
okText: "仍然离开",
cancelText: "留在预演台",
closable: false,
mask: { closable: false },
keyboard: false,
// 预演视口持续占用主线程时,zoom 离场要等 rAF 和 transitionend。
// 动画停在可见态后,点「留在预演台」确认框也不会消失。
transitionName: "",
maskTransitionName: "",
onOk: () => onClose(),
onCancel: () => {
closingRef.current = false;
},
});
recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision });
message.error(decision.message);
closingRef.current = false;
})();
};
@@ -1325,12 +1246,6 @@ export function CanvasPrevisWorkbench({
<span className={`previs-desk-save ${saveStatusClass}`} aria-live="polite">
{saveIndicator.label}
</span>
{saveIndicator.retryable ? (
<button type="button" className="previs-desk-header-action" aria-label="重试保存" disabled={retrying || saveIndicator.busy} onClick={() => void retrySave()}>
<RotateCcw className="size-3.5" />
重试保存
</button>
) : null}
{onboardingScope ? (
<button type="button" className="previs-desk-icon-button" aria-label="重新开始引导" title="重新开始引导" onClick={() => setOnboardingRestartSignal((value) => value + 1)}>
<Lightbulb className="size-4" />

View File

@@ -1,41 +1,25 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPrevisSaveCoordinator, type PrevisCloseDecision, type PrevisDraftEnvelope, type PrevisSaveCoordinator } from "@/lib/canvas/previs/previs-save";
import { previsSaveProgress, idlePrevisSaveProgress, resolvePrevisDraftStorage, type PrevisSaveProgress } from "@/lib/canvas/previs/previs-save-wiring";
import { createPrevisSaveCoordinator, type PrevisCloseDecision, type PrevisSaveCoordinator } from "@/lib/canvas/previs/previs-save";
import { idlePrevisSaveProgress, previsSaveProgress, type PrevisSaveProgress } from "@/lib/canvas/previs/previs-save-wiring";
import { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder";
import { useUserStore } from "@/stores/use-user-store";
import type { PrevisScene } from "@/types/previs";
export type PrevisSaveController = {
progress: PrevisSaveProgress;
/** canonical 提交入口:预览态与「仅镜像」路径都不得调用。 */
commitScene: (scene: PrevisScene) => void;
retry: () => Promise<boolean>;
restoreCandidate: () => PrevisDraftEnvelope | null;
restoreDraft: (candidate: PrevisDraftEnvelope) => boolean;
discardDraft: () => boolean;
prepareClose: () => Promise<PrevisCloseDecision>;
handlePageHide: () => Promise<boolean>;
};
/**
* 按 scene.id 持有唯一 coordinator:切换场景或卸载时 dispose。
* persistScene 必须把 request.scene 落到项目状态,flushPersistence 负责真正落盘。
*/
export function usePrevisSaveCoordinator(input: { sceneId: string | null; initialScene: PrevisScene | null; persistScene: (scene: PrevisScene) => void; flushPersistence: () => void | Promise<void> }): PrevisSaveController {
const { sceneId, initialScene, persistScene, flushPersistence } = input;
const coordinatorRef = useRef<PrevisSaveCoordinator | null>(null);
const persistSceneRef = useRef(persistScene);
const flushPersistenceRef = useRef(flushPersistence);
const initialSceneRef = useRef(initialScene);
const [progress, setProgress] = useState<PrevisSaveProgress>(idlePrevisSaveProgress);
// scope 取响应式 user id:user-session 先切 active scope 再 setUser,
// 因此这里能在用户变化时重建 coordinator,且不会在播放时每帧读 localStorage。
const scope = useUserStore((state) => state.user?.id) ?? "guest";
// 依赖为空的 effect 每次 render 后都跑,保证 coordinator effect 读到的是最新回调。
useEffect(() => {
persistSceneRef.current = persistScene;
flushPersistenceRef.current = flushPersistence;
@@ -52,15 +36,11 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
let active = true;
const coordinator = createPrevisSaveCoordinator({
initialScene: base,
scope,
storage: resolvePrevisDraftStorage(() => globalThis.localStorage),
flush: async (request) => {
try {
// 先把这次 flush 对应的场景写进项目状态,再等持久化真正完成。
persistSceneRef.current(request.scene);
await flushPersistenceRef.current();
} catch (error) {
// 只记录稳定码与安全字段;原始 error 继续上抛给 coordinator 走 error/draft 语义。
recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: request.scene.id, revision: request.revision });
throw error;
}
@@ -69,39 +49,28 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
coordinatorRef.current = coordinator;
setProgress(previsSaveProgress(coordinator.getSnapshot()));
// 通知里必须读完整快照:只搬 status 会让 confirmedRevision/draftStored 停在旧值,
// UI 继续显示未保存、beforeunload 也会误拦。
const unsubscribe = coordinator.onStatusChange(() => {
if (!active) return;
setProgress(previsSaveProgress(coordinator.getSnapshot()));
if (active) setProgress(previsSaveProgress(coordinator.getSnapshot()));
});
return () => {
active = false;
unsubscribe();
coordinatorRef.current = null;
// 卸载前把未确认改动推完再 dispose,否则 debounce 里的最后一次编辑只剩本地草稿。
const pending = coordinator.getSnapshot();
if (pending.confirmedRevision < pending.revision) {
void coordinator
.flushLatest()
.catch(() => undefined)
.finally(() => coordinator.dispose());
void coordinator.flushLatest().catch(() => undefined).finally(() => coordinator.dispose());
return;
}
coordinator.dispose();
};
}, [sceneId, scope]);
}, [sceneId]);
/** dispose 后 coordinatorRef 为 null,因此卸载期的调用不会再 setState。 */
const syncProgress = useCallback(() => {
const coordinator = coordinatorRef.current;
if (coordinator) setProgress(previsSaveProgress(coordinator.getSnapshot()));
}, []);
/** canonical 提交唯一入口:预览态和「仅镜像当前 draft」都不得调用。 */
const commitScene = useCallback(
(scene: PrevisScene) => {
if (!coordinatorRef.current?.edit(scene)) return;
@@ -110,35 +79,6 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
[syncProgress],
);
const retry = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return false;
const result = await coordinator.retry();
syncProgress();
return result;
}, [syncProgress]);
const restoreCandidate = useCallback(() => coordinatorRef.current?.restoreCandidate() ?? null, []);
const restoreDraft = useCallback(
(candidate: PrevisDraftEnvelope) => {
const coordinator = coordinatorRef.current;
if (!coordinator?.explicitRestore(candidate)) return false;
// 恢复后必须回到项目状态并触发可靠保存,否则草稿只活在本地。
persistSceneRef.current(candidate.scene);
void coordinator.flushLatest().finally(syncProgress);
syncProgress();
return true;
},
[syncProgress],
);
const discardDraft = useCallback(() => {
const discarded = coordinatorRef.current?.discardDraft() ?? false;
syncProgress();
return discarded;
}, [syncProgress]);
const prepareClose = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return "close" as PrevisCloseDecision;
@@ -147,11 +87,6 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
return decision;
}, [syncProgress]);
/**
* pagehide:dirty 时只能走 coordinator.handlePageHide —— 它的 flush 已经
* persist(request.scene) + await flushPersistence,组件再叠一次就是重复落盘。
* clean 时 coordinator 不会 flush,因此这里显式落盘,保留原有「离开页面必落盘」语义。
*/
const handlePageHide = useCallback(async () => {
const coordinator = coordinatorRef.current;
try {
@@ -172,14 +107,5 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
}
}, [syncProgress]);
return {
progress,
commitScene,
retry,
restoreCandidate,
restoreDraft,
discardDraft,
prepareClose,
handlePageHide,
};
return { progress, commitScene, prepareClose, handlePageHide };
}