Files
ttstd-3d-director/src/editor/panels/TimelinePanel.tsx
LKH 3b053c714c init
all

视频

视频

视频

视频

视频

init
2026-07-29 09:17:00 +08:00

294 lines
12 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 { useState } from "react";
import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { exportVideo, type VideoExportView } from "../io/videoExport";
interface SelectedKeyframe {
kind: "object" | "camera";
targetId: string;
keyframeId: string;
}
function formatTime(t: number) {
return `${t.toFixed(1)}s`;
}
export function TimelinePanel() {
const project = useDirectorStore((state) => state.project);
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
const addKeyframe = useDirectorStore((state) => state.addKeyframe);
const removeKeyframe = useDirectorStore((state) => state.removeKeyframe);
const updateKeyframeValue = useDirectorStore((state) => state.updateKeyframeValue);
const applyKeyframeToObjectTransform = useDirectorStore((state) => state.applyKeyframeToObjectTransform);
const addCameraKeyframe = useDirectorStore((state) => state.addCameraKeyframe);
const removeCameraKeyframe = useDirectorStore((state) => state.removeCameraKeyframe);
const updateCameraKeyframeValue = useDirectorStore((state) => state.updateCameraKeyframeValue);
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
const setTimelineDuration = useDirectorStore((state) => state.setTimelineDuration);
const clearAllKeyframes = useDirectorStore((state) => state.clearAllKeyframes);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const playhead = usePlaybackStore((state) => state.playhead);
const duration = usePlaybackStore((state) => state.duration);
const togglePlaying = usePlaybackStore((state) => state.togglePlaying);
const setPlaying = usePlaybackStore((state) => state.setPlaying);
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
const [isRecording, setIsRecording] = useState(false);
const timeline = project.timeline;
// 选中目标解析:物体 or 相机
const selectedObject = project.objects.find((o) => o.id === selectedObjectId) ?? null;
const selectedCameraObject =
selectedObject?.kind === "camera" ? selectedObject : null;
const selectedShot = selectedCameraObject?.linkedCameraId
? project.cameras.find((c) => c.id === selectedCameraObject.linkedCameraId) ?? null
: null;
const canAddCameraKeyframe = Boolean(selectedShot && selectedShot.targetMode === "manual");
const selectionIsManualCamera = Boolean(selectedCameraObject);
function snapshotObjectTransform(): KeyframeTransformValue {
if (!selectedObject) throw new Error("no selected object");
return {
position: [...selectedObject.transform.position] as [number, number, number],
rotation: [...selectedObject.transform.rotation] as [number, number, number],
scale: [...selectedObject.transform.scale] as [number, number, number],
};
}
function snapshotShot(): CameraKeyframeValue {
if (!selectedShot) throw new Error("no selected shot");
return {
position: [...selectedShot.transform.position] as [number, number, number],
target: [...selectedShot.target] as [number, number, number],
fov: selectedShot.fov,
};
}
function handleAddKeyframe() {
if (selectionIsManualCamera) {
if (!selectedShot || selectedShot.targetMode !== "manual") return;
addCameraKeyframe(selectedShot.id, playhead, snapshotShot());
} else if (selectedObject) {
addKeyframe(selectedObject.id, playhead, snapshotObjectTransform());
}
}
function handleSelectObjectKeyframe(objectId: string, keyframeId: string, time: number, value: KeyframeTransformValue) {
setPlaying(false);
setPlayhead(time);
applyKeyframeToObjectTransform(objectId, value);
setSelectedKeyframe({ kind: "object", targetId: objectId, keyframeId });
}
function handleSelectCameraKeyframe(cameraId: string, keyframeId: string, time: number, value: CameraKeyframeValue) {
setPlaying(false);
setPlayhead(time);
applyCameraKeyframeToShot(cameraId, value);
setSelectedKeyframe({ kind: "camera", targetId: cameraId, keyframeId });
}
function handleUpdateSelected() {
if (!selectedKeyframe) return;
if (selectedKeyframe.kind === "object" && selectedObject) {
updateKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, snapshotObjectTransform());
} else if (selectedKeyframe.kind === "camera" && selectedShot) {
updateCameraKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, snapshotShot());
}
}
function handleDeleteSelected() {
if (!selectedKeyframe) return;
if (selectedKeyframe.kind === "object") {
removeKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
} else {
removeCameraKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
}
setSelectedKeyframe(null);
}
function handleClearAll() {
if (timeline.tracks.length === 0 && timeline.cameraTracks.length === 0) return;
if (window.confirm("确定清空所有关键帧吗?此操作可用撤销恢复。")) {
clearAllKeyframes();
setSelectedKeyframe(null);
}
}
async function handleExportVideo(view: VideoExportView) {
setIsRecording(true);
try {
await exportVideo(view);
} catch (error) {
console.error("[videoExport]", error);
window.alert(error instanceof Error ? error.message : "视频导出失败");
} finally {
setIsRecording(false);
}
}
const addKeyframeDisabled = selectionIsManualCamera ? !canAddCameraKeyframe : !selectedObject;
const addKeyframeTitle = selectionIsManualCamera
? canAddCameraKeyframe
? `把相机「${selectedShot?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧`
: "锁定物体的相机(targetMode=object)不支持运镜关键帧"
: selectedObject
? `把「${selectedObject.name}」当前位置记录为 ${formatTime(playhead)} 处的关键帧`
: "请先选择一个对象或相机";
return (
<section className="panel-card timeline-panel" aria-label="时间轴">
<div className="timeline-toolbar">
<button type="button" onClick={togglePlaying}>
{isPlaying ? "暂停" : "播放"}
</button>
<span className="timeline-time-readout">
{formatTime(playhead)} / {formatTime(duration)}
</span>
<input
type="range"
min={0}
max={duration}
step={0.05}
value={playhead}
onChange={(event) => setPlayhead(Number(event.target.value))}
aria-label="时间指针"
/>
<label className="timeline-duration">
时长
<input
type="number"
min={0.1}
step={0.5}
value={duration}
onChange={(event) => setTimelineDuration(Number(event.target.value))}
/>
</label>
<button type="button" disabled={addKeyframeDisabled} onClick={handleAddKeyframe} title={addKeyframeTitle}>
在指针处打关键帧
</button>
{selectedKeyframe ? (
<>
<button type="button" onClick={handleUpdateSelected}>更新选中关键帧</button>
<button type="button" onClick={handleDeleteSelected}>删除选中关键帧</button>
</>
) : null}
{timeline.tracks.length > 0 || timeline.cameraTracks.length > 0 ? (
<button type="button" className="timeline-clear-all" onClick={handleClearAll}>
清空全部
</button>
) : null}
<div className="timeline-export-row">
<button
type="button"
className="timeline-export-video"
disabled={isRecording}
onClick={() => void handleExportVideo("director")}
title="从当前导演视角录制整段动画为 WebM 视频"
>
{isRecording ? "录制中…" : "导出视频·上帝视角"}
</button>
<button
type="button"
className="timeline-export-video"
disabled={isRecording}
onClick={() => void handleExportVideo("camera")}
title="从当前选中相机的运镜画面录制整段动画为 WebM 视频"
>
{isRecording ? "录制中…" : "导出视频·机位视角"}
</button>
</div>
</div>
<div className="timeline-tracks">
{timeline.tracks.length === 0 && timeline.cameraTracks.length === 0 ? (
<p className="timeline-empty">还没有关键帧。选中对象或相机,摆好状态后点「在指针处打关键帧」。</p>
) : null}
{timeline.tracks.map((track) => {
const obj = project.objects.find((o) => o.id === track.objectId);
return (
<div className="timeline-track" key={track.objectId}>
<span className="timeline-track-label">{obj?.name ?? track.objectId}</span>
<div className="timeline-track-lane">
{track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "object" &&
selectedKeyframe.targetId === track.objectId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
>
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeKeyframe(track.objectId, kf.id);
}}
>
×
</button>
</div>
);
})}
</div>
</div>
);
})}
{timeline.cameraTracks.map((track) => {
const shot = project.cameras.find((c) => c.id === track.cameraId);
return (
<div className="timeline-track timeline-track-camera" key={track.cameraId}>
<span className="timeline-track-label">📷 {shot?.name ?? track.cameraId}</span>
<div className="timeline-track-lane">
{track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "camera" &&
selectedKeyframe.targetId === track.cameraId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
>
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeCameraKeyframe(track.cameraId, kf.id);
}}
>
×
</button>
</div>
);
})}
</div>
</div>
);
})}
</div>
</section>
);
}