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

This commit is contained in:
ddcat666 committed 2026-10-11 22:36:47 +08:00
1 parent 6dedc5973c
commit 5f359f2b1d
63 files changed
+3234 -6192

No files matched your search

@@ -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" />