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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
55
web/src/components/canvas/director/director-view-toolbar.tsx
Normal file
55
web/src/components/canvas/director/director-view-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user