feat(canvas): 3D 导演台 - 补齐非 P0 分层交互与回归保护 (#333)

* feat(canvas): 3D 导演台 - 补齐分层交互与回归保护

* fix(canvas): 3D 导演台 - 过滤非法轨迹关键帧

* chore(canvas): 3D 导演台 - 对齐前端格式规范

* fix(canvas): 3D 导演台 - 保证故障重试入口可点击

* test(canvas): 3D 导演台 - 稳定真实点击验证
This commit is contained in:
echoD886
2026-08-27 20:47:10 +08:00
committed by GitHub
parent c76f378811
commit 0e33cc805e
34 changed files with 5049 additions and 141 deletions

View File

@@ -0,0 +1,253 @@
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";
/**
* 导演台首次上手引导浮层(P2)。
*
* 硬约束:
* - 非阻塞。不是 dialog,没有遮罩,不抢焦点,不做焦点陷阱:引导旁边就是要动手的工作台,
* 任何模态化都会把「照着做」变成「先关掉」。因此用 role="region" 而不是 role="dialog"。
* - 只用原生 button。三个动作都是真正的按钮,键盘 Tab/Enter/Space 与读屏天然可用。
* - 只用既有 token(CSS 变量 + Tailwind 布局类),不新增全局 CSS,不写颜色/圆角/字号字面值。
* - 读不到持久进度(IndexedDB 失败)时不展示:无法确认用户是否已经跳过,就不要再骚扰他。
*/
type DirectorOnboardingController = {
/** null 表示不展示:未加载完、已跳过、已完成、或持久层不可用。 */
view: DirectorOnboardingView | null;
/** 有动作在写盘。用于禁用按钮,避免连点产生并发写。 */
busy: boolean;
next: () => void;
back: () => void;
dismiss: () => void;
complete: () => void;
/** 重新开始引导。已跳过/已完成的用户只能靠它回到第一步。 */
reset: () => void;
};
/**
* 引导状态接线。
*
* scope 变化(切账号)会重新加载并丢弃在途结果:generation 守卫保证旧账号的进度
* 不会落到新账号的界面上。enabled 为 false 或 scope 为空时不读盘、不展示。
*
* 并发写保护用 gateRef(同步闭包锁)而不是只靠 busy state:同一 tick 内的重复调用
* (双击、程序化连点)在 busy state 反映到下一次渲染之前就已经发生,只看 state 挡不住。
*
* 关键边界:gateRef 在换代(scope/enabled 变化)时必须整体替换成全新实例,而不是对
* 共享的旧实例调用 release()。原因:如果旧一代的写入仍在途,release 共享实例会把
* 刚为新一代腾出来的锁也一起放开;而新一代锁上之后,旧写入 .finally() 里如果重新读
* `gateRef.current`(这时已经指向新一代的锁)去 release,会把新一代还在写的锁误放掉——
* generation 守卫只保护展示状态,保护不了锁。因此 run() 必须在拿锁的同一刻把
* `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);
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const gateRef = useRef(createDirectorOnboardingGate());
const normalizedScope = scope.trim();
const active = enabled && normalizedScope.length > 0;
useEffect(() => {
generation.current += 1;
const current = generation.current;
// 换新实例而不是 release 旧实例:旧实例可能仍被上一代在途的写入持有。
gateRef.current = createDirectorOnboardingGate();
setProgress(null);
setBusy(false);
if (!active) return;
void loadDirectorOnboardingProgress(normalizedScope, storage)
.then((loaded) => {
if (generation.current === current) setProgress(loaded);
})
.catch(() => {
// 持久层不可用:保持不展示。引导不值得为它弹错误提示。
});
}, [active, normalizedScope, storage]);
const run = useCallback(
(action: DirectorOnboardingAction) => {
if (!active || !progress) return;
// 同步捕获本次调用时刻的锁实例:即使换代把 gateRef.current 换成新实例,
// 这次调用后续的 tryEnter/release 也只作用在捕获到的这一份上。
const gate = gateRef.current;
// 同步拦截:拿不到锁说明上一次调用还没落盘,本次直接放弃,不基于同一份旧 progress 重复发起写入。
if (!gate.tryEnter()) return;
const current = generation.current;
setBusy(true);
void advanceDirectorOnboarding(normalizedScope, progress, action, storage)
.then((next) => {
if (generation.current === current) setProgress(next);
})
.catch(() => {
// 写盘失败:停在当前步骤,用户可以再点一次。绝不在界面上假装已前进。
})
.finally(() => {
// 释放捕获的 gate,不是 gateRef.current —— 换代后 ref 可能已经指向
// 新一代的锁,重新读取会把新一代还在写的锁误放掉。
gate.release();
if (generation.current === current) setBusy(false);
});
},
[active, progress, normalizedScope, storage],
);
const next = useCallback(() => run("next"), [run]);
const back = useCallback(() => run("back"), [run]);
const dismiss = useCallback(() => run("dismiss"), [run]);
const complete = useCallback(() => run("complete"), [run]);
const reset = useCallback(() => {
if (!active) return;
const gate = gateRef.current;
if (!gate.tryEnter()) return;
// A restart supersedes the pending load for this scope. Advancing the
// generation prevents that older read from restoring a completed or
// dismissed progress record after the reset has landed.
generation.current += 1;
const current = generation.current;
setBusy(true);
void resetDirectorOnboardingProgress(normalizedScope, storage)
.then((next) => {
if (generation.current === current) setProgress(next);
})
.catch(() => {
if (generation.current !== current) return;
// 重置失败后把同一 scope 的持久进度重新读回来。重置开始时已经让旧读取
// 失效,若这里不补读,加载期触发重置的用户会一直停在 progress=null。
return loadDirectorOnboardingProgress(normalizedScope, storage)
.then((loaded) => {
if (generation.current === current) setProgress(loaded);
})
.catch(() => {
// 持久层仍不可用时继续隐藏;标题栏入口允许用户稍后再次尝试。
});
})
.finally(() => {
gate.release();
if (generation.current === current) setBusy(false);
});
}, [active, normalizedScope, storage]);
return { view: progress ? resolveDirectorOnboardingView(progress) : null, busy, next, back, dismiss, complete, reset };
}
/** 焦点环与既有 director 控件一致:token 化的 outline,不引入新的全局样式。 */
const FOCUS_RING = "focus-visible:[outline:var(--stroke-2)_solid_var(--control-focus-ring)] focus-visible:[outline-offset:1px]";
/**
* 引导卡片。
*
* 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 });
const previousRestartSignal = useRef(restartSignal);
useEffect(() => {
if (previousRestartSignal.current === restartSignal) return;
previousRestartSignal.current = restartSignal;
reset();
}, [reset, restartSignal]);
if (!view) return null;
const { step, position, total, isFirst, isLast } = view;
return (
<section
className={`grid gap-[var(--space-2)] ${className || ""}`}
aria-label="导演台上手引导"
style={{
padding: "var(--space-3)",
border: "var(--stroke-1) solid var(--border-semantic)",
borderRadius: "var(--r-lg)",
background: "var(--surface)",
boxShadow: "var(--elevation-overlay)",
color: "var(--cn-text)",
}}
>
<header className="flex min-w-0 items-center justify-between gap-[var(--space-2)]">
<p className="m-0 min-w-0 truncate" style={{ fontSize: "var(--fs-label)", fontWeight: 600 }}>
{step.title}
</p>
<span style={{ flex: "0 0 auto", color: "var(--cn-muted)", fontSize: "var(--fs-tiny)" }}>{`第 ${position} 步 / 共 ${total} 步`}</span>
</header>
{/* 步骤切换靠 aria-live 播报,不靠焦点转移:焦点必须留在用户正在操作的地方。 */}
<p className="m-0" aria-live="polite" style={{ color: "var(--cn-muted)", fontSize: "var(--fs-caption)", lineHeight: 1.5 }}>
{step.detail}
</p>
{/* 进度点是纯装饰,真实进度由上面的「第 N 步 / 共 M 步」文本承担。 */}
<div className="flex items-center gap-[var(--space-1)]" aria-hidden>
{Array.from({ length: total }, (_, index) => (
<span
key={index}
style={{
width: "var(--space-1-half)",
height: "var(--space-1-half)",
borderRadius: "var(--r-full)",
background: index < position ? "var(--workspace-accent)" : "var(--cn-stroke)",
}}
/>
))}
</div>
<div className="flex items-center justify-between gap-[var(--space-2)]">
<button type="button" className={FOCUS_RING} onClick={dismiss} disabled={busy} style={{ height: "var(--space-6)", padding: "0 var(--space-2)", borderRadius: "var(--r-sm)", color: "var(--cn-muted)", fontSize: "var(--fs-tiny)" }}>
跳过引导
</button>
<div className="flex items-center gap-[var(--space-1)]">
<button
type="button"
className={FOCUS_RING}
onClick={back}
disabled={busy || isFirst}
style={{
height: "var(--space-6)",
padding: "0 var(--space-3)",
border: "var(--stroke-1) solid var(--control-selected-border)",
borderRadius: "var(--r-sm)",
background: "var(--control-selected-bg)",
color: isFirst ? "var(--control-disabled-fg)" : "var(--control-selected-fg)",
fontSize: "var(--fs-tiny)",
}}
>
上一步
</button>
<button
type="button"
className={FOCUS_RING}
onClick={next}
disabled={busy}
style={{
height: "var(--space-6)",
padding: "0 var(--space-3)",
border: "var(--stroke-1) solid var(--workspace-accent)",
borderRadius: "var(--r-sm)",
background: "var(--workspace-accent)",
color: "var(--btn-solid-fg)",
fontSize: "var(--fs-tiny)",
}}
>
{isLast ? "结束引导" : "下一步"}
</button>
</div>
</div>
</section>
);
}

View File

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

View File

@@ -6,17 +6,20 @@ import { nanoid } from "nanoid";
import { Euler, Quaternion } from "three";
import type { AnimationClip } from "three";
import { CanvasDirectorOnboarding } from "@/components/canvas/director/canvas-director-onboarding";
import { DirectorViewport, type DirectorViewportHandle } from "@/components/canvas/director/director-viewport";
import { DirectorViewportDock } from "@/components/canvas/director/director-viewport-dock";
import { DirectorSequencer } from "@/components/canvas/director/director-sequencer";
import { canvasThemes } from "@/lib/canvas-theme";
import { compileDirectorPrompt } from "@/lib/canvas/director/director-prompt-compiler";
import { resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, snapDirectorTime } from "@/lib/canvas/director/director-animation-semantics";
import { advanceDirectorPlayhead, resolveDirectorCameraAlignment, resolveDirectorCameraMoveKeyframes, resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, snapDirectorTime } from "@/lib/canvas/director/director-animation-semantics";
import { createDirectorTransaction, installDirectorTerminalListeners, type DirectorTransaction } from "@/lib/canvas/director/director-gesture-transaction";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
import { DIRECTOR_MODES, directorModeCapabilities, type DirectorModeCapabilities } from "@/lib/canvas/director/director-modes";
import { resolveDirectorPlacement, resolveDirectorPlacementAnchor } from "@/lib/canvas/director/director-placement";
import { shouldReinitializeDirectorSession } from "@/lib/canvas/director/director-session";
import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorPoseLabel, interpolateDirectorTransform, touchDirectorScene, upsertDirectorBoneKeyframe } from "@/lib/canvas/director/director-scene";
import { isDirectorOutputSnapshotCurrent, shouldReinitializeDirectorSession } from "@/lib/canvas/director/director-session";
import { blocksDirectorShortcut, releaseDirectorFocusAfterPointer, resolveDirectorShortcut, type DirectorShortcutAction } from "@/lib/canvas/director/director-shortcuts";
import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorFocalLengthToFov, directorPoseLabel, interpolateDirectorTransform, removeDirectorSceneKeyframe, setDirectorSceneKeyframeEasing, touchDirectorScene, upsertDirectorBoneKeyframe } from "@/lib/canvas/director/director-scene";
import { describeDirectorSaveStatus, resolveDirectorCloseOutcome, shouldBlockDirectorUnload, shouldOfferDirectorDraftRecovery } from "@/lib/canvas/director/director-save-wiring";
import { useDirectorSaveCoordinator } from "@/components/canvas/director/use-director-save-coordinator";
import { uploadMediaFile } from "@/services/file-storage";
@@ -24,9 +27,9 @@ import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { DirectorCamera, DirectorCameraMove, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorRig, DirectorScene, DirectorSceneOutput, DirectorShot, DirectorShotSize, DirectorTransform, DirectorVec3 } from "@/types/director";
import type { DirectorCamera, DirectorCameraMove, DirectorHumanoidBone, DirectorKeyframeDeleteTarget, DirectorKeyframeEasing, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorSceneOutput, DirectorShot, DirectorShotSize, DirectorTransform, DirectorVec3 } from "@/types/director";
export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply, onDeleteImageNode, onFlush }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise<void>; onDeleteImageNode: (nodeId: string) => void; onFlush?: () => void | Promise<void> }) {
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[useThemeStore((state) => state.theme)];
const viewportRef = useRef<DirectorViewportHandle>(null);
@@ -36,6 +39,11 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const [future, setFuture] = useState<DirectorScene[]>([]);
const [saving, setSaving] = useState(false);
const [recording, setRecording] = useState(false);
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);
@@ -61,6 +69,10 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
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);
@@ -192,11 +204,16 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
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) => {
const delta = (now - last) / 1000;
pending += Math.max(0, (now - last) / 1000);
last = now;
const next = useDirectorWorkbenchStore.getState().playhead + delta;
setPlayhead(next >= activeShot.duration ? 0 : next);
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);
@@ -474,6 +491,92 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
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) => ({
@@ -515,17 +618,18 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const applyCameraMove = () => {
if (!activeCamera || !activeShot) return;
const start = activeCamera.transform;
const end = cameraMoveTransform(start, activeShot.cameraMove);
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, keyframes: [{ id: nanoid(), time: 0, transform: start }, { id: nanoid(), time: activeShot.duration, transform: end }] } : item) }));
message.success("已生成相机运动关键帧");
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 ? { ...item, transform } : item) }));
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? resolveDirectorCameraAlignment(item, transform, snappedPlayhead) : item) }));
message.success("摄影机已对齐当前视图");
};
@@ -533,9 +637,11 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
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);
@@ -553,6 +659,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
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;
@@ -561,9 +668,12 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
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);
await onApply({ scene: next, shot: activeShot, prompt: compileDirectorPrompt(next, activeShot), beauty: await viewportRef.current.capture("beauty"), clayVideo, clayVideoMimeType: clayVideo.type });
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 : "白膜视频导出失败");
@@ -578,12 +688,33 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
return (
<div 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="flex h-12 shrink-0 items-center gap-2 border-b px-2" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
<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。 */}
<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>
<div className="ml-auto flex items-center gap-2">
<span
aria-live="polite"
@@ -595,7 +726,8 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
{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">
<Select size="small" value={renderMode} className="w-24" options={[{ label: "预览", value: "beauty" }, { label: "彩色白膜", value: "clay" }, { label: "骨骼", value: "pose" }, { label: "深度", value: "depth" }, { label: "法线", value: "normal" }]} onChange={setRenderMode} />
{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>
@@ -626,22 +758,25 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
</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} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onObjectTransform={handleObjectTransform} onBoneTransform={handleBoneTransform} onActorRigReady={handleActorRigReady} />
<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>
<DirectorViewportDock transformMode={transformMode} renderMode={renderMode} onTransformModeChange={setTransformMode} onRenderModeChange={setRenderMode} onAddActor={addActor} onAddBox={() => addPrimitive("box", "立方体")} onAddLight={addLight} onAddCamera={addCamera} onAlignCamera={alignCameraToView} />
<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:hidden" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
{selectedObject ? <ObjectInspector object={selectedObject} rendered={selectedObjectRendered || selectedObject.transform} playhead={snappedPlayhead} selectedBone={selectedBone} 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 ? <LightInspector light={selectedLight} onUpdate={(patch) => updateLight(selectedLight.id, patch)} onDelete={() => removeLight(selectedLight.id)} /> : <ShotInspector shot={activeShot} camera={activeCamera} cameras={draft.cameras} 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 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>
<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} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} />
{/* 时间轴只属于动画模式:其他模式下它不渲染,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}
</div>
);
}
function ObjectInspector({ object, rendered, playhead, selectedBone, onSelectBone, onUpdate, onTransformEdit, onBoneRotationStage, onBoneRotationCommit, onAddKeyframe, onDelete }: { object: DirectorObject; rendered: DirectorTransform; playhead: number; selectedBone: string | null; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial<DirectorObject>) => void; onTransformEdit: (transform: DirectorTransform) => void; onBoneRotationStage: (rotation: DirectorQuat) => void; onBoneRotationCommit: () => void; onAddKeyframe: () => void; onDelete: () => void }) {
function ObjectInspector({ object, rendered, playhead, selectedBone, capabilities, onSelectBone, onUpdate, onTransformEdit, onBoneRotationStage, onBoneRotationCommit, onAddKeyframe, onDelete }: { object: DirectorObject; rendered: DirectorTransform; playhead: number; selectedBone: string | null; capabilities: DirectorModeCapabilities; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial<DirectorObject>) => void; onTransformEdit: (transform: DirectorTransform) => void; onBoneRotationStage: (rotation: DirectorQuat) => void; onBoneRotationCommit: () => void; onAddKeyframe: () => 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[];
@@ -649,25 +784,43 @@ function ObjectInspector({ object, rendered, playhead, selectedBone, onSelectBon
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 resetSelectedBone = () => {
if (!selectedBoneId) return;
const boneOverrides = { ...(object.boneOverrides || {}) };
delete boneOverrides[selectedBoneId];
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>
: <Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>}
{object.kind === "actor" || object.primitive === "character" || motionClips.length ? <>
{/*
骨骼与姿势入口:只在姿态/动画模式出现,且只对演员出现。
规格要求「仅在演员选择时展示现有骨骼/姿势入口」——
带动画的普通模型不是演员,不应拿到姿势预设与骨骼控制。
*/}
{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? <>
<section className="director-pose-section">
<div className="director-inspector-section-title"><span>姿势预设</span><span>{directorPoseLabel(object.pose || "stand")}</span></div>
<div className="director-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)}>{option.label}</button>)}</div>
<Button size="small" block onClick={() => applyPose("stand")}>重置姿态</Button>
</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></div>
{motionClips.length ? <><Field label="动作片段"><Select className="w-full" value={object.activeMotionClipId || ""} options={[{ label: "静态姿势", value: "" }, ...motionClips.map((clip) => ({ label: clip.name, value: clip.id }))]} onChange={(activeMotionClipId) => onUpdate({ activeMotionClipId: activeMotionClipId || undefined })} /></Field>{activeMotionClip ? <div className="grid grid-cols-2 gap-2"><Field label="播放速度"><InputNumber className="w-full" min={0.1} max={4} step={0.1} value={activeMotionClip.playbackRate} onChange={(playbackRate) => updateActiveMotion({ playbackRate: playbackRate || 1 })} /></Field><Field label="循环"><Switch checked={activeMotionClip.loop} onChange={(loop) => updateActiveMotion({ loop })} /></Field></div> : null}</> : <div className="text-[var(--fs-tiny)] opacity-50">模型加载后会显示可用动作 Clip</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)} /></Field> : null}
{selectedBoneId && selectedBoneRotation ? <BoneRotationFields rotation={selectedBoneRotation} onChange={onBoneRotationStage} onChangeComplete={onBoneRotationCommit} /> : null}
{selectedBoneId && selectedBoneRotation ? <><BoneRotationFields rotation={selectedBoneRotation} onChange={onBoneRotationStage} onChangeComplete={onBoneRotationCommit} /><Button size="small" block onClick={resetSelectedBone}>重置当前骨骼</Button></> : null}
{/* 演员还没加载出模型时给一句解释,避免「动作片段」区域凭空消失。 */}
{motionClips.length ? null : <div className="text-[var(--fs-tiny)] opacity-50">模型加载后会显示可用动作 Clip</div>}
</> : null}
{/* 动作片段是动画内容而非骨骼入口:任何带 Clip 的对象都能调,不限演员。 */}
{motionClips.length ? <><Field label="动作片段"><Select className="w-full" value={object.activeMotionClipId || ""} options={[{ label: "静态姿势", value: "" }, ...motionClips.map((clip) => ({ label: clip.name, value: clip.id }))]} onChange={(activeMotionClipId) => onUpdate({ activeMotionClipId: activeMotionClipId || undefined })} /></Field>{activeMotionClip ? <div className="grid grid-cols-2 gap-2"><Field label="播放速度"><InputNumber className="w-full" min={0.1} max={4} step={0.1} value={activeMotionClip.playbackRate} onChange={(playbackRate) => updateActiveMotion({ playbackRate: playbackRate || 1 })} /></Field><Field label="循环"><Switch checked={activeMotionClip.loop} onChange={(loop) => updateActiveMotion({ loop })} /></Field></div> : null}</> : null}
<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field>
<Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field>
<Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>{selectedBone ? `在 ${playhead.toFixed(1)}s 记录骨骼` : `在 ${playhead.toFixed(1)}s 记录关键帧`}</Button>
<div className="text-[var(--fs-tiny)] opacity-50">Transform {object.keyframes.length} 个 · 骨骼 {object.boneTracks?.reduce((sum, track) => sum + track.keyframes.length, 0) || 0} 个</div>
{/* 记录关键帧属于动画模式;摆场与姿态模式不默认制造关键帧。 */}
{capabilities.keyframes ? <>
<Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>{selectedBone ? `在 ${playhead.toFixed(1)}s 记录骨骼` : `在 ${playhead.toFixed(1)}s 记录关键帧`}</Button>
<div className="text-[var(--fs-tiny)] opacity-50">Transform {object.keyframes.length} 个 · 骨骼 {object.boneTracks?.reduce((sum, track) => sum + track.keyframes.length, 0) || 0} 个</div>
</> : null}
</Inspector>;
}
@@ -675,14 +828,14 @@ function LightInspector({ light, onUpdate, onDelete }: { light: DirectorLight; o
return <Inspector title={light.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}><Field label="类型"><Select className="w-full" value={light.type} options={[{ label: "方向光", value: "directional" }, { label: "点光源", value: "point" }, { label: "聚光灯", value: "spot" }, { label: "环境光", value: "ambient" }]} onChange={(type) => onUpdate({ type })} /></Field><Vec3Field label="位置" value={light.transform.position} onChange={(position) => onUpdate({ transform: { ...light.transform, position } })} /><Field label="颜色"><ColorPicker value={light.color} onChange={(_, color) => onUpdate({ color })} /></Field><Field label="强度"><InputNumber className="w-full" min={0} max={20} step={0.1} value={light.intensity} onChange={(value) => onUpdate({ intensity: value || 0 })} /></Field><Field label="投射阴影"><Switch checked={light.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field></Inspector>;
}
function ShotInspector({ shot, camera, cameras, onUpdateShot, onUpdateCamera, onAddCameraKeyframe, onApplyCameraMove, onAlignCameraToView, onExportClay, recording }: { shot: DirectorShot; camera: DirectorCamera | null; cameras: DirectorScene["cameras"]; onUpdateShot: (patch: Partial<DirectorShot>) => void; onUpdateCamera: (patch: Partial<DirectorCamera>) => void; onAddCameraKeyframe: () => void; onApplyCameraMove: () => void; onAlignCameraToView: () => void; onExportClay: () => void; recording: boolean }) {
function ShotInspector({ shot, camera, cameras, capabilities, onUpdateShot, onUpdateCamera, onAddCameraKeyframe, onApplyCameraMove, onAlignCameraToView, onExportClay, recording }: { shot: DirectorShot; camera: DirectorCamera | null; cameras: DirectorScene["cameras"]; capabilities: DirectorModeCapabilities; onUpdateShot: (patch: Partial<DirectorShot>) => void; onUpdateCamera: (patch: Partial<DirectorCamera>) => void; onAddCameraKeyframe: () => void; onApplyCameraMove: () => void; onAlignCameraToView: () => void; onExportClay: () => void; recording: boolean }) {
return <Inspector title={shot.name} onTitleChange={(name) => onUpdateShot({ name })}>
<Field label="摄影机"><Select className="w-full" value={shot.cameraId} options={cameras.map((item) => ({ label: item.name, value: item.id }))} onChange={(cameraId) => onUpdateShot({ cameraId })} /></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 })} /></Field><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></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 })} /></Field>
<Field label="镜头意图"><Input.TextArea autoSize={{ minRows: 3, maxRows: 7 }} value={shot.prompt} placeholder="人物表演、动作、叙事目标…" onChange={(event) => onUpdateShot({ prompt: event.target.value })} /></Field>
{camera ? <><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: focalLengthToFov(focalLength || 35) })} /></Field><div className="grid grid-cols-2 gap-2"><Field label="光圈"><InputNumber className="w-full" min={0.7} max={32} step={0.1} value={camera.aperture} addonBefore="f/" onChange={(aperture) => onUpdateCamera({ aperture: aperture || 2.8 })} /></Field><Field label="焦点距离"><InputNumber className="w-full" min={0.1} max={200} step={0.1} value={camera.focusDistance} addonAfter="m" onChange={(focusDistance) => onUpdateCamera({ focusDistance: focusDistance || 5 })} /></Field></div><Button block icon={<Camera className="size-3.5" />} onClick={onAlignCameraToView}>摄影机对齐当前视图</Button><Button block icon={<Video className="size-3.5" />} onClick={onApplyCameraMove}>按运镜生成轨迹</Button><Button block icon={<Focus className="size-3.5" />} onClick={onAddCameraKeyframe}>记录摄影机关键帧</Button><Button block type="primary" ghost icon={<Video className="size-3.5" />} loading={recording} onClick={onExportClay}>导出白膜视频</Button></> : null}
{camera ? <><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) })} /></Field><div className="grid grid-cols-2 gap-2"><Field label="光圈"><InputNumber className="w-full" min={0.7} max={32} step={0.1} value={camera.aperture} addonBefore="f/" onChange={(aperture) => onUpdateCamera({ aperture: aperture || 2.8 })} /></Field><Field label="焦点距离"><InputNumber className="w-full" min={0.1} max={200} step={0.1} value={camera.focusDistance} addonAfter="m" onChange={(focusDistance) => onUpdateCamera({ focusDistance: focusDistance || 5 })} /></Field></div><Button block icon={<Camera className="size-3.5" />} onClick={onAlignCameraToView}>摄影机对齐当前视图</Button><Button block icon={<Video className="size-3.5" />} onClick={onApplyCameraMove}>按运镜生成轨迹</Button>{capabilities.keyframes ? <Button block icon={<Focus className="size-3.5" />} onClick={onAddCameraKeyframe}>记录摄影机关键帧</Button> : null}<Button block type="primary" ghost icon={<Video className="size-3.5" />} loading={recording} onClick={onExportClay}>导出白膜视频</Button></> : null}
</Inspector>;
}
@@ -737,20 +890,25 @@ function Vec3Field({ label, value, step = 0.1, onChange }: { label: string; valu
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="block"><span className="mb-1 block text-[var(--fs-label)] opacity-55">{label}</span>{children}</label>; }
function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return <div className="flex h-9 items-center px-3 text-[var(--fs-tiny)] font-semibold uppercase opacity-55"><span className="flex-1">{title}</span>{action}</div>; }
/**
* 场景列表行。选择按钮点完必须释放焦点:
*「点选对象 -> 按 Delete」是 delete-selected 快捷键的主流程,
* 焦点留在按钮上会让守卫把 Delete 吃掉。
*/
function SceneRow({ active, icon, label, onClick, onDelete }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void; onDelete?: () => void }) {
return <div className={`flex h-8 w-full items-center gap-1 px-1 text-left text-xs transition ${active ? "bg-black/10 dark:bg-white/10" : "hover:bg-black/5 dark:hover:bg-white/5"}`}>
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left" onClick={onClick}>
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left" onClick={(event) => { onClick(); releaseDirectorFocusAfterPointer(event); }}>
<span className="[&>svg]:size-3.5">{icon}</span>
<span className="truncate">{label}</span>
</button>
{onDelete ? <button type="button" aria-label={`删除${label}`} title={`删除${label}`} className="grid size-6 shrink-0 place-items-center rounded opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10" onClick={(event) => { event.stopPropagation(); onDelete(); }}><Trash2 className="size-3.5" /></button> : null}
{onDelete ? <button type="button" aria-label={`删除${label}`} title={`删除${label}`} className="grid size-6 shrink-0 place-items-center rounded opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10" onClick={(event) => { event.stopPropagation(); onDelete(); releaseDirectorFocusAfterPointer(event); }}><Trash2 className="size-3.5" /></button> : null}
</div>;
}
function AddMenuButton({ label, items }: { label: string; items: MenuProps["items"] }) {
return <Dropdown trigger={["click"]} placement="bottomRight" menu={{ items }}><button type="button" aria-label={label} title={label} className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 dark:hover:bg-white/10"><Plus className="size-3.5" /></button></Dropdown>;
}
function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return <button type="button" 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={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} disabled={disabled} 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={onClick}>{children}</button>; }
function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return <button type="button" 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); }}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} disabled={disabled} 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); }}>{children}</button>; }
const poseOptions: Array<{ label: string; value: DirectorPose }> = [
{ label: "站立", value: "stand" }, { label: "T 型", value: "t_pose" }, { label: "行走", value: "walk" }, { label: "跑步", value: "run" },
{ label: "坐姿", value: "sit" }, { label: "蹲下", value: "squat" }, { label: "单膝跪", value: "kneel_single" }, { label: "双膝跪", value: "kneel_double" },
@@ -760,6 +918,14 @@ const poseOptions: Array<{ label: string; value: DirectorPose }> = [
];
const shotSizeOptions = [{ label: "大远景", value: "extreme_wide" }, { label: "远景", value: "wide" }, { label: "全身景", value: "full" }, { label: "中景", value: "medium" }, { label: "近景", value: "close_up" }, { label: "大特写", value: "extreme_close_up" }];
const cameraMoveOptions = [{ label: "固定", value: "static" }, { label: "推进", value: "push_in" }, { label: "拉远", value: "pull_out" }, { label: "左摇", value: "pan_left" }, { label: "右摇", value: "pan_right" }, { label: "上摇", value: "tilt_up" }, { label: "下摇", value: "tilt_down" }, { label: "左环绕", value: "orbit_left" }, { label: "右环绕", value: "orbit_right" }, { label: "手持", value: "handheld" }];
/** 渲染视图全集。实际可选项由当前模式的 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;
@@ -767,5 +933,3 @@ function cameraMoveTransform(transform: DirectorTransform, move: DirectorCameraM
const offset = offsets[move];
return { ...transform, position: [x + offset[0], y + offset[1], z + offset[2]] };
}
function focalLengthToFov(focalLength: number) { return (2 * Math.atan(36 / (2 * focalLength)) * 180) / Math.PI; }

View File

@@ -1,8 +1,9 @@
import { ChevronDown, ChevronRight, ChevronUp, KeyRound, Magnet, Pause, Play, Plus, Rows3, ZoomIn, ZoomOut } from "lucide-react";
import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
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 type { DirectorCamera, DirectorObject, DirectorScene, DirectorShot } from "@/types/director";
import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts";
import type { DirectorCamera, DirectorKeyframeDeleteTarget, DirectorKeyframeEasing, DirectorObject, DirectorScene, DirectorShot } from "@/types/director";
type DirectorSequencerProps = {
scene: DirectorScene;
@@ -25,21 +26,37 @@ type DirectorSequencerProps = {
onSelectBone: (bone: string | null) => void;
onRecordKeyframe: () => void;
onAddShot: () => void;
/** 删除某条可见轨道上的关键帧;覆盖 transform / camera / bone 三类。 */
onDeleteKeyframe: (target: DirectorKeyframeDeleteTarget) => void;
/** 更新关键帧到下一帧区间的缓动。 */
onSetKeyframeEasing: (target: DirectorKeyframeDeleteTarget, easing: DirectorKeyframeEasing) => void;
onSelectShot: (id: string) => void;
};
type TrackKey = { id: string; time: number; color?: string };
/**
* 轨道上的一个关键帧标记。
* 带 target 才可删除;镜头总轨等只读轨道不给 target。
*/
type TrackKey = { id: string; time: number; color?: string; label?: string; easing?: DirectorKeyframeEasing; target?: DirectorKeyframeDeleteTarget };
export function DirectorSequencer({ scene, shot, camera, objects, selectedObjectId, selectedBone, playhead, playing, autoKey, height, visible, onPlayToggle, onPlayheadChange, onAutoKeyChange, onHeightChange, onVisibilityChange, onSelectObject, onSelectBone, onRecordKeyframe, onAddShot, onSelectShot }: DirectorSequencerProps) {
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) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [snapEnabled, setSnapEnabled] = useState(true);
const [showDetails, setShowDetails] = useState(true);
const [timelineScale, setTimelineScale] = useState(1);
const [selectedKey, setSelectedKey] = useState<TrackKey | null>(null);
const duration = Math.max(0.5, shot.duration);
const fps = shot.fps || 24;
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;
// 选择只对当前可见轨道有效。切换镜头/摄影机或外部删帧后立即废弃旧 target,
// 避免顶部缓动与删除控件继续修改已经不可见的轨道。
useEffect(() => {
setSelectedKey((current) => current?.target && !directorKeyframeTargetExists(current.target, camera, objects) ? null : current);
}, [camera, objects]);
const setTimeFromPointer = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
@@ -60,7 +77,26 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
window.addEventListener("pointerup", stop, { once: true });
};
const cameraKeys = camera?.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#78a9ff" })) || [];
// 摄影机关键帧只在摄影机行提供删除入口;Camera Cut 是概览轨,保持只读。
const cameraKeys: TrackKey[] = camera?.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#78a9ff", label: `${camera.name} 镜头`, easing: key.easing, target: { track: "camera", cameraId: camera.id, keyframeId: key.id } })) || [];
const selectTrackKey = (key: TrackKey) => {
if (!key.target) return;
setSelectedKey(key);
onPlayheadChange(key.time);
if (key.target.track === "camera") {
onSelectObject(null);
onSelectBone(null);
} else {
onSelectObject(key.target.objectId);
onSelectBone(key.target.track === "object-bone" ? key.target.bone : null);
}
};
const deleteTrackKey = (target: DirectorKeyframeDeleteTarget) => {
onDeleteKeyframe(target);
if (selectedKey?.target && directorKeyframeTargetId(selectedKey.target) === directorKeyframeTargetId(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)" }}>
@@ -84,6 +120,24 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
<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>
{activeSelectedKey?.target ? <>
<select
className="director-sequencer-shot-select"
value={activeSelectedKey.easing || "linear"}
aria-label="关键帧缓动"
title="关键帧到下一帧的缓动"
onChange={(event) => {
const easing = event.target.value as DirectorKeyframeEasing;
onSetKeyframeEasing(activeSelectedKey.target!, easing);
setSelectedKey((current) => current ? { ...current, easing } : current);
}}
>
<option value="step">保持</option>
<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>
</> : 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>
@@ -102,42 +156,44 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
<span className="director-sequencer-playhead" style={{ left: `${(playhead / duration) * 100}%` }} />
</div>
<SequencerRow label="镜头总轨" icon="◈" selected={false} keys={[]} onClick={() => undefined}>
<SequencerRow label="镜头总轨" icon="◈" selected={false} onClick={() => undefined}>
<TrackBar duration={duration} color="#7da2ff" label={`${shot.name} · ${formatTime(duration)}`} />
</SequencerRow>
<SequencerRow label="Camera Cut" icon="▣" selected={false} keys={cameraKeys} onClick={() => undefined}>
<SequencerRow label="Camera Cut" icon="▣" selected={false} onClick={() => undefined}>
<TrackKeys duration={duration} keys={cameraKeys} />
</SequencerRow>
{camera ? <SequencerRow label={camera.name} icon="⌾" selected={!selectedObjectId} keys={cameraKeys} onClick={() => { onSelectObject(null); onSelectBone(null); }}>
{camera ? <SequencerRow label={camera.name} icon="⌾" selected={!selectedObjectId} onClick={() => { onSelectObject(null); onSelectBone(null); }}>
<TrackBar duration={duration} color="#78a9ff" label="Transform · 焦距 · 景深" />
<TrackKeys duration={duration} keys={cameraKeys} />
<TrackKeys duration={duration} keys={cameraKeys} selectedTarget={activeSelectedKey?.target} onSelectKey={selectTrackKey} onDeleteKey={deleteTrackKey} />
</SequencerRow> : null}
{actorObjects.map((object) => {
const isExpanded = expanded[object.id] ?? object.id === selectedObjectId;
const activeClip = object.motionClips?.find((clip) => clip.id === object.activeMotionClipId);
const boneTrackKeys = object.boneTracks?.flatMap((track) => track.keyframes.map((key) => ({ id: `${track.bone}-${key.id}`, time: key.time, color: "#f0b36a" }))) || [];
const transformKeys = object.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#61d2ad" }));
// 骨骼帧 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 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} keys={transformKeys} onClick={() => { onSelectObject(object.id); onSelectBone(null); setExpanded((current) => ({ ...current, [object.id]: !isExpanded })); }}>
<TrackKeys duration={duration} keys={[...transformKeys, ...boneTrackKeys]} />
<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 })); }}>
{/* 折叠时这是唯一的关键帧入口,必须可删除。 */}
<TrackKeys duration={duration} keys={[...transformKeys, ...boneTrackKeys]} selectedTarget={activeSelectedKey?.target} onSelectKey={selectTrackKey} onDeleteKey={deleteTrackKey} />
</SequencerRow>
{isExpanded && showDetails ? <>
<SequencerRow label="动作片段" icon="▶" indent selected={selectedObjectId === object.id && !selectedBone} keys={[]} onClick={() => onSelectObject(object.id)}>
<SequencerRow label="动作片段" icon="▶" indent selected={selectedObjectId === object.id && !selectedBone} onClick={() => onSelectObject(object.id)}>
<TrackBar duration={duration} color="#61d2ad" label={activeClip ? `${activeClip.name}${activeClip.loop ? " · 循环" : ""}` : "姿势 / 动作"} start={activeClip?.start || 0} clipDuration={activeClip?.loop ? duration : activeClip?.duration || duration} />
</SequencerRow>
<SequencerRow label="Transform" icon="◇" indent selected={false} keys={transformKeys} onClick={() => onSelectObject(object.id)}>
<TrackKeys duration={duration} keys={transformKeys} />
<SequencerRow label="Transform" icon="◇" indent selected={selectedObjectId === object.id && !selectedBone} onClick={() => { onSelectObject(object.id); onSelectBone(null); }}>
<TrackKeys duration={duration} keys={transformKeys} selectedTarget={activeSelectedKey?.target} onSelectKey={selectTrackKey} onDeleteKey={deleteTrackKey} />
</SequencerRow>
{object.boneTracks?.map((track) => {
const keys = track.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#f0b36a" }));
return <SequencerRow key={track.bone} label={directorBoneLabel(track.bone)} icon="◌" indent selected={selectedBone === track.bone} keys={keys} onClick={() => { onSelectObject(object.id); onSelectBone(track.bone); }}><TrackKeys duration={duration} keys={keys} /></SequencerRow>;
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>;
})}
</> : null}
</div>;
})}
{objects.filter((object) => !actorObjects.some((actor) => actor.id === object.id)).map((object) => {
const keys = object.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#b8c0ca" }));
return <SequencerRow key={object.id} label={object.name} icon="□" selected={selectedObjectId === object.id} keys={keys} onClick={() => { onSelectObject(object.id); onSelectBone(null); }}><TrackKeys duration={duration} keys={keys} /></SequencerRow>;
const keys: TrackKey[] = object.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#b8c0ca", label: `${object.name} Transform`, easing: key.easing, target: { track: "object-transform", objectId: object.id, keyframeId: key.id } }));
return <SequencerRow key={object.id} label={object.name} icon="□" selected={selectedObjectId === object.id} onClick={() => { onSelectObject(object.id); onSelectBone(null); }}><TrackKeys duration={duration} keys={keys} selectedTarget={activeSelectedKey?.target} onSelectKey={selectTrackKey} onDeleteKey={deleteTrackKey} /></SequencerRow>;
})}
</div>
</div>
@@ -145,15 +201,75 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
);
}
function SequencerRow({ label, icon, selected, indent, children, onClick }: { label: string; icon: ReactNode; selected: boolean; indent?: boolean; keys?: TrackKey[]; children: ReactNode; onClick: () => void }) {
/**
* 轨道行。标签按钮是选择控件,点完必须释放焦点:
*「点选轨道 -> 按 Delete」与场景列表同源,焦点留在按钮上会让守卫吃掉 Delete。
* 注意 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={onClick}><span className="director-sequencer-row-icon">{icon}</span><span className="min-w-0 truncate">{label}</span></button>
<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>
</div>;
}
function TrackKeys({ duration, keys }: { duration: number; keys: TrackKey[] }) {
return <div className="director-sequencer-track-content">{keys.map((key) => <span key={key.id} className="director-sequencer-key" style={{ left: `${(key.time / duration) * 100}%`, background: key.color || "#d7dee8" }} title={`${key.time.toFixed(2)}s`} />)}</div>;
/**
* 关键帧渲染为真实 button:可 Tab 聚焦、可 Enter/Space/Delete 触发、也可点击。
* 之前是惰性 span,关键帧一旦记录就无法删除。
*
* 只有同时具备 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 }) {
return (
<div className="director-sequencer-track-content">
{keys.map((key) => {
const target = onDeleteKey && key.target;
if (!target) {
return <span
key={key.id}
className="director-sequencer-key"
style={{ left: `${(key.time / duration) * 100}%`, background: key.color || "#d7dee8" }}
title={`${key.time.toFixed(2)}s`}
/>;
}
return <button
key={key.id}
type="button"
className={`director-sequencer-key is-actionable ${selectedTarget && directorKeyframeTargetId(selectedTarget) === directorKeyframeTargetId(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))}
title={`${key.time.toFixed(2)}s · 点击定位,按 Delete 删除`}
onClick={(event) => {
event.stopPropagation();
onSelectKey?.(key);
}}
onKeyDown={(event) => {
if (!["Enter", " ", "Delete", "Backspace"].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
if (event.key === "Delete" || event.key === "Backspace") onDeleteKey(target);
else onSelectKey?.(key);
}}
/>;
})}
</div>
);
}
function directorKeyframeTargetExists(target: DirectorKeyframeDeleteTarget, camera: DirectorCamera | null, objects: DirectorObject[]) {
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;
if (target.track === "object-transform") return object.keyframes.some((keyframe) => keyframe.id === target.keyframeId);
return object.boneTracks?.some((track) => track.bone === target.bone && track.keyframes.some((keyframe) => keyframe.id === target.keyframeId)) ?? false;
}
function directorKeyframeTargetId(target: DirectorKeyframeDeleteTarget) {
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 }) {

View File

@@ -0,0 +1,55 @@
import { Tooltip } from "antd";
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>
);
}

View File

@@ -1,12 +1,15 @@
import { Tooltip } from "antd";
import { Bone, Box, Camera, Crosshair, Lightbulb, Move3D, Palette, Rotate3D, Scaling, UserRound } from "lucide-react";
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";
type DirectorViewportDockProps = {
transformMode: "translate" | "rotate" | "scale";
renderMode: DirectorRenderMode;
/** 当前模式允许的渲染视图。dock 只展示这些,避免成为绕过模式门控的第二条路径。 */
renderModes: DirectorRenderMode[];
onTransformModeChange: (mode: DirectorViewportDockProps["transformMode"]) => void;
onRenderModeChange: (mode: DirectorRenderMode) => void;
onAddActor: () => void;
@@ -16,7 +19,16 @@ type DirectorViewportDockProps = {
onAlignCamera: () => void;
};
export function DirectorViewportDock({ transformMode, renderMode, onTransformModeChange, onRenderModeChange, onAddActor, onAddBox, onAddLight, onAddCamera, onAlignCamera }: DirectorViewportDockProps) {
/** 渲染视图按钮的展示顺序与图标。实际可见项由 renderModes 过滤。 */
const RENDER_VIEW_BUTTONS: Array<{ mode: DirectorRenderMode; label: string; icon: ReactNode }> = [
{ mode: "beauty", label: "构图预览", icon: <Camera /> },
{ mode: "clay", label: "彩色白膜", icon: <Palette /> },
{ mode: "pose", label: "骨骼视图", icon: <Bone /> },
{ mode: "depth", label: "深度视图", icon: <Layers /> },
{ mode: "normal", label: "法线视图", icon: <Compass /> },
];
export function DirectorViewportDock({ transformMode, renderMode, renderModes, onTransformModeChange, onRenderModeChange, onAddActor, onAddBox, onAddLight, onAddCamera, onAlignCamera }: DirectorViewportDockProps) {
return (
<nav className="director-viewport-dock" aria-label="导演台视口工具">
<DockButton label="移动对象" active={transformMode === "translate"} onClick={() => onTransformModeChange("translate")}><Move3D /></DockButton>
@@ -29,17 +41,34 @@ export function DirectorViewportDock({ transformMode, renderMode, onTransformMod
<DockButton label="添加摄影机" onClick={onAddCamera}><Camera /></DockButton>
<DockButton label="摄影机对齐当前视图" onClick={onAlignCamera}><Crosshair /></DockButton>
<DockDivider />
<DockButton label="构图预览" active={renderMode === "beauty"} onClick={() => onRenderModeChange("beauty")}><Camera /></DockButton>
<DockButton label="彩色白膜" active={renderMode === "clay"} onClick={() => onRenderModeChange("clay")}><Palette /></DockButton>
<DockButton label="骨骼视图" active={renderMode === "pose"} onClick={() => onRenderModeChange("pose")}><Bone /></DockButton>
{RENDER_VIEW_BUTTONS.filter((item) => renderModes.includes(item.mode)).map((item) => (
<DockButton key={item.mode} label={item.label} active={renderMode === item.mode} onClick={() => onRenderModeChange(item.mode)}>{item.icon}</DockButton>
))}
</nav>
);
}
/**
* Dock 按钮。
*
* 鼠标点完后释放焦点:这个 dock 承载 W/E/R 变换工具,焦点留在按钮上会让
* 交互控件守卫吃掉这三个键。规则集中在 releaseDirectorFocusAfterPointer。
*/
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" : ""}`} aria-label={label} aria-pressed={active} onClick={onClick}>{children}</button>
<button
type="button"
className={`director-viewport-dock-button ${active ? "is-active" : ""}`}
aria-label={label}
aria-pressed={active}
onClick={(event) => {
onClick();
releaseDirectorFocusAfterPointer(event);
}}
>
{children}
</button>
</Tooltip>
);
}

View File

@@ -1,20 +1,22 @@
import { Canvas, useFrame, useThree, type ThreeEvent } from "@react-three/fiber";
import { Grid, OrbitControls, TransformControls } from "@react-three/drei";
import { Grid, Line, OrbitControls, TransformControls } from "@react-three/drei";
import { Component, forwardRef, memo, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState, type ComponentRef, type ReactNode } from "react";
import { AnimationClip, AnimationMixer, Box3, Bone, Camera, Color, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshNormalMaterial, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, Plane, Quaternion, Raycaster, Scene, SkeletonHelper, Texture, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three";
import type { Material } from "three";
import { GLTFLoader, SkeletonUtils } from "three-stdlib";
import { directorCameraSyncKey, resolveDirectorBoneRotation } from "@/lib/canvas/director/director-animation-semantics";
import { resolveDirectorBoneRotation } from "@/lib/canvas/director/director-animation-semantics";
import { createDirectorTransaction, installDirectorTerminalListeners } from "@/lib/canvas/director/director-gesture-transaction";
import { emptyDirectorPlacementIntent, finiteDirectorGroundPoint, type DirectorGroundPoint, type DirectorPlacementIntent } from "@/lib/canvas/director/director-placement";
import { directorDiagnosticObjectKind } from "@/lib/canvas/director/director-diagnostics";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
import { directorCaptureInitial, directorCaptureUsable, directorLoadIdentity, directorLoadInitial, installDirectorContextListeners, reduceDirectorCapture, reduceDirectorLoad, releaseDirectorCapture, resolveDirectorDisplay, restoreDirectorCapture, upsertDirectorFailedLoad, type DirectorFailedLoads, type DirectorLoadSignal } from "@/lib/canvas/director/director-recovery";
import { disposeDirectorAdoptionFailure, disposeDirectorHelper, disposeDirectorMaterials, disposeDirectorModelResources, disposeDirectorObject3D, resolveDirectorLoadOwnership } from "@/lib/canvas/director/director-resources";
import { DIRECTOR_DEFAULT_ACTOR_URL, directorPoseBoneDeltas, interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
import { DIRECTOR_DEFAULT_ACTOR_URL, directorPoseBoneDeltas, directorTransformPathLength, finiteDirectorTransformKeyframes, interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
import { DIRECTOR_DEFAULT_VIEW_MODE, directorViewFramingKey, resolveDirectorEffectiveViewport, resolveDirectorOrthographicFraming, resolveDirectorOrthographicFrustum, resolveDirectorViewFraming, type DirectorOrthographicFraming, type DirectorViewFraming, type DirectorViewMode } from "@/lib/canvas/director/director-view-modes";
import { DirectorViewToolbar } from "@/components/canvas/director/director-view-toolbar";
import { resolveMediaUrl } from "@/services/file-storage";
import type { DirectorCamera, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
import type { DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
export type DirectorOrbitControls = ComponentRef<typeof OrbitControls>;
@@ -34,6 +36,12 @@ type DirectorViewportProps = {
renderMode: DirectorRenderMode;
playhead: number;
playing: boolean;
/** 动画模式下显示演员与摄影机 Transform 关键帧形成的空间路径。 */
showMotionPaths?: boolean;
/** 取景模式。省略即自由视角,保持接线前的行为不变。 */
viewMode?: DirectorViewMode;
/** 提供该回调即在视口内渲染 3D/CAM 切换器;不提供则不显示,视口仍按 viewMode 取景。 */
onViewModeChange?: (mode: DirectorViewMode) => void;
onSelectObject: (id: string | null) => void;
onSelectBone: (bone: string | null) => void;
onObjectTransform: (id: string, from: DirectorTransform, to: DirectorTransform) => void;
@@ -41,7 +49,7 @@ type DirectorViewportProps = {
onActorRigReady: (id: string, rig: DirectorRig, animations: AnimationClip[]) => void;
};
type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: PerspectiveCamera; suspendDisplayMaterialOverride: () => () => void };
type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: Camera; suspendDisplayMaterialOverride: () => () => void };
// 稳定空值:identity 不匹配时返回同一引用,避免下游 effect 依赖每次 render 都变化。
const emptyAnimations: AnimationClip[] = [];
@@ -53,8 +61,14 @@ const emptyRestRotations: Partial<Record<DirectorHumanoidBone, DirectorQuat>> =
const directorCanvasGl = { antialias: true, preserveDrawingBuffer: true, alpha: false } as const;
const directorCanvasCamera = { position: [4.8, 2.7, 6.8] as [number, number, number], fov: 50, near: 0.05, far: 500 } as const;
const directorCanvasDpr: [number, number] = [1, 1.5];
// 自由视角固定环绕焦点:free 是独立观察相机,不跟随 shot 摄影机的 target 走,
// 否则切换镜头/摄影机会连带把用户正在环绕的焦点也悄悄挪走。
const DIRECTOR_FREE_ORBIT_TARGET: DirectorVec3 = [0, 1, 0];
export const DirectorViewport = forwardRef<DirectorViewportHandle, DirectorViewportProps>(function DirectorViewport(props, ref) {
// onViewModeChange 只服务 DOM 层的切换器,绝不进 Canvas 子树:它的身份每次父级
// render 都可能变化,穿透到 memo 化的 Canvas 会触发 configure 重建 renderer。
const { onViewModeChange, ...sceneProps } = props;
const captureContext = useRef<CaptureContext | null>(null);
// 地面点连同 owner canvas 一起记录:owner 不是当前 renderer 的 canvas 就是陈旧值。
const groundRef = useRef<{ owner: HTMLCanvasElement; point: DirectorGroundPoint } | null>(null);
@@ -145,7 +159,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, DirectorViewp
<DirectorViewportErrorBoundary key={`boundary-${retryKey}`} onRelease={releaseCapture} onRetry={retry}>
<DirectorCanvasSurface
key={`canvas-${retryKey}`}
{...props}
{...sceneProps}
onCaptureContext={onCaptureContext}
onRelease={releaseCapture}
onContextLost={onContextLost}
@@ -155,6 +169,8 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, DirectorViewp
onOrbitControls={onOrbitControls}
/>
</DirectorViewportErrorBoundary>
{/* 取景切换是纯视口状态:放在 DOM 层,不随 Canvas 重建而丢失。 */}
{onViewModeChange ? <DirectorViewToolbar viewMode={props.viewMode ?? DIRECTOR_DEFAULT_VIEW_MODE} onViewModeChange={onViewModeChange} /> : null}
{capture.contextLost ? (
<DirectorViewportNotice
title="3D 显示上下文已丢失"
@@ -180,7 +196,8 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, DirectorViewp
);
});
type DirectorCanvasSurfaceProps = DirectorViewportProps & {
// onViewModeChange 被显式排除:切换器活在 DOM 层,Canvas 子树只需要 viewMode 取值。
type DirectorCanvasSurfaceProps = Omit<DirectorViewportProps, "onViewModeChange"> & {
onCaptureContext: (context: CaptureContext) => void;
onRelease: () => void;
onContextLost: () => void;
@@ -270,13 +287,28 @@ function DirectorViewportNotice({ title, description, actionLabel, onAction, var
);
}
function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorViewportProps & { onCaptureContext: (context: CaptureContext) => void; onRelease: () => void; onContextLost: () => void; onContextRestored: () => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void; onGroundPoint: (owner: HTMLCanvasElement, point: DirectorGroundPoint | null) => void; onOrbitControls: (controls: DirectorOrbitControls | null) => void }) {
const { gl, camera, scene: threeScene, invalidate } = useThree();
function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, showMotionPaths = false, viewMode = DIRECTOR_DEFAULT_VIEW_MODE, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorCanvasSurfaceProps) {
const { gl, camera, scene: threeScene, invalidate, set, size } = useThree();
const orbitRef = useRef<DirectorOrbitControls>(null);
const [transforming, setTransforming] = useState(false);
const displayClayRestoreRef = useRef<(() => void) | null>(null);
const shot = scene.shots.find((item) => item.id === scene.activeShotId) || scene.shots[0];
const activeCamera = scene.cameras.find((item) => item.id === shot?.cameraId) || scene.cameras[0];
// 三台相机各司其职、互不共享:free 只由 OrbitControls 驱动,CAM/正交只在各自模式下
// 由取景数据接管。切换 viewMode 只挪动「谁是活动相机」这个指针,任何一台的内部状态
// 都不会因为切换而被读写——这是「切换不丢失/不污染任一相机状态」的唯一来源。
const [freeCamera] = useState(() => {
const instance = new PerspectiveCamera(directorCanvasCamera.fov, 1, directorCanvasCamera.near, directorCanvasCamera.far);
instance.position.set(...directorCanvasCamera.position);
return instance;
});
const [camCamera] = useState(() => new PerspectiveCamera(directorCanvasCamera.fov, 1, directorCanvasCamera.near, directorCanvasCamera.far));
const [orthoCamera] = useState(() => new OrthographicCamera(-1, 1, 1, -1, 0.05, 500));
// CAM 与正交轴向的取景解算都是纯函数:mode 不匹配时各自返回 null,互不冲突。
// 活动相机不直接看 viewMode:CAM 取景失败必须回落 free,否则会露出从未写入的 camCamera。
const camFraming = resolveDirectorViewFraming({ scene, mode: viewMode, playhead });
const orthoFraming = resolveDirectorOrthographicFraming({ scene, mode: viewMode });
const effectiveViewport = resolveDirectorEffectiveViewport({ mode: viewMode, framing: camFraming });
const actorMotionPaths = useMemo(() => showMotionPaths ? scene.objects.filter((object) => object.visible && (object.kind === "actor" || object.primitive === "character") && directorTransformPathLength(object.keyframes) > 0.001) : [], [scene.objects, showMotionPaths]);
const cameraMotionPaths = useMemo(() => showMotionPaths ? scene.cameras.filter((item) => directorTransformPathLength(item.keyframes) > 0.001) : [], [scene.cameras, showMotionPaths]);
const suspendDisplayMaterialOverride = useCallback(() => {
const suspended = Boolean(displayClayRestoreRef.current);
displayClayRestoreRef.current?.();
@@ -286,7 +318,7 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
};
}, [threeScene]);
const readCaptureContext = useCallback((): CaptureContext => ({ gl, camera: camera as PerspectiveCamera, scene: threeScene, suspendDisplayMaterialOverride }), [camera, gl, suspendDisplayMaterialOverride, threeScene]);
const readCaptureContext = useCallback((): CaptureContext => ({ gl, camera: camera as Camera, scene: threeScene, suspendDisplayMaterialOverride }), [camera, gl, suspendDisplayMaterialOverride, threeScene]);
useEffect(() => {
onCaptureContext(readCaptureContext());
@@ -367,9 +399,32 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
};
}, [invalidate, renderMode, scene.objects, threeScene]);
// 透视相机(free/CAM)的宽高比随容器尺寸变化;正交相机的半范围在自己的同步 effect 里
// 按水平/竖直跨度与 aspect 同时拟合,这里只负责两台透视相机的 aspect + 投影矩阵。
useEffect(() => {
const aspect = size.width / Math.max(size.height, 1);
freeCamera.aspect = aspect;
freeCamera.updateProjectionMatrix();
camCamera.aspect = aspect;
camCamera.updateProjectionMatrix();
invalidate();
}, [camCamera, freeCamera, invalidate, size]);
// 唯一决定「视口活动相机是谁」的入口:只挪动 state.camera 指针,从不读写另外两台
// 相机对象的内部状态——这是「切换模式互不污染」的保证来源。
// CAM 无合法取景时指针指向 freeCamera,取景恢复后再指回 camCamera。
useEffect(() => {
const next = effectiveViewport.camera === "orthographic" ? orthoCamera : effectiveViewport.camera === "camera" ? camCamera : freeCamera;
set({ camera: next });
invalidate();
}, [camCamera, effectiveViewport.camera, freeCamera, invalidate, orthoCamera, set]);
return (
<>
<CameraSync camera={activeCamera} playhead={playhead} playing={playing} />
{/* CAM 与正交轴向的取景各自独立同步到专属相机对象,互不干扰;free 完全交给
下面的 OrbitControls,这里不对它做任何写入。 */}
<DirectorShotCameraSync camera={camCamera} framing={camFraming} />
<DirectorOrthoCameraSync camera={orthoCamera} framing={orthoFraming} aspect={size.width / Math.max(size.height, 1)} />
<ambientLight intensity={scene.environmentIntensity * 0.35} />
{scene.lights.map((light) => <DirectorLightView key={light.id} light={light} />)}
{scene.gridVisible ? <Grid position={[0, 0, 0]} infiniteGrid fadeDistance={40} fadeStrength={5} cellSize={0.5} sectionSize={5} cellColor="#8f99a3" sectionColor="#626d77" /> : null}
@@ -377,6 +432,8 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
<planeGeometry args={[120, 120]} />
<meshStandardMaterial color="#aeb7bf" roughness={0.92} />
</mesh>
{actorMotionPaths.map((object) => <DirectorTransformPath key={`actor-path-${object.id}`} keyframes={object.keyframes} playhead={playhead} color="#61d2ad" />)}
{cameraMotionPaths.map((item) => <DirectorTransformPath key={`camera-path-${item.id}`} keyframes={item.keyframes} playhead={playhead} color="#78a9ff" />)}
{scene.objects.filter((item) => item.visible).map((object) => (
<DirectorObjectView
key={object.id}
@@ -394,30 +451,100 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
onLoadStateChange={onLoadStateChange}
/>
))}
<OrbitControls ref={orbitRef} makeDefault enabled={!transforming} target={activeCamera?.target || [0, 1, 0]} minDistance={0.6} maxDistance={80} />
{/* 只有有效 free 回落允许环绕:drei 只在 enabled 时调 controls.update(),CAM/正交下这是
真正的锁定,不会有 controls 每帧把相机拽回自己 target 的回写竞争。
camera 显式绑定 freeCamera:即使 state.camera 当前指向别的相机,也绝不会
被这份环绕状态误伤。CAM 取景失败时 orbit 打开,用已有的自由视角,不改场景。 */}
<OrbitControls ref={orbitRef} makeDefault camera={freeCamera} enabled={!transforming && effectiveViewport.orbit} target={DIRECTOR_FREE_ORBIT_TARGET} minDistance={0.6} maxDistance={80} />
</>
);
}
function CameraSync({ camera, playhead, playing }: { camera?: DirectorCamera; playhead: number; playing: boolean }) {
const threeCamera = useThree((state) => state.camera as PerspectiveCamera);
/** 起点、终点、路径点、方向与当前进度共用一条 Transform 关键帧路径。 */
function DirectorTransformPath({ keyframes, playhead, color }: { keyframes: DirectorObject["keyframes"]; playhead: number; color: string }) {
const sorted = useMemo(() => finiteDirectorTransformKeyframes(keyframes).toSorted((left, right) => left.time - right.time), [keyframes]);
const points = useMemo(() => sorted.map((keyframe) => keyframe.transform.position), [sorted]);
const current = interpolateDirectorTransform(sorted[0].transform, sorted, playhead).position;
const previous = points[points.length - 2];
const end = points[points.length - 1];
const direction = useMemo(() => {
const vector = new Vector3(...end).sub(new Vector3(...previous));
if (vector.lengthSq() < 1e-8) return [0, 0, 0, 1] as DirectorQuat;
return new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), vector.normalize()).toArray() as DirectorQuat;
}, [end, previous]);
return <group>
<Line points={points} color={color} lineWidth={2} />
{points.map((point, index) => <mesh key={`${index}-${point.join("-")}`} position={point}>
<sphereGeometry args={[index === 0 || index === points.length - 1 ? 0.11 : 0.075, 10, 8]} />
<meshBasicMaterial color={index === 0 ? "#61d2ad" : index === points.length - 1 ? "#f08b6a" : color} />
</mesh>)}
<mesh position={end} quaternion={direction}>
<coneGeometry args={[0.1, 0.28, 10]} />
<meshBasicMaterial color={color} />
</mesh>
<mesh position={current}>
<sphereGeometry args={[0.14, 12, 8]} />
<meshBasicMaterial color="#f0d36a" />
</mesh>
</group>;
}
/**
* CAM 取景:把 shot 摄影机的解算结果写进专属的机位相机对象。
*
* 这台相机独立持有,不是共享的视口默认相机:离开 CAM 模式后不需要任何快照/还原——
* 没有人会在其他模式下碰它,回到 CAM 时上一次写入的状态原样还在,物理上不可能被污染。
*
* 只写这台相机对象,绝不触碰 DirectorScene:换一只眼睛看场景不是内容改动,
* 因此不产生任何 undo/history 记录,也不会触发保存。
*/
function DirectorShotCameraSync({ camera, framing }: { camera: PerspectiveCamera; framing: DirectorViewFraming | null }) {
const invalidate = useThree((state) => state.invalidate);
// 只有相机真正被关键帧/播放驱动时才写入,暂停时用户 Orbit 不会被拉回。
const syncKey = directorCameraSyncKey({ camera, playhead, playing });
const framingKey = directorViewFramingKey(framing);
useEffect(() => {
if (!camera || !syncKey) return;
const transform = interpolateDirectorTransform(camera.transform, camera.keyframes, playhead);
threeCamera.position.set(...transform.position);
threeCamera.rotation.set(...transform.rotation);
threeCamera.fov = camera.fov;
threeCamera.near = camera.near;
threeCamera.far = camera.far;
threeCamera.lookAt(...camera.target);
threeCamera.updateProjectionMatrix();
if (!framing) return;
// up 必须先写:lookAt 用当前 camera.up 解基向量,顺序颠倒会丢掉荷兰角。
camera.up.set(...framing.up);
camera.position.set(...framing.position);
camera.fov = framing.fov;
camera.near = framing.near;
camera.far = framing.far;
camera.lookAt(...framing.target);
camera.updateProjectionMatrix();
invalidate();
// camera/playhead 只通过 syncKey 参与依赖,避免草稿对象身份变化触发无谓回写。
// framing 只通过 framingKey 参与依赖:草稿对象身份变化不触发无谓回写。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invalidate, syncKey, threeCamera]);
}, [framingKey, invalidate, camera]);
return null;
}
/**
* 正交轴向取景:把包围盒解算结果写进专属的正交相机对象,语义与 DirectorShotCameraSync
* 完全对称——独立持有、无需快照还原、绝不碰 DirectorScene。
*
* 水平/竖直跨度由领域层给出;视口只传入运行时 aspect,半范围换算走
* resolveDirectorOrthographicFrustum,同时装下两条轴,避免宽内容被裁切。
*/
function DirectorOrthoCameraSync({ camera, framing, aspect }: { camera: OrthographicCamera; framing: DirectorOrthographicFraming | null; aspect: number }) {
const invalidate = useThree((state) => state.invalidate);
const framingKey = framing ? [...framing.position, ...framing.target, ...framing.up, framing.horizontalSpan, framing.verticalSpan, framing.near, framing.far].map((value) => value.toFixed(4)).join("|") : "";
useEffect(() => {
if (!framing) return;
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect });
camera.up.set(...framing.up);
camera.position.set(...framing.position);
camera.left = frustum.left;
camera.right = frustum.right;
camera.top = frustum.top;
camera.bottom = frustum.bottom;
camera.near = framing.near;
camera.far = framing.far;
camera.lookAt(...framing.target);
camera.updateProjectionMatrix();
invalidate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [framingKey, aspect, invalidate, camera]);
return null;
}