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(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 (
{formatTime(playhead)} / {formatTime(duration)} setPlayhead(Number(event.target.value))} aria-label="时间指针" /> {selectedKeyframe ? ( <> ) : null} {timeline.tracks.length > 0 || timeline.cameraTracks.length > 0 ? ( ) : null}
{timeline.tracks.length === 0 && timeline.cameraTracks.length === 0 ? (

还没有关键帧。选中对象或相机,摆好状态后点「在指针处打关键帧」。

) : null} {timeline.tracks.map((track) => { const obj = project.objects.find((o) => o.id === track.objectId); return (
{obj?.name ?? track.objectId}
{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 (
handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)} >
); })}
); })} {timeline.cameraTracks.map((track) => { const shot = project.cameras.find((c) => c.id === track.cameraId); return (
📷 {shot?.name ?? track.cameraId}
{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 (
handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)} >
); })}
); })}
); }