refactor(canvas): 预演台 - 重构工作台与场景交互链路

This commit is contained in:
ddcat666
2026-10-06 09:06:43 +08:00
parent 1dfc8ce7ac
commit 112916587c
128 changed files with 42372 additions and 5011 deletions

View File

@@ -278,12 +278,13 @@ export function applyAgentEvent(
setMessages((current) => mergeAgentToolRetry(current, message));
if (event.type === "tool_failed") return;
}
if (event.type === "tool_completed" && payload.toolName === "director_preview") {
if (event.type === "tool_completed" && payload.toolName === "previs_preview") {
const result = payload.result && typeof payload.result === "object" ? (payload.result as Record<string, unknown>) : {};
window.dispatchEvent(
new CustomEvent("director:preview-requested", {
new CustomEvent("previs:preview-requested", {
detail: {
sceneId: String(result.sceneId || ""),
canvasId: String(result.canvasId || ""),
shotId: String(result.shotId || ""),
duration: Number(result.duration || 0),
fps: Number(result.fps || 0),
@@ -295,8 +296,8 @@ export function applyAgentEvent(
appendUniqueMessage(current, {
id: event.eventId,
role: "tool",
title: "director_preview",
text: text || "已请求导演台生成白模预演",
title: "previs_preview",
text: text || "已请求预演台生成白模预演",
detail: { ...payload, eventType: event.type },
}),
);

View File

@@ -31,7 +31,7 @@ type CanvasNodeContextMenuProps = {
onAddNode: (type: CanvasNodeTypeId) => void;
onAddFolder: () => void;
onChooseStyle: () => void;
onOpenDirector: (position: Position) => void;
onOpenPrevis: (position: Position) => void;
onUpload: () => void;
onOpenAssets: () => void;
onOpenProjectCharacters: () => void;
@@ -71,7 +71,7 @@ export function CanvasNodeContextMenu({
onAddNode,
onAddFolder,
onChooseStyle,
onOpenDirector,
onOpenPrevis,
onUpload,
onOpenAssets,
onOpenProjectCharacters,
@@ -273,7 +273,7 @@ export function CanvasNodeContextMenu({
onAddNode={(type) => runAction(() => onAddNode(type))}
onAddFolder={() => runAction(onAddFolder)}
onChooseStyle={() => runAction(onChooseStyle)}
onOpenDirector={() => runAction(() => onOpenDirector(menu.position))}
onOpenPrevis={() => runAction(() => onOpenPrevis(menu.position))}
onUpload={() => runAction(onUpload)}
onOpenAssets={() => runAction(onOpenAssets)}
onOpenProjectCharacters={() => runAction(onOpenProjectCharacters)}
@@ -284,7 +284,7 @@ export function CanvasNodeContextMenu({
);
}
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, onAddNode, onAddFolder, onChooseStyle, onOpenPrevis, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenPrevis: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
const theme = canvasThemes[useActiveTheme()];
const installations = usePluginStore((state) => state.installations);
const pluginStates = usePluginStore((state) => state.pluginStates);
@@ -305,7 +305,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, on
onAddExtensionNode: onAddNode,
onAddWorkflow: () => onAddNode(CanvasNodeType.Config),
onChooseStyle,
onOpenDirector,
onOpenPrevis,
onUpload,
onOpenMyAssets: onOpenAssets,
onOpenProjectCharacters,

View File

@@ -69,7 +69,7 @@ export function CanvasNodeContent(props: CanvasNodeContentProps) {
props.node.type === CanvasNodeType.Config ||
props.node.type === CanvasNodeType.Script ||
props.node.type === CanvasNodeType.BatchTable ||
Boolean(props.node.metadata?.directorSceneId) ||
Boolean(props.node.metadata?.previsSceneId) ||
(props.node.metadata?.workflowKind === "character" && Boolean(props.node.metadata.characterAssetId)) ||
(props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent) ||
(props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content);

View File

@@ -62,7 +62,7 @@ type CanvasNodeProps = {
onViewImage?: (node: CanvasNodeData) => void;
onReplaceMedia?: (node: CanvasNodeData) => void;
onOpenTextEditor?: (node: CanvasNodeData) => void;
onOpenDirector?: (node: CanvasNodeData) => void;
onOpenPrevis?: (node: CanvasNodeData) => void;
onOpenDrawing?: (node: CanvasNodeData) => void;
onMediaPlayRequest?: (nodeId: string) => void;
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
@@ -110,7 +110,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onOpenVersions,
onViewImage,
onOpenTextEditor,
onOpenDirector,
onOpenPrevis,
onOpenDrawing,
onMediaPlayRequest,
onContextMenu,
@@ -338,9 +338,9 @@ export const CanvasNode = React.memo(function CanvasNode({
onViewImage?.(data);
return;
}
if (data.metadata?.directorSceneId) {
if (data.metadata?.previsSceneId) {
event.stopPropagation();
onOpenDirector?.(data);
onOpenPrevis?.(data);
return;
}
if (data.type === CanvasNodeType.Drawing) {
@@ -551,7 +551,7 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.onViewImage === next.onViewImage &&
previous.onReplaceMedia === next.onReplaceMedia &&
previous.onOpenTextEditor === next.onOpenTextEditor &&
previous.onOpenDirector === next.onOpenDirector &&
previous.onOpenPrevis === next.onOpenPrevis &&
previous.onOpenDrawing === next.onOpenDrawing &&
previous.onContextMenu === next.onContextMenu
);

View File

@@ -40,7 +40,7 @@ export function CanvasToolbar({
onAddDrawing,
onAddExtensionNode,
onAddWorkflow,
onOpenDirector,
onOpenPrevis,
onUndo,
onRedo,
onUpload,
@@ -76,7 +76,7 @@ export function CanvasToolbar({
onAddDrawing: () => void;
onAddExtensionNode: (type: CanvasNodeTypeId) => void;
onAddWorkflow: () => void;
onOpenDirector: () => void;
onOpenPrevis: () => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
@@ -150,7 +150,7 @@ export function CanvasToolbar({
onAddExtensionNode,
onAddWorkflow,
onChooseStyle,
onOpenDirector,
onOpenPrevis,
onUpload,
onOpenWorkspace,
onOpenMyAssets,

View File

@@ -77,7 +77,7 @@
.canvas-main-with-workspace .canvas-editor-shell {
z-index: 0;
}
.canvas-main-with-workspace .canvas-editor-shell:has([data-canvas-director-workbench]) {
.canvas-main-with-workspace .canvas-editor-shell:has([data-canvas-previs-workbench]) {
z-index: var(--z-toast);
}
.canvas-main-with-workspace .canvas-topbar {

View File

@@ -1,78 +0,0 @@
import { useState, type ReactNode } from "react";
import { Box, Camera, Clapperboard, Lightbulb, LockKeyhole, Move3d } from "lucide-react";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { gateDirectorPreviewFailure, resolveDirectorActiveShot, resolveDirectorPreviewSource, type DirectorNodeContentReader } from "@/lib/canvas/director/director-preview";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { DirectorScene } from "@/types/director";
export function CanvasDirectorNodePanel({ node, scene, readNodeContent, onOpen, professional = true }: { node: CanvasNodeData; scene: DirectorScene | null; readNodeContent: DirectorNodeContentReader; onOpen: () => void; professional?: boolean }) {
const theme = canvasThemes[useActiveTheme()];
const shot = resolveDirectorActiveShot(scene, node.metadata?.directorShotId);
// 记录「失败的那个 URL」而非布尔量:同一个坏 URL 不再反复渲染,换成另一个 URL 时自动重试。
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const preview = gateDirectorPreviewFailure(
resolveDirectorPreviewSource({ scene, shot, previewNodeId: node.metadata?.directorPreviewNodeId, readNodeContent }),
failedUrl,
);
return (
<div className="flex h-full w-full cursor-move flex-col p-3 pt-7" style={{ color: theme.node.text }}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-7 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}><Clapperboard className="size-3.5" /></span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{node.metadata?.workflowTitle || node.title}</div>
<div className="truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{shot?.name || "未设置镜头"}</div>
</div>
</div>
<span className="shrink-0 text-[var(--fs-tiny)] font-semibold" style={{ color: theme.accent.primary }}>3D</span>
</div>
<button
type="button"
data-canvas-no-zoom
className="group relative min-h-0 flex-1 cursor-pointer overflow-hidden rounded-lg border text-left focus-visible:outline-none focus-visible:ring-2 disabled:cursor-default"
style={{ background: theme.node.fill, borderColor: theme.node.stroke }}
title={professional ? "打开 3D 导演台" : "切换到专业模式后编辑导演台"}
disabled={!professional}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onOpen(); }}
>
{preview.kind === "image"
? <img src={preview.url} alt={`${node.metadata?.workflowTitle || node.title} 已回写的导演台构图`} className="h-full w-full object-contain" draggable={false} onError={() => setFailedUrl(preview.url)} />
: <DirectorPreviewState kind={preview.kind} theme={theme} />}
<span className={`absolute inset-x-0 bottom-0 flex h-10 items-center justify-center gap-1.5 text-xs font-semibold backdrop-blur-sm transition-opacity ${professional ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : "opacity-100"}`} style={{ background: `${theme.toolbar.panel}dd`, color: theme.node.text }}>{professional ? <><Move3d className="size-3.5" />进入导演台</> : <><LockKeyhole className="size-3.5" />专业模式可编辑</>}</span>
</button>
<div className="mt-2 grid grid-cols-3 gap-1 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
<Stat icon={<Box className="size-3" />} value={scene?.objects.length || 0} label="对象" />
<Stat icon={<Camera className="size-3" />} value={scene?.cameras.length || 0} label="机位" />
<Stat icon={<Lightbulb className="size-3" />} value={scene?.lights.length || 0} label="灯光" />
</div>
</div>
);
}
/**
* 诚实空态/准备态:不绘制地面、地平线、机位或任何伪 3D 物体。
* 颜色全部走画布主题 token;层级靠字号/字重区分,不靠降低对比度。
*/
function DirectorPreviewState({ kind, theme }: { kind: "loading" | "empty"; theme: CanvasTheme }) {
const loading = kind === "loading";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 pb-10 text-center">
<span className="grid size-10 shrink-0 place-items-center rounded-[var(--r-lg)]" style={{ background: theme.toolbar.activeBg }}>
<Clapperboard className="size-4" style={{ color: theme.node.muted }} aria-hidden />
</span>
<span className="max-w-full truncate text-[var(--fs-tiny)] font-semibold" style={{ color: theme.node.text }}>{loading ? "正在准备场景" : "尚未生成预览"}</span>
<span className="max-w-full text-[var(--fs-tiny)] leading-4" style={{ color: theme.node.muted }}>{loading ? "场景数据加载完成后显示" : "进入导演台并回写构图后显示"}</span>
</div>
);
}
function Stat({ icon, value, label }: { icon: ReactNode; value: number; label: string }) {
return <span className="inline-flex min-w-0 items-center justify-center gap-1 rounded-md py-1" title={`${value} 个${label}`}>{icon}<b>{value}</b>{label}</span>;
}

View File

@@ -1,929 +0,0 @@
import { cameraMoveOptions, poseOptions, shotSizeOptions, IconButton, PanelTitle, AddMenuButton, SceneRow, QuickAdd, ObjectInspector, LightInspector, ShotInspector } from "./director-inspectors";
import { snapDirectorTime, advanceDirectorPlayhead, resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, resolveDirectorCameraMoveKeyframes, resolveDirectorCameraAlignment } from "@/lib/canvas/director/director-animation-semantics";
import { interpolateDirectorTransform, touchDirectorScene, createDirectorObject, createDirectorActor, DIRECTOR_ACTOR_COLORS, createDirectorModel, createDirectorBillboard, createDirectorCamera, createDirectorLight, upsertDirectorBoneKeyframe, removeDirectorSceneKeyframe, setDirectorSceneKeyframeEasing, directorPoseLabel } from "@/lib/canvas/director/director-scene";
import { type DirectorObject, type DirectorLight, type DirectorShot, type DirectorHumanoidBone, type DirectorQuat, type DirectorKeyframeDeleteTarget, type DirectorKeyframeEasing, type DirectorRig, type DirectorPose, type DirectorShotSize } from "@/types/director";
import { resolveDirectorPlacementAnchor, resolveDirectorPlacement, type DirectorGroundPoint } from "@/lib/canvas/director/director-placement";
import { uploadMediaFile } from "@/services/file-storage";
import { saveRemoteUserDataNow, localSavedRemotePendingMessage } from "@/services/user-data-sync";
import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, FileUp, X, Undo2, Redo2, RotateCcw, Video, Save, Plus, MousePointer2, WandSparkles, Focus, BoxSelect, Image as ImageIcon } from "lucide-react";
import { nanoid } from "nanoid";
import { type DirectorShortcutAction, resolveDirectorShortcut, blocksDirectorShortcut, releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts";
import { type AnimationClip } from "three";
import { compileDirectorPrompt } from "@/lib/canvas/director/director-prompt-compiler";
import { Select } from "@/components/ui/base/select";
import { DirectorViewport } from "./director-viewport";
import { CanvasDirectorOnboarding } from "@/components/canvas/director/canvas-director-onboarding";
import { DirectorViewportDock } from "./director-viewport-dock";
import { DirectorSequencer } from "./director-sequencer";
import { App, type MenuProps, Input, Button } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type DirectorViewportHandle } from "@/components/canvas/director/director-viewport";
import { canvasThemes } from "@/lib/canvas-theme";
import { type DirectorTransaction, createDirectorTransaction, installDirectorTerminalListeners } from "@/lib/canvas/director/director-gesture-transaction";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
import { directorModeCapabilities, DIRECTOR_MODES } from "@/lib/canvas/director/director-modes";
import { shouldReinitializeDirectorSession, isDirectorOutputSnapshotCurrent } from "@/lib/canvas/director/director-session";
import { describeDirectorSaveStatus, shouldOfferDirectorDraftRecovery, shouldBlockDirectorUnload, resolveDirectorCloseOutcome } from "@/lib/canvas/director/director-save-wiring";
import { useDirectorSaveCoordinator } from "@/components/canvas/director/use-director-save-coordinator";
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { DirectorCameraMove, DirectorRenderMode, DirectorScene, DirectorSceneOutput, DirectorTransform, DirectorVec3 } from "@/types/director";
export { cameraMoveOptions, poseOptions, shotSizeOptions } from "./director-inspectors";
export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingScope, onClose, onChange, onApply, onDeleteImageNode, onFlush }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onboardingScope: string; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise<void>; onDeleteImageNode: (nodeId: string) => void; onFlush?: () => void | Promise<void> }) {
const { message, modal } = App.useApp();
const theme = canvasThemes[useActiveTheme()];
const viewportRef = useRef<DirectorViewportHandle>(null);
const modelInputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState<DirectorScene | null>(null);
const [history, setHistory] = useState<DirectorScene[]>([]);
const [future, setFuture] = useState<DirectorScene[]>([]);
const [saving, setSaving] = useState(false);
const [recording, setRecording] = useState(false);
// 默认进入镜头优先的快速工作流,完整的摆场/姿态/动画能力仍保留在高级模式。
const [quickMode, setQuickMode] = useState(true);
const [onboardingRestartSignal, setOnboardingRestartSignal] = useState(0);
const mode = useDirectorWorkbenchStore((state) => state.mode);
const viewMode = useDirectorWorkbenchStore((state) => state.viewMode);
const setViewMode = useDirectorWorkbenchStore((state) => state.setViewMode);
const setMode = useDirectorWorkbenchStore((state) => state.setMode);
const selectedObjectId = useDirectorWorkbenchStore((state) => state.selectedObjectId);
const selectedLightId = useDirectorWorkbenchStore((state) => state.selectedLightId);
const transformMode = useDirectorWorkbenchStore((state) => state.transformMode);
const renderMode = useDirectorWorkbenchStore((state) => state.renderMode);
const playhead = useDirectorWorkbenchStore((state) => state.playhead);
const playing = useDirectorWorkbenchStore((state) => state.playing);
const selectedBone = useDirectorWorkbenchStore((state) => state.selectedBone);
const autoKey = useDirectorWorkbenchStore((state) => state.autoKey);
const sequencerHeight = useDirectorWorkbenchStore((state) => state.sequencerHeight);
const sequencerVisible = useDirectorWorkbenchStore((state) => state.sequencerVisible);
const setSelectedObjectId = useDirectorWorkbenchStore((state) => state.setSelectedObjectId);
const setSelectedLightId = useDirectorWorkbenchStore((state) => state.setSelectedLightId);
const setTransformMode = useDirectorWorkbenchStore((state) => state.setTransformMode);
const setRenderMode = useDirectorWorkbenchStore((state) => state.setRenderMode);
const setPlayhead = useDirectorWorkbenchStore((state) => state.setPlayhead);
const setPlaying = useDirectorWorkbenchStore((state) => state.setPlaying);
const setSelectedBone = useDirectorWorkbenchStore((state) => state.setSelectedBone);
const setAutoKey = useDirectorWorkbenchStore((state) => state.setAutoKey);
const setSequencerHeight = useDirectorWorkbenchStore((state) => state.setSequencerHeight);
const setSequencerVisible = useDirectorWorkbenchStore((state) => state.setSequencerVisible);
const resetWorkbench = useDirectorWorkbenchStore((state) => state.reset);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const modelAssets = useMemo(() => assets.filter((asset): asset is ModelAsset => asset.kind === "model"), [assets]);
// 模式决定显示什么:时间轴、关键帧、骨骼、摄影机工具与可选渲染视图都从这里派生。
const capabilities = directorModeCapabilities(mode);
const renderModeOptions = useMemo(() => DIRECTOR_RENDER_MODE_LABELS.filter((option) => capabilities.renderModes.includes(option.value)), [capabilities.renderModes]);
const draftRef = useRef<DirectorScene | null>(null);
const stagedRef = useRef<DirectorTransaction | null>(null);
const initializedSceneIdRef = useRef<string | null>(null);
const onChangeRef = useRef(onChange);
const onFlushRef = useRef(onFlush);
useEffect(() => { onChangeRef.current = onChange; onFlushRef.current = onFlush; }, [onChange, onFlush]);
const closingRef = useRef(false);
const recoveryPromptedRef = useRef<string | null>(null);
const [retrying, setRetrying] = useState(false);
// 按 scene.id 持有唯一 coordinator:flush 先把 request.scene 写回项目,再等持久化完成。
const saveController = useDirectorSaveCoordinator({
sceneId: scene?.id ?? null,
initialScene: scene,
persistScene: (next) => onChangeRef.current(next),
flushPersistence: () => onFlushRef.current?.(),
});
const saveControllerRef = useRef(saveController);
saveControllerRef.current = saveController;
const saveIndicator = describeDirectorSaveStatus(saveController.progress);
const retrySave = async () => {
setRetrying(true);
try {
if (await saveController.retry()) {
recordDirectorDiagnostic("DIRECTOR_SAVE_RETRY_RECOVERED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, userInitiated: true });
message.success("已保存到项目");
return;
}
// 只有真的存在合法本地候选才敢说草稿已保留。
const draftStored = Boolean(saveController.restoreCandidate());
recordDirectorDiagnostic("DIRECTOR_SAVE_RETRY_FAILED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, draftStored, userInitiated: true });
if (!draftStored) recordDirectorDiagnostic("DIRECTOR_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: DirectorScene | null) => {
draftRef.current = next;
setDraft(next);
}, []);
/**
* 仅镜像当前 draft 到项目 directorScenes,不产生 canonical 提交。
* 用于取消预览、idle pagehide、卸载兜底 —— 这些都不是新的用户改动。
*/
const mirrorDraft = useCallback(() => {
const current = draftRef.current;
if (!current || initializedSceneIdRef.current !== current.id) return;
onChangeRef.current(current);
}, []);
/** 真实 canonical 提交:先交给 coordinator(本地草稿 + 远端保存),再镜像到项目。 */
const commitDraft = useCallback(() => {
const current = draftRef.current;
if (!current || initializedSceneIdRef.current !== current.id) return;
saveControllerRef.current?.commitScene(current);
onChangeRef.current(current);
}, []);
const writeAndPublish = useCallback((next: DirectorScene) => {
writeDraft(next);
saveControllerRef.current?.commitScene(next);
onChangeRef.current(next);
}, [writeDraft]);
// 会话初始化只认 scene id:同 id 的父级镜像回流不得重建会话。
useEffect(() => {
if (!open || !scene) return;
if (!shouldReinitializeDirectorSession({ initializedSceneId: initializedSceneIdRef.current, nextSceneId: scene.id })) return;
const next = structuredClone(scene);
next.shots = next.shots.map((shot) => ({ ...shot, fps: shot.fps || 24 }));
stagedRef.current?.end("cancel");
initializedSceneIdRef.current = scene.id;
writeDraft(next);
setHistory([]);
setFuture([]);
resetWorkbench();
}, [open, resetWorkbench, scene, 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 (!shouldOfferDirectorDraftRecovery({ 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;
const selectedObject = draft?.objects?.find((item) => item.id === selectedObjectId) || null;
const selectedLight = draft?.lights?.find((item) => item.id === selectedLightId) || null;
// 写入关键帧的目的时间用吸附值;取值/显示/手势起点一律用 raw playhead,
// 否则处在两个帧格之间时 AutoKey OFF 的增量会从错误起点计算而产生漂移。
const snappedPlayhead = snapDirectorTime(playhead, activeShot?.fps || 24);
const selectedObjectRendered = selectedObject ? interpolateDirectorTransform(selectedObject.transform, selectedObject.keyframes, playhead) : null;
useEffect(() => {
if (!playing || !activeShot) return;
let frame = 0;
let last = performance.now();
let pending = 0;
const frameInterval = 1 / Math.max(1, Math.min(120, activeShot.fps || 24));
const tick = (now: number) => {
pending += Math.max(0, (now - last) / 1000);
last = now;
if (pending >= frameInterval) {
const elapsed = Math.floor(pending / frameInterval) * frameInterval;
pending -= elapsed;
setPlayhead(advanceDirectorPlayhead(useDirectorWorkbenchStore.getState().playhead, elapsed, activeShot.duration));
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [activeShot, playing, setPlayhead]);
const commit = useCallback((updater: (current: DirectorScene) => DirectorScene) => {
// 普通提交前先终结暂存手势,避免新动作消费旧 base。
stagedRef.current?.end("commit");
const current = draftRef.current;
if (!current) return;
setHistory((items) => [...items.slice(-49), structuredClone(current)]);
setFuture([]);
writeAndPublish(touchDirectorScene(updater(current)));
}, [writeAndPublish]);
/** 暂存型手势(数值滑杆):实时预览写草稿但不产生历史,也不镜像到项目。 */
const stagedTransaction = useMemo(() => createDirectorTransaction<DirectorScene>({
read: () => draftRef.current,
// 取消:恢复快照且绝不发布被取消的值。
restore: (snapshot) => writeDraft(snapshot),
commit: (from) => {
setHistory((items) => [...items.slice(-49), from]);
setFuture([]);
// 手势成功终态是真实 canonical 提交。
commitDraft();
},
setActive: () => undefined,
}), [commitDraft, writeDraft]);
stagedRef.current = stagedTransaction;
const stageGesture = useCallback((updater: (current: DirectorScene) => DirectorScene) => {
const current = draftRef.current;
if (!current) return;
stagedTransaction.begin();
writeDraft(touchDirectorScene(updater(current)));
}, [stagedTransaction, writeDraft]);
/** 无历史但持久的变化(标题、rig/motionClips 等)同样要镜像。 */
const replaceWithoutHistory = useCallback((updater: (current: DirectorScene) => DirectorScene) => {
const current = draftRef.current;
if (current) writeAndPublish(touchDirectorScene(updater(current)));
}, [writeAndPublish]);
// 暂存手势的终止生命周期:常驻安装,非活跃时 end 为空操作。
useEffect(() => installDirectorTerminalListeners(stagedTransaction, {
window,
document,
isHidden: () => document.visibilityState === "hidden",
}), [stagedTransaction]);
// 切换选择/骨骼、关闭或卸载前必须先终止旧手势,不能让新选择消费旧 base。
useEffect(() => () => stagedTransaction.end("cancel"), [open, selectedBone, selectedObjectId, stagedTransaction]);
// 离开页面:active 预览由 end("commit") 完成真实提交,idle 只镜像;落盘统一交给 controller。
useEffect(() => {
const onPageHide = () => {
if (stagedTransaction.active()) stagedTransaction.end("commit");
else mirrorDraft();
// 只调用 handlePageHide:dirty 时它自己会 persist + flush,组件再叠一次就是重复落盘。
void saveControllerRef.current?.handlePageHide();
};
// 异步 flush 不可能阻塞卸载:这里只同步声明「仍有未确认改动」,让浏览器自己弹保护。
const onBeforeUnload = (event: BeforeUnloadEvent) => {
const controller = saveControllerRef.current;
if (!controller || !shouldBlockDirectorUnload(controller.progress)) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [mirrorDraft, stagedTransaction]);
// 卸载兜底:只把最新 draft 镜像回项目,不制造新的 canonical revision。
useEffect(() => () => {
stagedRef.current?.end("cancel");
mirrorDraft();
}, [mirrorDraft]);
const undo = () => {
const previous = history.at(-1);
if (!previous || !draft) return;
setHistory((items) => items.slice(0, -1));
setFuture((items) => [structuredClone(draft), ...items].slice(0, 50));
writeAndPublish(previous);
};
const redo = () => {
const next = future[0];
if (!next || !draft) return;
setFuture((items) => items.slice(1));
setHistory((items) => [...items, structuredClone(draft)].slice(-50));
writeAndPublish(next);
};
/**
* 关闭统一入口:取消未结束的预览、镜像当前 draft,再按 prepareClose 决策是否真的退出。
* 这里只镜像不提交:取消预览不是新的 canonical 变化。
*/
const closeWorkbench = () => {
if (closingRef.current) return;
closingRef.current = true;
stagedTransaction.end("cancel");
mirrorDraft();
void (async () => {
let decision;
try {
decision = resolveDirectorCloseOutcome(await saveController.prepareClose());
} catch {
message.error("关闭前的保存检查失败,已留在导演台");
closingRef.current = false;
return;
}
if (decision.kind === "close") {
onClose();
return;
}
if (decision.kind === "blocked") {
recordDirectorDiagnostic("DIRECTOR_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,
onOk: () => onClose(),
onCancel: () => {
closingRef.current = false;
},
});
})();
};
const updateObject = (id: string, patch: Partial<DirectorObject>) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const updateLight = (id: string, patch: Partial<DirectorLight>) => commit((current) => ({ ...current, lights: current.lights.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const updateShot = (id: string, patch: Partial<DirectorShot>) => commit((current) => ({ ...current, shots: current.shots.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const removeObject = (id: string) => {
commit((current) => ({ ...current, objects: current.objects.filter((item) => item.id !== id) }));
if (selectedObjectId === id) {
setSelectedObjectId(null);
setSelectedBone(null);
}
};
const removeLight = (id: string) => {
commit((current) => ({ ...current, lights: current.lights.filter((item) => item.id !== id) }));
if (selectedLightId === id) setSelectedLightId(null);
};
const removeCamera = (id: string) => {
if (!draft || draft.cameras.length <= 1) {
message.warning("至少保留一台摄影机");
return;
}
const fallback = draft.cameras.find((item) => item.id !== id);
if (!fallback) return;
commit((current) => ({
...current,
cameras: current.cameras.filter((item) => item.id !== id),
shots: current.shots.map((shot) => shot.cameraId === id ? { ...shot, cameraId: fallback.id } : shot),
}));
};
/**
* 所有「新增到场景」的唯一入口。
* 在 commit 内读取一次 placement intent:因此模型上传等异步路径拿到的是
* 「点击添加完成那一刻」的意图,而不是发起上传时捕获的过时坐标。
* 锚点只提供 XZ,Y 严格保留构造器给定值,再交给 resolveDirectorPlacement 做碰撞避让。
*/
const addObject = (object: DirectorObject) => {
commit((current) => {
const anchored = resolveDirectorPlacementAnchor({
intent: viewportRef.current?.readPlacementIntent() ?? null,
fallback: object.transform.position,
});
const position = resolveDirectorPlacement({ object: { ...object, transform: { ...object.transform, position: anchored } }, existing: current.objects });
return { ...current, objects: [...current.objects, { ...object, transform: { ...object.transform, position } }] };
});
setSelectedObjectId(object.id);
};
const addPrimitive = (primitive: DirectorObject["primitive"], name: string) => addObject(createDirectorObject(primitive, name));
const addActor = () => {
const actorCount = draft?.objects.filter((item) => item.kind === "actor").length || 0;
addObject(createDirectorActor(`演员 ${actorCount + 1}`, [0, 0, 0], DIRECTOR_ACTOR_COLORS[actorCount % DIRECTOR_ACTOR_COLORS.length]));
};
const addModelAsset = (asset: ModelAsset) => addObject(createDirectorModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType }));
const uploadModel = async (file?: File) => {
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
const uploaded = await uploadMediaFile(file, "model");
const assetId = addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "导演台", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "director" } });
const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model");
if (asset) addModelAsset(asset);
try {
await saveRemoteUserDataNow();
message.success("3D 模型已加入场景和素材库");
} catch (error) {
message.warning(localSavedRemotePendingMessage("3D 模型已加入场景和本机素材库", error));
}
};
const addBillboard = (node: CanvasNodeData) => {
if (!node.metadata?.content) return;
addObject(createDirectorBillboard(node.title, node.metadata.content, node.metadata.storageKey, node.id));
};
const addCamera = () => {
const camera = createDirectorCamera(`摄影机 ${draft?.cameras.length ? draft.cameras.length + 1 : 1}`);
commit((current) => ({ ...current, cameras: [...current.cameras, camera] }));
if (activeShot) updateShot(activeShot.id, { cameraId: camera.id });
};
const addLight = (type: DirectorLight["type"] = "point", label = "灯光", position: DirectorVec3 = [2, 3, 2], intensity = 1.5) => {
const light = createDirectorLight(type, `${label} ${draft?.lights.length ? draft.lights.length + 1 : 1}`, position, intensity);
commit((current) => ({ ...current, lights: [...current.lights, light] }));
setSelectedLightId(light.id);
};
const addCameraMenuItems: MenuProps["items"] = [
{ key: "camera", icon: <Camera className="size-3.5" />, label: "添加摄影机", onClick: addCamera },
];
const addLightMenuItems: MenuProps["items"] = [
{ key: "directional", icon: <Lightbulb className="size-3.5" />, label: "方向光", onClick: () => addLight("directional", "方向光", [4, 6, 4], 2.4) },
{ key: "point", icon: <Lightbulb className="size-3.5" />, label: "点光源", onClick: () => addLight("point", "点光源") },
{ key: "spot", icon: <Lightbulb className="size-3.5" />, label: "聚光灯", onClick: () => addLight("spot", "聚光灯", [2, 4, 2], 2) },
{ key: "ambient", icon: <LampDesk className="size-3.5" />, label: "环境光", onClick: () => addLight("ambient", "环境光", [0, 0, 0], 0.65) },
];
const addObjectMenuItems: MenuProps["items"] = [
{ key: "actor", icon: <UserRound className="size-3.5" />, label: "演员", onClick: addActor },
{ key: "box", icon: <Box className="size-3.5" />, label: "立方体", onClick: () => addPrimitive("box", "立方体") },
{ key: "sphere", icon: <Circle className="size-3.5" />, label: "球体", onClick: () => addPrimitive("sphere", "球体") },
{ key: "cylinder", icon: <Cuboid className="size-3.5" />, label: "圆柱", onClick: () => addPrimitive("cylinder", "圆柱") },
{ key: "model", icon: <FileUp className="size-3.5" />, label: "上传模型", onClick: () => modelInputRef.current?.click() },
];
const addShot = () => {
if (!activeCamera) return;
const shot: DirectorShot = { id: nanoid(), name: `镜头 ${(draft?.shots.length || 0) + 1}`, cameraId: activeCamera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" };
commit((current) => ({ ...current, shots: [...current.shots, shot], activeShotId: shot.id }));
setPlayhead(0);
};
const addObjectKeyframe = () => {
if (!selectedObject) return;
// 取值用 raw playhead(视口真正渲染的时间),写入用 snapped 目的时间。
const record = resolveDirectorKeyframeRecord({ base: selectedObject.transform, keyframes: selectedObject.keyframes, rawTime: playhead, snappedTime: snappedPlayhead });
updateObject(selectedObject.id, { keyframes: record.keyframes });
};
const addCameraKeyframe = () => {
if (!activeCamera) return;
commit((current) => ({
...current,
cameras: current.cameras.map((item) => item.id === activeCamera.id
? { ...item, keyframes: resolveDirectorKeyframeRecord({ base: item.transform, keyframes: item.keyframes, rawTime: playhead, snappedTime: snappedPlayhead }).keyframes }
: item),
}));
};
const recordSelectedKeyframe = () => {
if (selectedObject && selectedBone) {
const rotation = selectedObject.boneOverrides?.[selectedBone as DirectorHumanoidBone] || [0, 0, 0, 1] as DirectorQuat;
updateObject(selectedObject.id, { boneTracks: upsertDirectorBoneKeyframe(selectedObject.boneTracks || [], selectedBone as DirectorHumanoidBone, snappedPlayhead, rotation) });
return;
}
if (selectedObject) addObjectKeyframe();
else addCameraKeyframe();
};
/**
* 时间轴删除关键帧的唯一入口。
*
* 未命中(对象/摄影机/关键帧已不存在)时 removeDirectorSceneKeyframe 返回同一引用,
* 此时不进 commit:不记历史、不产生修订、不触发保存。
*/
const deleteKeyframe = useCallback((target: DirectorKeyframeDeleteTarget) => {
const current = draftRef.current;
if (!current || removeDirectorSceneKeyframe(current, target) === current) return;
commit((scene) => removeDirectorSceneKeyframe(scene, target));
}, [commit]);
const setKeyframeEasing = useCallback((target: DirectorKeyframeDeleteTarget, easing: DirectorKeyframeEasing) => {
const current = draftRef.current;
if (!current || setDirectorSceneKeyframeEasing(current, target, easing) === current) return;
commit((scene) => setDirectorSceneKeyframeEasing(scene, target, easing));
}, [commit]);
/**
* 快捷键执行器。放在 ref 里:监听只在 open 变化时注册一次,
* 但每次渲染都能拿到最新的选择、历史和 draft,避免闭包读到过期状态。
*
* 返回值表示「动作真的执行了」,只有执行了才 preventDefault:
* 没有选中对象时的 Delete 仍然交还给浏览器。
*/
const runShortcut = (action: DirectorShortcutAction): boolean => {
switch (action.kind) {
case "transform-mode":
setTransformMode(action.mode);
return true;
case "delete-selected":
if (selectedObject) {
removeObject(selectedObject.id);
return true;
}
if (selectedLight) {
removeLight(selectedLight.id);
return true;
}
return false;
case "undo":
if (!history.length) return false;
undo();
return true;
case "redo":
if (!future.length) return false;
redo();
return true;
case "toggle-visibility":
if (!selectedObject) return false;
updateObject(selectedObject.id, { visible: !selectedObject.visible });
return true;
case "deselect":
if (!selectedObjectId && !selectedLightId && !selectedBone) return false;
setSelectedObjectId(null);
setSelectedLightId(null);
setSelectedBone(null);
return true;
case "toggle-play":
setPlaying(!playing);
return true;
}
};
const runShortcutRef = useRef(runShortcut);
runShortcutRef.current = runShortcut;
// 导演台是全屏浮层,快捷键挂在 window;焦点落在任何交互控件内时交还给该控件,
// 关键帧按钮再额外拦截自己的 Enter/Space/Delete/Backspace。
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
const action = resolveDirectorShortcut({
key: event.key,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
isInteractiveTarget: blocksDirectorShortcut(event.target),
});
if (!action) return;
if (runShortcutRef.current(action)) event.preventDefault();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
/** 对象 transform 编辑的唯一入口:gizmo 与检查器共用同一套静态/动画语义。 */
const handleObjectTransform = useCallback((id: string, from: DirectorTransform, to: DirectorTransform) => {
commit((current) => ({
...current,
objects: current.objects.map((item) => {
if (item.id !== id) return item;
const edit = resolveDirectorObjectTransformEdit({ base: item.transform, keyframes: item.keyframes, rendered: from, edited: to, autoKey, time: snappedPlayhead });
return { ...item, transform: edit.transform, keyframes: edit.keyframes };
}),
}));
}, [autoKey, commit, snappedPlayhead]);
/** 骨骼写入语义:静态覆盖 + autoKey 时在吸附播放头补关键帧。gizmo 与数值编辑器共用。 */
const writeBoneRotation = useCallback((id: string, bone: string, rotation: DirectorQuat, mode: "stage" | "commit") => {
const write = mode === "stage" ? stageGesture : commit;
write((current) => ({
...current,
objects: current.objects.map((item) => item.id === id ? {
...item,
boneOverrides: { ...item.boneOverrides, [bone]: rotation },
boneTracks: autoKey ? upsertDirectorBoneKeyframe(item.boneTracks || [], bone as DirectorHumanoidBone, snappedPlayhead, rotation) : item.boneTracks,
} : item),
}));
}, [autoKey, commit, snappedPlayhead, stageGesture]);
const handleBoneTransform = useCallback((id: string, bone: string, rotation: DirectorQuat) => writeBoneRotation(id, bone, rotation, "commit"), [writeBoneRotation]);
const handleActorRigReady = useCallback((id: string, rig: DirectorRig, animations: AnimationClip[]) => {
replaceWithoutHistory((current) => ({
...current,
objects: current.objects.map((item) => {
if (item.id !== id) return item;
const existing = item.motionClips || [];
const motionClips = existing.length ? existing : animations.map((clip) => ({ id: nanoid(), name: clip.name || "动作片段", sourceAnimation: clip.name, start: 0, duration: Math.max(0.1, clip.duration), playbackRate: 1, loop: true }));
return { ...item, rig, motionClips };
}),
}));
}, [replaceWithoutHistory]);
const applyCameraMove = () => {
if (!activeCamera || !activeShot) return;
const cameraId = activeCamera.id;
const move = activeShot.cameraMove;
const duration = activeShot.duration;
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === cameraId ? { ...item, keyframes: resolveDirectorCameraMoveKeyframes(item.keyframes, item.transform, cameraMoveTransform(item.transform, move), duration) } : item) }));
message.success("已更新运镜首尾关键帧,可在动画模式继续编辑");
};
const alignCameraToView = () => {
if (!activeCamera) return;
const transform = viewportRef.current?.readCameraTransform();
if (!transform) return;
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? resolveDirectorCameraAlignment(item, transform, snappedPlayhead) : item) }));
message.success("摄影机已对齐当前视图");
};
const applyToCanvas = async () => {
stagedTransaction.end("commit");
const current = draftRef.current;
if (!current || !activeShot || !viewportRef.current) return;
const expected = { scene: current, shotId: activeShot.id };
setSaving(true);
try {
const beauty = await viewportRef.current.capture("beauty");
if (!isDirectorOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("输出期间场景或镜头已变化,请重试");
const prompt = compileDirectorPrompt(current, activeShot);
// 先镜像最新 scene,再做 canvas 输出;失败时 draft 保留可继续重试。
const next = touchDirectorScene(current);
writeAndPublish(next);
await onApply({ scene: next, shot: activeShot, prompt, beauty });
message.success("导演台构图已回写画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "导演台输出失败");
} finally {
setSaving(false);
}
};
const exportClayVideo = async () => {
stagedTransaction.end("commit");
const current = draftRef.current;
if (!current || !activeShot || !viewportRef.current || recording) return;
const expected = { scene: current, shotId: activeShot.id };
setRecording(true);
const wasPlaying = playing;
const previousPlayhead = playhead;
setPlayhead(0);
setPlaying(true);
try {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const clayVideo = await viewportRef.current.recordVideo(activeShot.duration, activeShot.fps);
if (!isDirectorOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("录制期间场景或镜头已变化,请重试");
const next = touchDirectorScene(draftRef.current || current);
writeAndPublish(next);
const beauty = await viewportRef.current.capture("beauty");
if (!isDirectorOutputSnapshotCurrent(draftRef.current, { scene: next, shotId: expected.shotId })) throw new Error("输出期间场景或镜头已变化,请重试");
await onApply({ scene: next, shot: activeShot, prompt: compileDirectorPrompt(next, activeShot), beauty, clayVideo, clayVideoMimeType: clayVideo.type });
message.success("白膜视频已回写画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "白膜视频导出失败");
} finally {
setPlaying(wasPlaying);
setPlayhead(previousPlayhead);
setRecording(false);
}
};
const quickActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
const quickSelectedActor = quickActors.find((item) => item.id === selectedObjectId) || quickActors[0] || null;
const handleQuickGroundClick = useCallback((point: DirectorGroundPoint) => {
if (!quickSelectedActor) {
message.info("先添加或选择一个演员");
return;
}
setSelectedObjectId(quickSelectedActor.id);
handleObjectTransform(quickSelectedActor.id, quickSelectedActor.transform, {
...quickSelectedActor.transform,
position: [point.x, quickSelectedActor.transform.position[1], point.z],
});
message.success("演员已落位");
}, [handleObjectTransform, message, quickSelectedActor]);
const quickPoseOptions: Array<{ value: DirectorPose; label: string }> = [
{ value: "stand", label: "站立" },
{ value: "sit", label: "坐下" },
{ value: "walk", label: "走动" },
{ value: "wave", label: "挥手" },
{ value: "think", label: "思考" },
{ value: "fight", label: "对抗" },
];
const applyQuickPose = (pose: DirectorPose) => {
if (!quickSelectedActor) {
message.info("先添加或选择一个演员");
return;
}
setSelectedObjectId(quickSelectedActor.id);
updateObject(quickSelectedActor.id, { pose, activeMotionClipId: undefined, boneOverrides: {} });
};
const placeQuickActor = () => {
if (!quickSelectedActor) {
message.info("先添加或选择一个演员");
return;
}
const intent = viewportRef.current?.readPlacementIntent();
const point = intent?.pointer || intent?.orbitTarget;
if (!point) {
message.info("先在视口里移动鼠标到地面,或旋转视图后再落位");
return;
}
setSelectedObjectId(quickSelectedActor.id);
handleObjectTransform(quickSelectedActor.id, quickSelectedActor.transform, { ...quickSelectedActor.transform, position: [point.x, quickSelectedActor.transform.position[1], point.z] });
};
const addQuickActor = () => {
addActor();
message.success("演员已加入;直接点击视口地面即可落位");
};
const selectQuickShotSize = (shotSize: DirectorShotSize) => {
if (activeShot) updateShot(activeShot.id, { shotSize });
};
const selectQuickCameraMove = (move: DirectorCameraMove) => {
if (activeShot) updateShot(activeShot.id, { cameraMove: move });
};
useEffect(() => {
const onPreviewRequested = (event: Event) => {
const detail = (event as CustomEvent<{ sceneId?: string; shotId?: string; duration?: number; fps?: number }>).detail;
if (!detail || detail.sceneId !== draft?.id || (detail.shotId && detail.shotId !== activeShot?.id)) return;
void exportClayVideo();
};
window.addEventListener("director:preview-requested", onPreviewRequested);
return () => window.removeEventListener("director:preview-requested", onPreviewRequested);
}, [activeShot?.id, draft?.id, exportClayVideo]);
if (!open || !draft || !activeShot) return null;
return (
<div data-canvas-director-workbench data-canvas-no-zoom className="fixed inset-0 z-[var(--z-toast)] flex min-h-0 flex-col overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<header className="thin-scrollbar flex h-12 shrink-0 items-center gap-2 overflow-x-auto overflow-y-hidden border-b px-2" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
<IconButton label="关闭导演台" onClick={closeWorkbench}><X className="size-4" /></IconButton>
<Input variant="borderless" value={draft.title} className="max-w-56 font-medium" onChange={(event) => replaceWithoutHistory((current) => ({ ...current, title: event.target.value }))} />
<span className="h-5 w-px" style={{ background: theme.toolbar.border }} />
<IconButton label="撤销" disabled={!history.length} onClick={undo}><Undo2 className="size-4" /></IconButton>
<IconButton label="重做" disabled={!future.length} onClick={redo}><Redo2 className="size-4" /></IconButton>
<span className="h-5 w-px" style={{ background: theme.toolbar.border }} />
{/* 一级模式切换:小屏也必须可达,因此不加 max-lg:hidden。 */}
<button
type="button"
className={`director-mode-switch-button ${quickMode ? "is-active" : ""}`}
aria-pressed={quickMode}
title="用镜头优先的最短路径完成摆位、姿态和预演"
onClick={() => setQuickMode(true)}
>
快速镜头
</button>
<button
type="button"
className={`director-mode-switch-button ${!quickMode ? "is-active" : ""}`}
aria-pressed={!quickMode}
title="打开完整的摆场、姿态、动画和摄影机工具"
onClick={() => setQuickMode(false)}
>
高级工作台
</button>
{!quickMode ? <nav className="director-mode-switch" aria-label="导演台模式">
{DIRECTOR_MODES.map((item) => (
<button
key={item.mode}
type="button"
data-mode={item.mode}
className={`director-mode-switch-button ${mode === item.mode ? "is-active" : ""}`}
aria-pressed={mode === item.mode}
title={item.hint}
onClick={(event) => {
setMode(item.mode);
// 焦点留在模式按钮上会让守卫吃掉 W/E/R/Delete。
releaseDirectorFocusAfterPointer(event);
}}
>
{item.label}
</button>
))}
</nav> : null}
<div className="ml-auto flex items-center gap-2">
<span
aria-live="polite"
className="text-[var(--fs-tiny)]"
style={{ color: saveIndicator.tone === "danger" ? "var(--status-error)" : undefined, opacity: saveIndicator.tone === "idle" ? 0.55 : 1 }}
>
{saveIndicator.label}
</span>
{saveIndicator.retryable ? <Button size="small" icon={<RotateCcw className="size-3.5" />} loading={retrying || saveIndicator.busy} onClick={() => void retrySave()}>重试保存</Button> : null}
</div>
<div className="flex items-center gap-1">
{onboardingScope ? <IconButton label="重新开始引导" onClick={() => setOnboardingRestartSignal((value) => value + 1)}><Lightbulb className="size-4" /></IconButton> : null}
<Select size="small" value={renderMode} className="w-24" options={renderModeOptions} onChange={setRenderMode} />
<Button size="small" icon={<Video className="size-3.5" />} loading={recording} onClick={() => void exportClayVideo()}>导出白膜</Button>
<Button size="small" type="primary" icon={<Save className="size-3.5" />} loading={saving} onClick={() => void applyToCanvas()}>应用到镜头</Button>
</div>
</header>
{quickMode ? <div className="grid min-h-0 flex-1 grid-cols-[minmax(220px,280px)_minmax(0,1fr)_minmax(260px,320px)] max-lg:grid-cols-[180px_minmax(0,1fr)]">
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r p-3" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold">镜头对象</div>
<div className="text-[var(--fs-tiny)] opacity-55">先解决站位和关系,不先建复杂模型</div>
</div>
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} onClick={addQuickActor}>演员</Button>
</div>
<div className="space-y-1">
{quickActors.map((actor) => <button key={actor.id} type="button" className={`flex w-full items-center gap-2 rounded-md border px-2.5 py-2 text-left text-sm transition-colors ${quickSelectedActor?.id === actor.id ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent hover:bg-black/5 dark:hover:bg-white/5"}`} onClick={() => setSelectedObjectId(actor.id)}>
<span className="flex size-7 items-center justify-center rounded-full text-white" style={{ background: actor.color }}><UserRound className="size-3.5" /></span>
<span className="min-w-0 flex-1 truncate">{actor.name}</span>
<span className="text-[var(--fs-tiny)] opacity-45">{actor.pose ? directorPoseLabel(actor.pose) : "站立"}</span>
</button>)}
</div>
{!quickActors.length ? <div className="rounded-lg border border-dashed p-4 text-center text-xs opacity-60">还没有演员<br />先加一个人偶,再用镜头完成预演</div> : null}
<div className="mt-5 rounded-lg border p-3" style={{ borderColor: theme.toolbar.border }}>
<div className="mb-2 flex items-center gap-2 text-sm font-medium"><MousePointer2 className="size-4" />快速落位</div>
<p className="mb-3 text-xs leading-5 opacity-60">直接点击视口地面即可落位;也可以先移动鼠标,再用按钮落到当前指针位置。无需输入 XYZ。</p>
<Button block size="small" disabled={!quickSelectedActor} onClick={placeQuickActor}>按当前指针落位</Button>
</div>
</aside>
<main className="relative min-h-0 overflow-hidden bg-neutral-900">
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={quickSelectedActor?.id || null} selectedBone={null} transformMode="translate" renderMode="clay" playhead={0} playing={false} showMotionPaths={false} viewMode={viewMode} onViewModeChange={setViewMode} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onGroundClick={handleQuickGroundClick} onObjectTransform={handleObjectTransform} onBoneTransform={handleBoneTransform} onActorRigReady={handleActorRigReady} />
<div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2 rounded-md bg-black/35 px-2.5 py-1.5 text-xs font-medium text-white/80"><WandSparkles className="size-3.5" />{activeShot.name} · {activeCamera?.name || "无摄影机"}</div>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-lg border border-white/10 bg-black/55 p-1.5 text-white shadow-lg">
<span className="px-2 text-xs text-white/65">白模预演</span>
<Button size="small" ghost icon={<Video className="size-3.5" />} loading={recording} onClick={() => void exportClayVideo()}>生成预演</Button>
<Button size="small" type="primary" icon={<Save className="size-3.5" />} loading={saving} onClick={() => void applyToCanvas()}>应用镜头</Button>
</div>
</main>
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-l p-3" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<div className="mb-4 flex items-center justify-between"><div><div className="text-sm font-semibold">镜头卡</div><div className="text-[var(--fs-tiny)] opacity-55">用语义参数替代工程参数</div></div><Camera className="size-4 opacity-45" /></div>
<div className="mb-4 grid grid-cols-3 gap-1.5">{(["wide", "full", "medium", "close_up", "extreme_close_up"] as DirectorShotSize[]).map((size) => <button key={size} type="button" className={`rounded-md border px-2 py-2 text-xs ${activeShot.shotSize === size ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent bg-black/5 dark:bg-white/5"}`} onClick={() => selectQuickShotSize(size)}>{({ wide: "全景", full: "全身", medium: "中景", close_up: "近景", extreme_close_up: "特写" } as Record<string, string>)[size]}</button>)}</div>
<div className="mb-4"><div className="mb-2 text-xs font-medium opacity-65">姿态</div><div className="grid grid-cols-3 gap-1.5">{quickPoseOptions.map((option) => <button key={option.value} type="button" disabled={!quickSelectedActor} className={`rounded-md border px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-35 ${quickSelectedActor?.pose === option.value ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent bg-black/5 dark:bg-white/5"}`} onClick={() => applyQuickPose(option.value)}>{option.label}</button>)}</div></div>
<div className="mb-4"><div className="mb-2 text-xs font-medium opacity-65">运镜</div><Select className="w-full" size="small" value={activeShot.cameraMove} options={[{ value: "static", label: "固定" }, { value: "push_in", label: "推进" }, { value: "pull_out", label: "拉远" }, { value: "pan_left", label: "左摇" }, { value: "pan_right", label: "右摇" }, { value: "handheld", label: "手持" }]} onChange={selectQuickCameraMove} /></div>
<div className="space-y-2"><Button block size="small" icon={<Focus className="size-3.5" />} onClick={alignCameraToView}>用当前视图设机位</Button><Button block size="small" onClick={applyCameraMove}>生成运镜</Button></div>
<div className="mt-5 rounded-lg border p-3 text-xs leading-5 opacity-70" style={{ borderColor: theme.toolbar.border }}><div className="mb-1 font-medium opacity-100">给视频模型的参考</div>白模只负责镜头、动作和空间关系;生成真实人体比例,不沿用白模轮廓。</div>
</aside>
</div> : null}
{!quickMode ? <>
<div className="grid min-h-0 flex-1 grid-cols-[220px_minmax(0,1fr)_292px] max-lg:grid-cols-[180px_minmax(0,1fr)]">
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<PanelTitle title="场景对象" action={<AddMenuButton label="添加场景对象" items={addObjectMenuItems} />} />
<div className="px-2 pb-2">
{draft.objects.map((object) => <SceneRow key={object.id} active={selectedObjectId === object.id} icon={object.kind === "actor" || object.primitive === "character" ? <UserRound /> : object.kind === "model" ? <BoxSelect /> : object.kind === "billboard" ? <ImageIcon /> : <Cuboid />} label={object.name} onClick={() => setSelectedObjectId(object.id)} onDelete={() => removeObject(object.id)} />)}
</div>
<PanelTitle title="摄影机" action={<AddMenuButton label="添加摄影机" items={addCameraMenuItems} />} />
<div className="px-2 pb-2">{draft.cameras.map((camera) => <SceneRow key={camera.id} active={activeShot.cameraId === camera.id && !selectedObjectId && !selectedLightId} icon={<Camera />} label={camera.name} onClick={() => { setSelectedObjectId(null); setSelectedLightId(null); updateShot(activeShot.id, { cameraId: camera.id }); }} onDelete={() => removeCamera(camera.id)} />)}</div>
<PanelTitle title="灯光" action={<AddMenuButton label="添加灯光" items={addLightMenuItems} />} />
<div className="px-2 pb-2">{draft.lights.map((light) => <SceneRow key={light.id} active={selectedLightId === light.id} icon={<Lightbulb />} label={light.name} onClick={() => setSelectedLightId(light.id)} onDelete={() => removeLight(light.id)} />)}</div>
<PanelTitle title="快速添加" />
<div className="grid grid-cols-2 gap-1.5 px-2 pb-3">
<QuickAdd label="演员" icon={<UserRound />} onClick={addActor} />
<QuickAdd label="立方体" icon={<Box />} onClick={() => addPrimitive("box", "立方体")} />
<QuickAdd label="球体" icon={<Circle />} onClick={() => addPrimitive("sphere", "球体")} />
<QuickAdd label="圆柱" icon={<Cuboid />} onClick={() => addPrimitive("cylinder", "圆柱")} />
<QuickAdd label="上传模型" icon={<FileUp />} onClick={() => modelInputRef.current?.click()} />
<QuickAdd label="添加灯光" icon={<LampDesk />} onClick={addLight} />
</div>
{modelAssets.length ? <><PanelTitle title="3D 素材" /><div className="px-2 pb-3">{modelAssets.map((asset) => <SceneRow key={asset.id} icon={<BoxSelect />} label={asset.title} onClick={() => addModelAsset(asset)} />)}</div></> : null}
{imageNodes.length ? <><PanelTitle title="画布图片立牌" /><div className="px-2 pb-3">{imageNodes.slice(0, 20).map((node) => <SceneRow key={node.id} icon={<ImageIcon />} label={node.title} onClick={() => addBillboard(node)} onDelete={() => onDeleteImageNode(node.id)} />)}</div></> : null}
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void uploadModel(event.target.files?.[0]); event.currentTarget.value = ""; }} />
</aside>
<main className="relative min-h-0 overflow-hidden bg-neutral-900">
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={selectedObjectId} selectedBone={selectedBone} transformMode={transformMode} renderMode={renderMode} playhead={playhead} playing={playing} showMotionPaths={capabilities.timeline} viewMode={viewMode} onViewModeChange={setViewMode} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onObjectTransform={handleObjectTransform} onBoneTransform={handleBoneTransform} onActorRigReady={handleActorRigReady} />
<div className="pointer-events-none absolute left-3 top-3 text-[var(--fs-tiny)] font-medium text-white/70">{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s</div>
<CanvasDirectorOnboarding scope={onboardingScope} open={open} restartSignal={onboardingRestartSignal} className="absolute right-3 top-3 z-[var(--z-popover)] w-[min(360px,calc(100%-24px))]" />
<DirectorViewportDock transformMode={transformMode} renderMode={renderMode} renderModes={capabilities.renderModes} onTransformModeChange={setTransformMode} onRenderModeChange={setRenderMode} onAddActor={addActor} onAddBox={() => addPrimitive("box", "立方体")} onAddLight={addLight} onAddCamera={addCamera} onAlignCamera={alignCameraToView} />
</main>
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-l max-lg:col-span-2 max-lg:max-h-[40vh] max-lg:border-l-0 max-lg:border-t" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
{/* 摄影机模式下右栏固定显示 shot/camera 检查器:对齐视图与运镜是这个模式的主入口。 */}
{selectedObject && !capabilities.cameraTools ? <ObjectInspector object={selectedObject} rendered={selectedObjectRendered || selectedObject.transform} playhead={snappedPlayhead} selectedBone={selectedBone} capabilities={capabilities} onSelectBone={setSelectedBone} onUpdate={(patch) => updateObject(selectedObject.id, patch)} onTransformEdit={(edited) => handleObjectTransform(selectedObject.id, selectedObjectRendered || selectedObject.transform, edited)} onBoneRotationStage={(rotation) => selectedBone && writeBoneRotation(selectedObject.id, selectedBone, rotation, "stage")} onBoneRotationCommit={() => stagedTransaction.end("commit")} onAddKeyframe={recordSelectedKeyframe} onDelete={() => removeObject(selectedObject.id)} /> : selectedLight && !capabilities.cameraTools ? <LightInspector light={selectedLight} onUpdate={(patch) => updateLight(selectedLight.id, patch)} onDelete={() => removeLight(selectedLight.id)} /> : <ShotInspector shot={activeShot} camera={activeCamera} cameras={draft.cameras} capabilities={capabilities} onUpdateShot={(patch) => updateShot(activeShot.id, patch)} onUpdateCamera={(patch) => activeCamera && commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, ...patch } : item) }))} onAddCameraKeyframe={addCameraKeyframe} onApplyCameraMove={applyCameraMove} onAlignCameraToView={alignCameraToView} onExportClay={() => void exportClayVideo()} recording={recording} />}
</aside>
</div>
{/* 时间轴只属于动画模式:其他模式下它不渲染,Auto Key 与录制入口一并消失。 */}
{capabilities.timeline ? <DirectorSequencer scene={draft} shot={activeShot} camera={activeCamera} objects={draft.objects} selectedObjectId={selectedObjectId} selectedBone={selectedBone} playhead={playhead} playing={playing} autoKey={autoKey} height={sequencerHeight} visible={sequencerVisible} onPlayToggle={() => setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onDeleteKeyframe={deleteKeyframe} onSetKeyframeEasing={setKeyframeEasing} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} /> : null}
</> : null}
</div>
);
}
/** 渲染视图全集。实际可选项由当前模式的 capabilities.renderModes 过滤。 */
const DIRECTOR_RENDER_MODE_LABELS: Array<{ label: string; value: DirectorRenderMode }> = [
{ label: "预览", value: "beauty" },
{ label: "彩色白膜", value: "clay" },
{ label: "骨骼", value: "pose" },
{ label: "深度", value: "depth" },
{ label: "法线", value: "normal" },
];
function cameraMoveTransform(transform: DirectorTransform, move: DirectorCameraMove): DirectorTransform {
const [x, y, z] = transform.position;
const offsets: Record<DirectorCameraMove, DirectorVec3> = { static: [0, 0, 0], push_in: [0, 0, -2], pull_out: [0, 0, 2], pan_left: [-2, 0, 0], pan_right: [2, 0, 0], tilt_up: [0, 1.5, 0], tilt_down: [0, -1.2, 0], orbit_left: [-2.5, 0, -1.5], orbit_right: [2.5, 0, -1.5], handheld: [0.18, 0.08, -0.15] };
const offset = offsets[move];
return { ...transform, position: [x + offset[0], y + offset[1], z + offset[2]] };
}

View File

@@ -1,55 +0,0 @@
import { Tooltip } from "@/components/ui/base/tooltip";
import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts";
import { DIRECTOR_VIEW_MODES, type DirectorViewMode } from "@/lib/canvas/director/director-view-modes";
type DirectorViewToolbarProps = {
viewMode: DirectorViewMode;
onViewModeChange: (mode: DirectorViewMode) => void;
};
/**
* 取景模式切换(3D / CAM)。
*
* 独立于底部 dock:dock 装的是「改内容」的工具(变换、添加、渲染视图),
* 这里只切换「从哪只眼睛看」,不产生任何场景改动,也不进 undo/history。
*
* 用文字标签而不是图标:3D 与 CAM 是两个含义相反的取景状态,图标化只会更难认。
* 因此不复用 .director-viewport-dock-button —— 那条规则写死了正方形尺寸且未分层,
* Tailwind 工具类改不动它。这里用同一批 --director-* token 自行排布,不动 globals.css。
*/
export function DirectorViewToolbar({ viewMode, onViewModeChange }: DirectorViewToolbarProps) {
return (
<div
role="group"
aria-label="导演台取景模式"
className="absolute right-3 top-3 z-[var(--z-toolbar)] inline-flex items-center gap-1 rounded-[var(--r-lg)] border p-1 shadow-xl backdrop-blur"
style={{ borderColor: "var(--director-sequencer-border)", background: "var(--director-dock-surface)", color: "var(--director-dock-fg)" }}
>
{DIRECTOR_VIEW_MODES.map((item) => {
const active = viewMode === item.mode;
return (
<Tooltip key={item.mode} title={item.hint} placement="bottom">
<button
type="button"
// aria-pressed 而不是 type="primary" 语义:这是持久的取景状态切换,
// 不是「当前主要命令」。屏幕阅读器要能读出哪一只眼睛是开着的。
aria-pressed={active}
aria-label={`${item.label} ${item.hint}`}
title={item.hint}
className="inline-flex h-8 min-w-11 items-center justify-center rounded-[var(--r-md)] px-2 text-[var(--fs-tiny)] font-semibold tracking-wide transition-colors hover:bg-[var(--director-control-hover)] hover:text-[var(--director-dock-fg-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--control-focus-ring)] motion-reduce:transition-none"
style={active ? { background: "var(--director-dock-active-surface)", color: "var(--director-dock-fg-strong)" } : undefined}
onClick={(event) => {
onViewModeChange(item.mode);
// 焦点留在按钮上会让交互控件守卫吃掉 W/E/R 变换快捷键。
releaseDirectorFocusAfterPointer(event);
}}
>
{item.label}
</button>
</Tooltip>
);
})}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
/* 画布上的预演台节点卡片样式。必须随节点面板加载,不能依赖懒加载的预演台工作台 CSS。 */
.previs-node-card { display: flex; width: 100%; height: 100%; min-width: 0; min-height: 0; flex-direction: column; gap: 10px; padding: 16px 16px 14px; overflow: hidden; background: linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.018)); }
.previs-node-card-header, .previs-node-card-title-row, .previs-node-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.previs-node-card-kicker, .previs-node-card-status, .previs-node-card-footer-action, .previs-node-card-duration { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.previs-node-card-kicker { color: color-mix(in srgb, currentColor 62%, transparent); font-size: 9px; font-weight: 700; letter-spacing: .14em; }
.previs-node-card-mark { display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; background: rgba(255,255,255,.07); color: currentColor; }
.previs-node-card-status { color: color-mix(in srgb, currentColor 62%, transparent); font-size: 10px; }
.previs-node-card-title-row { align-items: flex-end; }
.previs-node-card-title { margin: 0; overflow: hidden; color: currentColor; font-size: 16px; font-weight: 700; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; }
.previs-node-card-shot { margin: 4px 0 0; overflow: hidden; color: color-mix(in srgb, currentColor 56%, transparent); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.previs-node-card-duration { color: color-mix(in srgb, currentColor 65%, transparent); font-size: 10px; }
.previs-node-preview { position: relative; display: flex; min-height: 0; flex: 1 1 auto; width: 100%; overflow: hidden; border: 1px solid; border-radius: 14px; text-align: left; cursor: pointer; }
.previs-node-preview:disabled { cursor: default; }
.previs-node-preview-image { width: 100%; height: 100%; object-fit: cover; }
.previs-node-preview-cta { position: absolute; inset-inline: 0; bottom: 0; display: flex; height: 38px; align-items: center; justify-content: center; gap: 6px; opacity: 0; font-size: 11px; font-weight: 700; transition: opacity 160ms cubic-bezier(.16,1,.3,1); }
.previs-node-preview:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.previs-node-card-footer { min-height: 24px; border-top: 1px solid; padding-top: 9px; color: color-mix(in srgb, currentColor 62%, transparent); font-size: 10px; }
.previs-node-card-footer b { color: currentColor; font-variant-numeric: tabular-nums; }
.previs-node-card-footer-action { margin-left: auto; color: color-mix(in srgb, currentColor 48%, transparent); }
.previs-node-preview .previs-mini-viewport-canvas,
.previs-node-preview .previs-mini-viewport-canvas canvas { position: absolute !important; inset: 0; width: 100% !important; height: 100% !important; }
.previs-node-preview { min-height: 120px; }

View File

@@ -0,0 +1,86 @@
import { useState, type ReactNode } from "react";
import { Box, Camera, Clapperboard, Lightbulb, LockKeyhole, Move3d, Sparkles, Timer } from "lucide-react";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { gatePrevisPreviewFailure, resolvePrevisActiveShot, resolvePrevisPreviewSource, type PrevisNodeContentReader } from "@/lib/canvas/previs/previs-preview";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { PrevisScene } from "@/types/previs";
import { PrevisMiniViewport } from "./previs-mini-viewport";
import "./canvas-previs-node-card.css";
export function CanvasPrevisNodePanel({ node, scene, readNodeContent, onOpen, professional = true }: { node: CanvasNodeData; scene: PrevisScene | null; readNodeContent: PrevisNodeContentReader; onOpen: () => void; professional?: boolean }) {
const theme = canvasThemes[useActiveTheme()];
const shot = resolvePrevisActiveShot(scene, node.metadata?.previsShotId);
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const preview = gatePrevisPreviewFailure(
resolvePrevisPreviewSource({ scene, shot, previewNodeId: node.metadata?.previsPreviewNodeId, readNodeContent }),
failedUrl,
);
const title = node.metadata?.workflowTitle || node.title;
const objectCount = scene?.objects.filter((item) => item.visible !== false).length || 0;
const cameraCount = scene?.cameras.length || 0;
const lightCount = scene?.lights.length || 0;
return (
<div className="previs-node-card" style={{ color: theme.node.text }}>
<header className="previs-node-card-header">
<div className="previs-node-card-kicker"><span className="previs-node-card-mark"><Clapperboard className="size-3.5" /></span><span>PREVIS / 3D</span></div>
<span className="previs-node-card-status"><span className="size-1.5 rounded-full bg-emerald-400" />可编辑</span>
</header>
<div className="previs-node-card-title-row">
<div className="min-w-0">
<h3 className="previs-node-card-title" title={title}>{title}</h3>
<p className="previs-node-card-shot" title={shot?.name || "未设置镜头"}>{shot?.name || "未设置镜头"}</p>
</div>
<span className="previs-node-card-duration"><Timer className="size-3" />{shot?.duration ?? 0}s</span>
</div>
<button
type="button"
data-canvas-no-zoom
className="previs-node-preview group"
style={{ background: theme.node.fill, borderColor: theme.node.stroke }}
title={professional ? "打开 3D 预演台" : "切换到专业模式后编辑预演台"}
disabled={!professional}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onOpen(); }}
>
{preview.kind === "image"
? <CachedResourceImage src={preview.url} storageKey={preview.storageKey} alt={`${title} 已回写的预演台构图`} className="previs-node-preview-image" draggable={false} eager fallback={<PrevisPreviewState kind="empty" theme={theme} />} onError={() => setFailedUrl(preview.url || preview.storageKey || "" )} />
: scene && scene.objects.length > 0
? <PrevisMiniViewport scene={scene} />
: <PrevisPreviewState kind={preview.kind} theme={theme} />}
<span className={`previs-node-preview-cta ${professional ? "group-hover:opacity-100 group-focus-visible:opacity-100" : "opacity-100"}`} style={{ background: `${theme.toolbar.panel}e8`, color: theme.node.text }}>
{professional ? <><Move3d className="size-3.5" />进入预演台</> : <><LockKeyhole className="size-3.5" />专业模式可编辑</>}
</span>
</button>
<footer className="previs-node-card-footer" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
<Stat icon={<Box className="size-3" />} value={objectCount} label="对象" />
<Stat icon={<Camera className="size-3" />} value={cameraCount} label="机位" />
<Stat icon={<Lightbulb className="size-3" />} value={lightCount} label="灯光" />
<span className="previs-node-card-footer-action"><Sparkles className="size-3" />双击打开</span>
</footer>
</div>
);
}
function PrevisPreviewState({ kind, theme }: { kind: "loading" | "empty"; theme: CanvasTheme }) {
const loading = kind === "loading";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
<Clapperboard className="size-4" style={{ color: theme.node.muted }} aria-hidden />
</span>
<span className="max-w-full truncate text-xs font-semibold" style={{ color: theme.node.text }}>{loading ? "正在准备场景" : "尚未生成预览"}</span>
<span className="max-w-full text-[11px] leading-4" style={{ color: theme.node.muted }}>{loading ? "场景数据加载完成后显示" : "进入预演台并回写构图后显示"}</span>
</div>
);
}
function Stat({ icon, value, label }: { icon: ReactNode; value: number; label: string }) {
return <span className="inline-flex min-w-0 items-center gap-1" title={`${value} 个${label}`}>{icon}<b>{value}</b><span>{label}</span></span>;
}

View File

@@ -1,19 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
advanceDirectorOnboarding,
createDirectorOnboardingGate,
loadDirectorOnboardingProgress,
resetDirectorOnboardingProgress,
resolveDirectorOnboardingView,
type DirectorOnboardingAction,
type DirectorOnboardingProgress,
type DirectorOnboardingStorage,
type DirectorOnboardingView,
} from "@/lib/canvas/director/director-onboarding";
advancePrevisOnboarding,
createPrevisOnboardingGate,
loadPrevisOnboardingProgress,
resetPrevisOnboardingProgress,
resolvePrevisOnboardingView,
type PrevisOnboardingAction,
type PrevisOnboardingProgress,
type PrevisOnboardingStorage,
type PrevisOnboardingView,
} from "@/lib/canvas/previs/previs-onboarding";
/**
* 导演台首次上手引导浮层。
* 预演台首次上手引导浮层。
*
* 硬约束:
* - 非阻塞。不是 dialog,没有遮罩,不抢焦点,不做焦点陷阱:引导旁边就是要动手的工作台,
@@ -23,9 +23,9 @@ import {
* - 读不到持久进度(IndexedDB 失败)时不展示:无法确认用户是否已经跳过,就不要再骚扰他。
*/
type DirectorOnboardingController = {
type PrevisOnboardingController = {
/** null 表示不展示:未加载完、已跳过、已完成、或持久层不可用。 */
view: DirectorOnboardingView | null;
view: PrevisOnboardingView | null;
/** 有动作在写盘。用于禁用按钮,避免连点产生并发写。 */
busy: boolean;
next: () => void;
@@ -53,11 +53,11 @@ type DirectorOnboardingController = {
* `gateRef.current` 同步捕获进局部变量 `gate`,tryEnter/release 全部作用在这份捕获上,
* 绝不在 .then/.catch/.finally 里重新读取 `gateRef.current`。
*/
export function useDirectorOnboarding({ scope, enabled = true, storage }: { scope: string; enabled?: boolean; storage?: DirectorOnboardingStorage }): DirectorOnboardingController {
const [progress, setProgress] = useState<DirectorOnboardingProgress | null>(null);
export function usePrevisOnboarding({ scope, enabled = true, storage }: { scope: string; enabled?: boolean; storage?: PrevisOnboardingStorage }): PrevisOnboardingController {
const [progress, setProgress] = useState<PrevisOnboardingProgress | null>(null);
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const gateRef = useRef(createDirectorOnboardingGate());
const gateRef = useRef(createPrevisOnboardingGate());
const normalizedScope = scope.trim();
const active = enabled && normalizedScope.length > 0;
@@ -65,11 +65,11 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop
generation.current += 1;
const current = generation.current;
// 换新实例而不是 release 旧实例:旧实例可能仍被上一代在途的写入持有。
gateRef.current = createDirectorOnboardingGate();
gateRef.current = createPrevisOnboardingGate();
setProgress(null);
setBusy(false);
if (!active) return;
void loadDirectorOnboardingProgress(normalizedScope, storage)
void loadPrevisOnboardingProgress(normalizedScope, storage)
.then((loaded) => {
if (generation.current === current) setProgress(loaded);
})
@@ -79,7 +79,7 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop
}, [active, normalizedScope, storage]);
const run = useCallback(
(action: DirectorOnboardingAction) => {
(action: PrevisOnboardingAction) => {
if (!active || !progress) return;
// 同步捕获本次调用时刻的锁实例:即使换代把 gateRef.current 换成新实例,
// 这次调用后续的 tryEnter/release 也只作用在捕获到的这一份上。
@@ -88,7 +88,7 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop
if (!gate.tryEnter()) return;
const current = generation.current;
setBusy(true);
void advanceDirectorOnboarding(normalizedScope, progress, action, storage)
void advancePrevisOnboarding(normalizedScope, progress, action, storage)
.then((next) => {
if (generation.current === current) setProgress(next);
})
@@ -119,7 +119,7 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop
generation.current += 1;
const current = generation.current;
setBusy(true);
void resetDirectorOnboardingProgress(normalizedScope, storage)
void resetPrevisOnboardingProgress(normalizedScope, storage)
.then((next) => {
if (generation.current === current) setProgress(next);
})
@@ -127,7 +127,7 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop
if (generation.current !== current) return;
// 重置失败后把同一 scope 的持久进度重新读回来。重置开始时已经让旧读取
// 失效,若这里不补读,加载期触发重置的用户会一直停在 progress=null。
return loadDirectorOnboardingProgress(normalizedScope, storage)
return loadPrevisOnboardingProgress(normalizedScope, storage)
.then((loaded) => {
if (generation.current === current) setProgress(loaded);
})
@@ -141,10 +141,10 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop
});
}, [active, normalizedScope, storage]);
return { view: progress ? resolveDirectorOnboardingView(progress) : null, busy, next, back, dismiss, complete, reset };
return { view: progress ? resolvePrevisOnboardingView(progress) : null, busy, next, back, dismiss, complete, reset };
}
/** 焦点环与既有 director 控件一致:token 化的 outline,不引入新的全局样式。 */
/** 焦点环与既有 previs 控件一致:token 化的 outline,不引入新的全局样式。 */
const FOCUS_RING = "focus-visible:[outline:var(--stroke-2)_solid_var(--control-focus-ring)] focus-visible:[outline-offset:1px]";
/**
@@ -153,8 +153,8 @@ const FOCUS_RING = "focus-visible:[outline:var(--stroke-2)_solid_var(--control-f
* scope 必须由调用方显式传入已认证用户 id —— 组件不读取 guest fallback 或当前活跃
* scope,避免绕过「显式用户 scope」这条边界。
*/
export function CanvasDirectorOnboarding({ scope, open = true, restartSignal = 0, className, storage }: { scope: string; open?: boolean; restartSignal?: number; className?: string; storage?: DirectorOnboardingStorage }) {
const { view, busy, next, back, dismiss, reset } = useDirectorOnboarding({ scope, enabled: open, storage });
export function CanvasPrevisOnboarding({ scope, open = true, restartSignal = 0, className, storage }: { scope: string; open?: boolean; restartSignal?: number; className?: string; storage?: PrevisOnboardingStorage }) {
const { view, busy, next, back, dismiss, reset } = usePrevisOnboarding({ scope, enabled: open, storage });
const previousRestartSignal = useRef(restartSignal);
useEffect(() => {
@@ -170,7 +170,7 @@ export function CanvasDirectorOnboarding({ scope, open = true, restartSignal = 0
return (
<section
className={`grid gap-[var(--space-2)] ${className || ""}`}
aria-label="导演台上手引导"
aria-label="预演台上手引导"
style={{
padding: "var(--space-3)",
border: "var(--stroke-1) solid var(--border-semantic)",

View File

@@ -1,31 +1,31 @@
import { Modal } from "antd";
import { DIRECTOR_TEMPLATES, type DirectorTemplateId } from "@/lib/canvas/director/director-templates";
import { PREVIS_TEMPLATES, type PrevisTemplateId } from "@/lib/canvas/previs/previs-templates";
/**
* 新建导演台镜头前的模板选择。
* 新建预演台镜头前的模板选择。
*
* 为什么必须显式选:过去新建场景无条件塞一个默认演员,
* 产品镜头和空场景用户第一件事就是把它删掉。这里让开局内容由用户决定。
* 没有「默认」按钮 —— 空场景本身就是那个选项,且它真的没有演员。
*/
export function CanvasDirectorTemplateModal({ open, onClose, onSelect }: { open: boolean; onClose: () => void; onSelect: (templateId: DirectorTemplateId) => void }) {
export function CanvasPrevisTemplateModal({ open, onClose, onSelect }: { open: boolean; onClose: () => void; onSelect: (templateId: PrevisTemplateId) => void }) {
return (
<Modal open={open} onCancel={onClose} footer={null} width={560} title="选择镜头模板" destroyOnHidden>
<div className="director-template-grid">
{DIRECTOR_TEMPLATES.map((template) => (
<div className="previs-template-grid">
{PREVIS_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
className="director-template-card"
className="previs-template-card"
onClick={() => {
onSelect(template.id);
onClose();
}}
>
<span className="director-template-card-name">{template.name}</span>
<span className="director-template-card-summary">{template.summary}</span>
<span className="director-template-card-description">{template.description}</span>
<span className="previs-template-card-name">{template.name}</span>
<span className="previs-template-card-summary">{template.summary}</span>
<span className="previs-template-card-description">{template.description}</span>
</button>
))}
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { Camera, Check, Focus, Minus, Move3d, PanelLeft, Plus, Rotate3d, Route, Scale3d, Scan, SlidersHorizontal, WandSparkles } from "lucide-react";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { PREVIS_VIEW_MODES, type PrevisViewMode } from "@/lib/canvas/previs/previs-view-modes";
import type { PrevisCamera } from "@/types/previs";
type TransformMode = "translate" | "rotate" | "scale";
export type PrevisCanvasDockProps = {
theme: CanvasTheme;
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
trajectoryDrawing: boolean;
trajectoryKind: "actor" | "camera" | null;
onToggleTrajectory: () => void;
viewMode: PrevisViewMode;
onViewModeChange: (mode: PrevisViewMode) => void;
cameras: PrevisCamera[];
viewedCameraId: string | null;
onViewCamera: (id: string) => void;
onFocusSelected: () => void;
onFrameScene: () => void;
onZoom: (factor: number) => void;
timelineOpen: boolean;
onOpenTimeline: () => void;
scenePanelOpen: boolean;
onToggleScenePanel: () => void;
inspectorOpen: boolean;
onToggleInspector: () => void;
};
/**
* 预演台视口工具条:与画布主工具条共用 FloatingDock 组件、dock token 与提示/动效。
* 一条 dock 承载「改内容」(变换、画轨迹) 与「怎么看」(取景、导航),取景细项收进弹层,
* 避免视口四角各挂一组浮层遮挡画面。
*/
export function PrevisCanvasDock(props: PrevisCanvasDockProps) {
const { theme } = props;
const rootRef = useRef<HTMLDivElement>(null);
const [viewMenuOpen, setViewMenuOpen] = useState(false);
useEffect(() => {
if (!viewMenuOpen) return;
const close = (event: PointerEvent) => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setViewMenuOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setViewMenuOpen(false);
};
document.addEventListener("pointerdown", close, true);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", close, true);
document.removeEventListener("keydown", closeOnEscape);
};
}, [viewMenuOpen]);
const currentView = PREVIS_VIEW_MODES.find((item) => item.mode === props.viewMode) ?? PREVIS_VIEW_MODES[0];
const trajectoryLabel = props.trajectoryDrawing ? "结束绘制轨迹" : props.trajectoryKind === "camera" ? "画运镜路径" : "画走位";
const items: FloatingDockEntry[] = [
{ id: "previs-scene-panel", label: props.scenePanelOpen ? "收起场景面板" : "打开场景面板", icon: <PanelLeft />, active: props.scenePanelOpen, expands: true, onClick: props.onToggleScenePanel },
{ kind: "separator", id: "previs-sep-scene" },
{
kind: "switch",
id: "previs-transform",
label: "变换模式",
value: props.transformMode,
onChange: (value) => props.onTransformModeChange(value as TransformMode),
options: [
{ id: "previs-translate", value: "translate", label: "移动对象 (W)", icon: <Move3d /> },
{ id: "previs-rotate", value: "rotate", label: "旋转对象 (E)", icon: <Rotate3d /> },
{ id: "previs-scale", value: "scale", label: "缩放对象 (R)", icon: <Scale3d /> },
],
},
{ id: "previs-trajectory", label: trajectoryLabel, icon: <Route />, active: props.trajectoryDrawing, onClick: props.onToggleTrajectory },
{ kind: "separator", id: "previs-sep-view" },
{
id: "previs-view-mode",
label: `取景:${currentView.label}`,
wide: true,
quiet: true,
expands: true,
active: viewMenuOpen,
icon: <span className="inline-flex h-full items-center justify-center whitespace-nowrap text-[var(--fs-caption)] font-semibold leading-none tracking-wide">{currentView.label}</span>,
onClick: () => setViewMenuOpen((value) => !value),
},
{ id: "previs-focus", label: "聚焦选中对象", icon: <Focus />, onClick: props.onFocusSelected },
{ id: "previs-frame", label: "适配全部对象", icon: <Scan />, onClick: props.onFrameScene },
{ id: "previs-zoom-out", label: "拉远视角", icon: <Minus />, onClick: () => props.onZoom(1.38) },
{ id: "previs-zoom-in", label: "拉近视角", icon: <Plus />, onClick: () => props.onZoom(0.72) },
{ kind: "separator", id: "previs-sep-panels" },
{ id: "previs-timeline", label: "动画时间轴", icon: <WandSparkles />, active: props.timelineOpen, onClick: props.onOpenTimeline },
{ id: "previs-inspector", label: props.inspectorOpen ? "收起属性面板" : "打开属性面板", icon: <SlidersHorizontal />, active: props.inspectorOpen, expands: true, onClick: props.onToggleInspector },
];
return (
<div ref={rootRef} data-canvas-no-zoom className="pv-canvas-dock" onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<AnimatePresence>
{viewMenuOpen ? (
<motion.div
role="dialog"
aria-label="取景模式"
initial={{ opacity: 0, y: 14, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 9, scale: 0.96 }}
transition={aceternityMotion.spring.panel}
className="pv-canvas-dock__menu aceternity-floating-panel"
style={{ background: theme.spatial.elevated, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
>
<div className="pv-canvas-dock__menu-title">
<span>取景</span>
<small style={{ color: theme.node.muted }}>只改变观察方式,不写入场景</small>
</div>
<div className="pv-canvas-dock__view-grid" role="radiogroup" aria-label="取景模式">
{PREVIS_VIEW_MODES.map((item) => {
const checked = item.mode === props.viewMode;
return (
<button
key={item.mode}
type="button"
role="radio"
aria-checked={checked}
aria-label={`${item.label} ${item.hint}`}
title={item.hint}
className={checked ? "is-active" : undefined}
onClick={() => {
props.onViewModeChange(item.mode);
setViewMenuOpen(false);
}}
>
{item.label}
</button>
);
})}
</div>
{props.cameras.length ? (
<>
<div className="pv-canvas-dock__menu-title is-sub"><span>机位</span></div>
<div className="pv-canvas-dock__camera-list">
{props.cameras.map((camera) => {
const current = props.viewMode === "camera" && camera.id === props.viewedCameraId;
return (
<button
key={camera.id}
type="button"
aria-pressed={current}
className={current ? "is-active" : undefined}
onClick={() => {
props.onViewCamera(camera.id);
setViewMenuOpen(false);
}}
>
<Camera className="size-3.5" />
<span>{camera.name}</span>
<small>{camera.focalLength}mm</small>
{current ? <Check className="size-3.5" /> : null}
</button>
);
})}
</div>
</>
) : null}
</motion.div>
) : null}
</AnimatePresence>
<FloatingDock items={items} className="canvas-floating-dock" style={canvasDockStyle(theme)} ariaLabel="预演台视口工具" />
</div>
);
}

View File

@@ -1,30 +1,57 @@
// 导演台右侧检查器:物体、灯光、镜头、变换与骨骼旋转字段,以及场景列表的小控件。
// 预演台右侧检查器:物体、灯光、镜头、变换与骨骼旋转字段,以及场景列表的小控件。
import {
type DirectorCamera,
type DirectorCameraMove,
type DirectorHumanoidBone,
type DirectorLight,
type DirectorObject,
type DirectorPose,
type DirectorQuat,
type DirectorScene,
type DirectorShot,
type DirectorShotSize,
type DirectorTransform,
type DirectorVec3,
} from "@/types/director";
import { type DirectorModeCapabilities } from "@/lib/canvas/director/director-modes";
import { DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorFocalLengthToFov, directorPoseLabel } from "@/lib/canvas/director/director-scene";
type PrevisAspectRatio,
type PrevisCamera,
type PrevisCameraMove,
type PrevisHumanoidBone,
type PrevisLight,
type PrevisObject,
type PrevisActorArchetype,
type PrevisPose,
type PrevisQuat,
type PrevisScene,
type PrevisShot,
type PrevisShotSize,
type PrevisTransform,
type PrevisVec3,
} from "@/types/previs";
import { type PrevisModeCapabilities } from "@/lib/canvas/previs/previs-modes";
import { PREVIS_ACTOR_ARCHETYPES, PREVIS_ACTOR_COLORS, previsActorArchetypeLabel, previsActorProfileForArchetype, previsBoneLabel, previsFocalLengthToFov, previsPoseLabel } from "@/lib/canvas/previs/previs-scene";
import { Button, ColorPicker, Dropdown, Input, InputNumber, type MenuProps, Slider } from "antd";
import { Select } from "@/components/ui/base/select";
import { Switch } from "@/components/ui/base/switch";
import { Camera, Focus, Plus, Trash2, Video } from "lucide-react";
import { Camera, Copy, Focus, Plus, Trash2, Video } from "lucide-react";
import { type ReactElement, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Euler, Quaternion } from "three";
import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts";
import { releasePrevisFocusAfterPointer } from "@/lib/canvas/previs/previs-shortcuts";
export const poseOptions: Array<{ label: string; value: DirectorPose }> = [
const PREVIS_HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
function normalizePrevisHexColor(value: string, fallback = "#ffffff") {
const trimmed = value.trim().toLowerCase();
if (!PREVIS_HEX_COLOR_PATTERN.test(trimmed)) return fallback;
if (trimmed.length === 4) return `#${trimmed.slice(1).split("").map((part) => part + part).join("")}`;
return trimmed;
}
function HexColorInput({ value, onChange, label = "十六进制颜色" }: { value: string; onChange: (value: string) => void; label?: string }) {
const normalized = normalizePrevisHexColor(value);
const [draft, setDraft] = useState(normalized);
useEffect(() => setDraft(normalized), [normalized]);
const commit = () => {
const next = normalizePrevisHexColor(draft, "");
if (!next) {
setDraft(normalized);
return;
}
setDraft(next);
onChange(next);
};
return <Input size="small" aria-label={label} value={draft} maxLength={9} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onPressEnter={commit} placeholder="#RRGGBB" />;
}
export const poseOptions: Array<{ label: string; value: PrevisPose }> = [
{ label: "站立", value: "stand" },
{ label: "T 型", value: "t_pose" },
{ label: "行走", value: "walk" },
@@ -47,6 +74,8 @@ export const poseOptions: Array<{ label: string; value: DirectorPose }> = [
{ label: "看手机", value: "phone" },
];
export const poseEditBones: PrevisHumanoidBone[] = ["head", "spine", "leftUpperArm", "leftLowerArm", "rightUpperArm", "rightLowerArm", "leftUpperLeg", "rightUpperLeg"];
export const shotSizeOptions = [
{ label: "大远景", value: "extreme_wide" },
{ label: "远景", value: "wide" },
@@ -81,28 +110,32 @@ export function ObjectInspector({
onBoneRotationStage,
onBoneRotationCommit,
onAddKeyframe,
onDuplicate,
onDelete,
}: {
object: DirectorObject;
rendered: DirectorTransform;
object: PrevisObject;
rendered: PrevisTransform;
playhead: number;
selectedBone: string | null;
capabilities: DirectorModeCapabilities;
capabilities: PrevisModeCapabilities;
onSelectBone: (bone: string | null) => void;
onUpdate: (patch: Partial<DirectorObject>) => void;
onTransformEdit: (transform: DirectorTransform) => void;
onBoneRotationStage: (rotation: DirectorQuat) => void;
onUpdate: (patch: Partial<PrevisObject>) => void;
onTransformEdit: (transform: PrevisTransform) => void;
onBoneRotationStage: (rotation: PrevisQuat) => void;
onBoneRotationCommit: () => void;
onAddKeyframe: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const motionClips = object.motionClips || [];
const activeMotionClip = motionClips.find((clip) => clip.id === object.activeMotionClipId);
const mappedBones = Object.keys(object.rig?.boneMap || {}) as DirectorHumanoidBone[];
const selectedBoneId = selectedBone as DirectorHumanoidBone | null;
const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || ([0, 0, 0, 1] as DirectorQuat) : null;
const updateActiveMotion = (patch: Partial<NonNullable<DirectorObject["motionClips"]>[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => (clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip)) });
const applyPose = (pose: DirectorPose) => onUpdate({ pose, activeMotionClipId: undefined, boneOverrides: {} });
const isActor = object.kind === "actor" || object.primitive === "character";
const mappedBones = Object.keys(object.rig?.boneMap || {}) as PrevisHumanoidBone[];
const poseBones = mappedBones.length ? mappedBones : poseEditBones;
const selectedBoneId = selectedBone as PrevisHumanoidBone | null;
const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || ([0, 0, 0, 1] as PrevisQuat) : null;
const updateActiveMotion = (patch: Partial<NonNullable<PrevisObject["motionClips"]>[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => (clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip)) });
const applyPose = (pose: PrevisPose) => onUpdate({ pose, activeMotionClipId: undefined, boneOverrides: {} });
const resetSelectedBone = () => {
if (!selectedBoneId) return;
const boneOverrides = { ...(object.boneOverrides || {}) };
@@ -110,27 +143,30 @@ export function ObjectInspector({
onUpdate({ boneOverrides });
};
return (
<Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}>
<TransformFields transform={rendered} onChange={onTransformEdit} />
{object.kind === "actor" || object.primitive === "character" ? (
<Field label="角色颜色">
<div className="director-actor-colors">
{DIRECTOR_ACTOR_COLORS.map((color) => (
<button
key={color}
type="button"
className={`director-actor-color ${object.color.toLowerCase() === color ? "is-active" : ""}`}
style={{ background: color }}
aria-label={`设置颜色 ${color}`}
onClick={() => onUpdate({ color })}
/>
))}
<ColorPicker value={object.color} size="small" onChange={(_, color) => onUpdate({ color })} />
</div>
</Field>
<Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDuplicate={onDuplicate} onDelete={onDelete}>
<TransformFields transform={rendered} onChange={onTransformEdit} scaleLabel={isActor ? "比例" : "尺寸(W / H / D)"} scaleAxisLabels={isActor ? undefined : ["W", "H", "D"]} />
{isActor ? (
<>
<Field label="角色原型">
<Select className="w-full" size="small" value={object.archetype || "adult"} options={PREVIS_ACTOR_ARCHETYPES.map((item) => ({ label: item.label, value: item.value }))} onChange={(archetype: PrevisActorArchetype) => onUpdate({ archetype, actorProfile: previsActorProfileForArchetype(archetype) })} />
<div className="mt-1 text-[10px] opacity-50">{previsActorArchetypeLabel(object.archetype)}会影响体型、头身比和配件轮廓。</div>
</Field>
<Field label="角色颜色">
<div className="previs-actor-colors">
{PREVIS_ACTOR_COLORS.map((color) => <button key={color} type="button" className={`previs-actor-color ${object.color.toLowerCase() === color ? "is-active" : ""}`} style={{ background: color }} aria-label={`设置颜色 ${color}`} onClick={() => onUpdate({ color })} />)}
<ColorPicker value={normalizePrevisHexColor(object.color)} size="small" onChange={(color) => onUpdate({ color: color.toHexString() })} />
</div>
<div className="mt-1">
<HexColorInput value={object.color} onChange={(color) => onUpdate({ color })} label="角色十六进制颜色" />
</div>
</Field>
</>
) : (
<Field label="颜色">
<ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} />
<div className="space-y-1">
<ColorPicker value={normalizePrevisHexColor(object.color)} onChange={(color) => onUpdate({ color: color.toHexString() })} />
<HexColorInput value={object.color} onChange={(color) => onUpdate({ color })} />
</div>
</Field>
)}
{/*
@@ -140,14 +176,14 @@ export function ObjectInspector({
*/}
{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? (
<>
<section className="director-pose-section">
<div className="director-inspector-section-title">
<section className="previs-pose-section">
<div className="previs-inspector-section-title">
<span>姿势预设</span>
<span>{directorPoseLabel(object.pose || "stand")}</span>
<span>{previsPoseLabel(object.pose || "stand")}</span>
</div>
<div className="director-pose-grid">
<div className="previs-pose-grid">
{poseOptions.map((option) => (
<button key={option.value} type="button" className={`director-pose-button ${object.pose === option.value && !object.activeMotionClipId ? "is-active" : ""}`} title={option.label} onClick={() => applyPose(option.value)}>
<button key={option.value} type="button" className={`previs-pose-button ${object.pose === option.value && !object.activeMotionClipId ? "is-active" : ""}`} title={option.label} onClick={() => applyPose(option.value)}>
{option.label}
</button>
))}
@@ -158,11 +194,11 @@ export function ObjectInspector({
</section>
<div className="flex items-center justify-between border-y py-2 text-[var(--fs-label)]">
<span>角色绑定</span>
<span className="opacity-55">{object.rig?.status === "ready" ? `${mappedBones.length} 根骨骼` : "等待模型"}</span>
<span>{mappedBones.length ? `${mappedBones.length} 根骨骼` : "白膜骨架"}</span>
</div>
{mappedBones.length ? (
<Field label="骨骼控制">
<Select className="w-full" allowClear value={selectedBone || undefined} options={mappedBones.map((bone) => ({ label: directorBoneLabel(bone), value: bone }))} onChange={(bone) => onSelectBone(bone || null)} />
{poseBones.length ? (
<Field label="姿势调节">
<Select className="w-full" allowClear value={selectedBone || undefined} options={poseBones.map((bone) => ({ label: previsBoneLabel(bone), value: bone }))} onChange={(bone) => onSelectBone(bone || null)} />
</Field>
) : null}
{selectedBoneId && selectedBoneRotation ? (
@@ -221,7 +257,7 @@ export function ObjectInspector({
);
}
export function LightInspector({ light, onUpdate, onDelete }: { light: DirectorLight; onUpdate: (patch: Partial<DirectorLight>) => void; onDelete: () => void }) {
export function LightInspector({ light, onUpdate, onDelete }: { light: PrevisLight; onUpdate: (patch: Partial<PrevisLight>) => void; onDelete: () => void }) {
return (
<Inspector title={light.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}>
<Field label="类型">
@@ -239,7 +275,10 @@ export function LightInspector({ light, onUpdate, onDelete }: { light: DirectorL
</Field>
<Vec3Field label="位置" value={light.transform.position} onChange={(position) => onUpdate({ transform: { ...light.transform, position } })} />
<Field label="颜色">
<ColorPicker value={light.color} onChange={(_, color) => onUpdate({ color })} />
<div className="space-y-1">
<ColorPicker value={normalizePrevisHexColor(light.color)} onChange={(color) => onUpdate({ color: color.toHexString() })} />
<HexColorInput value={light.color} onChange={(color) => onUpdate({ color })} />
</div>
</Field>
<Field label="强度">
<InputNumber className="w-full" min={0} max={20} step={0.1} value={light.intensity} onChange={(value) => onUpdate({ intensity: value || 0 })} />
@@ -264,12 +303,12 @@ export function ShotInspector({
onExportClay,
recording,
}: {
shot: DirectorShot;
camera: DirectorCamera | null;
cameras: DirectorScene["cameras"];
capabilities: DirectorModeCapabilities;
onUpdateShot: (patch: Partial<DirectorShot>) => void;
onUpdateCamera: (patch: Partial<DirectorCamera>) => void;
shot: PrevisShot;
camera: PrevisCamera | null;
cameras: PrevisScene["cameras"];
capabilities: PrevisModeCapabilities;
onUpdateShot: (patch: Partial<PrevisShot>) => void;
onUpdateCamera: (patch: Partial<PrevisCamera>) => void;
onAddCameraKeyframe: () => void;
onApplyCameraMove: () => void;
onAlignCameraToView: () => void;
@@ -283,17 +322,22 @@ export function ShotInspector({
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="景别">
<Select className="w-full" value={shot.shotSize} options={shotSizeOptions} onChange={(shotSize: DirectorShotSize) => onUpdateShot({ shotSize })} />
<Select className="w-full" value={shot.shotSize} options={shotSizeOptions} onChange={(shotSize: PrevisShotSize) => onUpdateShot({ shotSize })} />
</Field>
<Field label="画幅">
<Select className="w-full" value={shot.aspectRatio || "9:16"} options={["9:16", "16:9", "2.39:1"].map((value) => ({ label: value, value }))} onChange={(aspectRatio: PrevisAspectRatio) => onUpdateShot({ aspectRatio })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="帧率">
<Select className="w-full" value={shot.fps} options={[24, 25, 30].map((fps) => ({ label: `${fps} fps`, value: fps }))} onChange={(fps: 24 | 25 | 30) => onUpdateShot({ fps })} />
</Field>
<Field label="时长">
<InputNumber className="w-full" min={0.5} max={60} step={0.5} value={shot.duration} addonAfter="秒" onChange={(value) => onUpdateShot({ duration: value || 5 })} />
</Field>
</div>
<Field label="运镜">
<Select className="w-full" value={shot.cameraMove} options={cameraMoveOptions} onChange={(cameraMove: DirectorCameraMove) => onUpdateShot({ cameraMove })} />
</Field>
<Field label="时长">
<InputNumber className="w-full" min={0.5} max={60} step={0.5} value={shot.duration} addonAfter="秒" onChange={(value) => onUpdateShot({ duration: value || 5 })} />
<Select className="w-full" value={shot.cameraMove} options={cameraMoveOptions} onChange={(cameraMove: PrevisCameraMove) => onUpdateShot({ cameraMove })} />
</Field>
<Field label="镜头意图">
<Input.TextArea autoSize={{ minRows: 3, maxRows: 7 }} value={shot.prompt} placeholder="人物表演、动作、叙事目标…" onChange={(event) => onUpdateShot({ prompt: event.target.value })} />
@@ -303,7 +347,7 @@ export function ShotInspector({
<Vec3Field label="摄影机位置" value={camera.transform.position} onChange={(position) => onUpdateCamera({ transform: { ...camera.transform, position } })} />
<Vec3Field label="焦点" value={camera.target} onChange={(target) => onUpdateCamera({ target })} />
<Field label="焦距">
<InputNumber className="w-full" min={12} max={200} value={camera.focalLength} addonAfter="mm" onChange={(focalLength) => onUpdateCamera({ focalLength: focalLength || 35, fov: directorFocalLengthToFov(focalLength || 35) })} />
<InputNumber className="w-full" min={12} max={200} value={camera.focalLength} addonAfter="mm" onChange={(focalLength) => onUpdateCamera({ focalLength: focalLength || 35, fov: previsFocalLengthToFov(focalLength || 35) })} />
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="光圈">
@@ -316,8 +360,8 @@ export function ShotInspector({
<Button block icon={<Camera className="size-3.5" />} onClick={onAlignCameraToView}>
摄影机对齐当前视图
</Button>
<Button block icon={<Video className="size-3.5" />} onClick={onApplyCameraMove}>
按运镜生成轨迹
<Button block icon={<Video className="size-3.5" />} title="根据当前运镜预设自动生成路径" onClick={onApplyCameraMove}>
用预设生成路径
</Button>
{capabilities.keyframes ? (
<Button block icon={<Focus className="size-3.5" />} onClick={onAddCameraKeyframe}>
@@ -333,11 +377,16 @@ export function ShotInspector({
);
}
export function Inspector({ title, children, onTitleChange, onDelete }: { title: string; children: ReactNode; onTitleChange: (value: string) => void; onDelete?: () => void }) {
export function Inspector({ title, children, onTitleChange, onDuplicate, onDelete }: { title: string; children: ReactNode; onTitleChange: (value: string) => void; onDuplicate?: () => void; onDelete?: () => void }) {
return (
<div className="space-y-3 p-3">
<div className="flex items-center gap-2">
<Input variant="borderless" value={title} className="min-w-0 flex-1 px-0 font-medium" onChange={(event) => onTitleChange(event.target.value)} />
{onDuplicate ? (
<IconButton label="复制" onClick={onDuplicate}>
<Copy className="size-4" />
</IconButton>
) : null}
{onDelete ? (
<IconButton label="删除" onClick={onDelete}>
<Trash2 className="size-4" />
@@ -349,34 +398,34 @@ export function Inspector({ title, children, onTitleChange, onDelete }: { title:
);
}
export function TransformFields({ transform, onChange }: { transform: DirectorTransform; onChange: (transform: DirectorTransform) => void }) {
export function TransformFields({ transform, onChange, scaleLabel = "缩放", scaleAxisLabels }: { transform: PrevisTransform; onChange: (transform: PrevisTransform) => void; scaleLabel?: string; scaleAxisLabels?: [string, string, string] }) {
return (
<>
<Vec3Field label="位置" value={transform.position} onChange={(position) => onChange({ ...transform, position })} />
<Vec3Field label="旋转" value={transform.rotation} step={0.05} onChange={(rotation) => onChange({ ...transform, rotation })} />
<Vec3Field label="缩放" value={transform.scale} step={0.1} onChange={(scale) => onChange({ ...transform, scale })} />
<Vec3Field label={scaleLabel} value={transform.scale} step={0.1} axisLabels={scaleAxisLabels} onChange={(scale) => onChange({ ...transform, scale })} />
</>
);
}
export function BoneRotationFields({ rotation, onChange, onChangeComplete }: { rotation: DirectorQuat; onChange: (rotation: DirectorQuat) => void; onChangeComplete: () => void }) {
export function BoneRotationFields({ rotation, onChange, onChangeComplete }: { rotation: PrevisQuat; onChange: (rotation: PrevisQuat) => void; onChangeComplete: () => void }) {
const initialDegrees = useMemo(() => {
const euler = new Euler().setFromQuaternion(new Quaternion(...rotation), "XYZ");
return [euler.x, euler.y, euler.z].map((value) => Number(((value * 180) / Math.PI).toFixed(1))) as DirectorVec3;
return [euler.x, euler.y, euler.z].map((value) => Number(((value * 180) / Math.PI).toFixed(1))) as PrevisVec3;
}, [rotation]);
const [degrees, setDegrees] = useState<DirectorVec3>(initialDegrees);
const lastEmittedRotation = useRef<DirectorQuat | null>(null);
const [degrees, setDegrees] = useState<PrevisVec3>(initialDegrees);
const lastEmittedRotation = useRef<PrevisQuat | null>(null);
useEffect(() => {
if (lastEmittedRotation.current && sameDirectorQuaternion(rotation, lastEmittedRotation.current)) {
if (lastEmittedRotation.current && samePrevisQuaternion(rotation, lastEmittedRotation.current)) {
lastEmittedRotation.current = null;
return;
}
setDegrees(initialDegrees);
}, [initialDegrees, rotation]);
const updateAxis = (index: number, value: number) => {
const next = degrees.map((entry, entryIndex) => (entryIndex === index ? value : entry)) as DirectorVec3;
const radians = next.map((entry) => (entry * Math.PI) / 180) as DirectorVec3;
const nextRotation = new Quaternion().setFromEuler(new Euler(radians[0], radians[1], radians[2], "XYZ")).toArray() as DirectorQuat;
const next = degrees.map((entry, entryIndex) => (entryIndex === index ? value : entry)) as PrevisVec3;
const radians = next.map((entry) => (entry * Math.PI) / 180) as PrevisVec3;
const nextRotation = new Quaternion().setFromEuler(new Euler(radians[0], radians[1], radians[2], "XYZ")).toArray() as PrevisQuat;
setDegrees(next);
lastEmittedRotation.current = nextRotation;
onChange(nextRotation);
@@ -396,19 +445,20 @@ export function BoneRotationFields({ rotation, onChange, onChangeComplete }: { r
);
}
export function sameDirectorQuaternion(left: DirectorQuat, right: DirectorQuat) {
export function samePrevisQuaternion(left: PrevisQuat, right: PrevisQuat) {
const directDistance = left.reduce((sum, value, index) => sum + Math.abs(value - right[index]), 0);
const inverseDistance = left.reduce((sum, value, index) => sum + Math.abs(value + right[index]), 0);
return Math.min(directDistance, inverseDistance) < 0.0001;
}
export function Vec3Field({ label, value, step = 0.1, onChange }: { label: string; value: DirectorVec3; step?: number; onChange: (value: DirectorVec3) => void }) {
export function Vec3Field({ label, value, step = 0.1, axisLabels, onChange }: { label: string; value: PrevisVec3; step?: number; axisLabels?: [string, string, string]; onChange: (value: PrevisVec3) => void }) {
return (
<Field label={label}>
<div className="grid grid-cols-3 gap-1">
{value.map((item, index) => (
<InputNumber key={index} className="w-full" size="small" step={step} value={Number(item.toFixed(2))} onChange={(next) => onChange(value.map((entry, itemIndex) => (itemIndex === index ? next || 0 : entry)) as DirectorVec3)} />
))}
{value.map((item, index) => <div key={index} className="relative">
<InputNumber className="w-full" size="small" step={step} value={Number(item.toFixed(2))} onChange={(next) => onChange(value.map((entry, itemIndex) => (itemIndex === index ? next || 0 : entry)) as PrevisVec3)} />
{axisLabels ? <span className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 text-[9px] opacity-45">{axisLabels[index]}</span> : null}
</div>)}
</div>
</Field>
);
@@ -445,7 +495,7 @@ export function SceneRow({ active, icon, label, onClick, onDelete }: { active?:
className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left"
onClick={(event) => {
onClick();
releaseDirectorFocusAfterPointer(event);
releasePrevisFocusAfterPointer(event);
}}
>
<span className="[&>svg]:size-3.5">{icon}</span>
@@ -460,7 +510,7 @@ export function SceneRow({ active, icon, label, onClick, onDelete }: { active?:
onClick={(event) => {
event.stopPropagation();
onDelete();
releaseDirectorFocusAfterPointer(event);
releasePrevisFocusAfterPointer(event);
}}
>
<Trash2 className="size-3.5" />
@@ -487,7 +537,7 @@ export function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactE
className="flex h-8 items-center gap-1.5 border px-2 text-[var(--fs-tiny)] transition hover:bg-black/5 dark:hover:bg-white/5"
onClick={(event) => {
onClick();
releaseDirectorFocusAfterPointer(event);
releasePrevisFocusAfterPointer(event);
}}
>
<span className="[&>svg]:size-3.5">{icon}</span>
@@ -506,7 +556,7 @@ export function IconButton({ label, disabled, children, onClick }: { label: stri
className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10"
onClick={(event) => {
onClick();
releaseDirectorFocusAfterPointer(event);
releasePrevisFocusAfterPointer(event);
}}
>
{children}

View File

@@ -0,0 +1,96 @@
import { Canvas } from "@react-three/fiber";
import { OrthographicCamera } from "@react-three/drei";
import { Suspense } from "react";
import type { PrevisObject, PrevisScene } from "@/types/previs";
/**
* 画布卡片的轻量场景缩略图:固定俯视正交相机,只渲染简化几何体。
* 演员使用带朝向的 clay marker,避免缩略图里只剩一根胶囊或骨骼线。
*/
export function PrevisMiniViewport({ scene }: { scene: PrevisScene }) {
const activeShot = scene.shots.find((shot) => shot.id === scene.activeShotId) || scene.shots[0];
const visibleObjects = scene.objects.filter((object) => object.visible !== false);
return (
<div className="relative h-full w-full overflow-hidden rounded-[inherit] bg-[radial-gradient(circle_at_50%_35%,#2e3949_0%,#151b25_58%,#0d1118_100%)]">
<Canvas className="previs-mini-viewport-canvas" gl={{ alpha: true, antialias: true }} dpr={[1, 1.5]}>
<OrthographicCamera makeDefault position={[0, 10, 0]} zoom={46} up={[0, 0, -1]} />
<ambientLight intensity={0.72} />
<directionalLight position={[4, 8, 4]} intensity={1.1} />
<Suspense fallback={null}>
<gridHelper args={[20, 20, "#65738a", "#2b3544"]} />
{visibleObjects.map((object) => <MiniObject key={object.id} object={object} />)}
</Suspense>
</Canvas>
<div className="pointer-events-none absolute inset-x-3 top-3 flex items-center justify-between text-[9px] font-semibold uppercase tracking-[0.16em] text-white/55">
<span>Top view</span><span>{activeShot?.name || "未设置镜头"}</span>
</div>
<div className="pointer-events-none absolute inset-x-3 bottom-3 flex items-center justify-between gap-2 rounded-lg border border-white/12 bg-[#0d1118cc] px-3 py-2 text-[10px] font-medium text-white/80 backdrop-blur-md">
<Stat value={visibleObjects.length} label="对象" />
<Stat value={scene.cameras.length} label="机位" />
<Stat value={`${activeShot?.duration ?? 0}s`} label="时长" />
</div>
</div>
);
}
function MiniObject({ object }: { object: PrevisObject }) {
const [x, , z] = object.transform.position;
const position: [number, number, number] = [x, 0, z];
const scale = object.transform.scale as [number, number, number];
const rotation: [number, number, number] = [0, object.transform.rotation[1], 0];
const color = object.color || "#d6d9dd";
if (object.kind === "actor" || object.primitive === "character") {
return (
<group position={position} rotation={rotation} scale={scale}>
<mesh position={[0, 0.08, 0]} castShadow receiveShadow>
<cylinderGeometry args={[0.34, 0.34, 0.14, 24]} />
<meshStandardMaterial color={color} roughness={0.82} metalness={0.02} />
</mesh>
<mesh position={[0, 0.18, -0.26]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.16, 0.34, 3]} />
<meshStandardMaterial color={color} roughness={0.82} metalness={0.02} />
</mesh>
<mesh position={[0, 0.155, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.39, 0.43, 24]} />
<meshBasicMaterial color={color} transparent opacity={0.28} />
</mesh>
</group>
);
}
if (object.primitive === "sphere") {
return (
<mesh position={position} rotation={rotation} scale={scale} castShadow receiveShadow>
<sphereGeometry args={[0.52, 16, 12]} />
<meshStandardMaterial color={color} roughness={0.72} metalness={0.05} />
</mesh>
);
}
if (object.primitive === "cylinder") {
return (
<mesh position={position} rotation={rotation} scale={scale} castShadow receiveShadow>
<cylinderGeometry args={[0.48, 0.48, 0.9, 16]} />
<meshStandardMaterial color={color} roughness={0.72} metalness={0.05} />
</mesh>
);
}
if (object.primitive === "plane" || object.kind === "billboard") {
return (
<mesh position={[x, -0.02, z]} rotation={[-Math.PI / 2, 0, object.transform.rotation[1]]} scale={scale} receiveShadow>
<planeGeometry args={[1.5, 0.9]} />
<meshStandardMaterial color={color} roughness={0.8} metalness={0} transparent opacity={0.76} side={2} />
</mesh>
);
}
return (
<mesh position={position} rotation={rotation} scale={scale} castShadow receiveShadow>
<boxGeometry args={[0.85, 0.85, 0.85]} />
<meshStandardMaterial color={color} roughness={0.72} metalness={0.05} />
</mesh>
);
}
function Stat({ value, label }: { value: number | string; label: string }) {
return <span className="inline-flex min-w-0 items-center gap-1"><b className="text-white">{value}</b><span>{label}</span></span>;
}

View File

@@ -1,15 +1,15 @@
import { ChevronDown, ChevronRight, ChevronUp, KeyRound, Magnet, Pause, Play, Plus, Rows3, Trash2, ZoomIn, ZoomOut } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { directorBoneLabel } from "@/lib/canvas/director/director-scene";
import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts";
import type { DirectorCamera, DirectorKeyframeDeleteTarget, DirectorKeyframeEasing, DirectorObject, DirectorScene, DirectorShot } from "@/types/director";
import { previsBoneLabel } from "@/lib/canvas/previs/previs-scene";
import { releasePrevisFocusAfterPointer } from "@/lib/canvas/previs/previs-shortcuts";
import type { PrevisCamera, PrevisKeyframeDeleteTarget, PrevisKeyframeEasing, PrevisObject, PrevisScene, PrevisShot } from "@/types/previs";
type DirectorSequencerProps = {
scene: DirectorScene;
shot: DirectorShot;
camera: DirectorCamera | null;
objects: DirectorObject[];
type PrevisSequencerProps = {
scene: PrevisScene;
shot: PrevisShot;
camera: PrevisCamera | null;
objects: PrevisObject[];
selectedObjectId: string | null;
selectedBone: string | null;
playhead: number;
@@ -27,9 +27,9 @@ type DirectorSequencerProps = {
onRecordKeyframe: () => void;
onAddShot: () => void;
/** 删除某条可见轨道上的关键帧;覆盖 transform / camera / bone 三类。 */
onDeleteKeyframe: (target: DirectorKeyframeDeleteTarget) => void;
onDeleteKeyframe: (target: PrevisKeyframeDeleteTarget) => void;
/** 更新关键帧到下一帧区间的缓动。 */
onSetKeyframeEasing: (target: DirectorKeyframeDeleteTarget, easing: DirectorKeyframeEasing) => void;
onSetKeyframeEasing: (target: PrevisKeyframeDeleteTarget, easing: PrevisKeyframeEasing) => void;
onSelectShot: (id: string) => void;
};
@@ -37,9 +37,9 @@ type DirectorSequencerProps = {
* 轨道上的一个关键帧标记。
* 带 target 才可删除;镜头总轨等只读轨道不给 target。
*/
type TrackKey = { id: string; time: number; color?: string; label?: string; easing?: DirectorKeyframeEasing; target?: DirectorKeyframeDeleteTarget };
type TrackKey = { id: string; time: number; color?: string; label?: string; easing?: PrevisKeyframeEasing; target?: PrevisKeyframeDeleteTarget };
export function DirectorSequencer({ scene, shot, camera, objects, selectedObjectId, selectedBone, playhead, playing, autoKey, height, visible, onPlayToggle, onPlayheadChange, onAutoKeyChange, onHeightChange, onVisibilityChange, onSelectObject, onSelectBone, onRecordKeyframe, onAddShot, onDeleteKeyframe, onSetKeyframeEasing, onSelectShot }: DirectorSequencerProps) {
export function PrevisSequencer({ scene, shot, camera, objects, selectedObjectId, selectedBone, playhead, playing, autoKey, height, visible, onPlayToggle, onPlayheadChange, onAutoKeyChange, onHeightChange, onVisibilityChange, onSelectObject, onSelectBone, onRecordKeyframe, onAddShot, onDeleteKeyframe, onSetKeyframeEasing, onSelectShot }: PrevisSequencerProps) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [snapEnabled, setSnapEnabled] = useState(true);
const [showDetails, setShowDetails] = useState(true);
@@ -50,12 +50,12 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
const rootRef = useRef<HTMLDivElement>(null);
const ticks = useMemo(() => Array.from({ length: Math.ceil(duration) + 1 }, (_, index) => index), [duration]);
const actorObjects = objects.filter((object) => object.kind === "actor" || object.primitive === "character");
const activeSelectedKey = selectedKey?.target && directorKeyframeTargetExists(selectedKey.target, camera, objects) ? selectedKey : null;
const activeSelectedKey = selectedKey?.target && previsKeyframeTargetExists(selectedKey.target, camera, objects) ? selectedKey : null;
// 选择只对当前可见轨道有效。切换镜头/摄影机或外部删帧后立即废弃旧 target,
// 避免顶部缓动与删除控件继续修改已经不可见的轨道。
useEffect(() => {
setSelectedKey((current) => current?.target && !directorKeyframeTargetExists(current.target, camera, objects) ? null : current);
setSelectedKey((current) => current?.target && !previsKeyframeTargetExists(current.target, camera, objects) ? null : current);
}, [camera, objects]);
const setTimeFromPointer = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -93,41 +93,41 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
}
};
const deleteTrackKey = (target: DirectorKeyframeDeleteTarget) => {
const deleteTrackKey = (target: PrevisKeyframeDeleteTarget) => {
onDeleteKeyframe(target);
if (selectedKey?.target && directorKeyframeTargetId(selectedKey.target) === directorKeyframeTargetId(target)) setSelectedKey(null);
if (selectedKey?.target && previsKeyframeTargetId(selectedKey.target) === previsKeyframeTargetId(target)) setSelectedKey(null);
};
if (!visible) {
return <section className="director-sequencer shrink-0 border-t" style={{ height: "var(--space-8)", minHeight: 0, background: "var(--director-sequencer-surface)", borderColor: "var(--director-sequencer-border)" }}>
return <section className="previs-sequencer shrink-0 border-t" style={{ height: "var(--space-8)", minHeight: 0, background: "var(--previs-sequencer-surface)", borderColor: "var(--previs-sequencer-border)" }}>
<div className="flex h-full items-center justify-end px-3">
<button type="button" className="director-sequencer-tool" title="显示时间轴" aria-label="显示时间轴" onClick={() => onVisibilityChange(true)}><ChevronUp className="size-3.5" /><span>显示时间轴</span></button>
<button type="button" className="previs-sequencer-tool" title="显示时间轴" aria-label="显示时间轴" onClick={() => onVisibilityChange(true)}><ChevronUp className="size-3.5" /><span>显示时间轴</span></button>
</div>
</section>;
}
return (
<section ref={rootRef} className="director-sequencer shrink-0 border-t" style={{ height, minHeight: "var(--director-sequencer-min-height)", background: "var(--director-sequencer-surface)", borderColor: "var(--director-sequencer-border)" }}>
<div className="director-sequencer-resizer" onPointerDown={startResize} role="separator" aria-label="调整时间轴高度" />
<header className="flex h-10 shrink-0 items-center gap-2 border-b px-3" style={{ borderColor: "var(--director-sequencer-border)" }}>
<button type="button" className="director-sequencer-transport" onClick={onPlayToggle} aria-label={playing ? "暂停" : "播放"} title={playing ? "暂停" : "播放"}>{playing ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}</button>
<section ref={rootRef} className="previs-sequencer shrink-0 border-t" style={{ height, minHeight: "var(--previs-sequencer-min-height)", background: "var(--previs-sequencer-surface)", borderColor: "var(--previs-sequencer-border)" }}>
<div className="previs-sequencer-resizer" onPointerDown={startResize} role="separator" aria-label="调整时间轴高度" />
<header className="flex h-10 shrink-0 items-center gap-2 border-b px-3" style={{ borderColor: "var(--previs-sequencer-border)" }}>
<button type="button" className="previs-sequencer-transport" onClick={onPlayToggle} aria-label={playing ? "暂停" : "播放"} title={playing ? "暂停" : "播放"}>{playing ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}</button>
<span className="w-14 text-right text-[var(--fs-caption)] font-medium tabular-nums text-white/75">{formatTime(playhead)}</span>
<span className="text-[var(--fs-micro)] text-white/35">/ {formatTime(duration)} · {fps}fps</span>
<span className="mx-1 h-4 w-px bg-white/10" />
<select className="director-sequencer-shot-select" value={shot.id} aria-label="当前镜头" onChange={(event) => onSelectShot(event.target.value)}>
<select className="previs-sequencer-shot-select" value={shot.id} aria-label="当前镜头" onChange={(event) => onSelectShot(event.target.value)}>
{scene.shots.map((item, index) => <option key={item.id} value={item.id}>{index + 1}. {item.name}</option>)}
</select>
<button type="button" className={`director-sequencer-tool ${autoKey ? "is-active" : ""}`} onClick={() => onAutoKeyChange(!autoKey)} aria-pressed={autoKey} title="自动关键帧"><KeyRound className="size-3.5" /><span>自动关键帧</span></button>
<button type="button" className={`director-sequencer-tool ${snapEnabled ? "is-active" : ""}`} title="吸附到帧" aria-pressed={snapEnabled} onClick={() => setSnapEnabled((value) => !value)}><Magnet className="size-3.5" /><span>吸附</span></button>
<button type="button" className="director-sequencer-tool" title="记录当前关键帧" onClick={onRecordKeyframe}><KeyRound className="size-3.5" /><span>记录</span></button>
<button type="button" className={`previs-sequencer-tool ${autoKey ? "is-active" : ""}`} onClick={() => onAutoKeyChange(!autoKey)} aria-pressed={autoKey} title="自动关键帧"><KeyRound className="size-3.5" /><span>自动关键帧</span></button>
<button type="button" className={`previs-sequencer-tool ${snapEnabled ? "is-active" : ""}`} title="吸附到帧" aria-pressed={snapEnabled} onClick={() => setSnapEnabled((value) => !value)}><Magnet className="size-3.5" /><span>吸附</span></button>
<button type="button" className="previs-sequencer-tool" title="记录当前关键帧" onClick={onRecordKeyframe}><KeyRound className="size-3.5" /><span>记录</span></button>
{activeSelectedKey?.target ? <>
<select
className="director-sequencer-shot-select"
className="previs-sequencer-shot-select"
value={activeSelectedKey.easing || "linear"}
aria-label="关键帧缓动"
title="关键帧到下一帧的缓动"
onChange={(event) => {
const easing = event.target.value as DirectorKeyframeEasing;
const easing = event.target.value as PrevisKeyframeEasing;
onSetKeyframeEasing(activeSelectedKey.target!, easing);
setSelectedKey((current) => current ? { ...current, easing } : current);
}}
@@ -136,24 +136,24 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
<option value="linear">线性</option>
<option value="smooth">平滑</option>
</select>
<button type="button" className="director-sequencer-icon" title="删除所选关键帧" aria-label="删除所选关键帧" onClick={() => deleteTrackKey(activeSelectedKey.target!)}><Trash2 className="size-3.5" /></button>
<button type="button" className="previs-sequencer-icon" title="删除所选关键帧" aria-label="删除所选关键帧" onClick={() => deleteTrackKey(activeSelectedKey.target!)}><Trash2 className="size-3.5" /></button>
</> : null}
<span className="ml-auto flex items-center gap-1">
<button type="button" className="director-sequencer-icon" title="缩小时间轴" aria-label="缩小时间轴" onClick={() => setTimelineScale((value) => Math.max(0.75, value - 0.25))}><ZoomOut className="size-3.5" /></button>
<button type="button" className="director-sequencer-icon" title="放大时间轴" aria-label="放大时间轴" onClick={() => setTimelineScale((value) => Math.min(2.5, value + 0.25))}><ZoomIn className="size-3.5" /></button>
<button type="button" className={`director-sequencer-icon ${showDetails ? "is-active" : ""}`} title="显示子轨道" aria-label="显示子轨道" aria-pressed={showDetails} onClick={() => setShowDetails((value) => !value)}><Rows3 className="size-3.5" /></button>
<button type="button" className="director-sequencer-icon" title="新增镜头" aria-label="新增镜头" onClick={onAddShot}><Plus className="size-3.5" /></button>
<button type="button" className="director-sequencer-icon" title="隐藏时间轴" aria-label="隐藏时间轴" onClick={() => onVisibilityChange(false)}><ChevronDown className="size-3.5" /></button>
<button type="button" className="previs-sequencer-icon" title="缩小时间轴" aria-label="缩小时间轴" onClick={() => setTimelineScale((value) => Math.max(0.75, value - 0.25))}><ZoomOut className="size-3.5" /></button>
<button type="button" className="previs-sequencer-icon" title="放大时间轴" aria-label="放大时间轴" onClick={() => setTimelineScale((value) => Math.min(2.5, value + 0.25))}><ZoomIn className="size-3.5" /></button>
<button type="button" className={`previs-sequencer-icon ${showDetails ? "is-active" : ""}`} title="显示子轨道" aria-label="显示子轨道" aria-pressed={showDetails} onClick={() => setShowDetails((value) => !value)}><Rows3 className="size-3.5" /></button>
<button type="button" className="previs-sequencer-icon" title="新增镜头" aria-label="新增镜头" onClick={onAddShot}><Plus className="size-3.5" /></button>
<button type="button" className="previs-sequencer-icon" title="隐藏时间轴" aria-label="隐藏时间轴" onClick={() => onVisibilityChange(false)}><ChevronDown className="size-3.5" /></button>
</span>
</header>
<div className="director-sequencer-body thin-scrollbar overflow-auto">
<div className="director-sequencer-grid" style={{ "--director-sequencer-duration": duration, "--director-sequencer-track-scale": timelineScale } as CSSProperties}>
<span className="director-sequencer-global-playhead"><i style={{ left: `${(playhead / duration) * 100}%` }} /></span>
<div className="director-sequencer-label director-sequencer-ruler-label">轨道</div>
<div className="director-sequencer-ruler" onPointerDown={setTimeFromPointer}>
{ticks.map((tick) => <span key={tick} className="director-sequencer-tick" style={{ left: `${(tick / duration) * 100}%` }}>{tick}s</span>)}
<span className="director-sequencer-playhead" style={{ left: `${(playhead / duration) * 100}%` }} />
<div className="previs-sequencer-body thin-scrollbar overflow-auto">
<div className="previs-sequencer-grid" style={{ "--previs-sequencer-duration": duration, "--previs-sequencer-track-scale": timelineScale } as CSSProperties}>
<span className="previs-sequencer-global-playhead"><i style={{ left: `${(playhead / duration) * 100}%` }} /></span>
<div className="previs-sequencer-label previs-sequencer-ruler-label">轨道</div>
<div className="previs-sequencer-ruler" onPointerDown={setTimeFromPointer}>
{ticks.map((tick) => <span key={tick} className="previs-sequencer-tick" style={{ left: `${(tick / duration) * 100}%` }}>{tick}s</span>)}
<span className="previs-sequencer-playhead" style={{ left: `${(playhead / duration) * 100}%` }} />
</div>
<SequencerRow label="镜头总轨" icon="◈" selected={false} onClick={() => undefined}>
@@ -170,7 +170,7 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
const isExpanded = expanded[object.id] ?? object.id === selectedObjectId;
const activeClip = object.motionClips?.find((clip) => clip.id === object.activeMotionClipId);
// 骨骼帧 id 在不同轨道间可能重复,React key 用「骨骼-帧」组合;删除目标仍指向原始帧 id。
const boneTrackKeys: TrackKey[] = object.boneTracks?.flatMap((track) => track.keyframes.map((key) => ({ id: `bone-${track.bone}-${key.id}`, time: key.time, color: "#f0b36a", label: `${object.name} ${directorBoneLabel(track.bone)}`, easing: key.easing, target: { track: "object-bone" as const, objectId: object.id, bone: track.bone, keyframeId: key.id } }))) || [];
const boneTrackKeys: TrackKey[] = object.boneTracks?.flatMap((track) => track.keyframes.map((key) => ({ id: `bone-${track.bone}-${key.id}`, time: key.time, color: "#f0b36a", label: `${object.name} ${previsBoneLabel(track.bone)}`, easing: key.easing, target: { track: "object-bone" as const, objectId: object.id, bone: track.bone, keyframeId: key.id } }))) || [];
const transformKeys: TrackKey[] = object.keyframes.map((key) => ({ id: `transform-${key.id}`, time: key.time, color: "#61d2ad", label: `${object.name} Transform`, easing: key.easing, target: { track: "object-transform" as const, objectId: object.id, keyframeId: key.id } }));
return <div key={object.id} className="contents">
<SequencerRow label={object.name} icon={isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />} selected={selectedObjectId === object.id && !selectedBone} onClick={() => { onSelectObject(object.id); onSelectBone(null); setExpanded((current) => ({ ...current, [object.id]: !isExpanded })); }}>
@@ -185,8 +185,8 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
<TrackKeys duration={duration} keys={transformKeys} selectedTarget={activeSelectedKey?.target} onSelectKey={selectTrackKey} onDeleteKey={deleteTrackKey} />
</SequencerRow>
{object.boneTracks?.map((track) => {
const keys: TrackKey[] = track.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#f0b36a", label: `${object.name} ${directorBoneLabel(track.bone)}`, easing: key.easing, target: { track: "object-bone", objectId: object.id, bone: track.bone, keyframeId: key.id } }));
return <SequencerRow key={track.bone} label={directorBoneLabel(track.bone)} icon="◌" indent selected={selectedObjectId === object.id && selectedBone === track.bone} onClick={() => { onSelectObject(object.id); onSelectBone(track.bone); }}><TrackKeys duration={duration} keys={keys} selectedTarget={activeSelectedKey?.target} onSelectKey={selectTrackKey} onDeleteKey={deleteTrackKey} /></SequencerRow>;
const keys: TrackKey[] = track.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#f0b36a", label: `${object.name} ${previsBoneLabel(track.bone)}`, easing: key.easing, target: { track: "object-bone", objectId: object.id, bone: track.bone, keyframeId: key.id } }));
return <SequencerRow key={track.bone} label={previsBoneLabel(track.bone)} icon="◌" indent selected={selectedObjectId === object.id && selectedBone === track.bone} onClick={() => { onSelectObject(object.id); onSelectBone(track.bone); }}><TrackKeys duration={duration} keys={keys} selectedTarget={activeSelectedKey?.target} onSelectKey={selectTrackKey} onDeleteKey={deleteTrackKey} /></SequencerRow>;
})}
</> : null}
</div>;
@@ -207,9 +207,9 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
* 注意 children 里的 TrackKeys 不走这条规则 —— 关键帧按钮自己拥有那些键。
*/
function SequencerRow({ label, icon, selected, indent, children, onClick }: { label: string; icon: ReactNode; selected: boolean; indent?: boolean; children: ReactNode; onClick: () => void }) {
return <div className={`director-sequencer-row ${selected ? "is-selected" : ""}`}>
<button type="button" className={`director-sequencer-label ${indent ? "is-indent" : ""}`} onClick={(event) => { onClick(); releaseDirectorFocusAfterPointer(event); }}><span className="director-sequencer-row-icon">{icon}</span><span className="min-w-0 truncate">{label}</span></button>
<div className="director-sequencer-track">{children}</div>
return <div className={`previs-sequencer-row ${selected ? "is-selected" : ""}`}>
<button type="button" className={`previs-sequencer-label ${indent ? "is-indent" : ""}`} onClick={(event) => { onClick(); releasePrevisFocusAfterPointer(event); }}><span className="previs-sequencer-row-icon">{icon}</span><span className="min-w-0 truncate">{label}</span></button>
<div className="previs-sequencer-track">{children}</div>
</div>;
}
@@ -220,15 +220,15 @@ function SequencerRow({ label, icon, selected, indent, children, onClick }: { la
* 只有同时具备 onDeleteKey 和 key.target 才可交互;
* 概览轨(Camera Cut)保持只读 span,避免同一帧出现两个语义相同的删除入口。
*/
function TrackKeys({ duration, keys, selectedTarget, onSelectKey, onDeleteKey }: { duration: number; keys: TrackKey[]; selectedTarget?: DirectorKeyframeDeleteTarget; onSelectKey?: (key: TrackKey) => void; onDeleteKey?: (target: DirectorKeyframeDeleteTarget) => void }) {
function TrackKeys({ duration, keys, selectedTarget, onSelectKey, onDeleteKey }: { duration: number; keys: TrackKey[]; selectedTarget?: PrevisKeyframeDeleteTarget; onSelectKey?: (key: TrackKey) => void; onDeleteKey?: (target: PrevisKeyframeDeleteTarget) => void }) {
return (
<div className="director-sequencer-track-content">
<div className="previs-sequencer-track-content">
{keys.map((key) => {
const target = onDeleteKey && key.target;
if (!target) {
return <span
key={key.id}
className="director-sequencer-key"
className="previs-sequencer-key"
style={{ left: `${(key.time / duration) * 100}%`, background: key.color || "#d7dee8" }}
title={`${key.time.toFixed(2)}s`}
/>;
@@ -236,10 +236,10 @@ function TrackKeys({ duration, keys, selectedTarget, onSelectKey, onDeleteKey }:
return <button
key={key.id}
type="button"
className={`director-sequencer-key is-actionable ${selectedTarget && directorKeyframeTargetId(selectedTarget) === directorKeyframeTargetId(target) ? "is-selected" : ""}`}
className={`previs-sequencer-key is-actionable ${selectedTarget && previsKeyframeTargetId(selectedTarget) === previsKeyframeTargetId(target) ? "is-selected" : ""}`}
style={{ left: `${(key.time / duration) * 100}%`, background: key.color || "#d7dee8" }}
aria-label={`选择 ${key.label ?? "关键帧"} ${key.time.toFixed(2)}s 的关键帧`}
aria-pressed={Boolean(selectedTarget && directorKeyframeTargetId(selectedTarget) === directorKeyframeTargetId(target))}
aria-pressed={Boolean(selectedTarget && previsKeyframeTargetId(selectedTarget) === previsKeyframeTargetId(target))}
title={`${key.time.toFixed(2)}s · 点击定位,按 Delete 删除`}
onClick={(event) => {
event.stopPropagation();
@@ -258,7 +258,7 @@ function TrackKeys({ duration, keys, selectedTarget, onSelectKey, onDeleteKey }:
);
}
function directorKeyframeTargetExists(target: DirectorKeyframeDeleteTarget, camera: DirectorCamera | null, objects: DirectorObject[]) {
function previsKeyframeTargetExists(target: PrevisKeyframeDeleteTarget, camera: PrevisCamera | null, objects: PrevisObject[]) {
if (target.track === "camera") return camera?.id === target.cameraId && camera.keyframes.some((keyframe) => keyframe.id === target.keyframeId);
const object = objects.find((item) => item.id === target.objectId);
if (!object) return false;
@@ -266,14 +266,14 @@ function directorKeyframeTargetExists(target: DirectorKeyframeDeleteTarget, came
return object.boneTracks?.some((track) => track.bone === target.bone && track.keyframes.some((keyframe) => keyframe.id === target.keyframeId)) ?? false;
}
function directorKeyframeTargetId(target: DirectorKeyframeDeleteTarget) {
function previsKeyframeTargetId(target: PrevisKeyframeDeleteTarget) {
if (target.track === "camera") return `camera:${target.cameraId}:${target.keyframeId}`;
if (target.track === "object-transform") return `object:${target.objectId}:transform:${target.keyframeId}`;
return `object:${target.objectId}:bone:${target.bone}:${target.keyframeId}`;
}
function TrackBar({ duration, color, label, start = 0, clipDuration }: { duration: number; color: string; label: string; start?: number; clipDuration?: number }) {
return <div className="director-sequencer-track-content"><span className="director-sequencer-clip" style={{ left: `${(start / duration) * 100}%`, width: `${((clipDuration ?? duration) / duration) * 100}%`, background: `${color}33`, borderColor: `${color}88`, color }}><span className="truncate">{label}</span></span></div>;
return <div className="previs-sequencer-track-content"><span className="previs-sequencer-clip" style={{ left: `${(start / duration) * 100}%`, width: `${((clipDuration ?? duration) / duration) * 100}%`, background: `${color}33`, borderColor: `${color}88`, color }}><span className="truncate">{label}</span></span></div>;
}
function formatTime(time: number) {

View File

@@ -0,0 +1,56 @@
import { Tooltip } from "@/components/ui/base/tooltip";
import { releasePrevisFocusAfterPointer } from "@/lib/canvas/previs/previs-shortcuts";
import { PREVIS_VIEW_MODES, type PrevisViewMode } from "@/lib/canvas/previs/previs-view-modes";
type PrevisViewToolbarProps = {
viewMode: PrevisViewMode;
onViewModeChange: (mode: PrevisViewMode) => void;
inline?: boolean;
};
/**
* 取景模式切换(3D / CAM)。
*
* 独立于底部 dock:dock 装的是「改内容」的工具(变换、添加、渲染视图),
* 这里只切换「从哪只眼睛看」,不产生任何场景改动,也不进 undo/history。
*
* 用文字标签而不是图标:3D 与 CAM 是两个含义相反的取景状态,图标化只会更难认。
* 因此不复用 .previs-viewport-dock-button —— 那条规则写死了正方形尺寸且未分层,
* Tailwind 工具类改不动它。这里用同一批 --previs-* token 自行排布,不动 globals.css。
*/
export function PrevisViewToolbar({ viewMode, onViewModeChange, inline = false }: PrevisViewToolbarProps) {
return (
<div
role="group"
aria-label="预演台取景模式"
className={inline ? "previs-view-toolbar-inline" : "absolute right-3 top-3 z-[var(--z-toolbar)] inline-flex items-center gap-1 rounded-[var(--r-lg)] border p-1 shadow-xl backdrop-blur"}
style={inline ? undefined : { borderColor: "var(--previs-sequencer-border)", background: "var(--previs-dock-surface)", color: "var(--previs-dock-fg)" }}
>
{PREVIS_VIEW_MODES.map((item) => {
const active = viewMode === item.mode;
return (
<Tooltip key={item.mode} title={item.hint} placement="bottom">
<button
type="button"
// aria-pressed 而不是 type="primary" 语义:这是持久的取景状态切换,
// 不是「当前主要命令」。屏幕阅读器要能读出哪一只眼睛是开着的。
aria-pressed={active}
aria-label={`${item.label} ${item.hint}`}
title={item.hint}
className="previs-view-toolbar-button inline-flex h-8 min-w-11 items-center justify-center rounded-[var(--r-md)] px-2 text-[var(--fs-tiny)] font-semibold tracking-wide transition-colors hover:bg-[var(--previs-control-hover)] hover:text-[var(--previs-dock-fg-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--control-focus-ring)] motion-reduce:transition-none"
style={active ? { color: "var(--previs-dock-fg-strong)" } : undefined}
onClick={(event) => {
onViewModeChange(item.mode);
// 焦点留在按钮上会让交互控件守卫吃掉 W/E/R 变换快捷键。
releasePrevisFocusAfterPointer(event);
}}
>
{item.label}
</button>
</Tooltip>
);
})}
</div>
);
}

View File

@@ -1,11 +1,11 @@
// 导演台画面截帧与预演录制(MediaRecorder),以及录制时长探测。
// 预演台画面截帧与预演录制(MediaRecorder),以及录制时长探测。
import { type DirectorRenderMode } from "@/types/director";
import { type PrevisRenderMode } from "@/types/previs";
import { MeshBasicMaterial, MeshDepthMaterial, MeshNormalMaterial } from "three";
import { applyClaySceneMaterials } from "@/lib/canvas/director/director-clay-materials";
import type { CaptureContext } from "./director-viewport";
import { applyClaySceneMaterials } from "@/lib/canvas/previs/previs-clay-materials";
import type { CaptureContext } from "./previs-viewport";
export async function captureFrame(context: CaptureContext | null, mode: DirectorRenderMode) {
export async function captureFrame(context: CaptureContext | null, mode: PrevisRenderMode) {
if (!context) throw new Error("3D 视口尚未就绪");
const { gl, scene, camera } = context;
const resumeDisplayMaterialOverride = context.suspendDisplayMaterialOverride();

View File

@@ -3,16 +3,16 @@ import { Tooltip } from "@/components/ui/base/tooltip";
import { Bone, Box, Camera, Compass, Crosshair, Layers, Lightbulb, Move3D, Palette, Rotate3D, Scaling, UserRound } from "lucide-react";
import type { ReactNode } from "react";
import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts";
import type { DirectorRenderMode } from "@/types/director";
import { releasePrevisFocusAfterPointer } from "@/lib/canvas/previs/previs-shortcuts";
import type { PrevisRenderMode } from "@/types/previs";
type DirectorViewportDockProps = {
type PrevisViewportDockProps = {
transformMode: "translate" | "rotate" | "scale";
renderMode: DirectorRenderMode;
renderMode: PrevisRenderMode;
/** 当前模式允许的渲染视图。dock 只展示这些,避免成为绕过模式门控的第二条路径。 */
renderModes: DirectorRenderMode[];
onTransformModeChange: (mode: DirectorViewportDockProps["transformMode"]) => void;
onRenderModeChange: (mode: DirectorRenderMode) => void;
renderModes: PrevisRenderMode[];
onTransformModeChange: (mode: PrevisViewportDockProps["transformMode"]) => void;
onRenderModeChange: (mode: PrevisRenderMode) => void;
onAddActor: () => void;
onAddBox: () => void;
onAddLight: () => void;
@@ -21,7 +21,7 @@ type DirectorViewportDockProps = {
};
/** 渲染视图按钮的展示顺序与图标。实际可见项由 renderModes 过滤。 */
const RENDER_VIEW_BUTTONS: Array<{ mode: DirectorRenderMode; label: string; icon: ReactNode }> = [
const RENDER_VIEW_BUTTONS: Array<{ mode: PrevisRenderMode; label: string; icon: ReactNode }> = [
{ mode: "beauty", label: "构图预览", icon: <Camera /> },
{ mode: "clay", label: "彩色白膜", icon: <Palette /> },
{ mode: "pose", label: "骨骼视图", icon: <Bone /> },
@@ -29,9 +29,9 @@ const RENDER_VIEW_BUTTONS: Array<{ mode: DirectorRenderMode; label: string; icon
{ mode: "normal", label: "法线视图", icon: <Compass /> },
];
export function DirectorViewportDock({ transformMode, renderMode, renderModes, onTransformModeChange, onRenderModeChange, onAddActor, onAddBox, onAddLight, onAddCamera, onAlignCamera }: DirectorViewportDockProps) {
export function PrevisViewportDock({ transformMode, renderMode, renderModes, onTransformModeChange, onRenderModeChange, onAddActor, onAddBox, onAddLight, onAddCamera, onAlignCamera }: PrevisViewportDockProps) {
return (
<nav className="director-viewport-dock" aria-label="导演台视口工具">
<nav className="previs-viewport-dock" aria-label="预演台视口工具">
<DockButton label="移动对象" active={transformMode === "translate"} onClick={() => onTransformModeChange("translate")}><Move3D /></DockButton>
<DockButton label="旋转对象" active={transformMode === "rotate"} onClick={() => onTransformModeChange("rotate")}><Rotate3D /></DockButton>
<DockButton label="缩放对象" active={transformMode === "scale"} onClick={() => onTransformModeChange("scale")}><Scaling /></DockButton>
@@ -53,19 +53,19 @@ export function DirectorViewportDock({ transformMode, renderMode, renderModes, o
* Dock 按钮。
*
* 鼠标点完后释放焦点:这个 dock 承载 W/E/R 变换工具,焦点留在按钮上会让
* 交互控件守卫吃掉这三个键。规则集中在 releaseDirectorFocusAfterPointer。
* 交互控件守卫吃掉这三个键。规则集中在 releasePrevisFocusAfterPointer。
*/
function DockButton({ label, active, children, onClick }: { label: string; active?: boolean; children: ReactNode; onClick: () => void }) {
return (
<Tooltip title={label} placement="top">
<button
type="button"
className={`director-viewport-dock-button ${active ? "is-active" : ""}`}
className={`previs-viewport-dock-button ${active ? "is-active" : ""}`}
aria-label={label}
aria-pressed={active}
onClick={(event) => {
onClick();
releaseDirectorFocusAfterPointer(event);
releasePrevisFocusAfterPointer(event);
}}
>
{children}
@@ -75,5 +75,5 @@ function DockButton({ label, active, children, onClick }: { label: string; activ
}
function DockDivider() {
return <span className="director-viewport-dock-divider" aria-hidden />;
return <span className="previs-viewport-dock-divider" aria-hidden />;
}

View File

@@ -1,10 +1,10 @@
// 导演台人物模型的骨骼推断、姿态轨道应用与参考材质。纯 three.js 操作,不涉及 React。
// 预演台人物模型的骨骼推断、姿态轨道应用与参考材质。纯 three.js 操作,不涉及 React。
import { Bone, Box3, Camera, type Material, Mesh, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, Quaternion, Vector3 } from "three";
import { disposeDirectorMaterials } from "@/lib/canvas/director/director-resources";
import { type DirectorHumanoidBone, type DirectorObject, type DirectorQuat, type DirectorRig } from "@/types/director";
import { directorPoseBoneDeltas } from "@/lib/canvas/director/director-scene";
import { resolveDirectorBoneRotation } from "@/lib/canvas/director/director-animation-semantics";
import { Bone, Box3, Camera, Color, type Material, Mesh, Object3D, OrthographicCamera, PerspectiveCamera, Quaternion, ShaderMaterial, Vector3 } from "three";
import { disposePrevisMaterials } from "@/lib/canvas/previs/previs-resources";
import { type PrevisHumanoidBone, type PrevisObject, type PrevisQuat, type PrevisRig } from "@/types/previs";
import { previsPoseBoneDeltas } from "@/lib/canvas/previs/previs-scene";
import { resolvePrevisBoneRotation } from "@/lib/canvas/previs/previs-animation-semantics";
export function screenPixelsToWorldRadius(camera: Camera, distance: number, pixels: number, viewportHeight: number) {
const height = Math.max(1, viewportHeight);
@@ -13,7 +13,7 @@ export function screenPixelsToWorldRadius(camera: Camera, distance: number, pixe
return pixels * 0.001;
}
export function directorFingerGroup(bone: string | null) {
export function previsFingerGroup(bone: string | null) {
const match = bone?.match(/^(left|right)(Thumb|Index|Middle|Ring|Pinky)\d$/);
return match ? `${match[1]}${match[2]}` : null;
}
@@ -37,42 +37,95 @@ export function normalizeModel(root: Object3D, castShadow: boolean, receiveShado
});
}
export function applyActorReferenceMaterial(root: Object3D, color: string) {
const material = new MeshStandardMaterial({ color, roughness: 0.74, metalness: 0.02 });
export function applyActorReferenceMaterial(root: Object3D, baseColor: string) {
const color = new Color(baseColor);
const material = new ShaderMaterial({
uniforms: {
uBaseColor: { value: color },
uRimColor: { value: new Color(0xffffff) },
uRimPower: { value: 3.2 },
uRimIntensity: { value: 0.42 },
uTopColor: { value: new Color(0x87ceeb).multiplyScalar(0.16) },
uBottomColor: { value: new Color(0x2c2c2c).multiplyScalar(0.28) },
uRoughness: { value: 0.7 },
uMetalness: { value: 0.05 },
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vViewPosition;
varying float vHeight;
void main() {
vNormal = normalize(normalMatrix * normal);
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;
vHeight = position.y;
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
uniform vec3 uBaseColor;
uniform vec3 uRimColor;
uniform float uRimPower;
uniform float uRimIntensity;
uniform vec3 uTopColor;
uniform vec3 uBottomColor;
uniform float uRoughness;
uniform float uMetalness;
varying vec3 vNormal;
varying vec3 vViewPosition;
varying float vHeight;
void main() {
vec3 normal = normalize(vNormal);
vec3 viewDir = normalize(vViewPosition);
float facing = max(dot(normal, viewDir), 0.0);
float rim = pow(1.0 - facing, uRimPower) * uRimIntensity;
float heightFactor = clamp(vHeight * 0.5, 0.0, 1.0);
vec3 ambientGradient = mix(uBottomColor, uTopColor, heightFactor);
float softSpecular = pow(facing, mix(10.0, 28.0, 1.0 - uRoughness)) * (0.08 + uMetalness * 0.12);
vec3 finalColor = uBaseColor * (0.9 + facing * 0.1) + ambientGradient + rim * uRimColor + softSpecular;
gl_FragColor = vec4(finalColor, 1.0);
}
`,
});
const replaced: Material[] = [];
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
// 被顶掉的原材质不再被任何 mesh 引用,必须释放,否则每次加载都泄漏一份。
if (!mesh.isMesh || mesh.userData.previsNoMaterialOverride) return;
if (mesh.material) replaced.push(...(Array.isArray(mesh.material) ? mesh.material : [mesh.material]));
mesh.material = material;
mesh.userData.directorActor = true;
mesh.userData.directorActorMaterial = material;
mesh.userData.previsActor = true;
mesh.userData.previsActorMaterial = material;
});
disposeDirectorMaterials(replaced.filter((item) => item !== material));
disposePrevisMaterials(replaced.filter((item) => item !== material));
}
export function updateActorReferenceColor(root: Object3D, color: string) {
export function updateActorReferenceColor(root: Object3D, baseColor: string) {
const color = new Color(baseColor);
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh || !mesh.userData.directorActor) return;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach((material) => {
if (material instanceof MeshStandardMaterial) material.color.set(color);
});
if (!mesh.isMesh || !mesh.userData.previsActor) return;
const material = mesh.material as Material;
if (material instanceof ShaderMaterial && material.uniforms.uBaseColor) {
material.uniforms.uBaseColor.value = color.clone();
}
});
}
export function readRigRestRotations(root: Object3D, rig: DirectorRig) {
export function readRigRestRotations(root: Object3D, rig: PrevisRig) {
return Object.fromEntries(
Object.entries(rig.boneMap).flatMap(([bone, name]) => {
const target = name ? root.getObjectByName(name) : null;
return target ? [[bone, target.quaternion.toArray() as DirectorQuat]] : [];
return target ? [[bone, target.quaternion.toArray() as PrevisQuat]] : [];
}),
) as Partial<Record<DirectorHumanoidBone, DirectorQuat>>;
) as Partial<Record<PrevisHumanoidBone, PrevisQuat>>;
}
export function inferDirectorRig(root: Object3D, animationNames: string[]): DirectorRig {
export function inferPrevisRig(root: Object3D, animationNames: string[]): PrevisRig {
const names = new Map<string, string>();
root.traverse((child) => {
if (child instanceof Bone) names.set(normalizeBoneName(child.name), child.name);
@@ -83,7 +136,7 @@ export function inferDirectorRig(root: Object3D, animationNames: string[]): Dire
new RegExp(`^${side}${finger}${segment}$`),
new RegExp(`^${finger}0?${segment}${side === "left" ? "l" : "r"}$`),
];
const patterns: Record<DirectorHumanoidBone, RegExp[]> = {
const patterns: Record<PrevisHumanoidBone, RegExp[]> = {
root: [/^root$/, /armature/],
hips: [/hips|pelvis/, /mixamorig.*hip/],
spine: [/spine1?$|lowerback/],
@@ -139,7 +192,7 @@ export function inferDirectorRig(root: Object3D, animationNames: string[]): Dire
Object.entries(patterns)
.map(([bone, candidates]) => [bone, candidates.map((pattern) => [...names.entries()].find(([normalized]) => pattern.test(normalized))?.[1]).find(Boolean)])
.filter(([, name]) => Boolean(name)),
) as DirectorRig["boneMap"];
) as PrevisRig["boneMap"];
return { status: Object.keys(boneMap).length >= 8 ? "ready" : "unmapped", boneMap, animationNames };
}
@@ -147,16 +200,16 @@ export function normalizeBoneName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
export function applyDirectorBoneTracks(model: Object3D, object: DirectorObject, playhead: number, rig: DirectorRig | null, restRotations: Partial<Record<DirectorHumanoidBone, DirectorQuat>>, hasActiveMotion: boolean) {
export function applyPrevisBoneTracks(model: Object3D, object: PrevisObject, playhead: number, rig: PrevisRig | null, restRotations: Partial<Record<PrevisHumanoidBone, PrevisQuat>>, hasActiveMotion: boolean) {
if (!rig) return;
const poseDeltas = hasActiveMotion ? {} : directorPoseBoneDeltas(object.pose || "stand");
const poseDeltas = hasActiveMotion ? {} : previsPoseBoneDeltas(object.pose || "stand");
Object.entries(rig.boneMap).forEach(([bone, name]) => {
const target = name ? model.getObjectByName(name) : null;
if (!target) return;
const humanoidBone = bone as DirectorHumanoidBone;
const humanoidBone = bone as PrevisHumanoidBone;
// hasActiveMotion 时 mixer 已写入 target.quaternion,直接作为动作层输入。
const rotation = resolveDirectorBoneRotation({
motion: hasActiveMotion ? (target.quaternion.toArray() as DirectorQuat) : null,
const rotation = resolvePrevisBoneRotation({
motion: hasActiveMotion ? (target.quaternion.toArray() as PrevisQuat) : null,
rest: hasActiveMotion ? null : restRotations[humanoidBone] || null,
poseDelta: hasActiveMotion ? null : poseDeltas[humanoidBone] || null,
override: object.boneOverrides?.[humanoidBone] || null,

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createDirectorSaveCoordinator, type DirectorCloseDecision, type DirectorDraftEnvelope, type DirectorSaveCoordinator } from "@/lib/canvas/director/director-save";
import { directorSaveProgress, idleDirectorSaveProgress, resolveDirectorDraftStorage, type DirectorSaveProgress } from "@/lib/canvas/director/director-save-wiring";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
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 { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder";
import { useUserStore } from "@/stores/use-user-store";
import type { DirectorScene } from "@/types/director";
import type { PrevisScene } from "@/types/previs";
export type DirectorSaveController = {
progress: DirectorSaveProgress;
export type PrevisSaveController = {
progress: PrevisSaveProgress;
/** canonical 提交入口:预览态与「仅镜像」路径都不得调用。 */
commitScene: (scene: DirectorScene) => void;
commitScene: (scene: PrevisScene) => void;
retry: () => Promise<boolean>;
restoreCandidate: () => DirectorDraftEnvelope | null;
restoreDraft: (candidate: DirectorDraftEnvelope) => boolean;
restoreCandidate: () => PrevisDraftEnvelope | null;
restoreDraft: (candidate: PrevisDraftEnvelope) => boolean;
discardDraft: () => boolean;
prepareClose: () => Promise<DirectorCloseDecision>;
prepareClose: () => Promise<PrevisCloseDecision>;
handlePageHide: () => Promise<boolean>;
};
@@ -22,14 +22,14 @@ export type DirectorSaveController = {
* 按 scene.id 持有唯一 coordinator:切换场景或卸载时 dispose。
* persistScene 必须把 request.scene 落到项目状态,flushPersistence 负责真正落盘。
*/
export function useDirectorSaveCoordinator(input: { sceneId: string | null; initialScene: DirectorScene | null; persistScene: (scene: DirectorScene) => void; flushPersistence: () => void | Promise<void> }): DirectorSaveController {
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<DirectorSaveCoordinator | null>(null);
const coordinatorRef = useRef<PrevisSaveCoordinator | null>(null);
const persistSceneRef = useRef(persistScene);
const flushPersistenceRef = useRef(flushPersistence);
const initialSceneRef = useRef(initialScene);
const [progress, setProgress] = useState<DirectorSaveProgress>(idleDirectorSaveProgress);
const [progress, setProgress] = useState<PrevisSaveProgress>(idlePrevisSaveProgress);
// scope 取响应式 user id:user-session 先切 active scope 再 setUser,
// 因此这里能在用户变化时重建 coordinator,且不会在播放时每帧读 localStorage。
@@ -50,10 +50,10 @@ export function useDirectorSaveCoordinator(input: { sceneId: string | null; init
}
let active = true;
const coordinator = createDirectorSaveCoordinator({
const coordinator = createPrevisSaveCoordinator({
initialScene: base,
scope,
storage: resolveDirectorDraftStorage(() => globalThis.localStorage),
storage: resolvePrevisDraftStorage(() => globalThis.localStorage),
flush: async (request) => {
try {
// 先把这次 flush 对应的场景写进项目状态,再等持久化真正完成。
@@ -61,20 +61,20 @@ export function useDirectorSaveCoordinator(input: { sceneId: string | null; init
await flushPersistenceRef.current();
} catch (error) {
// 只记录稳定码与安全字段;原始 error 继续上抛给 coordinator 走 error/draft 语义。
recordDirectorDiagnostic("DIRECTOR_SAVE_FLUSH_FAILED", { sceneId: request.scene.id, revision: request.revision });
recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: request.scene.id, revision: request.revision });
throw error;
}
},
});
coordinatorRef.current = coordinator;
setProgress(directorSaveProgress(coordinator.getSnapshot()));
setProgress(previsSaveProgress(coordinator.getSnapshot()));
// 通知里必须读完整快照:只搬 status 会让 confirmedRevision/draftStored 停在旧值,
// UI 继续显示未保存、beforeunload 也会误拦。
const unsubscribe = coordinator.onStatusChange(() => {
if (!active) return;
setProgress(directorSaveProgress(coordinator.getSnapshot()));
setProgress(previsSaveProgress(coordinator.getSnapshot()));
});
return () => {
@@ -98,12 +98,12 @@ export function useDirectorSaveCoordinator(input: { sceneId: string | null; init
/** dispose 后 coordinatorRef 为 null,因此卸载期的调用不会再 setState。 */
const syncProgress = useCallback(() => {
const coordinator = coordinatorRef.current;
if (coordinator) setProgress(directorSaveProgress(coordinator.getSnapshot()));
if (coordinator) setProgress(previsSaveProgress(coordinator.getSnapshot()));
}, []);
/** canonical 提交唯一入口:预览态和「仅镜像当前 draft」都不得调用。 */
const commitScene = useCallback(
(scene: DirectorScene) => {
(scene: PrevisScene) => {
if (!coordinatorRef.current?.edit(scene)) return;
syncProgress();
},
@@ -121,7 +121,7 @@ export function useDirectorSaveCoordinator(input: { sceneId: string | null; init
const restoreCandidate = useCallback(() => coordinatorRef.current?.restoreCandidate() ?? null, []);
const restoreDraft = useCallback(
(candidate: DirectorDraftEnvelope) => {
(candidate: PrevisDraftEnvelope) => {
const coordinator = coordinatorRef.current;
if (!coordinator?.explicitRestore(candidate)) return false;
// 恢复后必须回到项目状态并触发可靠保存,否则草稿只活在本地。
@@ -141,7 +141,7 @@ export function useDirectorSaveCoordinator(input: { sceneId: string | null; init
const prepareClose = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return "close" as DirectorCloseDecision;
if (!coordinator) return "close" as PrevisCloseDecision;
const decision = await coordinator.prepareClose();
syncProgress();
return decision;