From 2c59709f6b47d08120645611f55b100a0bef349f Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Sat, 19 Sep 2026 04:41:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E3=80=81=E8=BF=87=E6=B8=A1=E4=B8=8E=E7=BC=93?= =?UTF-8?q?=E5=8A=A8=E5=8F=8A=E9=87=8D=E5=81=9A=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents.md | 26 ++ src/App.tsx | 7 + src/editor/components/InspectorDropdown.tsx | 112 +++++++ src/editor/io/videoExport.ts | 55 ++-- src/editor/panels/InspectorControls.tsx | 87 +---- src/editor/panels/TimelinePanel.test.tsx | 63 +++- src/editor/panels/TimelinePanel.tsx | 305 +++++++++++------- .../runtime/ActiveCameraPlaybackSync.tsx | 21 +- src/editor/runtime/playbackStore.ts | 5 + src/editor/runtime/playbackTransforms.test.ts | 12 +- src/editor/runtime/playbackTransforms.ts | 16 +- src/editor/schema/directorProject.ts | 59 ++++ src/editor/schema/timeline.test.ts | 119 +++++++ src/editor/schema/timeline.ts | 278 +++++++++++++++- src/editor/store/directorStore.test.ts | 72 ++++- src/editor/store/directorStore.ts | 81 ++++- src/styles/index.css | 203 ++++++------ src/styles/index.css.test.ts | 4 +- 18 files changed, 1174 insertions(+), 351 deletions(-) create mode 100644 agents.md create mode 100644 src/editor/components/InspectorDropdown.tsx diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..e847859 --- /dev/null +++ b/agents.md @@ -0,0 +1,26 @@ +# Agent 协作规则 + +## 下拉菜单(Dropdown)必须统一使用 `inspector-dropdown` 样式 + +所有「弹出式单选 / 下拉菜单」都复用同一个自定义组件,禁止各面板各写一套下拉样式: + +- 组件:`src/editor/components/InspectorDropdown.tsx` 的 `InspectorDropdown`。 +- 样式类(定义在 `src/styles/index.css`):`inspector-dropdown` / `inspector-dropdown-trigger` / + `inspector-dropdown-value` / `inspector-dropdown-chevron` / `inspector-dropdown-menu` / + `inspector-dropdown-option`(选中态加 `is-selected`)。 +- 该样式**不限定**在右侧 inspector 面板内,时间轴等其它面板直接套用即可,视觉与交互完全一致 + (点外部 / Esc 关闭、触发键 ↓ / Enter / 空格 打开、选中项靠强调色区分)。 + +### 何时用、怎么用 + +- 新增任何「点开选一个」的交互,一律用 `InspectorDropdown`,传 `ariaLabel` / `value` / `options` / `onChange`。 +- `options` 的 `value` 固定为 `string`;需要数字值时在 `onChange` 里 `Number(...)` 转换。 +- 右侧 inspector 字段式下拉走 `InspectorControls` 的 `InspectorSelectField`(内部已用 `InspectorDropdown`)。 +- 需要特定上下文尺寸时,给 `InspectorDropdown` 传 `className` 覆盖外层宽度,**不要**另起一套 CSS 类名或 + 复制 `.timeline-camera-select-*` 这类私有下拉样式(该类已废弃删除)。 + +### 禁止 + +- 不要新增 `timeline-camera-select-*` 之类的私有下拉样式。 +- 不要为了单个面板重新实现一遍下拉(开关状态、点外部关闭、键盘打开等),直接复用 `InspectorDropdown`。 +- 主题相关的下拉优先用自定义组件而非原生 `,从而能跟随主题配色、在深色下正常显示。 + * 交互:点外部 / Esc 关闭,触发键支持 ↓ / Enter / 空格 打开。 + * 任何新增的「弹出式单选」都应复用本组件,而非另起一套下拉样式。 + */ +export function InspectorDropdown({ + ariaLabel, + value, + options, + onChange, + placeholder, + className, + showCheck = false, +}: InspectorDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const selectedOption = options.find((option) => option.value === value) ?? null; + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + if (!dropdownRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen]); + + function selectOption(option: InspectorDropdownOption) { + if (option.disabled) return; + onChange(option.value); + setIsOpen(false); + } + + function handleTriggerKeyDown(event: ReactKeyboardEvent) { + if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setIsOpen(true); + } + } + + return ( +
+ + {isOpen ? ( +
+ {options.map((option) => { + const isSelected = option.value === value; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index 0c4f057..b3358d8 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -9,12 +9,10 @@ import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../r import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, type CameraViewSnapshot } from "../schema/cameraGeometry"; import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio"; import { getActiveCameraIdAtTime, normalizeTimelineFps } from "../schema/timeline"; +import { EXPORT_CODEC_OPTIONS, EXPORT_QUALITY_OPTIONS, type ExportCodecPreference, type ExportSettings } from "../schema/directorProject"; export type VideoExportView = "director" | "camera"; -/** 导出分辨率按「短边 = 720」基准缩放,画面比例取自 viewportAspectRatio(auto 时用视口当前比例) */ -const EXPORT_SHORT_EDGE = 720; - function pickMimeType(): { mimeType: string; ext: string } { if (typeof MediaRecorder === "undefined") return { mimeType: "", ext: "webm" }; const candidates = [ @@ -74,34 +72,51 @@ function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: Camer } /** - * 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。 + * 按视频比例(viewportAspectRatio)计算导出尺寸,短边取自导出设置(默认 720),宽高取偶(编码器要求)。 * 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响, * 否则视口是扁的时导出也会跟着扁。 */ export function computeExportSize( - aspectRatioId: ReturnType["viewportAspectRatio"] + aspectRatioId: ReturnType["viewportAspectRatio"], + resolutionShortEdge: number ): { width: number; height: number } { const aspect = getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9; let width: number; let height: number; if (aspect >= 1) { - height = EXPORT_SHORT_EDGE; + height = resolutionShortEdge; width = Math.round(height * aspect); } else { - width = EXPORT_SHORT_EDGE; + width = resolutionShortEdge; height = Math.round(width / aspect); } return { width: width + (width % 2), height: height + (height % 2) }; } -async function pickVideoEncoderConfig(width: number, height: number, fps: number) { - const candidates = [ - { codec: "avc1.640028" }, // High 4.0 - { codec: "avc1.4d0028" }, // Main 4.0 - { codec: "avc1.42001f" }, // Baseline 3.1 - ]; - const bitrate = Math.round(width * height * fps * 0.15); // ≈6Mbps @720p30 - for (const { codec } of candidates) { +/** 按画质档位算目标码率(custom 时直接用手填值)。统一 H.264,这里只影响码率大小。 */ +function computeExportBitrate(width: number, height: number, fps: number, settings: ExportSettings): number { + if (settings.quality === "custom") return Math.max(100_000, Math.round(settings.customBitrate)); + const opt = EXPORT_QUALITY_OPTIONS.find((item) => item.value === settings.quality); + const multiplier = opt?.bitrateMultiplier ?? 0.15; + return Math.round(width * height * fps * multiplier); +} + +/** 编码档位 → 实际要尝试的 H.264 codec 串列表(auto 时从高规格往下兜底) */ +function buildCodecCandidates(pref: ExportCodecPreference): string[] { + const chosen = EXPORT_CODEC_OPTIONS.find((item) => item.value === pref); + if (chosen?.codec) return [chosen.codec]; + return EXPORT_CODEC_OPTIONS.filter((item) => item.codec).map((item) => item.codec as string); +} + +async function pickVideoEncoderConfig( + width: number, + height: number, + fps: number, + settings: ExportSettings +): Promise { + const bitrate = computeExportBitrate(width, height, fps, settings); + const candidates = buildCodecCandidates(settings.codec); + for (const codec of candidates) { const config: VideoEncoderConfig = { codec, width, @@ -121,7 +136,8 @@ async function pickVideoEncoderConfig(width: number, height: number, fps: number /** * 逐帧离线导出(快,不用实时播放一遍): - * - 渲染分辨率固定为视频比例(短边 720),不受视口布局影响 —— 时间轴拖多高画面都不会变扁 + * - 渲染分辨率 / 码率 / 编码档位取自工程里的导出设置,且固定为视频比例,不受视口布局影响 + * —— 时间轴拖多高画面都不会变扁 * - 每帧直接 setPlayhead(t) + 手动渲染 + WebCodecs 编码,渲染多快导多快 * - H.264 编码进 MP4(Chrome / Electron 均支持) */ @@ -132,13 +148,14 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { const canvas = gl.domElement; const directorStore = useDirectorStore.getState(); - const { width, height } = computeExportSize(directorStore.viewportAspectRatio); + const exportSettings = directorStore.project.exportSettings; + const { width, height } = computeExportSize(directorStore.viewportAspectRatio, exportSettings.resolutionShortEdge); const duration = directorStore.project.timeline.duration; // 帧率取自工程设置(时间轴设置里的「每秒帧数」),导出与读数保持一致 const fps = normalizeTimelineFps(directorStore.project.timeline.fps); const totalFrames = Math.max(1, Math.round(duration * fps)); - const encoderConfig = await pickVideoEncoderConfig(width, height, fps); + const encoderConfig = await pickVideoEncoderConfig(width, height, fps, exportSettings); if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge"); // 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)。 @@ -227,6 +244,8 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { playhead: time, viewMode: view, editingObjectId: usePlaybackStore.getState().editingObjectId, + transition: directorStore.project.timeline.cameraTransition, + fallbackCameraId: directorStore.project.activeCameraId, }); // 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机, diff --git a/src/editor/panels/InspectorControls.tsx b/src/editor/panels/InspectorControls.tsx index d2f99a1..1371d2b 100644 --- a/src/editor/panels/InspectorControls.tsx +++ b/src/editor/panels/InspectorControls.tsx @@ -9,8 +9,9 @@ import { type MouseEvent, type ReactNode, } from "react"; -import { ChevronDown, RotateCcw } from "lucide-react"; +import { RotateCcw } from "lucide-react"; import { useDirectorStore } from "../store/directorStore"; +import { InspectorDropdown } from "../components/InspectorDropdown"; type InspectorTab = { label: string; @@ -232,88 +233,18 @@ export function InspectorSelectField({ children?: ReactNode; options?: InspectorSelectOption[]; }) { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); const resolvedOptions = options ?? parseSelectOptions(children); - const selectedOption = resolvedOptions.find((option) => option.value === value) ?? resolvedOptions[0]; - - useEffect(() => { - if (!isOpen) return; - - const handlePointerDown = (event: globalThis.MouseEvent) => { - const target = event.target as Node; - if (!dropdownRef.current?.contains(target)) { - setIsOpen(false); - } - }; - - const handleKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") { - setIsOpen(false); - } - }; - - document.addEventListener("mousedown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("mousedown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [isOpen]); - - function selectOption(option: InspectorSelectOption) { - if (option.disabled) return; - - onChange(option.value); - setIsOpen(false); - } - - function handleTriggerKeyDown(event: KeyboardEvent) { - if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setIsOpen(true); - } - } return (
{label} -
- - {isOpen ? ( -
- {resolvedOptions.map((option) => { - const isSelected = option.value === value; - - return ( - - ); - })} -
- ) : null} -
+
); } diff --git a/src/editor/panels/TimelinePanel.test.tsx b/src/editor/panels/TimelinePanel.test.tsx index 615ad8d..c31ac2b 100644 --- a/src/editor/panels/TimelinePanel.test.tsx +++ b/src/editor/panels/TimelinePanel.test.tsx @@ -1448,7 +1448,7 @@ it("opens the timeline settings with the current frame rate and loop state", () fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); - expect(within(dialog).getByLabelText("每秒帧数")).toHaveValue(24); + expect(within(dialog).getByLabelText("每秒帧数")).toHaveTextContent("24 fps"); expect(within(dialog).getByLabelText("播放循环")).toBeChecked(); }); @@ -1457,7 +1457,8 @@ it("only saves and applies the settings after pressing 完成", () => { fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); - fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "60" } }); + fireEvent.click(within(dialog).getByLabelText("每秒帧数")); + fireEvent.click(within(dialog).getByRole("option", { name: "60 fps" })); fireEvent.click(within(dialog).getByLabelText("播放循环")); // 还没点「完成」:工程里的设置不变 @@ -1477,16 +1478,17 @@ it("discards the edited settings when the dialog is dismissed without 完成", ( fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); - fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "120" } }); - // 点遮罩关掉 = 取消 - fireEvent.click(screen.getByTestId("timeline-settings-overlay")); + fireEvent.click(within(dialog).getByLabelText("每秒帧数")); + fireEvent.click(within(dialog).getByRole("option", { name: "120 fps" })); + // 点遮罩不再关闭弹窗,改用右上角关闭按钮 = 取消 + fireEvent.click(within(dialog).getByLabelText("关闭设置")); expect(useDirectorStore.getState().project.timeline.fps).toBe(30); expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument(); // 再次打开时草稿值重新从工程读取,不会残留上次没保存的 120 fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); - expect(screen.getByLabelText("每秒帧数")).toHaveValue(30); + expect(screen.getByLabelText("每秒帧数")).toHaveTextContent("30 fps"); }); it("closes the settings dialog from the top-right icon button without applying", () => { @@ -1499,22 +1501,35 @@ it("closes the settings dialog from the top-right icon button without applying", name: "关闭设置", })).toBeInTheDocument(); - fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "50" } }); + fireEvent.click(within(dialog).getByLabelText("每秒帧数")); + fireEvent.click(within(dialog).getByRole("option", { name: "60 fps" })); fireEvent.click(within(dialog).getByRole("button", { name: "关闭设置" })); expect(useDirectorStore.getState().project.timeline.fps).toBe(30); expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument(); }); -it("clamps an out-of-range frame rate when the settings are applied", () => { +it("offers fixed frame rate tiers and applies the chosen one", () => { render(); fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); - fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "999" } }); + fireEvent.click(within(dialog).getByLabelText("每秒帧数")); + + const menu = within(dialog).getByRole("listbox", { name: "每秒帧数" }); + expect(within(menu).getAllByRole("option").map((option) => option.textContent)).toEqual([ + "24 fps", + "30 fps", + "60 fps", + "90 fps", + "120 fps", + "144 fps", + ]); + + fireEvent.click(within(menu).getByRole("option", { name: "144 fps" })); fireEvent.click(within(dialog).getByRole("button", { name: "完成" })); - expect(useDirectorStore.getState().project.timeline.fps).toBe(120); + expect(useDirectorStore.getState().project.timeline.fps).toBe(144); }); it("closes the settings dialog with Escape without applying", () => { @@ -1522,13 +1537,39 @@ it("closes the settings dialog with Escape without applying", () => { fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); - fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "50" } }); + fireEvent.click(within(dialog).getByLabelText("每秒帧数")); + fireEvent.click(within(dialog).getByRole("option", { name: "60 fps" })); fireEvent.keyDown(document, { key: "Escape" }); expect(useDirectorStore.getState().project.timeline.fps).toBe(30); expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument(); }); +it("applies the camera transition setting after pressing 完成", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + + const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); + fireEvent.change(within(dialog).getByLabelText("过渡时长"), { target: { value: "1.2" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "完成" })); + + const transition = useDirectorStore.getState().project.timeline.cameraTransition; + expect(transition.type).toBe("smooth"); + expect(transition.duration).toBeCloseTo(1.2, 5); +}); + +it("switches the camera transition type via the popup menu", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + + const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); + fireEvent.click(within(dialog).getByLabelText("机位切换过渡方式")); + fireEvent.click(within(dialog).getByRole("option", { name: "硬切" })); + fireEvent.click(within(dialog).getByRole("button", { name: "完成" })); + + expect(useDirectorStore.getState().project.timeline.cameraTransition.type).toBe("cut"); +}); + it("nudges the playhead with the ruler keyboard controls", () => { usePlaybackStore.getState().setPlayhead(2); render(); diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index 465d5da..f8a8612 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -1,14 +1,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import type { +import type { CSSProperties, + ChangeEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, -} from "react"; + } from "react"; import { Box, Camera, - Check, ChevronDown, ChevronRight, ChevronUp, @@ -34,20 +34,28 @@ import { CAMERA_KEYFRAME_MIN_GAP, DEFAULT_TIMELINE_FPS, MAX_TIMELINE_DURATION, - MAX_TIMELINE_FPS, MIN_TIMELINE_DURATION, - MIN_TIMELINE_FPS, TIMELINE_DURATION_STEP, + TIMELINE_FPS_TIERS, + MIN_CAMERA_TRANSITION_DURATION, + MAX_CAMERA_TRANSITION_DURATION, + DEFAULT_CAMERA_TRANSITION_DURATION, + normalizeCameraTransitionDuration, getCameraClipSpan, moveCameraKeyframeTime, normalizeTimelineDuration, normalizeTimelineFps, scaleCameraKeyframeStart, scaleCameraKeyframeTimes, + EASING_KINDS, + EASING_LABELS, type CameraKeyframeValue, + type CameraTransitionType, + type EasingKind, type KeyframeTransformValue, } from "../schema/timeline"; -import type { DirectorCameraShot, MotionClip, MotionKind } from "../schema/directorProject"; +import type { DirectorCameraShot, MotionClip, MotionKind, ExportQuality, ExportCodecPreference } from "../schema/directorProject"; +import { EXPORT_RESOLUTION_OPTIONS, EXPORT_QUALITY_OPTIONS, EXPORT_CODEC_OPTIONS } from "../schema/directorProject"; import { TRAJECTORY_POINT_MIN_GAP, getMotionEndTime, @@ -61,6 +69,7 @@ import { import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; import { exportVideo, type VideoExportView } from "../io/videoExport"; +import { InspectorDropdown } from "../components/InspectorDropdown"; interface SelectedKeyframe { kind: "object" | "camera"; @@ -319,6 +328,9 @@ export function TimelinePanel() { const addCameraKeyframe = useDirectorStore((state) => state.addCameraKeyframe); const removeCameraKeyframe = useDirectorStore((state) => state.removeCameraKeyframe); const updateCameraKeyframeValue = useDirectorStore((state) => state.updateCameraKeyframeValue); + const updateKeyframeEasing = useDirectorStore((state) => state.updateKeyframeEasing); + const updateExportSettings = useDirectorStore((state) => state.updateExportSettings); + const updateCameraKeyframeEasing = useDirectorStore((state) => state.updateCameraKeyframeEasing); const setCameraKeyframeTimes = useDirectorStore((state) => state.setCameraKeyframeTimes); const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot); const setActiveCamera = useDirectorStore((state) => state.setActiveCamera); @@ -355,6 +367,7 @@ export function TimelinePanel() { const stopPlayback = usePlaybackStore((state) => state.stop); const setPlaying = usePlaybackStore((state) => state.setPlaying); const setPlayhead = usePlaybackStore((state) => state.setPlayhead); + const setScrubbing = usePlaybackStore((state) => state.setScrubbing); // 帧率 / 循环来自工程设置(旧工程在 store 迁移时补默认值) const fps = project.timeline.fps ?? DEFAULT_TIMELINE_FPS; @@ -372,6 +385,16 @@ export function TimelinePanel() { const [settingsOpen, setSettingsOpen] = useState(false); const [draftFps, setDraftFps] = useState(fps); const [draftLoop, setDraftLoop] = useState(loop); + // 帧率档位:固定常用值;若工程里是档位外的帧率(历史数据),也一并列出避免被吞 + const fpsOptions = Array.from(new Set([...TIMELINE_FPS_TIERS, draftFps])).sort((a, b) => a - b); + // 多机位切换过渡草稿(全局默认):类型 + 时长,点「完成」才写回工程 + const [draftTransitionType, setDraftTransitionType] = useState("smooth"); + const [draftTransitionDuration, setDraftTransitionDuration] = useState(DEFAULT_CAMERA_TRANSITION_DURATION); + // 导出设置草稿:分辨率 / 画质 / 自定义码率 / 编码档位,点「完成」才写回工程 + const [draftResolution, setDraftResolution] = useState(720); + const [draftQuality, setDraftQuality] = useState("medium"); + const [draftCustomBitrate, setDraftCustomBitrate] = useState(8_000_000); + const [draftCodec, setDraftCodec] = useState("auto"); /** 拖动总时长:按下时记一次起始时长与指针位置,移动中只做线性换算 */ const durationDragRef = useRef<{ pointerId: number; startClientX: number; startDuration: number } | null>(null); // 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次; @@ -598,6 +621,27 @@ export function TimelinePanel() { setSelectedKeyframe(null); } + /** 选中关键帧当前使用的缓动曲线(用于下拉框的选中项) */ + const selectedKeyframeEasing: EasingKind = useMemo(() => { + if (!selectedKeyframe) return "linear"; + const findEasing = (track: { keyframes: { id: string; easing?: EasingKind }[] } | undefined) => + track?.keyframes.find((k) => k.id === selectedKeyframe.keyframeId)?.easing ?? "linear"; + if (selectedKeyframe.kind === "object") { + return findEasing(project.timeline.tracks.find((t) => t.objectId === selectedKeyframe.targetId)); + } + return findEasing(project.timeline.cameraTracks.find((t) => t.cameraId === selectedKeyframe.targetId)); + }, [selectedKeyframe, project.timeline]); + + function handleEasingChange(event: ChangeEvent) { + if (!selectedKeyframe) return; + const easing = event.target.value as EasingKind; + if (selectedKeyframe.kind === "object") { + updateKeyframeEasing(selectedKeyframe.targetId, selectedKeyframe.keyframeId, easing); + } else { + updateCameraKeyframeEasing(selectedKeyframe.targetId, selectedKeyframe.keyframeId, easing); + } + } + /** 人物轨迹上的「轨迹关键帧」:轨迹点本身就是关键帧,点它即选中对应轨迹点并把指针定位到该点时间 */ function handleSelectTrajectoryKeyframe(objectId: string, pointId: string, time: number) { setPlaying(false); @@ -662,12 +706,31 @@ export function TimelinePanel() { // 每次打开都从当前工程值重新生成草稿,避免上次没点「完成」的残留值 setDraftFps(fps); setDraftLoop(loop); + setDraftTransitionType(project.timeline.cameraTransition.type); + setDraftTransitionDuration(project.timeline.cameraTransition.duration); + setDraftResolution(project.exportSettings.resolutionShortEdge); + setDraftQuality(project.exportSettings.quality); + setDraftCustomBitrate(project.exportSettings.customBitrate); + setDraftCodec(project.exportSettings.codec); setSettingsOpen(true); } - /** 「完成」才保存并应用:帧率 / 循环一次性写回工程,弹窗随之关闭 */ + /** 「完成」才保存并应用:帧率 / 循环 / 过渡 / 导出设置一次性写回工程,弹窗随之关闭 */ function confirmSettings() { - setTimelinePlaybackSettings({ fps: normalizeTimelineFps(draftFps), loop: draftLoop }); + setTimelinePlaybackSettings({ + fps: normalizeTimelineFps(draftFps), + loop: draftLoop, + cameraTransition: { + type: draftTransitionType, + duration: normalizeCameraTransitionDuration(draftTransitionDuration), + }, + }); + updateExportSettings({ + resolutionShortEdge: draftResolution, + quality: draftQuality, + customBitrate: draftCustomBitrate, + codec: draftCodec, + }); setSettingsOpen(false); } @@ -1401,6 +1464,7 @@ export function TimelinePanel() { event.preventDefault(); setIsScrubbing(true); + setScrubbing(true); seekTo(time); try { @@ -1421,6 +1485,7 @@ export function TimelinePanel() { if (!isScrubbing) return; setIsScrubbing(false); + setScrubbing(false); rulerRectRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); @@ -1635,6 +1700,21 @@ export function TimelinePanel() {