Files
ttstd-infinite-canvas/web/src/components/canvas/previs/canvas-previs-workbench.tsx

1890 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cameraMoveOptions, poseEditBones, BoneRotationFields, shotSizeOptions, ObjectInspector, LightInspector, ShotInspector } from "./previs-inspectors";
import { snapPrevisTime, advancePrevisPlayhead, resolvePrevisKeyframeRecord, resolvePrevisObjectTransformEdit, resolvePrevisCameraMoveKeyframes, resolvePrevisCameraAlignment } from "@/lib/canvas/previs/previs-animation-semantics";
import {
interpolatePrevisTransform,
touchPrevisScene,
createPrevisObject,
createPrevisActor,
PREVIS_ACTOR_COLORS,
resolvePrevisActorColor,
createPrevisModel,
createPrevisBillboard,
createPrevisCamera,
createPrevisLight,
upsertPrevisBoneKeyframe,
removePrevisSceneKeyframe,
setPrevisSceneKeyframeEasing,
previsPoseLabel,
previsBoneLabel,
previsActorProfileForArchetype,
previsActorArchetypeLabel,
uniquePrevisName,
} from "@/lib/canvas/previs/previs-scene";
import { type PrevisObject, type PrevisLight, type PrevisShot, type PrevisHumanoidBone, type PrevisQuat, type PrevisKeyframeDeleteTarget, type PrevisKeyframeEasing, type PrevisRig, type PrevisShotSize, type PrevisActorProfile } from "@/types/previs";
import { resolvePrevisPlacementAnchor, resolvePrevisPlacement, type PrevisGroundPoint } from "@/lib/canvas/previs/previs-placement";
import { uploadMediaFile } from "@/services/file-storage";
import { saveRemoteUserDataNow, localSavedRemotePendingMessage } from "@/services/user-data-sync";
import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react";
import { PrevisCanvasDock } from "./previs-canvas-dock";
import { Dropdown } from "antd";
import { nanoid } from "nanoid";
import { type PrevisShortcutAction, resolvePrevisShortcut, blocksPrevisShortcut, releasePrevisFocusAfterPointer } from "@/lib/canvas/previs/previs-shortcuts";
import { Euler, Vector3, type AnimationClip } from "three";
import { compilePrevisPrompt } from "@/lib/canvas/previs/previs-prompt-compiler";
import { Select } from "@/components/ui/base/select";
import { PrevisViewport } from "./previs-viewport";
import { CanvasPrevisOnboarding } from "@/components/canvas/previs/canvas-previs-onboarding";
import { PrevisSequencer } from "./previs-sequencer";
import { App, type MenuProps, Input, Button } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent, type PointerEvent } from "react";
import { type PrevisViewportHandle } from "@/components/canvas/previs/previs-viewport";
import { transcodeVideoToMp4 } from "@/lib/canvas/canvas-video-merge";
import { canvasThemes } from "@/lib/canvas-theme";
import { type PrevisTransaction, createPrevisTransaction, installPrevisTerminalListeners } from "@/lib/canvas/previs/previs-gesture-transaction";
import { buildPrevisPathKeyframes } from "@/lib/canvas/previs/previs-path";
import { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder";
import { shouldReinitializePrevisSession, isPrevisOutputSnapshotCurrent } from "@/lib/canvas/previs/previs-session";
import { isPrevisPreviewRequestForWorkbench, previsPreviewRequestKey, type PrevisPreviewRequest } from "@/lib/canvas/previs/previs-preview";
import { describePrevisSaveStatus, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring";
import { usePrevisSaveCoordinator } from "@/components/canvas/previs/use-previs-save-coordinator";
import "./canvas-previs-workbench.css";
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
import { usePrevisWorkbenchStore } from "@/stores/canvas/use-previs-workbench-store";
import { PREVIS_MODES, previsModeCapabilities } from "@/lib/canvas/previs/previs-modes";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { PrevisCameraMove, PrevisRenderMode, PrevisScene, PrevisSceneOutput, PrevisTransform, PrevisVec3 } from "@/types/previs";
export { cameraMoveOptions, poseOptions, shotSizeOptions } from "./previs-inspectors";
export function CanvasPrevisWorkbench({
open,
canvasId,
scene,
imageNodes,
onboardingScope,
onClose,
onChange,
onApply,
onDeleteImageNode,
onFlush,
}: {
open: boolean;
canvasId?: string;
scene: PrevisScene | null;
imageNodes: CanvasNodeData[];
onboardingScope: string;
onClose: () => void;
onChange: (scene: PrevisScene) => void;
onApply: (output: PrevisSceneOutput) => Promise<void>;
onDeleteImageNode: (nodeId: string) => void;
onFlush?: () => void | Promise<void>;
}) {
const { message, modal } = App.useApp();
const theme = canvasThemes[useActiveTheme()];
const effectiveCanvasId = useMemo(() => {
const explicit = canvasId?.trim();
if (explicit) return explicit;
if (typeof window === "undefined") return undefined;
const match = window.location.pathname.match(/^\/canvas\/([^/]+)/u);
return match ? decodeURIComponent(match[1]) : undefined;
}, [canvasId]);
const viewportRef = useRef<PrevisViewportHandle>(null);
const modelInputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState<PrevisScene | null>(null);
const [history, setHistory] = useState<PrevisScene[]>([]);
const [future, setFuture] = useState<PrevisScene[]>([]);
const [saving, setSaving] = useState(false);
const [recording, setRecording] = useState(false);
const [lastClayExport, setLastClayExport] = useState<{ shotId: string; at: number } | null>(null);
const [trajectoryDrawing, setTrajectoryDrawing] = useState(false);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [scenePanelOpen, setScenePanelOpen] = useState(false);
// 桌面端侧栏常驻,可收起换取沉浸视口;窄屏下改为 scenePanelOpen/inspectorOpen 控制的浮层。
const [sceneDocked, setSceneDocked] = useState(true);
const [inspectorDocked, setInspectorDocked] = useState(true);
const [compactLayout, setCompactLayout] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 860px)").matches);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const media = window.matchMedia("(max-width: 860px)");
const update = () => setCompactLayout(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
const [onboardingRestartSignal, setOnboardingRestartSignal] = useState(0);
const mode = usePrevisWorkbenchStore((state) => state.mode);
const setMode = usePrevisWorkbenchStore((state) => state.setMode);
const viewMode = usePrevisWorkbenchStore((state) => state.viewMode);
const setViewMode = usePrevisWorkbenchStore((state) => state.setViewMode);
const selectedObjectId = usePrevisWorkbenchStore((state) => state.selectedObjectId);
const selectedLightId = usePrevisWorkbenchStore((state) => state.selectedLightId);
const transformMode = usePrevisWorkbenchStore((state) => state.transformMode);
const renderMode = usePrevisWorkbenchStore((state) => state.renderMode);
const playhead = usePrevisWorkbenchStore((state) => state.playhead);
const playing = usePrevisWorkbenchStore((state) => state.playing);
const selectedBone = usePrevisWorkbenchStore((state) => state.selectedBone);
const autoKey = usePrevisWorkbenchStore((state) => state.autoKey);
const sequencerHeight = usePrevisWorkbenchStore((state) => state.sequencerHeight);
const sequencerVisible = usePrevisWorkbenchStore((state) => state.sequencerVisible);
const setSelectedObjectId = usePrevisWorkbenchStore((state) => state.setSelectedObjectId);
const [selectedCameraId, setSelectedCameraId] = useState<string | null>(null);
const [sceneQuery, setSceneQuery] = useState("");
const setSelectedLightId = usePrevisWorkbenchStore((state) => state.setSelectedLightId);
const setTransformMode = usePrevisWorkbenchStore((state) => state.setTransformMode);
const setRenderMode = usePrevisWorkbenchStore((state) => state.setRenderMode);
const setPlayhead = usePrevisWorkbenchStore((state) => state.setPlayhead);
const setPlaying = usePrevisWorkbenchStore((state) => state.setPlaying);
const setSelectedBone = usePrevisWorkbenchStore((state) => state.setSelectedBone);
const setAutoKey = usePrevisWorkbenchStore((state) => state.setAutoKey);
const setSequencerHeight = usePrevisWorkbenchStore((state) => state.setSequencerHeight);
const setSequencerVisible = usePrevisWorkbenchStore((state) => state.setSequencerVisible);
const resetWorkbench = usePrevisWorkbenchStore((state) => state.reset);
const stopPrevisEntityPointer = useCallback((event: MouseEvent | PointerEvent) => {
event.stopPropagation();
}, []);
const selectSceneObject = useCallback(
(id: string) => {
setSelectedObjectId(id);
setSelectedCameraId(null);
setSelectedBone(null);
setInspectorOpen(true);
setInspectorDocked(true);
},
[setSelectedBone, setSelectedObjectId],
);
const selectSceneCamera = useCallback(
(id: string) => {
setSelectedObjectId(null);
setSelectedLightId(null);
setSelectedCameraId(id);
setSelectedBone(null);
// CAM hides camera gizmos; keep the editable scene view so the selected camera is visible.
setViewMode("free");
setInspectorOpen(true);
setInspectorDocked(true);
},
[setSelectedBone, setSelectedLightId, setSelectedObjectId, setViewMode],
);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const modelAssets = useMemo(() => assets.filter((asset): asset is ModelAsset => asset.kind === "model"), [assets]);
const capabilities = previsModeCapabilities(mode);
const renderModeOptions = PREVIS_RENDER_MODE_LABELS.filter((option) => capabilities.renderModes.includes(option.value));
const draftRef = useRef<PrevisScene | null>(null);
const stagedRef = useRef<PrevisTransaction | null>(null);
const initializedSceneIdRef = useRef<string | null>(null);
const onChangeRef = useRef(onChange);
const onFlushRef = useRef(onFlush);
useEffect(() => {
onChangeRef.current = onChange;
onFlushRef.current = onFlush;
}, [onChange, onFlush]);
const closingRef = useRef(false);
const handledPreviewRequestsRef = useRef(new Set<string>());
const saveController = usePrevisSaveCoordinator({
sceneId: scene?.id ?? null,
initialScene: scene,
persistScene: (next) => onChangeRef.current(next),
flushPersistence: () => onFlushRef.current?.(),
});
const saveControllerRef = useRef(saveController);
saveControllerRef.current = saveController;
const saveIndicator = describePrevisSaveStatus(saveController.progress);
const writeDraft = useCallback((next: PrevisScene | null) => {
draftRef.current = next;
setDraft(next);
}, []);
/**
* 仅镜像当前 draft 到项目 previsScenes,不产生 canonical 提交。
* 用于取消预览、idle pagehide、卸载兜底 —— 这些都不是新的用户改动。
*/
const mirrorDraft = useCallback(() => {
const current = draftRef.current;
if (!current || initializedSceneIdRef.current !== current.id) return;
onChangeRef.current(current);
}, []);
/** 真实 canonical 提交:先交给 coordinator,再镜像到项目。 */
const commitDraft = useCallback(() => {
const current = draftRef.current;
if (!current || initializedSceneIdRef.current !== current.id) return;
saveControllerRef.current?.commitScene(current);
onChangeRef.current(current);
}, []);
const writeAndPublish = useCallback(
(next: PrevisScene) => {
writeDraft(next);
saveControllerRef.current?.commitScene(next);
onChangeRef.current(next);
},
[writeDraft],
);
// 会话初始化只认 scene id:同 id 的父级镜像回流不得重建会话。
useEffect(() => {
if (!open || !scene) return;
if (!shouldReinitializePrevisSession({ initializedSceneId: initializedSceneIdRef.current, nextSceneId: scene.id })) return;
const next = structuredClone(scene);
next.objects = next.objects.map((object) => (object.kind === "actor" || object.primitive === "character" ? { ...object, color: resolvePrevisActorColor(object.color), archetype: object.archetype || "adult" } : object));
next.shots = next.shots.map((shot) => ({ ...shot, fps: shot.fps || 24 }));
stagedRef.current?.end("cancel");
initializedSceneIdRef.current = scene.id;
writeDraft(next);
setHistory([]);
setFuture([]);
resetWorkbench();
}, [open, resetWorkbench, scene, writeDraft]);
// 打开会话时检查合法本地恢复候选
// 父级项目收到 Agent 的预演台语义补丁后,scene.id 不变但 updatedAt 会变化;
// 不能只按 scene.id 忽略,否则打开着的工作台永远看不到 Agent 的修改。
useEffect(() => {
if (!open || !scene || initializedSceneIdRef.current !== scene.id) return;
const current = draftRef.current;
if (!current || scene.updatedAt === current.updatedAt) return;
stagedRef.current?.end("cancel");
const next = structuredClone(scene);
writeDraft(next);
setHistory([]);
setFuture([]);
setSelectedObjectId(null);
setSelectedLightId(null);
setSelectedBone(null);
setPlayhead(0);
message.info("Agent 已更新预演场景");
}, [message, modal, open, scene, writeDraft]);
const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null;
const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null;
useEffect(() => {
const cameras = draft?.cameras || [];
if (!cameras.length) {
setSelectedCameraId(null);
return;
}
if (!selectedCameraId || !cameras.some((camera) => camera.id === selectedCameraId)) {
setSelectedCameraId(null);
}
}, [activeCamera?.id, draft?.cameras, selectedCameraId]);
useEffect(() => {
if (activeShot?.id) setSelectedCameraId(activeShot.cameraId || null);
}, [activeShot?.id]);
const compiledPrompt = useMemo(() => (draft && activeShot ? compilePrevisPrompt(draft, activeShot) : ""), [activeShot, draft]);
const selectedObject = draft?.objects?.find((item) => item.id === selectedObjectId) || null;
const selectedLight = draft?.lights?.find((item) => item.id === selectedLightId) || null;
const selectedCamera = draft?.cameras?.find((item) => item.id === selectedCameraId) || null;
const inspectorCamera = selectedCamera || activeCamera;
// 写入关键帧的目的时间用吸附值;取值/显示/手势起点一律用 raw playhead,
// 否则处在两个帧格之间时 AutoKey OFF 的增量会从错误起点计算而产生漂移。
const snappedPlayhead = snapPrevisTime(playhead, activeShot?.fps || 24);
const selectedObjectRendered = selectedObject ? interpolatePrevisTransform(selectedObject.transform, selectedObject.keyframes, playhead) : null;
useEffect(() => {
if (!playing || !activeShot) return;
let frame = 0;
let last = performance.now();
let pending = 0;
const frameInterval = 1 / Math.max(1, Math.min(120, activeShot.fps || 24));
const tick = (now: number) => {
pending += Math.max(0, (now - last) / 1000);
last = now;
if (pending >= frameInterval) {
const elapsed = Math.floor(pending / frameInterval) * frameInterval;
pending -= elapsed;
setPlayhead(advancePrevisPlayhead(usePrevisWorkbenchStore.getState().playhead, elapsed, activeShot.duration));
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [activeShot, playing, setPlayhead]);
const commit = useCallback(
(updater: (current: PrevisScene) => PrevisScene) => {
// 普通提交前先终结暂存手势,避免新动作消费旧 base。
stagedRef.current?.end("commit");
const current = draftRef.current;
if (!current) return;
setHistory((items) => [...items.slice(-49), structuredClone(current)]);
setFuture([]);
writeAndPublish(touchPrevisScene(updater(current)));
},
[writeAndPublish],
);
/** 暂存型手势(数值滑杆):实时预览写草稿但不产生历史,也不镜像到项目。 */
const stagedTransaction = useMemo(
() =>
createPrevisTransaction<PrevisScene>({
read: () => draftRef.current,
// 取消:恢复快照且绝不发布被取消的值。
restore: (snapshot) => writeDraft(snapshot),
commit: (from) => {
setHistory((items) => [...items.slice(-49), from]);
setFuture([]);
// 手势成功终态是真实 canonical 提交。
commitDraft();
},
setActive: () => undefined,
}),
[commitDraft, writeDraft],
);
stagedRef.current = stagedTransaction;
const stageGesture = useCallback(
(updater: (current: PrevisScene) => PrevisScene) => {
const current = draftRef.current;
if (!current) return;
stagedTransaction.begin();
writeDraft(touchPrevisScene(updater(current)));
},
[stagedTransaction, writeDraft],
);
/** 无历史但持久的变化(标题、rig/motionClips 等)同样要镜像。 */
const replaceWithoutHistory = useCallback(
(updater: (current: PrevisScene) => PrevisScene) => {
const current = draftRef.current;
if (current) writeAndPublish(touchPrevisScene(updater(current)));
},
[writeAndPublish],
);
// 打开会话时检查合法本地恢复候选之后,挂载暂存手势生命周期。
// 暂存手势的终止生命周期:常驻安装,非活跃时 end 为空操作。
useEffect(
() =>
installPrevisTerminalListeners(stagedTransaction, {
window,
document,
isHidden: () => document.visibilityState === "hidden",
}),
[stagedTransaction],
);
// 切换选择/骨骼、关闭或卸载前必须先终止旧手势,不能让新选择消费旧 base。
useEffect(() => () => stagedTransaction.end("cancel"), [open, selectedBone, selectedObjectId, stagedTransaction]);
// 离开页面:active 预览由 end("commit") 完成真实提交,idle 只镜像;落盘统一交给 controller。
useEffect(() => {
const onPageHide = () => {
if (stagedTransaction.active()) stagedTransaction.end("commit");
else mirrorDraft();
// 只调用 handlePageHide:dirty 时它自己会 persist + flush,组件再叠一次就是重复落盘。
void saveControllerRef.current?.handlePageHide();
};
// 异步 flush 不可能阻塞卸载:这里只同步声明「仍有未确认改动」,让浏览器自己弹保护。
const onBeforeUnload = (event: BeforeUnloadEvent) => {
const controller = saveControllerRef.current;
if (!controller || !shouldBlockPrevisUnload(controller.progress)) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [mirrorDraft, stagedTransaction]);
// 卸载兜底:只把最新 draft 镜像回项目,不制造新的 canonical revision。
useEffect(
() => () => {
stagedRef.current?.end("cancel");
mirrorDraft();
},
[mirrorDraft],
);
const undo = () => {
const previous = history.at(-1);
if (!previous || !draft) return;
setHistory((items) => items.slice(0, -1));
setFuture((items) => [structuredClone(draft), ...items].slice(0, 50));
writeAndPublish(previous);
};
const redo = () => {
const next = future[0];
if (!next || !draft) return;
setFuture((items) => items.slice(1));
setHistory((items) => [...items, structuredClone(draft)].slice(-50));
writeAndPublish(next);
};
/**
* 关闭统一入口:取消未结束的预览、镜像当前 draft,再按 prepareClose 决策是否真的退出。
* 这里只镜像不提交:取消预览不是新的 canonical 变化。
*/
const closeWorkbench = () => {
if (closingRef.current) return;
closingRef.current = true;
stagedTransaction.end("cancel");
mirrorDraft();
// 异步生成缩略图,不阻塞关闭流程
if (viewportRef.current && draftRef.current && activeShot) {
const currentScene = draftRef.current;
const currentShot = activeShot;
setTimeout(() => {
void (async () => {
try {
const thumbnail = await viewportRef.current?.capture("clay");
if (thumbnail) {
await onApply({
scene: currentScene,
shot: currentShot,
prompt: compilePrevisPrompt(currentScene, currentShot),
beauty: thumbnail,
clayVideo: undefined,
clayVideoMimeType: "",
});
}
} catch (error) {
console.warn("预演台缩略图后台生成失败", error);
}
})();
}, 100);
}
void (async () => {
let decision;
try {
decision = resolvePrevisCloseOutcome(await saveController.prepareClose());
} catch {
message.error("关闭前的保存检查失败,已留在预演台");
closingRef.current = false;
return;
}
if (decision.kind === "close") {
onClose();
return;
}
recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision });
modal.confirm({
title: "保存未完成",
content: decision.message,
okText: "放弃修改并关闭",
cancelText: "留在预演台",
okButtonProps: { danger: true },
onOk: onClose,
});
closingRef.current = false;
})();
};
const updateObject = (id: string, patch: Partial<PrevisObject>) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const duplicateObject = (id: string) => {
const source = draft?.objects.find((item) => item.id === id);
if (!source) return;
const copy = structuredClone(source);
copy.id = nanoid();
copy.name = `${source.name} 副本`;
copy.transform = { ...copy.transform, position: [source.transform.position[0] + 0.8, source.transform.position[1], source.transform.position[2] + 0.8] };
copy.keyframes = copy.keyframes.map((keyframe) => ({ ...keyframe, id: nanoid() }));
copy.boneTracks = copy.boneTracks?.map((track) => ({ ...track, keyframes: track.keyframes.map((keyframe) => ({ ...keyframe, id: nanoid() })) }));
commit((current) => ({ ...current, objects: [...current.objects, copy] }));
setSelectedObjectId(copy.id);
setSelectedBone(null);
message.success("对象已复制");
};
const updateLight = (id: string, patch: Partial<PrevisLight>) => commit((current) => ({ ...current, lights: current.lights.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const updateShot = (id: string, patch: Partial<PrevisShot>) => commit((current) => ({ ...current, shots: current.shots.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const removeObject = (id: string) => {
commit((current) => ({ ...current, objects: current.objects.filter((item) => item.id !== id) }));
if (selectedObjectId === id) {
setSelectedObjectId(null);
setSelectedBone(null);
}
};
const removeLight = (id: string) => {
commit((current) => ({ ...current, lights: current.lights.filter((item) => item.id !== id) }));
if (selectedLightId === id) setSelectedLightId(null);
};
const removeCamera = (id: string) => {
if (!draft || draft.cameras.length <= 1) {
message.warning("至少保留一台摄影机");
return;
}
const fallback = draft.cameras.find((item) => item.id !== id);
if (!fallback) return;
commit((current) => ({
...current,
cameras: current.cameras.filter((item) => item.id !== id),
shots: current.shots.map((shot) => (shot.cameraId === id ? { ...shot, cameraId: fallback.id } : shot)),
}));
};
/**
* 所有「新增到场景」的唯一入口。
* 在 commit 内读取一次 placement intent:因此模型上传等异步路径拿到的是
* 「点击添加完成那一刻」的意图,而不是发起上传时捕获的过时坐标。
* 锚点只提供 XZ,Y 严格保留构造器给定值,再交给 resolvePrevisPlacement 做碰撞避让。
*/
const addObject = (object: PrevisObject) => {
commit((current) => {
const anchored = resolvePrevisPlacementAnchor({
intent: viewportRef.current?.readPlacementIntent() ?? null,
fallback: object.transform.position,
});
const position = resolvePrevisPlacement({ object: { ...object, transform: { ...object.transform, position: anchored } }, existing: current.objects });
return { ...current, objects: [...current.objects, { ...object, transform: { ...object.transform, position } }] };
});
setSelectedObjectId(object.id);
};
const addPrimitive = (primitive: PrevisObject["primitive"], name: string) => addObject(createPrevisObject(primitive, name));
const addActor = () => {
const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
const colorIndex = existingActors.length % PREVIS_ACTOR_COLORS.length;
addObject(createPrevisActor(`演员 ${existingActors.length + 1}`, [0, 0, 0], PREVIS_ACTOR_COLORS[colorIndex]));
};
const addModelAsset = (asset: ModelAsset) => addObject(createPrevisModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType }));
const uploadModel = async (file?: File) => {
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
const uploaded = await uploadMediaFile(file, "model");
const assetId = addAsset({
kind: "model",
title: file.name.replace(/\.(glb|gltf)$/i, ""),
coverUrl: "",
tags: ["3D模型"],
source: "预演台",
data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name },
metadata: { source: "previs" },
});
const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model");
if (asset) addModelAsset(asset);
try {
await saveRemoteUserDataNow();
message.success("3D 模型已加入场景和素材库");
} catch (error) {
message.warning(localSavedRemotePendingMessage("3D 模型已加入场景和本机素材库", error));
}
};
const addBillboard = async (node: CanvasNodeData) => {
const url = node.metadata?.content || node.metadata?.storageKey;
if (!url) {
message.warning("该图片节点没有内容,无法添加到场景");
return;
}
// 如果是 storageKey,需要解析成实际URL
let actualUrl = url;
if (node.metadata?.storageKey && !url.startsWith("http") && !url.startsWith("blob:") && !url.startsWith("data:")) {
try {
const { resolveImageUrl } = await import("@/services/image-storage");
actualUrl = await resolveImageUrl(node.metadata.storageKey, url);
} catch (error) {
console.warn("解析图片URL失败,使用原URL:", error);
}
}
const billboard = createPrevisBillboard(node.title || "背板", actualUrl, node.metadata?.storageKey, node.id);
addObject(billboard);
message.success(`已添加背板:${node.title || "图片"}`);
};
const setPanoramaEnvironment = (node: CanvasNodeData) => {
const url = node.metadata?.content;
if (!url) return;
commit((current) => ({ ...current, environment: { mode: "panorama", url, storageKey: node.metadata?.storageKey, sourceNodeId: node.id, rotationY: 0, opacity: 1, exposure: 0, useAsLighting: false, lightingIntensity: 0.2 } }));
message.success("已设为 360° 背景;请确认图片是 2:1 全景图");
};
const clearPanoramaEnvironment = () => commit((current) => ({ ...current, environment: { mode: "color" } }));
const copyCompiledPrompt = async () => {
if (!compiledPrompt) return;
try {
await navigator.clipboard.writeText(compiledPrompt);
message.success("已复制可直接交给视频模型的提示词");
} catch {
message.warning("当前浏览器不允许自动复制,请从提示词预览中手动复制");
}
};
const addCamera = () => {
const camera = createPrevisCamera(`摄影机 ${draft?.cameras.length ? draft.cameras.length + 1 : 1}`);
commit((current) => ({ ...current, cameras: [...current.cameras, camera] }));
if (activeShot) updateShot(activeShot.id, { cameraId: camera.id });
};
const addLight = (type: PrevisLight["type"] = "point", label = "灯光", position: PrevisVec3 = [2, 3, 2], intensity = 1.5) => {
const light = createPrevisLight(type, `${label} ${draft?.lights.length ? draft.lights.length + 1 : 1}`, position, intensity);
commit((current) => ({ ...current, lights: [...current.lights, light] }));
setSelectedLightId(light.id);
};
const addCameraMenuItems: MenuProps["items"] = [{ key: "camera", icon: <Camera className="size-3.5" />, label: "添加摄影机", onClick: addCamera }];
const addLightMenuItems: MenuProps["items"] = [
{ key: "directional", icon: <Lightbulb className="size-3.5" />, label: "方向光", onClick: () => addLight("directional", "方向光", [4, 6, 4], 2.4) },
{ key: "point", icon: <Lightbulb className="size-3.5" />, label: "点光源", onClick: () => addLight("point", "点光源") },
{ key: "spot", icon: <Lightbulb className="size-3.5" />, label: "聚光灯", onClick: () => addLight("spot", "聚光灯", [2, 4, 2], 2) },
{ key: "ambient", icon: <LampDesk className="size-3.5" />, label: "环境光", onClick: () => addLight("ambient", "环境光", [0, 0, 0], 0.65) },
];
const addObjectMenuItems: MenuProps["items"] = [
{ key: "actor", icon: <UserRound className="size-3.5" />, label: "演员", onClick: addActor },
{ key: "box", icon: <Box className="size-3.5" />, label: "立方体", onClick: () => addPrimitive("box", "立方体") },
{ key: "sphere", icon: <Circle className="size-3.5" />, label: "球体", onClick: () => addPrimitive("sphere", "球体") },
{ key: "cylinder", icon: <Cuboid className="size-3.5" />, label: "圆柱", onClick: () => addPrimitive("cylinder", "圆柱") },
{ key: "model", icon: <FileUp className="size-3.5" />, label: "上传模型", onClick: () => modelInputRef.current?.click() },
];
const addShot = () => {
if (!activeCamera) return;
const shot: PrevisShot = { id: nanoid(), name: `镜头 ${(draft?.shots.length || 0) + 1}`, cameraId: activeCamera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" };
commit((current) => ({ ...current, shots: [...current.shots, shot], activeShotId: shot.id }));
setPlayhead(0);
};
const addObjectKeyframe = () => {
if (!selectedObject) return;
// 取值用 raw playhead(视口真正渲染的时间),写入用 snapped 目的时间。
const record = resolvePrevisKeyframeRecord({ base: selectedObject.transform, keyframes: selectedObject.keyframes, rawTime: playhead, snappedTime: snappedPlayhead });
updateObject(selectedObject.id, { keyframes: record.keyframes });
};
const addCameraKeyframe = () => {
if (!activeCamera) return;
commit((current) => ({
...current,
cameras: current.cameras.map((item) => (item.id === activeCamera.id ? { ...item, keyframes: resolvePrevisKeyframeRecord({ base: item.transform, keyframes: item.keyframes, rawTime: playhead, snappedTime: snappedPlayhead }).keyframes } : item)),
}));
};
const recordSelectedKeyframe = () => {
if (selectedObject && selectedBone) {
const rotation = selectedObject.boneOverrides?.[selectedBone as PrevisHumanoidBone] || ([0, 0, 0, 1] as PrevisQuat);
updateObject(selectedObject.id, { boneTracks: upsertPrevisBoneKeyframe(selectedObject.boneTracks || [], selectedBone as PrevisHumanoidBone, snappedPlayhead, rotation) });
return;
}
if (selectedObject) addObjectKeyframe();
else addCameraKeyframe();
};
/**
* 时间轴删除关键帧的唯一入口。
*
* 未命中(对象/摄影机/关键帧已不存在)时 removePrevisSceneKeyframe 返回同一引用,
* 此时不进 commit:不记历史、不产生修订、不触发保存。
*/
const deleteKeyframe = useCallback(
(target: PrevisKeyframeDeleteTarget) => {
const current = draftRef.current;
if (!current || removePrevisSceneKeyframe(current, target) === current) return;
commit((scene) => removePrevisSceneKeyframe(scene, target));
},
[commit],
);
const setKeyframeEasing = useCallback(
(target: PrevisKeyframeDeleteTarget, easing: PrevisKeyframeEasing) => {
const current = draftRef.current;
if (!current || setPrevisSceneKeyframeEasing(current, target, easing) === current) return;
commit((scene) => setPrevisSceneKeyframeEasing(scene, target, easing));
},
[commit],
);
/**
* 快捷键执行器。放在 ref 里:监听只在 open 变化时注册一次,
* 但每次渲染都能拿到最新的选择、历史和 draft,避免闭包读到过期状态。
*
* 返回值表示「动作真的执行了」,只有执行了才 preventDefault:
* 没有选中对象时的 Delete 仍然交还给浏览器。
*/
const runShortcut = (action: PrevisShortcutAction): 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 = resolvePrevisShortcut({
key: event.key,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
isInteractiveTarget: blocksPrevisShortcut(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: PrevisTransform, to: PrevisTransform) => {
commit((current) => ({
...current,
objects: current.objects.map((item) => {
if (item.id !== id) return item;
const edit = resolvePrevisObjectTransformEdit({ base: item.transform, keyframes: item.keyframes, rendered: from, edited: to, autoKey, time: snappedPlayhead });
return { ...item, transform: edit.transform, keyframes: edit.keyframes };
}),
}));
},
[autoKey, commit, snappedPlayhead],
);
/** 摄影机 transform gizmo 写入:直接覆盖摄影机 transform(不走 object autoKey 逻辑)。 */
const handleCameraTransform = useCallback(
(id: string, from: PrevisTransform, to: PrevisTransform) => {
commit((current) => ({
...current,
cameras: current.cameras.map((item) => {
if (item.id !== id) return item;
// 机位取景由 target 决定:平移时保持盯住原焦点;旋转时按新朝向把焦点推到同样距离。
const rotated = to.rotation.some((value, index) => Math.abs(value - from.rotation[index]) > 1e-4);
if (!rotated) return { ...item, transform: { ...to, scale: [1, 1, 1] } };
const distance = Math.max(0.5, new Vector3(...item.target).distanceTo(new Vector3(...from.position)));
const forward = new Vector3(0, 0, -1).applyEuler(new Euler(...to.rotation)).multiplyScalar(distance);
const target: PrevisVec3 = [to.position[0] + forward.x, to.position[1] + forward.y, to.position[2] + forward.z];
return { ...item, transform: { ...to, scale: [1, 1, 1] }, target };
}),
}));
},
[commit],
);
/** 骨骼写入语义:静态覆盖 + autoKey 时在吸附播放头补关键帧。gizmo 与数值编辑器共用。 */
const writeBoneRotation = useCallback(
(id: string, bone: string, rotation: PrevisQuat, mode: "stage" | "commit") => {
const write = mode === "stage" ? stageGesture : commit;
write((current) => ({
...current,
objects: current.objects.map((item) =>
item.id === id
? {
...item,
boneOverrides: { ...item.boneOverrides, [bone]: rotation },
boneTracks: autoKey ? upsertPrevisBoneKeyframe(item.boneTracks || [], bone as PrevisHumanoidBone, snappedPlayhead, rotation) : item.boneTracks,
}
: item,
),
}));
},
[autoKey, commit, snappedPlayhead, stageGesture],
);
const handleBoneTransform = useCallback((id: string, bone: string, rotation: PrevisQuat) => writeBoneRotation(id, bone, rotation, "commit"), [writeBoneRotation]);
const handleActorRigReady = useCallback(
(id: string, rig: PrevisRig, animations: AnimationClip[]) => {
replaceWithoutHistory((current) => ({
...current,
objects: current.objects.map((item) => {
if (item.id !== id) return item;
const existing = item.motionClips || [];
const motionClips = existing.length ? existing : animations.map((clip) => ({ id: nanoid(), name: clip.name || "动作片段", sourceAnimation: clip.name, start: 0, duration: Math.max(0.1, clip.duration), playbackRate: 1, loop: true }));
return { ...item, rig, motionClips };
}),
}));
},
[replaceWithoutHistory],
);
const applyCameraMove = () => {
if (!activeCamera || !activeShot) return;
const cameraId = activeCamera.id;
const move = activeShot.cameraMove;
const duration = activeShot.duration;
commit((current) => ({
...current,
cameras: current.cameras.map((item) => (item.id === cameraId ? { ...item, keyframes: resolvePrevisCameraMoveKeyframes(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 ? resolvePrevisCameraAlignment(item, transform, snappedPlayhead) : item)) }));
message.success("摄影机已对齐当前视图");
};
const applyToCanvas = async () => {
stagedTransaction.end("commit");
const current = draftRef.current;
if (!current || !activeShot || !viewportRef.current) return;
const expected = { scene: current, shotId: activeShot.id };
setSaving(true);
try {
const beauty = await viewportRef.current.capture("beauty");
if (!isPrevisOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("输出期间场景或镜头已变化,请重试");
const prompt = compilePrevisPrompt(current, activeShot);
// 先镜像最新 scene,再做 canvas 输出;失败时 draft 保留可继续重试。
const next = touchPrevisScene(current);
writeAndPublish(next);
await onApply({ scene: next, shot: activeShot, prompt, beauty });
message.success("预演台构图已回写画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "预演台输出失败");
} finally {
setSaving(false);
}
};
const exportClayVideo = async () => {
stagedTransaction.end("commit");
const current = draftRef.current;
if (!current || !activeShot || !viewportRef.current || recording) return;
const expected = { scene: current, shotId: activeShot.id };
setRecording(true);
const wasPlaying = playing;
const previousPlayhead = playhead;
setPlayhead(0);
setPlaying(true);
try {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const recordedVideo = await viewportRef.current.recordVideo(activeShot.duration, activeShot.fps);
const clayVideo = recordedVideo.type === "video/mp4" ? recordedVideo : await transcodeVideoToMp4(recordedVideo);
if (!isPrevisOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("录制期间场景或镜头已变化,请重试");
const next = touchPrevisScene(draftRef.current || current);
writeAndPublish(next);
const beauty = await viewportRef.current.capture("beauty");
if (!isPrevisOutputSnapshotCurrent(draftRef.current, { scene: next, shotId: expected.shotId })) throw new Error("输出期间场景或镜头已变化,请重试");
await onApply({ scene: next, shot: activeShot, prompt: compilePrevisPrompt(next, activeShot), beauty, clayVideo, clayVideoMimeType: clayVideo.type });
setLastClayExport({ shotId: expected.shotId, at: Date.now() });
message.success("白膜 MP4 视频已回写画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "白膜视频导出失败");
} finally {
setPlaying(wasPlaying);
setPlayhead(previousPlayhead);
setRecording(false);
}
};
const sceneActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
const sceneProps = draft?.objects.filter((item) => item.kind !== "actor" && item.primitive !== "character") || [];
const normalizedSceneQuery = sceneQuery.trim().toLowerCase();
const matchesSceneQuery = (name: string, meta = "") => !normalizedSceneQuery || `${name} ${meta}`.toLowerCase().includes(normalizedSceneQuery);
const visibleSceneActors = sceneActors.filter((actor) => matchesSceneQuery(actor.name, `${actor.archetype || ""} ${actor.pose || ""}`));
const visibleSceneProps = sceneProps.filter((object) => matchesSceneQuery(object.name, `${object.kind} ${object.primitive || ""}`));
const visibleCameras = draft?.cameras.filter((camera) => matchesSceneQuery(camera.name, `${camera.focalLength}mm`)) || [];
const visibleLights = draft?.lights.filter((light) => matchesSceneQuery(light.name, light.type)) || [];
const selectedActor = sceneActors.find((item) => item.id === selectedObjectId) ?? null;
const handleGroundClick = useCallback(
(point: PrevisGroundPoint) => {
if (!selectedActor || !selectedObjectId) {
message.info("先在左侧列表选中一个演员,再点击地面落位");
return;
}
setSelectedObjectId(selectedActor.id);
handleObjectTransform(selectedActor.id, selectedActor.transform, {
...selectedActor.transform,
position: [point.x, selectedActor.transform.position[1], point.z],
});
message.success("演员已落位");
},
[handleObjectTransform, message, selectedActor],
);
const addActorToScene = () => {
addActor();
message.success("演员已加入;直接点击视口地面即可落位");
};
const trajectoryTarget = selectedActor ? { kind: "actor" as const, id: selectedActor.id, name: selectedActor.name } : selectedCamera ? { kind: "camera" as const, id: selectedCamera.id, name: selectedCamera.name } : null;
const trajectoryPreviousViewModeRef = useRef(viewMode);
const trajectorySessionRef = useRef(false);
useEffect(() => {
if (trajectoryDrawing && !trajectorySessionRef.current) {
trajectorySessionRef.current = true;
trajectoryPreviousViewModeRef.current = viewMode;
if (viewMode !== "top") setViewMode("top");
message.info(trajectoryTarget ? `${trajectoryTarget.kind === "camera" ? "为摄影机绘制运镜路径" : "为角色绘制走位"}:按住地面拖动,松开完成,重新点击可取消` : "已进入画轨迹模式:请先在左侧选择演员或摄影机,再按住地面拖动");
return;
}
if (!trajectoryDrawing && trajectorySessionRef.current) {
trajectorySessionRef.current = false;
setViewMode(trajectoryPreviousViewModeRef.current);
}
}, [message, setViewMode, trajectoryDrawing, trajectoryTarget, viewMode]);
const handleTrajectoryComplete = useCallback(
(points: PrevisGroundPoint[]) => {
const current = draftRef.current;
const shot = current?.shots.find((item) => item.id === current.activeShotId) || current?.shots[0];
if (!current || !shot || points.length < 2) {
setTrajectoryDrawing(false);
if (points.length >= 2 && !trajectoryTarget) message.info("轨迹已绘制,但还没有选中的演员或摄影机;请先选择目标再重试");
return;
}
if (selectedActor) {
const y = selectedActor.transform.position[1];
const path = [[selectedActor.transform.position[0], y, selectedActor.transform.position[2]] as PrevisVec3, ...points.map((point) => [point.x, y, point.z] as PrevisVec3)];
const keyframes = buildPrevisPathKeyframes({ base: selectedActor.transform, points: path, duration: shot.duration, orientToPath: true });
commit((scene) => ({ ...scene, objects: scene.objects.map((object) => (object.id === selectedActor.id ? { ...object, keyframes, motionPath: { points: path, speed: "walk", startDelay: 0, orientToPath: true } } : object)) }));
message.success("演员轨迹已写入时间轴");
} else if (selectedCamera) {
const y = selectedCamera.transform.position[1];
const path = [[selectedCamera.transform.position[0], y, selectedCamera.transform.position[2]] as PrevisVec3, ...points.map((point) => [point.x, y, point.z] as PrevisVec3)];
const keyframes = buildPrevisPathKeyframes({ base: selectedCamera.transform, points: path, duration: shot.duration });
commit((scene) => ({ ...scene, cameras: scene.cameras.map((camera) => (camera.id === selectedCamera.id ? { ...camera, keyframes, motionPath: { points: path, speed: "walk", startDelay: 0, orientToPath: false } } : camera)) }));
message.success("摄影机运镜路径已写入时间轴");
}
setTrajectoryDrawing(false);
},
[commit, message, selectedActor, selectedCamera, trajectoryTarget],
);
// ── 演员体型模板 ──────────────────────────────────────────────────────────
/** 每个模板:name 显示名、archetype、体型 overrides(可覆盖 previsActorProfileForArchetype 默认值)*/
const ACTOR_TEMPLATES: Array<{ key: string; label: string; archetype: import("@/types/previs").PrevisActorArchetype; profileOverrides?: Partial<PrevisActorProfile>; crowd?: boolean }> = [
{ key: "std-male", label: "标准男性", archetype: "man" },
{ key: "std-female", label: "标准女性", archetype: "woman" },
{ key: "muscular", label: "健硕", archetype: "man", profileOverrides: { shoulderWidth: 1.32, torsoRatio: 1.12, height: 1.06 } },
{ key: "slim", label: "纤细", archetype: "woman", profileOverrides: { shoulderWidth: 0.78, torsoRatio: 0.88, height: 0.98 } },
{ key: "teen", label: "少年", archetype: "child", profileOverrides: { height: 0.86, headRatio: 1.12 } },
{ key: "child", label: "儿童", archetype: "child" },
{ key: "stocky", label: "宽厚", archetype: "man", profileOverrides: { shoulderWidth: 1.18, torsoRatio: 1.24, height: 0.96 } },
{ key: "chibi", label: "二头身", archetype: "adult", profileOverrides: { height: 0.62, headRatio: 1.68 } },
];
const addActorFromTemplate = (template: (typeof ACTOR_TEMPLATES)[number]) => {
const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
const colorIndex = existingActors.length % PREVIS_ACTOR_COLORS.length;
const baseProfile = previsActorProfileForArchetype(template.archetype);
const actorProfile = template.profileOverrides ? { ...baseProfile, ...template.profileOverrides } : baseProfile;
const actor = { ...createPrevisActor(`${template.label} ${existingActors.length + 1}`, [0, 0, 0], PREVIS_ACTOR_COLORS[colorIndex], template.archetype), actorProfile };
addObject(actor);
message.success(`已添加${template.label}`);
};
const addCrowdActors = () => {
const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
const base = existingActors.length;
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
const idx = base + row * 3 + col;
const colorIndex = idx % PREVIS_ACTOR_COLORS.length;
const actor = createPrevisActor(`群众 ${idx + 1}`, [col * 1.1 - 1.1, 0, row * 1.1 - 1.1], PREVIS_ACTOR_COLORS[colorIndex]);
addObject(actor);
}
}
message.success("已添加 3×3 群众阵列");
};
const actorTemplateMenuItems: MenuProps["items"] = [
{ key: "upload", icon: <FileUp className="size-3.5" />, label: "本地上传", onClick: () => modelInputRef.current?.click() },
{ type: "divider" },
...ACTOR_TEMPLATES.map((template) => ({
key: template.key,
icon: <UserRound className="size-3.5" />,
label: template.label,
onClick: () => addActorFromTemplate(template),
})),
{ type: "divider" },
{ key: "crowd", icon: <Users className="size-3.5" />, label: "群众 (3×3)", onClick: addCrowdActors },
{
key: "geometry",
icon: <Box className="size-3.5" />,
label: "几何模型",
children: [
{ key: "geo-upload", icon: <FileUp className="size-3.5" />, label: "上传文件", onClick: () => modelInputRef.current?.click() },
{ key: "geo-box", icon: <Box className="size-3.5" />, label: "立方体", onClick: () => addPrimitive("box", "立方体") },
{ key: "geo-sphere", icon: <Circle className="size-3.5" />, label: "球体", onClick: () => addPrimitive("sphere", "球体") },
{ key: "geo-cyl", icon: <Cuboid className="size-3.5" />, label: "圆柱体", onClick: () => addPrimitive("cylinder", "圆柱体") },
{ key: "geo-torus", icon: <Circle className="size-3.5" />, label: "环状体", onClick: () => addPrimitive("sphere", "环状体") },
{ key: "geo-cone", icon: <Square className="size-3.5" />, label: "圆锥", onClick: () => addPrimitive("plane", "圆锥") },
{ key: "geo-plane", icon: <Square className="size-3.5" />, label: "平面", onClick: () => addPrimitive("plane", "平面") },
{ key: "geo-null", icon: <Plus className="size-3.5" />, label: "添加空对象", onClick: () => addPrimitive("box", "空对象") },
],
},
];
// ── 机位预设 ────────────────────────────────────────────────────────────────
type CameraPreset = {
key: string;
label: string;
position: [number, number, number];
target: [number, number, number];
fov: number;
isCurrentView?: boolean;
};
const CAMERA_PRESETS: CameraPreset[] = [
{ key: "current", label: "当前视角", position: [4.8, 2.7, 6.8], target: [0, 1, 0], fov: 50, isCurrentView: true },
{ key: "front-medium", label: "正面中景", position: [0, 1.4, 4.0], target: [0, 1, 0], fov: 50 },
{ key: "front-cu", label: "正面特写", position: [0, 1.65, 1.8], target: [0, 1.6, 0], fov: 40 },
{ key: "front-wide", label: "正面全景", position: [0, 2.0, 7.5], target: [0, 1, 0], fov: 60 },
{ key: "side-follow", label: "侧面跟拍", position: [3.8, 1.4, 0], target: [0, 1, 0], fov: 50 },
{ key: "side-cu", label: "侧面近景", position: [2.0, 1.65, 0], target: [0, 1.6, 0], fov: 45 },
{ key: "back-medium", label: "背面中景", position: [0, 1.4, -4.0], target: [0, 1, 0], fov: 50 },
{ key: "bird-wide", label: "俯拍全景", position: [0, 6.5, 3.5], target: [0, 0, 0], fov: 65 },
{ key: "angle-45", label: "45° 俯拍", position: [3.0, 4.5, 4.5], target: [0, 0.8, 0], fov: 55 },
{ key: "low-uptilt", label: "低角度仰拍", position: [0, 0.3, 3.5], target: [0, 2.2, 0], fov: 55 },
{ key: "low-wide", label: "低角度广角", position: [0, 0.5, 4.5], target: [0, 1.2, 0], fov: 75 },
{ key: "ots-left", label: "过肩镜头", position: [-1.1, 1.75, 2.2], target: [0.6, 1.65, 0], fov: 50 },
{ key: "ots-right", label: "过肩镜头(右)", position: [1.1, 1.75, 2.2], target: [-0.6, 1.65, 0], fov: 50 },
{ key: "birds-eye", label: "鸟瞰", position: [0, 9.0, 0.1], target: [0, 0, 0], fov: 70 },
{ key: "dutch", label: "荷兰角", position: [2.8, 1.7, 4.0], target: [0, 1.2, 0], fov: 52 },
];
const addCameraPreset = (preset: CameraPreset) => {
if (preset.isCurrentView) {
const transform = viewportRef.current?.readCameraTransform();
if (transform) {
const cam = { ...createPrevisCamera(`机位 ${(draft?.cameras.length || 0) + 1}`), transform, target: preset.target };
commit((current) => ({ ...current, cameras: [...current.cameras, cam] }));
if (activeShot) updateShot(activeShot.id, { cameraId: cam.id });
setViewMode("camera");
message.success("已从当前视角创建机位");
} else {
addCamera();
message.info("已添加默认机位");
}
return;
}
const cam = {
...createPrevisCamera(`机位 ${(draft?.cameras.length || 0) + 1}`),
transform: { position: preset.position, rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] },
target: preset.target,
fov: preset.fov,
};
commit((current) => ({ ...current, cameras: [...current.cameras, cam] }));
if (activeShot) updateShot(activeShot.id, { cameraId: cam.id });
setViewMode("camera");
message.success(`已添加「${preset.label}」机位`);
};
const cameraPresetMenuItems: MenuProps["items"] = CAMERA_PRESETS.map((preset) => ({
key: preset.key,
icon: <Camera className="size-3.5" />,
label: preset.label,
onClick: () => addCameraPreset(preset),
}));
// ── 道具库 (语义化尺寸,单位米) ─────────────────────────────────────────
const PROP_LIBRARY: Array<{ label: string; items: Array<{ name: string; size: string; primitive: "box" | "sphere" | "cylinder" | "plane"; scale: [number, number, number] }> }> = [
{
label: "家具",
items: [
{ name: "单人沙发", size: "0.9×0.85×0.85m", primitive: "box", scale: [0.9, 0.85, 0.85] },
{ name: "三人沙发", size: "2.2×0.85×0.85m", primitive: "box", scale: [2.2, 0.85, 0.85] },
{ name: "餐桌", size: "1.6×0.75×0.9m", primitive: "box", scale: [1.6, 0.75, 0.9] },
{ name: "茶几", size: "1.0×0.45×0.6m", primitive: "box", scale: [1.0, 0.45, 0.6] },
{ name: "凳子", size: "0.4×0.45×0.4m", primitive: "box", scale: [0.4, 0.45, 0.4] },
{ name: "双人床", size: "1.8×0.5×2.1m", primitive: "box", scale: [1.8, 0.5, 2.1] },
],
},
{
label: "空间结构",
items: [
{ name: "墙板", size: "3×2.8×0.2m", primitive: "box", scale: [3, 2.8, 0.2] },
{ name: "门", size: "0.9×2.1×0.1m", primitive: "box", scale: [0.9, 2.1, 0.1] },
{ name: "窗框", size: "1.2×1.4×0.1m", primitive: "box", scale: [1.2, 1.4, 0.1] },
{ name: "地面板", size: "4×0.1×4m", primitive: "box", scale: [4, 0.1, 4] },
{ name: "台阶", size: "1×0.2×0.3m", primitive: "box", scale: [1, 0.2, 0.3] },
],
},
{
label: "自然/外景",
items: [
{ name: "山体", size: "8×4×6m", primitive: "box", scale: [8, 4, 6] },
{ name: "岩石", size: "1.5×1×1.2m", primitive: "sphere", scale: [1.5, 1, 1.2] },
{ name: "树干", size: "0.3×3×0.3m", primitive: "cylinder", scale: [0.3, 3, 0.3] },
{ name: "地面背板", size: "6×0.05×6m", primitive: "plane", scale: [6, 0.05, 6] },
],
},
{
label: "道具",
items: [
{ name: "汽车轮廓", size: "4.5×1.5×2m", primitive: "box", scale: [4.5, 1.5, 2] },
{ name: "柱子", size: "0.4×3×0.4m", primitive: "cylinder", scale: [0.4, 3, 0.4] },
{ name: "箱子", size: "0.6×0.6×0.6m", primitive: "box", scale: [0.6, 0.6, 0.6] },
{ name: "球体道具", size: "0.5m直径", primitive: "sphere", scale: [0.5, 0.5, 0.5] },
],
},
];
const enterAnimationMode = useCallback(() => {
stagedTransaction.end("commit");
if (selectedActor) {
setSelectedObjectId(selectedActor.id);
setSelectedLightId(null);
setSelectedBone(null);
}
setPlaying(false);
setMode("animate");
setSequencerVisible(true);
}, [selectedActor, setMode, setPlaying, setSelectedBone, setSelectedLightId, setSelectedObjectId, setSequencerVisible, stagedTransaction]);
useEffect(() => {
const onPreviewRequested = (event: Event) => {
const detail = (event as CustomEvent<PrevisPreviewRequest>).detail;
if (!isPrevisPreviewRequestForWorkbench({ request: detail, canvasId: effectiveCanvasId, sceneId: draft?.id, activeShotId: activeShot?.id })) return;
const requestKey = previsPreviewRequestKey(detail);
if (handledPreviewRequestsRef.current.has(requestKey)) return;
// 标记发生在 recording 判断之前:录制期间的重复/并发事件也不能在录制结束后再次执行。
handledPreviewRequestsRef.current.add(requestKey);
if (handledPreviewRequestsRef.current.size > 64) {
const oldest = handledPreviewRequestsRef.current.values().next().value;
if (typeof oldest === "string") handledPreviewRequestsRef.current.delete(oldest);
}
if (recording) return;
void exportClayVideo();
};
window.addEventListener("previs:preview-requested", onPreviewRequested);
return () => window.removeEventListener("previs:preview-requested", onPreviewRequested);
}, [activeShot?.id, draft?.id, effectiveCanvasId, exportClayVideo]);
if (!open || !draft || !activeShot) return null;
const saveStatusClass = saveIndicator.tone === "danger" ? "is-danger" : saveIndicator.busy ? "is-busy" : "";
// 位于 early return 之后,不能使用 Hook;每次渲染直接派生主题变量。
const workbenchThemeStyle = {
"--pd-s0": theme.canvas.background,
"--pd-s1": theme.toolbar.panel,
"--pd-s2": theme.toolbar.activeBg,
"--pd-s3": theme.toolbar.itemHover,
"--pd-s4": theme.toolbar.activeBg,
"--pd-s5": theme.spatial.elevated,
"--pd-b0": theme.toolbar.border,
"--pd-b1": theme.node.stroke,
"--pd-b2": theme.node.activeStroke,
"--pd-t0": theme.node.text,
"--pd-t1": theme.node.muted,
"--pd-t2": theme.node.muted,
"--pd-t3": theme.node.faint,
"--pd-a0": theme.accent.primary,
"--pd-a1": theme.accent.primary,
"--pd-a-bg": theme.accent.primarySoft,
"--pd-a-bd": theme.node.activeStroke,
"--pd-a-on": theme.accent.onPrimary,
"--pd-accent-rgb": theme.accent.primary === "#171717" || theme.accent.primary === "#f5f5f5" ? "255 255 255" : "79 140 255",
"--previs-accent": theme.accent.primary,
"--pd-a-on-theme": theme.accent.onPrimary,
"--pd-a-bg-theme": theme.accent.primarySoft,
"--pd-a-bd-theme": theme.node.activeStroke,
} as CSSProperties;
return (
<div data-canvas-previs-workbench data-canvas-no-zoom className="previs-desk fixed inset-0 z-[var(--z-toast)] flex min-h-0 min-w-0 flex-col overflow-hidden" style={workbenchThemeStyle}>
<header className="previs-desk-header thin-scrollbar overflow-x-auto overflow-y-hidden">
<button type="button" className="previs-desk-icon-button" aria-label="关闭预演台" title="关闭预演台" onClick={closeWorkbench}>
<X className="size-4" />
</button>
<div className="previs-desk-brand">
<span className="previs-desk-logo">影策</span>
<span className="previs-desk-brand-title">预演台</span>
<span className="previs-desk-brand-sub">AI 短剧预演</span>
</div>
<span className="previs-desk-divider" />
<input className="previs-desk-scene-title" aria-label="场景标题" value={draft.title} onChange={(event) => replaceWithoutHistory((current) => ({ ...current, title: event.target.value }))} />
<div className="previs-desk-history">
<button type="button" aria-label="撤销" title="撤销 Ctrl+Z" disabled={!history.length} onClick={undo}>
<Undo2 className="size-4" />
</button>
<button type="button" aria-label="重做" title="重做 Ctrl+Shift+Z" disabled={!future.length} onClick={redo}>
<Redo2 className="size-4" />
</button>
</div>
<div className="previs-desk-context-summary" aria-label="当前工作上下文">
<span className="previs-desk-context-dot" />
<span>{selectedActor ? `角色 · ${selectedActor.name}` : selectedLight ? `灯光 · ${selectedLight.name}` : selectedCameraId ? `机位 · ${selectedCamera?.name || activeCamera?.name || "摄影机"}` : `镜头 · ${activeShot.name}`}</span>
</div>
<nav className="previs-mode-switch" aria-label="预演台模式">
{PREVIS_MODES.map((item) => (
<button
key={item.mode}
type="button"
className={`previs-mode-switch-button ${mode === item.mode ? "is-active" : ""}`}
aria-pressed={mode === item.mode}
data-mode={item.mode}
title={item.hint}
onClick={(event) => {
setMode(item.mode);
releasePrevisFocusAfterPointer(event);
}}
>
{item.label}
</button>
))}
</nav>
<div className="previs-desk-header-spacer" />
<span className={`previs-desk-save ${saveStatusClass}`} aria-live="polite">
{saveIndicator.label}
</span>
{saveController.progress.status === "error" ? (
<button type="button" className="previs-desk-header-action" onClick={() => void saveController.retrySave()}>
<Save className="size-3.5" />
重试保存
</button>
) : null}
{onboardingScope ? (
<button type="button" className="previs-desk-icon-button" aria-label="重新开始引导" title="重新开始引导" onClick={() => setOnboardingRestartSignal((value) => value + 1)}>
<Lightbulb className="size-4" />
</button>
) : null}
<Select className="previs-desk-header-select" aria-label="预览模式" value={renderMode} options={renderModeOptions} onChange={(value: PrevisRenderMode) => setRenderMode(value)} size="small" />
<button type="button" className="previs-desk-header-action" onClick={() => void exportClayVideo()} disabled={recording}>
<Video className="size-3.5" />
生成白膜
</button>
<button type="button" className="previs-desk-header-action is-primary" onClick={() => void applyToCanvas()} disabled={saving}>
<Save className="size-3.5" />
回写构图
</button>
</header>
<div className={`pv-layout ${sceneDocked ? "" : "is-scene-collapsed"} ${inspectorDocked ? "" : "is-inspector-collapsed"}`}>
{/* LEFT: Scene Entity List */}
<aside className={`pv-panel pv-panel--left thin-scrollbar ${scenePanelOpen ? "is-open" : ""}`}>
<div className="pv-panel-header">
<div className="pv-panel-header__title">
<span className="pv-panel-header__label">场景</span>
<span className="pv-panel-header__count">{draft.objects.length + draft.cameras.length}</span>
</div>
<div className="pv-panel-header__actions">
<Dropdown menu={{ items: actorTemplateMenuItems }} trigger={["click"]} placement="bottomRight" classNames={{ root: "previs-add-menu-overlay" }}>
<button type="button" aria-label="添加角色" className="pv-icon-btn" title="添加角色">
<UserRound className="size-4" />
</button>
</Dropdown>
<Dropdown menu={{ items: addObjectMenuItems }} trigger={["click"]} placement="bottomRight" classNames={{ root: "previs-add-menu-overlay" }}>
<button type="button" aria-label="添加道具" className="pv-icon-btn" title="添加道具">
<Box className="size-4" />
</button>
</Dropdown>
<Dropdown menu={{ items: addLightMenuItems }} trigger={["click"]} placement="bottomRight" classNames={{ root: "previs-add-menu-overlay" }}>
<button type="button" aria-label="添加灯光" className="pv-icon-btn" title="添加灯光">
<Lightbulb className="size-4" />
</button>
</Dropdown>
<Dropdown menu={{ items: cameraPresetMenuItems }} trigger={["click"]} placement="bottomRight" classNames={{ root: "previs-add-menu-overlay" }}>
<button type="button" aria-label="添加机位" className="pv-icon-btn" title="添加机位">
<Camera className="size-4" />
</button>
</Dropdown>
<button type="button" className="pv-icon-btn pv-panel-close" aria-label="关闭场景面板" title="关闭场景面板" onClick={() => setScenePanelOpen(false)}>
<X className="size-3.5" />
</button>
</div>
</div>
<label className="pv-scene-search">
<Search className="size-3.5" />
<input aria-label="搜索场景对象" value={sceneQuery} onChange={(event) => setSceneQuery(event.target.value)} placeholder="搜索对象、机位、灯光…" />
{sceneQuery ? (
<button type="button" aria-label="清除搜索" onClick={() => setSceneQuery("")}>
<X className="size-3" />
</button>
) : null}
</label>
{visibleSceneActors.length > 0 ? (
<section className="pv-entity-section">
<div className="pv-entity-section__label">
角色{" "}
<span>
{visibleSceneActors.length}
{visibleSceneActors.length !== sceneActors.length ? `/${sceneActors.length}` : ""}
</span>
</div>
{visibleSceneActors.map((actor) => {
const isSelected = selectedActor?.id === actor.id;
const color = resolvePrevisActorColor(actor.color);
return (
<div key={actor.id} className={`pv-entity-row ${isSelected ? "pv-entity-row--active" : ""}`}>
<button type="button" className="pv-entity-row__main" onMouseDown={stopPrevisEntityPointer} onPointerDown={stopPrevisEntityPointer} onClick={() => selectSceneObject(actor.id)} aria-pressed={isSelected}>
<span className="pv-entity-row__avatar" style={{ background: color }}>
<UserRound className="size-3" />
</span>
<span className="pv-entity-row__info">
<span className="pv-entity-row__name">{actor.name}</span>
<span className="pv-entity-row__meta">
{previsActorArchetypeLabel(actor.archetype)} · {actor.pose ? previsPoseLabel(actor.pose) : "站立"}
</span>
</span>
</button>
<button type="button" className="pv-entity-row__del" aria-label={`删除 ${actor.name}`} onClick={() => removeObject(actor.id)}>
<X className="size-3" />
</button>
</div>
);
})}
</section>
) : (
<div className="pv-empty-hint">
<UserRound className="size-5 opacity-30" />
<span>还没有角色</span>
<Dropdown menu={{ items: actorTemplateMenuItems }} trigger={["click"]} placement="bottom" classNames={{ root: "previs-add-menu-overlay" }}>
<button type="button" className="pv-btn pv-btn--accent pv-btn--sm">
添加角色
</button>
</Dropdown>
</div>
)}
<section className="pv-entity-section">
<div className="pv-entity-section__label">
<span>
机位{" "}
<span className="pv-entity-section__count">
{visibleCameras.length}
{visibleCameras.length !== draft.cameras.length ? `/${draft.cameras.length}` : ""}
</span>
</span>
</div>
{visibleCameras.map((camera) => {
const isViewed = (selectedCameraId || activeShot?.cameraId) === camera.id;
const isActiveShotCamera = activeShot?.cameraId === camera.id;
return (
<div key={camera.id} className={`pv-entity-row ${isViewed && !selectedObjectId ? "pv-entity-row--active" : ""}`}>
<button type="button" className="pv-entity-row__main" onMouseDown={stopPrevisEntityPointer} onPointerDown={stopPrevisEntityPointer} onClick={() => selectSceneCamera(camera.id)} aria-pressed={isViewed}>
<span className="pv-entity-row__icon">
<Camera className="size-3.5" />
</span>
<span className="pv-entity-row__info">
<span className="pv-entity-row__name">{camera.name}</span>
<span className="pv-entity-row__meta">
{camera.focalLength}mm · f/{camera.aperture}
</span>
</span>
{isViewed ? <span className="pv-entity-row__badge">{isActiveShotCamera ? "当前" : "查看"}</span> : null}
</button>
{draft.cameras.length > 1 ? (
<button type="button" className="pv-entity-row__del" aria-label={`删除 ${camera.name}`} onClick={() => removeCamera(camera.id)}>
<X className="size-3" />
</button>
) : null}
</div>
);
})}
</section>
{visibleSceneProps.length > 0 ? (
<section className="pv-entity-section">
<div className="pv-entity-section__label">
道具{" "}
<span>
{visibleSceneProps.length}
{visibleSceneProps.length !== sceneProps.length ? `/${sceneProps.length}` : ""}
</span>
</div>
{visibleSceneProps.map((obj) => (
<div key={obj.id} className={`pv-entity-row ${selectedObjectId === obj.id ? "pv-entity-row--active" : ""}`}>
<button type="button" className="pv-entity-row__main" onMouseDown={stopPrevisEntityPointer} onPointerDown={stopPrevisEntityPointer} onClick={() => selectSceneObject(obj.id)}>
<span className="pv-entity-row__color-dot" style={{ background: obj.color }} />
<span className="pv-entity-row__info">
<span className="pv-entity-row__name">{obj.name}</span>
<span className="pv-entity-row__meta">
{obj.kind === "billboard" ? "背板" : obj.kind === "model" ? "模型" : obj.primitive === "plane" ? "平面" : obj.primitive === "sphere" ? "球体" : obj.primitive === "cylinder" ? "圆柱" : "几何体"} ·{" "}
{obj.transform.scale.map((v) => v.toFixed(1)).join("×")}m
</span>
</span>
</button>
<button type="button" className="pv-entity-row__del" aria-label={`删除 ${obj.name}`} onClick={() => removeObject(obj.id)}>
<X className="size-3" />
</button>
</div>
))}
</section>
) : null}
<section className="pv-entity-section pv-entity-section--prop-lib">
<div className="pv-entity-section__label">道具库</div>
<div className="pv-prop-cats">
{PROP_LIBRARY.map((cat) => (
<div key={cat.label} className="pv-prop-cat">
<div className="pv-prop-cat__label">{cat.label}</div>
<div className="pv-prop-cat__items pv-prop-cat__items--grid">
{cat.items.map((item) => (
<button
key={item.name}
type="button"
className="pv-prop-chip"
title={`${item.name} (${item.size})`}
onClick={() => {
const obj = createPrevisObject(item.primitive, item.name);
addObject({ ...obj, transform: { ...obj.transform, scale: item.scale } });
}}
>
<span className="pv-prop-chip__name">{item.name}</span>
<span className="pv-prop-chip__size">{item.size}</span>
</button>
))}
</div>
</div>
))}
</div>
<div className="pv-prop-upload-row">
<button type="button" className="pv-prop-upload-full" onClick={() => modelInputRef.current?.click()}>
<FileUp className="size-3.5" />
上传本地模型 (.glb / .gltf)
</button>
</div>
</section>
{modelAssets.length ? (
<section className="pv-entity-section">
<div className="pv-entity-section__label">
已上传模型 <span>{modelAssets.length}</span>
</div>
{modelAssets.map((asset) => (
<button key={asset.id} type="button" className="pv-entity-row" onClick={() => addModelAsset(asset)}>
<span className="pv-entity-row__icon">
<BoxSelect className="size-3.5" />
</span>
<span className="pv-entity-row__info">
<span className="pv-entity-row__name">{asset.title}</span>
<span className="pv-entity-row__meta">点击加入场景</span>
</span>
</button>
))}
</section>
) : null}
{imageNodes.length > 0 ? (
<section className="pv-entity-section">
<div className="pv-entity-section__label">背景图片</div>
<div className="pv-bg-list">
{imageNodes.slice(0, 4).map((node) => {
const linked = draft.objects.find((o) => o.sourceNodeId === node.id);
const isPanorama = draft.environment?.sourceNodeId === node.id;
return (
<div key={node.id} className="pv-bg-row">
<button
type="button"
className="pv-bg-row__thumb"
onMouseDown={stopPrevisEntityPointer}
onPointerDown={stopPrevisEntityPointer}
onClick={() => (linked ? selectSceneObject(linked.id) : void addBillboard(node))}
title={node.title || "画布图片"}
>
{node.metadata?.content ? <img src={node.metadata.content} alt="" className="pv-bg-row__img" /> : <ImageIcon className="size-3.5 opacity-50" />}
<span className="pv-bg-row__name">{node.title || "图片"}</span>
</button>
<div className="pv-bg-row__btns">
<button type="button" className={`pv-bg-btn ${isPanorama ? "pv-bg-btn--active" : ""}`} title="设为360°背景" onClick={() => setPanoramaEnvironment(node)}>
360°
</button>
{linked ? (
<button type="button" className="pv-bg-btn pv-bg-btn--danger" title="移除" onClick={() => removeObject(linked.id)}>
<X className="size-3" />
</button>
) : (
<button type="button" className="pv-bg-btn" title="加入场景" onClick={() => void addBillboard(node)}>
加入
</button>
)}
</div>
</div>
);
})}
</div>
{draft.environment?.mode === "panorama" ? (
<div className="pv-panorama-badge">
<span>360° 全景已启用</span>
<button type="button" onClick={clearPanoramaEnvironment}>
移除
</button>
</div>
) : null}
</section>
) : null}
<section className="pv-entity-section">
<div className="pv-entity-section__label">
灯光{" "}
<span>
{visibleLights.length}
{visibleLights.length !== draft.lights.length ? `/${draft.lights.length}` : ""}
</span>
</div>
{visibleLights.map((light) => (
<div key={light.id} className={`pv-entity-row ${selectedLightId === light.id ? "pv-entity-row--active" : ""}`}>
<button
type="button"
className="pv-entity-row__main"
onClick={() => {
setSelectedObjectId(null);
setSelectedCameraId(null);
setSelectedLightId(light.id);
setInspectorOpen(true);
setInspectorDocked(true);
}}
>
<Lightbulb className="size-3.5 opacity-50 flex-shrink-0" />
<span className="pv-entity-row__info">
<span className="pv-entity-row__name">{light.name}</span>
<span className="pv-entity-row__meta">
{light.type === "directional" ? "平行光" : light.type === "point" ? "点光源" : light.type === "spot" ? "聚光" : "环境"} × {light.intensity.toFixed(1)}
</span>
</span>
</button>
<button type="button" className="pv-entity-row__del" aria-label={`删除 ${light.name}`} onClick={() => removeLight(light.id)}>
<X className="size-3" />
</button>
</div>
))}
</section>
</aside>
{/* CENTER: Single viewport with contextual tool layer */}
<main className="pv-viewport-area">
<PrevisViewport
ref={viewportRef}
scene={draft}
selectedObjectId={selectedObjectId}
selectedBone={null}
transformMode={transformMode}
renderMode={renderMode}
playhead={playhead}
playing={playing}
showMotionPaths={capabilities.timeline}
trajectoryDrawing={trajectoryDrawing}
onTrajectoryComplete={handleTrajectoryComplete}
viewMode={viewMode}
onSelectObject={setSelectedObjectId}
onSelectBone={setSelectedBone}
onGroundClick={handleGroundClick}
onObjectTransform={handleObjectTransform}
onBoneTransform={handleBoneTransform}
onActorRigReady={handleActorRigReady}
selectedCameraId={selectedCameraId}
onSelectCamera={(id) => {
setSelectedCameraId(id);
setSelectedObjectId(null);
setSelectedLightId(null);
setViewMode("camera");
}}
onCameraTransform={handleCameraTransform}
showModelLoadNotice
showNavigation={false}
/>
<CanvasPrevisOnboarding scope={onboardingScope} open={open} restartSignal={onboardingRestartSignal} className="pv-onboarding" />
{viewMode === "camera" && (selectedCamera || activeCamera) ? (
<div className="pv-cam-hud" role="status">
<Camera className="size-3" />
<span>{(selectedCamera || activeCamera)?.name}</span>
<span className="pv-cam-hud__meta">{(selectedCamera || activeCamera)?.focalLength}mm</span>
</div>
) : null}
{trajectoryDrawing ? (
<div className="pv-trajectory-guide" role="status">
<Route className="size-3.5" />
{trajectoryTarget ? (
<>
<span>
正在为「{trajectoryTarget.name}」绘制{trajectoryTarget.kind === "camera" ? "运镜路径" : "走位"}
</span>
<small>按住地面拖动,松开完成 · 再点按钮取消</small>
</>
) : (
<>
<span>请先选择演员或摄影机</span>
<small>左侧列表选择目标后,再按住地面拖动绘制轨迹</small>
</>
)}
</div>
) : null}
<PrevisCanvasDock
theme={theme}
transformMode={transformMode}
onTransformModeChange={setTransformMode}
trajectoryDrawing={trajectoryDrawing}
trajectoryKind={trajectoryTarget?.kind ?? null}
onToggleTrajectory={() => setTrajectoryDrawing((active) => !active)}
viewMode={viewMode}
onViewModeChange={setViewMode}
cameras={draft.cameras}
viewedCameraId={selectedCameraId || activeShot.cameraId || null}
onViewCamera={(id) => {
setSelectedCameraId(id);
setSelectedObjectId(null);
setSelectedLightId(null);
setViewMode("camera");
}}
onFocusSelected={() => viewportRef.current?.focusSelected()}
onFrameScene={() => viewportRef.current?.frameScene()}
onZoom={(factor) => viewportRef.current?.zoom(factor)}
timelineOpen={capabilities.timeline && sequencerVisible}
onOpenTimeline={enterAnimationMode}
scenePanelOpen={compactLayout ? scenePanelOpen : sceneDocked}
onToggleScenePanel={() => (compactLayout ? setScenePanelOpen((value) => !value) : setSceneDocked((value) => !value))}
inspectorOpen={compactLayout ? inspectorOpen : inspectorDocked}
onToggleInspector={() => (compactLayout ? setInspectorOpen((value) => !value) : setInspectorDocked((value) => !value))}
/>
</main>
{/* RIGHT: Shot settings + output */}
<aside className={`pv-panel pv-panel--right thin-scrollbar ${inspectorOpen ? "is-open" : ""}`}>
<div className="pv-panel-header">
<div className="pv-panel-header__title">
<span className="pv-panel-header__label">{selectedObject?.name || selectedLight?.name || inspectorCamera?.name || activeShot.name}</span>
<span className="pv-panel-header__sub">{selectedObject ? "对象" : selectedLight ? "灯光" : selectedCameraId ? "摄影机" : "镜头"}</span>
</div>
<div className="pv-panel-header__actions">
<button type="button" className="pv-icon-btn" aria-label="新增镜头" title="新增镜头" onClick={addShot}>
<Plus className="size-3.5" />
</button>
<button type="button" className="pv-icon-btn" aria-label="打开动画时间轴" title="打开动画时间轴" onClick={enterAnimationMode}>
<WandSparkles className="size-3.5" />
</button>
<button type="button" className="pv-icon-btn pv-mobile-inspector-close" aria-label="关闭检查器" title="关闭检查器" onClick={() => setInspectorOpen(false)}>
<X className="size-3.5" />
</button>
</div>
</div>
{selectedActor ? (
<div className="pv-control-block">
<div className="pv-control-block__label">体型 · {selectedActor.name}</div>
<div className="pv-slider-list">
<label className="pv-slider-row">
<span>身高</span>
<input
type="range"
min={0.5}
max={1.5}
step={0.01}
value={selectedActor.actorProfile?.height ?? 1}
onChange={(e) => updateObject(selectedActor.id, { actorProfile: { ...(selectedActor.actorProfile || previsActorProfileForArchetype("adult")), height: Number(e.target.value) } })}
/>
<span className="pv-slider-val">{(selectedActor.actorProfile?.height ?? 1).toFixed(2)}</span>
</label>
<label className="pv-slider-row">
<span>肩宽</span>
<input
type="range"
min={0.5}
max={1.5}
step={0.01}
value={selectedActor.actorProfile?.shoulderWidth ?? 1}
onChange={(e) => updateObject(selectedActor.id, { actorProfile: { ...(selectedActor.actorProfile || previsActorProfileForArchetype("adult")), shoulderWidth: Number(e.target.value) } })}
/>
<span className="pv-slider-val">{(selectedActor.actorProfile?.shoulderWidth ?? 1).toFixed(2)}</span>
</label>
<label className="pv-slider-row">
<span>头身比</span>
<input
type="range"
min={0.5}
max={1.6}
step={0.01}
value={selectedActor.actorProfile?.headRatio ?? 1}
onChange={(e) => updateObject(selectedActor.id, { actorProfile: { ...(selectedActor.actorProfile || previsActorProfileForArchetype("adult")), headRatio: Number(e.target.value) } })}
/>
<span className="pv-slider-val">{(selectedActor.actorProfile?.headRatio ?? 1).toFixed(2)}</span>
</label>
</div>
<div className="pv-control-block__label" style={{ marginTop: 10 }}>
姿势微调
</div>
<Select
className="w-full"
size="small"
allowClear
value={selectedBone || undefined}
options={poseEditBones.map((bone) => ({ label: previsBoneLabel(bone), value: bone }))}
placeholder="选择身体部位"
onChange={(bone) => setSelectedBone(bone || null)}
/>
{selectedBone ? (
<div style={{ marginTop: 6 }}>
<BoneRotationFields
rotation={selectedActor.boneOverrides?.[selectedBone as PrevisHumanoidBone] || ([0, 0, 0, 1] as PrevisQuat)}
onChange={(rotation) => writeBoneRotation(selectedActor.id, selectedBone, rotation, "stage")}
onChangeComplete={() => stagedTransaction.end("commit")}
/>
</div>
) : null}
</div>
) : null}
{selectedObject && !capabilities.cameraTools ? (
<div className="pv-control-block pv-detail-block">
<div className="pv-control-block__label">对象属性</div>
<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}
onDuplicate={() => duplicateObject(selectedObject.id)}
onDelete={() => removeObject(selectedObject.id)}
/>
</div>
) : selectedLight && !capabilities.cameraTools ? (
<div className="pv-control-block pv-detail-block">
<div className="pv-control-block__label">灯光属性</div>
<LightInspector light={selectedLight} onUpdate={(patch) => updateLight(selectedLight.id, patch)} onDelete={() => removeLight(selectedLight.id)} />
</div>
) : (
<div className="pv-control-block pv-detail-block">
<div className="pv-control-block__label">摄影机属性</div>
<ShotInspector
shot={activeShot}
camera={inspectorCamera}
cameras={draft.cameras}
capabilities={capabilities}
onUpdateShot={(patch) => updateShot(activeShot.id, patch)}
onUpdateCamera={(patch) => inspectorCamera && commit((current) => ({ ...current, cameras: current.cameras.map((item) => (item.id === inspectorCamera.id ? { ...item, ...patch } : item)) }))}
onAddCameraKeyframe={addCameraKeyframe}
onApplyCameraMove={applyCameraMove}
onAlignCameraToView={alignCameraToView}
onExportClay={() => void exportClayVideo()}
recording={recording}
/>
</div>
)}
<div className="pv-output-block">
<div className="pv-output-block__header">
<span>输出</span>
<div className={`pv-output-dot ${lastClayExport?.shotId === activeShot.id ? "pv-output-dot--done" : recording ? "pv-output-dot--recording" : ""}`} />
</div>
<div className="pv-output-status">{lastClayExport?.shotId === activeShot.id ? "✓ 白膜视频已生成" : recording ? "正在录制白膜视频…" : "尚未生成白膜视频"}</div>
<div className="pv-prompt-preview">
<div className="pv-prompt-preview__label">
生成提示词
<button type="button" className="pv-prompt-copy" onClick={() => void copyCompiledPrompt()}>
复制
</button>
</div>
<div className="pv-prompt-preview__text">{compiledPrompt || "提示词将在生成时自动编译"}</div>
</div>
</div>
<div className="pv-shot-list" aria-label="镜头列表">
{draft.shots.map((shot, index) => (
<button
key={shot.id}
type="button"
className={`pv-shot-list__item ${shot.id === activeShot.id ? "is-active" : ""}`}
onClick={() => {
commit((current) => ({ ...current, activeShotId: shot.id }));
setPlayhead(0);
}}
>
<span>{index + 1}</span>
<strong>{shot.name}</strong>
<small>{shot.duration}s</small>
</button>
))}
</div>
<button type="button" className="pv-animation-link" onClick={enterAnimationMode}>
<WandSparkles className="size-3.5" />
打开动画时间轴
</button>
</aside>
</div>
{capabilities.timeline ? (
<section className={`pv-inline-timeline ${sequencerVisible ? "" : "is-collapsed"}`}>
<PrevisSequencer
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);
}}
/>
</section>
) : null}
</div>
);
}
/** 渲染视图全集。实际可选项由当前模式的 capabilities.renderModes 过滤。 */
const PREVIS_RENDER_MODE_LABELS: Array<{ label: string; value: PrevisRenderMode }> = [
{ label: "预览", value: "beauty" },
{ label: "彩色白膜", value: "clay" },
{ label: "骨骼", value: "pose" },
{ label: "深度", value: "depth" },
{ label: "法线", value: "normal" },
];
function cameraMoveTransform(transform: PrevisTransform, move: PrevisCameraMove): PrevisTransform {
const [x, y, z] = transform.position;
const offsets: Record<PrevisCameraMove, PrevisVec3> = {
static: [0, 0, 0],
push_in: [0, 0, -2],
pull_out: [0, 0, 2],
pan_left: [-2, 0, 0],
pan_right: [2, 0, 0],
tilt_up: [0, 1.5, 0],
tilt_down: [0, -1.2, 0],
orbit_left: [-2.5, 0, -1.5],
orbit_right: [2.5, 0, -1.5],
handheld: [0.18, 0.08, -0.15],
};
const offset = offsets[move];
return { ...transform, position: [x + offset[0], y + offset[1], z + offset[2]] };
}