feat: 新增导出设置、过渡与缓动及重做支持

This commit is contained in:
2026-09-19 04:41:48 +08:00
parent 482d649f84
commit 2c59709f6b
18 changed files with 1174 additions and 351 deletions

26
agents.md Normal file
View File

@@ -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`。
- 主题相关的下拉优先用自定义组件而非原生 `<select>`(原生 select 在深色下选项文字/配色不可控)。

View File

@@ -59,6 +59,13 @@ export default function App() {
if (key === "z" && !event.shiftKey) {
event.preventDefault();
useDirectorStore.getState().undo();
return;
}
// 重做:Ctrl/Cmd+Shift+Z 或 Ctrl+Y(Mac 上常见为 Cmd+Shift+Z)
if ((key === "z" && event.shiftKey) || key === "y") {
event.preventDefault();
useDirectorStore.getState().redo();
}
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { Check, ChevronDown } from "lucide-react";
export interface InspectorDropdownOption {
value: string;
label: ReactNode;
disabled?: boolean;
}
interface InspectorDropdownProps {
ariaLabel: string;
value: string;
options: InspectorDropdownOption[];
onChange: (value: string) => void;
/** 无匹配 value 时触发按钮里展示的占位文字 */
placeholder?: string;
/** 追加到外层容器的额外 className(用于特定上下文的尺寸覆盖等) */
className?: string;
/** 选中项是否在右侧打勾;默认不打勾,靠 is-selected 的强调色区分 */
showCheck?: boolean;
}
/**
* 全站统一的自定义下拉菜单:渲染 inspector-dropdown 系列样式(与右侧 inspector 面板一致),
* 不用原生 <select>,从而能跟随主题配色、在深色下正常显示。
* 交互:点外部 / Esc 关闭,触发键支持 ↓ / Enter / 空格 打开。
* 任何新增的「弹出式单选」都应复用本组件,而非另起一套下拉样式。
*/
export function InspectorDropdown({
ariaLabel,
value,
options,
onChange,
placeholder,
className,
showCheck = false,
}: InspectorDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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<HTMLButtonElement>) {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setIsOpen(true);
}
}
return (
<div className={`inspector-dropdown${className ? ` ${className}` : ""}`} ref={dropdownRef}>
<button
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={ariaLabel}
className="inspector-dropdown-trigger"
type="button"
onClick={() => setIsOpen((current) => !current)}
onKeyDown={handleTriggerKeyDown}
>
<span className="inspector-dropdown-value">{selectedOption ? selectedOption.label : placeholder ?? ""}</span>
<ChevronDown aria-hidden="true" className="inspector-dropdown-chevron" strokeWidth={1.8} />
</button>
{isOpen ? (
<div aria-label={ariaLabel} className="inspector-dropdown-menu" role="listbox">
{options.map((option) => {
const isSelected = option.value === value;
return (
<button
aria-selected={isSelected}
className={`inspector-dropdown-option${isSelected ? " is-selected" : ""}`}
disabled={option.disabled}
key={option.value}
role="option"
type="button"
onClick={() => selectOption(option)}
>
<span>{option.label}</span>
{showCheck && isSelected ? <Check aria-hidden="true" size={13} strokeWidth={2.4} /> : null}
</button>
);
})}
</div>
) : null}
</div>
);
}

View File

@@ -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<typeof useDirectorStore.getState>["viewportAspectRatio"]
aspectRatioId: ReturnType<typeof useDirectorStore.getState>["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<VideoEncoderConfig | null> {
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<void> {
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<void> {
playhead: time,
viewMode: view,
editingObjectId: usePlaybackStore.getState().editingObjectId,
transition: directorStore.project.timeline.cameraTransition,
fallbackCameraId: directorStore.project.activeCameraId,
});
// 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机,

View File

@@ -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<HTMLDivElement>(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<HTMLButtonElement>) {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setIsOpen(true);
}
}
return (
<div className="inspector-field inspector-select-field">
<span className="inspector-field-label">{label}</span>
<div className="inspector-dropdown" ref={dropdownRef}>
<button
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={ariaLabel}
className="inspector-dropdown-trigger"
type="button"
onClick={() => setIsOpen((current) => !current)}
onKeyDown={handleTriggerKeyDown}
>
<span className="inspector-dropdown-value">{selectedOption?.label ?? "请选择"}</span>
<ChevronDown aria-hidden="true" className="inspector-dropdown-chevron" strokeWidth={1.8} />
</button>
{isOpen ? (
<div aria-label={ariaLabel} className="inspector-dropdown-menu" role="listbox">
{resolvedOptions.map((option) => {
const isSelected = option.value === value;
return (
<button
aria-selected={isSelected}
className={`inspector-dropdown-option${isSelected ? " is-selected" : ""}`}
disabled={option.disabled}
key={option.value}
role="option"
type="button"
onClick={() => selectOption(option)}
>
<span>{option.label}</span>
</button>
);
})}
</div>
) : null}
</div>
<InspectorDropdown
ariaLabel={ariaLabel}
value={value}
options={resolvedOptions}
onChange={onChange}
placeholder="请选择"
/>
</div>
);
}

View File

@@ -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(<TimelinePanel />);
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(<TimelinePanel />);
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(<TimelinePanel />);
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(<TimelinePanel />);

View File

@@ -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<CameraTransitionType>("smooth");
const [draftTransitionDuration, setDraftTransitionDuration] = useState(DEFAULT_CAMERA_TRANSITION_DURATION);
// 导出设置草稿:分辨率 / 画质 / 自定义码率 / 编码档位,点「完成」才写回工程
const [draftResolution, setDraftResolution] = useState(720);
const [draftQuality, setDraftQuality] = useState<ExportQuality>("medium");
const [draftCustomBitrate, setDraftCustomBitrate] = useState(8_000_000);
const [draftCodec, setDraftCodec] = useState<ExportCodecPreference>("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<HTMLSelectElement>) {
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() {
<Trash2 aria-hidden="true" size={14} strokeWidth={1.8} />
删除选中关键帧
</button>
<label className="timeline-easing-control" title="该关键帧「出去的那一段」使用的缓动曲线">
<span className="timeline-easing-label">缓动</span>
<select
className="timeline-easing-select"
value={selectedKeyframeEasing}
onChange={handleEasingChange}
aria-label="关键帧缓动曲线"
>
{EASING_KINDS.map((kind) => (
<option key={kind} value={kind}>
{EASING_LABELS[kind]}
</option>
))}
</select>
</label>
</>
) : null}
</div>
@@ -1783,7 +1863,9 @@ export function TimelinePanel() {
return (
<div
key={kf.id}
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
className={`timeline-keyframe${isSelected ? " is-selected" : ""}${
kf.easing && kf.easing !== "linear" ? " has-easing" : ""
}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
@@ -1933,7 +2015,7 @@ export function TimelinePanel() {
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${
isSelected ? " is-selected" : ""
}`}
}${kf.easing && kf.easing !== "linear" ? " has-easing" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
@@ -2273,7 +2355,6 @@ export function TimelinePanel() {
<div
className="timeline-settings-overlay"
data-testid="timeline-settings-overlay"
onClick={() => setSettingsOpen(false)}
>
<div
className="timeline-settings-dialog"
@@ -2296,22 +2377,15 @@ export function TimelinePanel() {
</button>
</div>
<label className="timeline-settings-field">
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">每秒帧数</span>
<span className="timeline-settings-field-control">
<input
className="timeline-settings-input"
aria-label="每秒帧数"
type="number"
min={MIN_TIMELINE_FPS}
max={MAX_TIMELINE_FPS}
step={1}
value={draftFps}
onChange={(event) => setDraftFps(Number(event.target.value))}
/>
<span className="timeline-settings-unit">fps</span>
</span>
</label>
<InspectorDropdown
ariaLabel="每秒帧数"
value={String(draftFps)}
options={fpsOptions.map((value) => ({ value: String(value), label: `${value} fps` }))}
onChange={(selected) => setDraftFps(Number(selected))}
/>
</div>
<label className="timeline-settings-toggle">
<input
@@ -2323,8 +2397,96 @@ export function TimelinePanel() {
<span>播放循环(播到末尾自动回到起点接着播)</span>
</label>
<div className="timeline-settings-divider" />
<h4 className="timeline-settings-subtitle">机位切换过渡</h4>
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">切换方式</span>
<InspectorDropdown
ariaLabel="机位切换过渡方式"
value={draftTransitionType}
options={[
{ value: "cut", label: "硬切" },
{ value: "smooth", label: "平滑运镜" },
]}
onChange={(selected) => setDraftTransitionType(selected as CameraTransitionType)}
/>
</div>
<label className="timeline-settings-field">
<span className="timeline-settings-field-label">过渡时长</span>
<span className="timeline-settings-field-control">
<input
className="timeline-settings-input"
aria-label="过渡时长"
type="number"
min={MIN_CAMERA_TRANSITION_DURATION}
max={MAX_CAMERA_TRANSITION_DURATION}
step={0.1}
value={draftTransitionDuration}
onChange={(event) => setDraftTransitionDuration(Number(event.target.value))}
/>
<span className="timeline-settings-unit">秒</span>
</span>
</label>
<div className="timeline-settings-divider" />
<h4 className="timeline-settings-subtitle">视频导出</h4>
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">分辨率(短边)</span>
<InspectorDropdown
ariaLabel="导出分辨率"
value={String(draftResolution)}
options={EXPORT_RESOLUTION_OPTIONS.map((opt) => ({ value: String(opt.value), label: opt.label }))}
onChange={(selected) => setDraftResolution(Number(selected))}
/>
</div>
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">画质 / 码率</span>
<InspectorDropdown
ariaLabel="导出画质"
value={draftQuality}
options={EXPORT_QUALITY_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label }))}
onChange={(selected) => setDraftQuality(selected as ExportQuality)}
/>
</div>
{draftQuality === "custom" ? (
<label className="timeline-settings-field">
<span className="timeline-settings-field-label">自定义码率</span>
<span className="timeline-settings-field-control">
<input
className="timeline-settings-input"
aria-label="自定义码率(Mbps)"
type="number"
min={0.1}
step={0.5}
value={Math.round((draftCustomBitrate / 1_000_000) * 10) / 10}
onChange={(event) =>
setDraftCustomBitrate(
Math.max(100_000, Math.round(Number(event.target.value) * 1_000_000))
)
}
/>
<span className="timeline-settings-unit">Mbps</span>
</span>
</label>
) : null}
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">编码档位</span>
<InspectorDropdown
ariaLabel="导出编码档位"
value={draftCodec}
options={EXPORT_CODEC_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label }))}
onChange={(selected) => setDraftCodec(selected as ExportCodecPreference)}
/>
</div>
<p className="timeline-settings-hint">
帧率决定时间读数与导出视频的帧数;点「完成」后才保存并应用。
帧率决定时间读数与导出视频的帧数;分辨率 / 画质 / 编码在此设置,点「完成」后才保存并应用。
</p>
<div className="timeline-settings-actions">
@@ -2355,87 +2517,14 @@ function TimelineCameraSelect({
activeCameraId: string | null;
onSelect: (id: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const activeCamera = cameras.find((camera) => camera.id === activeCameraId) ?? null;
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: globalThis.MouseEvent) => {
if (!dropdownRef.current?.contains(event.target as Node)) {
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]);
const selectCamera = (id: string) => {
onSelect(id);
setIsOpen(false);
};
return (
<div className="timeline-camera-select" ref={dropdownRef}>
<button
aria-label="当前机位"
aria-haspopup="listbox"
aria-expanded={isOpen}
className="timeline-camera-select-trigger"
type="button"
onClick={() => setIsOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setIsOpen(true);
}
}}
>
<span className="timeline-camera-select-value">
{activeCamera?.name ?? "未选择机位"}
</span>
<ChevronDown
aria-hidden="true"
className="timeline-camera-select-chevron"
size={13}
strokeWidth={2.2}
/>
</button>
{isOpen ? (
<div className="timeline-camera-select-menu" role="listbox" aria-label="选择机位">
{cameras.map((camera) => {
const isSelected = camera.id === activeCameraId;
return (
<button
aria-selected={isSelected}
className={`timeline-camera-select-option${isSelected ? " is-selected" : ""}`}
key={camera.id}
role="option"
type="button"
onClick={() => selectCamera(camera.id)}
>
<span>{camera.name}</span>
{isSelected ? (
<Check aria-hidden="true" size={13} strokeWidth={2.4} />
) : null}
</button>
);
})}
</div>
) : null}
</div>
<InspectorDropdown
ariaLabel="当前机位"
className="timeline-camera-dropdown"
value={activeCameraId ?? ""}
placeholder="未选择机位"
options={cameras.map((camera) => ({ value: camera.id, label: camera.name }))}
onChange={onSelect}
/>
);
}

View File

@@ -12,16 +12,18 @@ import {
/**
* 机位视图下:
* - 播放 / 导出时按关键帧时间自动切机位:当前应显示的机位由所有相机轨道里「最近一帧 ≤ 播放头」
* 时间最大者决定(切点即关键帧时间点)。导出时虽然没在播放,也要让屏幕预览跟着切,
* 否则导出的画面在切、预览却在原地(预览与成片对不上)。
* - 非播放(编辑 / scrub)保持手动选中的机位,避免和「选中某机位编辑其关键帧」打架。
* - 播放 / 导出 / 拖动标尺 scrub 时按关键帧时间自动切机位:当前应显示的机位由所有相机轨道里
* 「最近一帧 ≤ 播放头」时间最大者决定(切点即关键帧时间点)。导出时虽然没在播放,也要让屏幕
* 预览跟着切,否则导出的画面在切、预览却在原地(预览与成片对不上);scrub 时同理,拖动标尺
* 预览要跟着时间轴的切点走,而不是停在手动选中的机位。
* - 其它非播放(静止编辑)保持手动选中的机位,避免和「选中某机位编辑其关键帧」打架。
* - 始终用「当前应显示的机位」的插值结果覆盖渲染相机的 position / lookAt / fov(逻辑在
* playbackTransforms.ts,与视频导出共用)。挂在 <Canvas> 内;导演视角下为空操作。
*/
export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: string | null }) {
const camera = useThree((state) => state.camera) as ThreePerspectiveCamera;
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
const cameraTransition = useDirectorStore((state) => state.project.timeline.cameraTransition);
const cameras = useDirectorStore((state) => state.project.cameras);
const projectScene = useDirectorStore((state) => state.project.scene);
const viewMode = useDirectorStore((state) => state.viewMode);
@@ -30,19 +32,20 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s
const editingObjectId = usePlaybackStore((state) => state.editingObjectId);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const isExporting = usePlaybackStore((state) => state.isExporting);
const isScrubbing = usePlaybackStore((state) => state.isScrubbing);
useFrame(() => {
if (viewMode !== "camera") return;
// 播放 / 导出时按关键帧自动切机位;否则沿用手动选中的机位
const autoCut = isPlaying || isExporting;
// 播放 / 导出 / 拖动标尺 scrub 时按关键帧自动切机位;其它时候沿用手动选中的机位
const autoCut = isPlaying || isExporting || isScrubbing;
const displayCameraId = autoCut
? getActiveCameraIdAtTime(cameraTracks, activeCameraId, playhead)
: activeCameraId;
// 仅播放且切点变化时把切换写回工程,让工具条 / 导出都跟随当前机位;
// 播放或 scrub 且切点变化时把切换写回工程,让工具条 / 相机面板跟随当前机位;
// 导出时不写回:导出是一次性跑完,无需同步 UI,也避免每帧触发 React 重渲染换相机。
if (isPlaying && displayCameraId && displayCameraId !== activeCameraId) {
if ((isPlaying || isScrubbing) && displayCameraId && displayCameraId !== activeCameraId) {
setActiveCamera(displayCameraId);
}
@@ -54,6 +57,8 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s
playhead,
viewMode,
editingObjectId,
transition: cameraTransition,
fallbackCameraId: activeCameraId,
});
// 该机位没有运镜轨道时,把机位「自身视角」直接写进渲染相机:

View File

@@ -9,6 +9,8 @@ export interface PlaybackState {
loop: boolean;
/** 是否正在导出视频:导出预览需要按时间轴自动切机位(与播放时一致),即便此刻并未播放 */
isExporting: boolean;
/** 是否正在拖动时间轴标尺 scrub:scrub 时也按关键帧自动切机位,让预览跟随时间轴的切点 */
isScrubbing: boolean;
/** 正在被 gizmo 拖拽的对象 id(DirectorObject.id 或 DirectorCameraShot.id)。
* PlaybackTransformSync 会跳过它,避免把 gizmo 改的值立刻覆盖回去。 */
editingObjectId: string | null;
@@ -22,6 +24,7 @@ export interface PlaybackState {
setLoop: (loop: boolean) => void;
setEditingObjectId: (id: string | null) => void;
setExporting: (exporting: boolean) => void;
setScrubbing: (isScrubbing: boolean) => void;
/** 供 useFrame 调用:推进 playhead;到末尾时按 loop 决定「循环回起点」还是「停住」。 */
advance: (deltaSeconds: number) => void;
}
@@ -43,6 +46,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
duration: DEFAULT_TIMELINE_DURATION,
loop: false,
isExporting: false,
isScrubbing: false,
editingObjectId: null,
setPlaying: (isPlaying) => set({ isPlaying }),
togglePlaying: () =>
@@ -62,6 +66,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
setLoop: (loop) => set({ loop }),
setEditingObjectId: (editingObjectId) => set({ editingObjectId }),
setExporting: (isExporting) => set({ isExporting }),
setScrubbing: (isScrubbing) => set({ isScrubbing }),
advance: (deltaSeconds) => {
if (!get().isPlaying) return;
set((state) => {

View File

@@ -170,6 +170,8 @@ it("overrides the render camera in camera view mode", () => {
playhead: 2,
viewMode: "camera",
editingObjectId: null,
transition: { type: "cut", duration: 0.5 },
fallbackCameraId: "cam-1",
});
expect(applied).toBe(true);
@@ -188,6 +190,8 @@ it("does not override the render camera in director view mode", () => {
playhead: 2,
viewMode: "director",
editingObjectId: null,
transition: { type: "cut", duration: 0.5 },
fallbackCameraId: "cam-1",
});
expect(applied).toBe(false);
@@ -235,20 +239,20 @@ describe("getActiveCameraIdAtTime", () => {
describe("computeExportSize", () => {
it("uses 16:9 short-edge-720 for a 16:9 ratio", () => {
expect(computeExportSize("16:9")).toEqual({ width: 1280, height: 720 });
expect(computeExportSize("16:9", 720)).toEqual({ width: 1280, height: 720 });
});
it("uses portrait 720x1280 for 9:16", () => {
expect(computeExportSize("9:16")).toEqual({ width: 720, height: 1280 });
expect(computeExportSize("9:16", 720)).toEqual({ width: 720, height: 1280 });
});
it("falls back to a fixed 16:9 when ratio is auto — never the squashed viewport aspect", () => {
// 时间轴拖高压扁视口(如 1200x400)时,auto 也固定导出 16:9,画面不会跟着变扁
expect(computeExportSize("auto")).toEqual({ width: 1280, height: 720 });
expect(computeExportSize("auto", 720)).toEqual({ width: 1280, height: 720 });
});
it("keeps even dimensions for the encoder", () => {
const { width, height } = computeExportSize("4:3");
const { width, height } = computeExportSize("4:3", 720);
expect(width % 2).toBe(0);
expect(height % 2).toBe(0);
});

View File

@@ -1,8 +1,8 @@
import type { Object3D, PerspectiveCamera } from "three";
import { degreesRotationToRadians } from "../schema/directorProject";
import type { DirectorObject } from "../schema/directorProject";
import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline";
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
import { evaluateCameraTrackAtTime, evaluateCameraViewAtTime, evaluateTrackAtTime } from "../schema/timeline";
import type { CameraTimelineTrack, CameraTransition, TimelineTrack } from "../schema/timeline";
import { evaluateTrajectoryMotionAtTime, hasTrajectoryMotion } from "../schema/trajectoryMotion";
import { getCameraLookQuaternion, getCameraRenderQuaternion } from "../schema/cameraGeometry";
@@ -103,6 +103,8 @@ export function applyActiveCameraViewAtTime({
playhead,
viewMode,
editingObjectId,
transition,
fallbackCameraId,
}: {
camera: PerspectiveCamera;
cameraTracks: CameraTimelineTrack[];
@@ -110,16 +112,18 @@ export function applyActiveCameraViewAtTime({
playhead: number;
viewMode: "director" | "camera";
editingObjectId: string | null;
transition: CameraTransition;
fallbackCameraId?: string | null;
}): boolean {
// 导演视角不覆盖,避免抢走 OrbitControls 控制的导演相机;
// 正在通过 gizmo / CameraPanel 编辑该相机时也不覆盖。
if (viewMode !== "camera") return false;
if (!activeCameraId) return false;
if (editingObjectId === activeCameraId) return false;
const track = cameraTracks.find((item) => item.cameraId === activeCameraId);
if (!track || track.keyframes.length === 0) return false;
const value = evaluateCameraTrackAtTime(track, playhead);
if (!value) return false;
// 含多机位切换过渡:切点附近把相机姿态从上一机位插值过渡到新机位(单相机即可)
const view = evaluateCameraViewAtTime(cameraTracks, transition, fallbackCameraId ?? activeCameraId, playhead);
if (!view) return false;
const value = view.pose;
camera.position.set(value.position[0], value.position[1], value.position[2]);
camera.quaternion.copy(getCameraRenderQuaternion(value.lookRotation));

View File

@@ -322,6 +322,63 @@ export interface DirectorCameraShot {
captures?: DirectorCameraCapture[];
}
// ===== 视频导出设置 =====
/** 编码档位偏好:统一走 H.264(MP4),这里只决定 profile(清晰度 / 兼容性权衡) */
export type ExportCodecPreference = "auto" | "avc-high" | "avc-main" | "avc-baseline";
/** 画质档位:决定目标码率;custom 时改用手填的 customBitrate */
export type ExportQuality = "low" | "medium" | "high" | "custom";
export interface ExportSettings {
/** 导出视频短边分辨率(像素):长边按画面比例自动计算 */
resolutionShortEdge: number;
/** 画质档位:决定码率(custom 时用手填的 customBitrate) */
quality: ExportQuality;
/** 自定义码率(bps),仅 quality === "custom" 时生效 */
customBitrate: number;
/** 编码档位偏好(H.264 profile 选择) */
codec: ExportCodecPreference;
}
/** 短边分辨率档位(像素) */
export const EXPORT_RESOLUTION_OPTIONS: { value: number; label: string }[] = [
{ value: 480, label: "480p(短边 480)" },
{ value: 720, label: "720p(短边 720)" },
{ value: 1080, label: "1080p(短边 1080)" },
{ value: 1440, label: "2K(短边 1440)" },
{ value: 2160, label: "4K(短边 2160)" },
];
/** 画质 → 码率系数(相对 宽×高×帧率)。统一 H.264 编码,这里只影响目标码率。 */
export const EXPORT_QUALITY_OPTIONS: {
value: ExportQuality;
label: string;
bitrateMultiplier: number;
}[] = [
{ value: "low", label: "低(省空间)", bitrateMultiplier: 0.08 },
{ value: "medium", label: "中(推荐)", bitrateMultiplier: 0.15 },
{ value: "high", label: "高(清晰)", bitrateMultiplier: 0.3 },
{ value: "custom", label: "自定义(手动填码率)", bitrateMultiplier: 0 },
];
/** 编码档位 → H.264 codec 串(auto 时由浏览器挑支持的最高规格) */
export const EXPORT_CODEC_OPTIONS: {
value: ExportCodecPreference;
label: string;
codec: string | null;
}[] = [
{ value: "auto", label: "自动(挑浏览器支持的最高规格)", codec: null },
{ value: "avc-high", label: "H.264 High(高质量)", codec: "avc1.640028" },
{ value: "avc-main", label: "H.264 Main(兼容性)", codec: "avc1.4d0028" },
{ value: "avc-baseline", label: "H.264 Baseline(最兼容)", codec: "avc1.42001f" },
];
export const DEFAULT_EXPORT_SETTINGS: ExportSettings = {
resolutionShortEdge: 720,
quality: "medium",
customBitrate: 8_000_000,
codec: "auto",
};
export interface DirectorProject {
version: 2;
scene: SceneSettings;
@@ -331,4 +388,6 @@ export interface DirectorProject {
activeCameraId: string | null;
panoramaAssetId: string | null;
timeline: Timeline;
/** 视频导出设置:分辨率 / 码率 / 编码档位,存进工程随工程一起保存 */
exportSettings: ExportSettings;
}

View File

@@ -12,6 +12,10 @@ import {
removeCameraKeyframe,
updateCameraKeyframeTime,
updateCameraKeyframeValue,
updateKeyframeEasing,
updateCameraKeyframeEasing,
applyEasing,
EASING_KINDS,
CAMERA_KEYFRAME_MIN_GAP,
DEFAULT_TIMELINE_FPS,
MAX_TIMELINE_DURATION,
@@ -26,6 +30,10 @@ import {
setCameraKeyframeTimes,
updateTimelineDuration,
updateTimelinePlaybackSettings,
evaluateCameraViewAtTime,
type CameraTimelineTrack,
type CameraKeyframeValue,
type CameraTransition,
} from "./timeline";
const V0 = { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] };
@@ -40,6 +48,7 @@ it("creates a default empty timeline with both track kinds and playback settings
cameraTracks: [],
fps: DEFAULT_TIMELINE_FPS,
loop: false,
cameraTransition: { type: "smooth", duration: 0.5 },
});
});
@@ -260,3 +269,113 @@ it("applies playback settings only for the fields being passed", () => {
expect(patched.fps).toBe(MAX_TIMELINE_FPS);
expect(patched.loop).toBe(false);
});
describe("evaluateCameraViewAtTime (多机位切换过渡)", () => {
const A: CameraKeyframeValue = { position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 30 };
const B: CameraKeyframeValue = { position: [10, 0, 0], lookRotation: [0, 0, 0], fov: 60 };
const tracks: CameraTimelineTrack[] = [
{ cameraId: "camA", keyframes: [{ id: "a", time: 0, value: A }] },
{ cameraId: "camB", keyframes: [{ id: "b", time: 2, value: B }] },
];
const smooth: CameraTransition = { type: "smooth", duration: 1 };
const cut: CameraTransition = { type: "cut", duration: 1 };
it("blends the pose across the transition window (camera smooth motion)", () => {
expect(evaluateCameraViewAtTime(tracks, smooth, null, 0)?.pose.position).toEqual([0, 0, 0]);
// 切点处仍是上一机位姿态,保证与切点前连续
expect(evaluateCameraViewAtTime(tracks, smooth, null, 2)?.pose.position).toEqual([0, 0, 0]);
// 过渡中点:A→B 在 easeInOutCubic(0.5)=0.5 处插值
const mid = evaluateCameraViewAtTime(tracks, smooth, null, 2.5);
expect(mid?.pose.position).toEqual([5, 0, 0]);
expect(mid?.pose.fov).toBe(45);
// 过渡窗口结束后落到新机位姿态
expect(evaluateCameraViewAtTime(tracks, smooth, null, 3)?.pose.position).toEqual([10, 0, 0]);
});
it("hard cut jumps straight to the new camera pose", () => {
expect(evaluateCameraViewAtTime(tracks, cut, null, 2.5)?.pose.position).toEqual([10, 0, 0]);
});
});
// ---------- 关键帧缓动曲线 ----------
it("exposes a fixed easing list that starts with linear", () => {
expect(EASING_KINDS[0]).toBe("linear");
// 与 applyEasing 能识别的种类一一对应
for (const kind of EASING_KINDS) {
expect(applyEasing(kind, 0.5)).toBeTypeOf("number");
}
});
it("keeps linear easing identical to the raw progress", () => {
for (let i = 0; i <= 10; i += 1) {
const t = i / 10;
expect(applyEasing("linear", t)).toBeCloseTo(t, 6);
}
});
it("anchors eased endpoints to 0 and 1 so keyframes are never pushed off", () => {
for (const kind of EASING_KINDS) {
expect(applyEasing(kind, 0)).toBe(0);
expect(applyEasing(kind, 1)).toBe(1);
}
});
it("easeInQuad starts slow (below the diagonal) and easeOutQuad starts fast (above it)", () => {
expect(applyEasing("easeInQuad", 0.5)).toBeLessThan(0.5);
expect(applyEasing("easeOutQuad", 0.5)).toBeGreaterThan(0.5);
// 缓入缓出在中点附近贴近 0.5
expect(applyEasing("easeInOutQuad", 0.5)).toBeCloseTo(0.5, 6);
});
it("applies the starting keyframe's easing to the whole segment", () => {
const track = {
objectId: "a",
keyframes: [
{ id: "k1", time: 0, value: V0, easing: "easeInQuad" as const },
{ id: "k2", time: 2, value: V1 },
],
};
// 中点原本线性为 5,缓入时进度被压低,位置应小于 5
const mid = evaluateTrackAtTime(track, 1);
expect(mid?.position[0]).toBeLessThan(5);
// 不填 easing 默认线性
const linear = evaluateTrackAtTime(
{ objectId: "a", keyframes: [{ id: "k1", time: 0, value: V0 }, { id: "k2", time: 2, value: V1 }] },
1
);
expect(linear?.position[0]).toBeCloseTo(5, 6);
});
it("applies easing to camera segments (position + fov share the curve)", () => {
const track = {
cameraId: "cam_1",
keyframes: [
{ id: "k1", time: 0, value: CAM_A, easing: "easeInOutCubic" as const },
{ id: "k2", time: 2, value: CAM_B },
],
};
const atHalf = evaluateCameraTrackAtTime(track, 1);
// 缓入缓出在中点进度≈0.5,位置≈5、fov≈60
expect(atHalf?.position[0]).toBeCloseTo(5, 5);
expect(atHalf?.fov).toBeCloseTo(60, 5);
});
it("updateKeyframeEasing writes the curve without moving the keyframe", () => {
let timeline = createDefaultTimeline();
timeline = upsertKeyframe(timeline, "obj_1", 0, V0, "kf_1");
timeline = upsertKeyframe(timeline, "obj_1", 2, V1, "kf_2");
timeline = updateKeyframeEasing(timeline, "obj_1", "kf_1", "easeOutBack");
const kf = timeline.tracks[0].keyframes[0];
expect(kf.easing).toBe("easeOutBack");
expect(kf.time).toBe(0);
// 其它关键帧不受影响
expect(timeline.tracks[0].keyframes[1].easing).toBeUndefined();
});
it("updateCameraKeyframeEasing writes the curve onto the camera keyframe", () => {
let timeline = createDefaultTimeline();
timeline = upsertCameraKeyframe(timeline, "cam_1", 0, CAM_A, "ckf_1");
timeline = upsertCameraKeyframe(timeline, "cam_1", 2, CAM_B, "ckf_2");
timeline = updateCameraKeyframeEasing(timeline, "cam_1", "ckf_1", "easeInExpo");
expect(timeline.cameraTracks[0].keyframes[0].easing).toBe("easeInExpo");
});

View File

@@ -4,6 +4,123 @@ import { slerpCameraLookRotation } from "./cameraGeometry";
export type TransformTuple = [number, number, number];
// ===== 关键帧缓动曲线 =====
export type EasingKind =
| "linear"
| "easeInSine"
| "easeOutSine"
| "easeInOutSine"
| "easeInQuad"
| "easeOutQuad"
| "easeInOutQuad"
| "easeInCubic"
| "easeOutCubic"
| "easeInOutCubic"
| "easeInExpo"
| "easeOutExpo"
| "easeInOutExpo"
| "easeInBack"
| "easeOutBack"
| "easeInOutBack";
/** 时间轴上缓动下拉用的固定顺序(linear 排第一作为默认) */
export const EASING_KINDS: EasingKind[] = [
"linear",
"easeInSine",
"easeOutSine",
"easeInOutSine",
"easeInQuad",
"easeOutQuad",
"easeInOutQuad",
"easeInCubic",
"easeOutCubic",
"easeInOutCubic",
"easeInExpo",
"easeOutExpo",
"easeInOutExpo",
"easeInBack",
"easeOutBack",
"easeInOutBack",
];
export const EASING_LABELS: Record<EasingKind, string> = {
linear: "线性(默认)",
easeInSine: "缓入·正弦",
easeOutSine: "缓出·正弦",
easeInOutSine: "缓入缓出·正弦",
easeInQuad: "缓入·二次",
easeOutQuad: "缓出·二次",
easeInOutQuad: "缓入缓出·二次",
easeInCubic: "缓入·三次",
easeOutCubic: "缓出·三次",
easeInOutCubic: "缓入缓出·三次",
easeInExpo: "缓入·指数",
easeOutExpo: "缓出·指数",
easeInOutExpo: "缓入缓出·指数",
easeInBack: "缓入·回弹",
easeOutBack: "缓出·回弹",
easeInOutBack: "缓入缓出·回弹",
};
/**
* 把「段内归一化进度 t∈[0,1]」映射成缓动后的进度。
* 缓动作用于关键帧之间的整段(取该段「起始关键帧」的 easing 设置),
* 所有通道(位置 / 旋转 / 缩放 / 注视 / FOV)共用同一条缓动曲线。
* 端点严格保持:t<=0 返回 0、t>=1 返回 1,确保关键帧本身不会被缓动推离原值。
*/
export function applyEasing(kind: EasingKind, t: number): number {
const x = Math.min(1, Math.max(0, t));
if (x <= 0) return 0;
if (x >= 1) return 1;
switch (kind) {
case "linear":
return x;
case "easeInSine":
return 1 - Math.cos((x * Math.PI) / 2);
case "easeOutSine":
return Math.sin((x * Math.PI) / 2);
case "easeInOutSine":
return -(Math.cos(Math.PI * x) - 1) / 2;
case "easeInQuad":
return x * x;
case "easeOutQuad":
return 1 - (1 - x) * (1 - x);
case "easeInOutQuad":
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
case "easeInCubic":
return x * x * x;
case "easeOutCubic":
return 1 - Math.pow(1 - x, 3);
case "easeInOutCubic":
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
case "easeInExpo":
return Math.pow(2, 10 * x - 10);
case "easeOutExpo":
return 1 - Math.pow(2, -10 * x);
case "easeInOutExpo":
return x < 0.5 ? Math.pow(2, 20 * x - 10) / 2 : (2 - Math.pow(2, -20 * x + 10)) / 2;
case "easeInBack": {
const c1 = 1.70158;
const c3 = c1 + 1;
return c3 * x * x * x - c1 * x * x;
}
case "easeOutBack": {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
}
case "easeInOutBack": {
const c1 = 1.70158;
const c2 = c1 * 1.525;
return x < 0.5
? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2
: (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (2 * x - 2) + c2) + 2) / 2;
}
default:
return x;
}
}
// ===== 物体关键帧 =====
export interface KeyframeTransformValue {
position: TransformTuple;
@@ -14,7 +131,8 @@ export interface TimelineKeyframe {
id: string;
time: number; // 秒,>= 0
value: KeyframeTransformValue;
easing?: "linear"; // 预留:MVP 只用线性插值,该字段当前不读取
/** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */
easing?: EasingKind;
}
export interface TimelineTrack {
objectId: string; // 对应 DirectorObject.id
@@ -32,6 +150,8 @@ export interface CameraTimelineKeyframe {
id: string;
time: number;
value: CameraKeyframeValue;
/** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */
easing?: EasingKind;
}
export interface CameraTimelineTrack {
cameraId: string; // 对应 DirectorCameraShot.id
@@ -46,6 +166,8 @@ export interface Timeline {
fps: number;
/** 播放到末尾是否循环回起点 */
loop: boolean;
/** 多机位切换过渡(全局默认):硬切或相机平滑运镜 */
cameraTransition: CameraTransition;
}
export const DEFAULT_TIMELINE_DURATION = 5;
@@ -56,7 +178,43 @@ export const MAX_TIMELINE_DURATION = 3600;
/** 默认帧率:与视频导出的常用帧率一致 */
export const DEFAULT_TIMELINE_FPS = 30;
export const MIN_TIMELINE_FPS = 1;
export const MAX_TIMELINE_FPS = 120;
export const MAX_TIMELINE_FPS = 144;
/** 帧率固定档位:用下拉选择代替手动输入,覆盖常用档(含 144),避免非法值 */
export const TIMELINE_FPS_TIERS: number[] = [24, 30, 60, 90, 120, 144];
// ===== 多机位切换过渡 =====
export type CameraTransitionType = "cut" | "smooth";
export interface CameraTransition {
type: CameraTransitionType;
/** 过渡时长(秒):切点时相机姿态从上一机位插值过渡到新机位的时间 */
duration: number;
}
export const MIN_CAMERA_TRANSITION_DURATION = 0.1;
export const MAX_CAMERA_TRANSITION_DURATION = 5;
export const DEFAULT_CAMERA_TRANSITION_DURATION = 0.5;
/** 把任意输入夹成合法的过渡时长(秒),非法值回落到默认,避免 NaN 把切点搞坏 */
export function normalizeCameraTransitionDuration(value: unknown): number {
const seconds = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_CAMERA_TRANSITION_DURATION;
return Math.min(Math.max(seconds, MIN_CAMERA_TRANSITION_DURATION), MAX_CAMERA_TRANSITION_DURATION);
}
/** 全局默认开启「相机平滑运镜」过渡,让多机位切换不再生硬硬切 */
export const DEFAULT_CAMERA_TRANSITION: CameraTransition = {
type: "smooth",
duration: DEFAULT_CAMERA_TRANSITION_DURATION,
};
/** 把任意(含旧工程缺失)的过渡配置归一化,保证字段齐全、数值合法 */
export function normalizeCameraTransition(
transition: Partial<CameraTransition> | null | undefined
): CameraTransition {
if (!transition) return { ...DEFAULT_CAMERA_TRANSITION };
const type: CameraTransitionType = transition.type === "smooth" ? "smooth" : "cut";
return { type, duration: normalizeCameraTransitionDuration(transition.duration) };
}
/** 时长微调刻度(秒):上下按钮与键盘方向键都按它走 */
export const TIMELINE_DURATION_STEP = 0.1;
@@ -81,6 +239,7 @@ export function createDefaultTimeline(): Timeline {
cameraTracks: [],
fps: DEFAULT_TIMELINE_FPS,
loop: false,
cameraTransition: DEFAULT_CAMERA_TRANSITION,
};
}
@@ -95,12 +254,16 @@ export function updateTimelineDuration(timeline: Timeline, seconds: number): Tim
*/
export function updateTimelinePlaybackSettings(
timeline: Timeline,
patch: { fps?: number; loop?: boolean }
patch: { fps?: number; loop?: boolean; cameraTransition?: CameraTransition }
): Timeline {
return {
...timeline,
fps: patch.fps === undefined ? timeline.fps : normalizeTimelineFps(patch.fps),
loop: patch.loop === undefined ? timeline.loop : patch.loop === true,
cameraTransition:
patch.cameraTransition === undefined
? timeline.cameraTransition
: normalizeCameraTransition(patch.cameraTransition),
};
}
@@ -126,6 +289,7 @@ function lerpTuple(a: TransformTuple, b: TransformTuple, t: number): TransformTu
interface TimedValue<V> {
time: number;
value: V;
easing?: EasingKind;
}
function evaluateKeyframes<V>(
keyframes: TimedValue<V>[],
@@ -142,7 +306,9 @@ function evaluateKeyframes<V>(
if (time >= a.time && time <= b.time) {
const span = b.time - a.time;
const t = span === 0 ? 0 : (time - a.time) / span;
return lerpFn(a.value, b.value, t);
// 取「起始关键帧」的缓动曲线作用于整段;端点处 applyEasing 返回 0/1,保证关键帧不被推离
const eased = applyEasing(a.easing ?? "linear", t);
return lerpFn(a.value, b.value, eased);
}
}
return last.value;
@@ -194,11 +360,18 @@ export function evaluateCameraTrackAtTime(track: CameraTimelineTrack, time: numb
* 没有任何机位在 t 之前打过关键帧时,退回 fallback(通常是当前激活机位)。
* 这样时间轴播放时,机位视角会按关键帧时间点自动切到对应机位。
*/
export function getActiveCameraIdAtTime(
export interface CameraSegment {
cameraId: string | null;
/** 该机位在 time 处成为激活机位的切点时间(即「≤ time 最近关键帧」的时间) */
cutTime: number;
}
/** 返回 time 处应显示的机位及其「切点」时间,供过渡计算使用 */
export function getActiveCameraSegmentAtTime(
cameraTracks: CameraTimelineTrack[],
fallbackCameraId: string | null,
time: number
): string | null {
): CameraSegment {
let bestCameraId: string | null = null;
let bestTime = -Infinity;
@@ -217,7 +390,64 @@ export function getActiveCameraIdAtTime(
}
}
return bestCameraId ?? fallbackCameraId;
return { cameraId: bestCameraId ?? fallbackCameraId, cutTime: bestTime };
}
export function getActiveCameraIdAtTime(
cameraTracks: CameraTimelineTrack[],
fallbackCameraId: string | null,
time: number
): string | null {
return getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time).cameraId;
}
export interface CameraViewAtTime {
cameraId: string | null;
pose: CameraKeyframeValue;
}
/**
* 计算 time 处机位视角应呈现的相机姿态,含多机位切换过渡:
* - 无过渡(硬切)或已过过渡窗口:直接用当前激活机位在 time 处的插值姿态;
* - 平滑运镜下:在 [cutTime, cutTime + duration) 内把姿态从「上一机位在切点处的姿态」
* 按缓动插值到「当前机位在 time 处的姿态」,形成相机平滑运镜过渡。
* 单相机即可实现,预览与导出共用,无需双画面渲染。
*/
export function evaluateCameraViewAtTime(
cameraTracks: CameraTimelineTrack[],
transition: CameraTransition,
fallbackCameraId: string | null,
time: number
): CameraViewAtTime | null {
const segment = getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time);
const incomingTrack = cameraTracks.find((item) => item.cameraId === segment.cameraId);
if (!incomingTrack || incomingTrack.keyframes.length === 0) return null;
const incomingPose = evaluateCameraTrackAtTime(incomingTrack, time);
if (!incomingPose) return null;
if (transition.type !== "smooth" || transition.duration <= 0) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
const elapsed = time - segment.cutTime;
if (elapsed >= transition.duration) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
// 过渡窗口内:找到切点之前的上一机位,取其切点处的姿态作为过渡起点
const prevSegment = getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, segment.cutTime - 1e-4);
if (!prevSegment.cameraId || prevSegment.cameraId === segment.cameraId) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
const prevTrack = cameraTracks.find((item) => item.cameraId === prevSegment.cameraId);
if (!prevTrack || prevTrack.keyframes.length === 0) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
const outgoingPose = evaluateCameraTrackAtTime(prevTrack, segment.cutTime);
if (!outgoingPose) return { cameraId: segment.cameraId, pose: incomingPose };
const progress = applyEasing("easeInOutCubic", elapsed / transition.duration);
return { cameraId: segment.cameraId, pose: lerpCameraValue(outgoingPose, incomingPose, progress) };
}
// ===== 物体增删改 =====
@@ -278,6 +508,23 @@ export function updateKeyframeValue(timeline: Timeline, objectId: string, keyfra
};
}
/** 修改某个物体关键帧「出去的那一段」的缓动曲线(位置 / 旋转 / 缩放共用) */
export function updateKeyframeEasing(
timeline: Timeline,
objectId: string,
keyframeId: string,
easing: EasingKind
): Timeline {
return {
...timeline,
tracks: timeline.tracks.map((track) =>
track.objectId === objectId
? { ...track, keyframes: track.keyframes.map((k) => (k.id === keyframeId ? { ...k, easing } : k)) }
: track
),
};
}
// ===== 相机增删改(与物体同构,操作 cameraTracks) =====
export function upsertCameraKeyframe(
timeline: Timeline,
@@ -336,6 +583,23 @@ export function updateCameraKeyframeValue(timeline: Timeline, cameraId: string,
};
}
/** 修改某个运镜关键帧「出去的那一段」的缓动曲线(位置 / 注视 / FOV 共用) */
export function updateCameraKeyframeEasing(
timeline: Timeline,
cameraId: string,
keyframeId: string,
easing: EasingKind
): Timeline {
return {
...timeline,
cameraTracks: timeline.cameraTracks.map((track) =>
track.cameraId === cameraId
? { ...track, keyframes: track.keyframes.map((k) => (k.id === keyframeId ? { ...k, easing } : k)) }
: track
),
};
}
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */
export const CAMERA_KEYFRAME_MIN_GAP = 0.05;

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, vi } from "vitest";
import { createDefaultDirectorProject, createInitialDirectorState, useDirectorStore } from "./directorStore";
import { selectRightPanelKind } from "./directorSelectors";
import { getCameraLookRotationFromTarget, getCameraRigPositionFromViewSnapshot } from "../schema/cameraGeometry";
import { DEFAULT_GROUND_COLOR } from "../schema/directorProject";
import { DEFAULT_GROUND_COLOR, DEFAULT_EXPORT_SETTINGS } from "../schema/directorProject";
function createMemoryStorage(): Storage {
const storage = new Map<string, string>();
@@ -32,6 +32,7 @@ beforeEach(() => {
clipboard: [],
clipboardPasteCount: 0,
undoStack: [],
redoStack: [],
undoBatchDepth: 0,
undoBatchSnapshot: null,
undoBatchHasTrackedChanges: false,
@@ -829,6 +830,43 @@ it("undoes the latest scene mutation", () => {
expect(useDirectorStore.getState().project.objects.filter((item) => item.kind === "character")).toHaveLength(1);
});
it("redoes an undone mutation and clears the redo stack on a new edit", () => {
useDirectorStore.getState().addPresetCharacter("female");
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "角色02")).toBe(true);
// 撤销后进入重做栈
useDirectorStore.getState().undo();
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "角色02")).toBe(false);
expect(useDirectorStore.getState().undoStack).toHaveLength(0);
expect(useDirectorStore.getState().redoStack).toHaveLength(1);
// 重做恢复被撤销的角色
useDirectorStore.getState().redo();
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "角色02")).toBe(true);
expect(useDirectorStore.getState().redoStack).toHaveLength(0);
// 新编辑清空重做栈
useDirectorStore.getState().addPresetCharacter("mannequin");
expect(useDirectorStore.getState().redoStack).toHaveLength(0);
});
it("redoes a batched mutation as a single step", () => {
useDirectorStore.getState().beginUndoBatch();
useDirectorStore.getState().updateObjectTransform("char_default_a", { position: [1, 0, 0] });
useDirectorStore.getState().updateObjectTransform("char_default_a", { position: [2, 0, 0] });
useDirectorStore.getState().endUndoBatch();
useDirectorStore.getState().undo();
expect(useDirectorStore.getState().project.objects.find((item) => item.id === "char_default_a")?.transform.position).toEqual([
0, 0, 0,
]);
useDirectorStore.getState().redo();
expect(useDirectorStore.getState().project.objects.find((item) => item.id === "char_default_a")?.transform.position).toEqual([
2, 0, 0,
]);
});
it("groups repeated transform updates into one undo step while batching", () => {
useDirectorStore.getState().beginUndoBatch();
useDirectorStore.getState().updateObjectTransform("char_default_a", { position: [1, 0, 0] });
@@ -859,6 +897,38 @@ it("createDefaultDirectorProject ships a default timeline and version 2", () =>
});
});
it("seeds default export settings (720p / medium / auto codec)", () => {
const project = createDefaultDirectorProject();
expect(project.exportSettings).toEqual(DEFAULT_EXPORT_SETTINGS);
});
it("updates export settings partially via updateExportSettings", () => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
useDirectorStore.getState().updateExportSettings({ resolutionShortEdge: 1080, codec: "avc-high" });
const settings = useDirectorStore.getState().project.exportSettings;
expect(settings.resolutionShortEdge).toBe(1080);
expect(settings.codec).toBe("avc-high");
// 未传的字段保持原值
expect(settings.quality).toBe("medium");
});
it("migrates a legacy project without exportSettings to the defaults", () => {
const legacyV1 = {
version: 1,
scene: { scale: 1, position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], backgroundColor: "#000000", panoramaYaw: 0, panoramaRadius: 60, showLabels: true, snapToGrid: false, showGround: true, groundOpacity: 0.4, groundHeight: 0 },
assets: [],
objects: [],
cameras: [],
activeCameraId: null,
panoramaAssetId: null,
};
useDirectorStore.getState().replaceProject(legacyV1 as never);
expect(useDirectorStore.getState().project.exportSettings).toEqual(DEFAULT_EXPORT_SETTINGS);
});
it("replaceProject migrates a legacy v1 project into a v2 project with a timeline", () => {
const legacyV1 = {
version: 1,

View File

@@ -3,6 +3,7 @@ import { MANNEQUIN_POSE_PRESETS } from "../presets/mannequinPosePresets";
import {
DEFAULT_GROUND_COLOR,
GEOMETRY_PRIMITIVE_OPTIONS,
DEFAULT_EXPORT_SETTINGS,
normalizeTrajectoryRotation,
normalizeTransformRotation,
} from "../schema/directorProject";
@@ -19,6 +20,7 @@ import type {
DirectorTrajectoryPoint,
DirectorTrajectoryPointRef,
DirectorTransform,
ExportSettings,
GeometryPrimitiveType,
MotionClip,
MotionKind,
@@ -43,19 +45,24 @@ import {
removeKeyframe as removeTimelineKeyframe,
updateKeyframeTime as updateTimelineKeyframeTime,
updateKeyframeValue as updateTimelineKeyframeValue,
updateKeyframeEasing as updateTimelineKeyframeEasing,
upsertCameraKeyframe,
removeCameraKeyframe as removeTimelineCameraKeyframe,
updateCameraKeyframeTime as updateTimelineCameraKeyframeTime,
updateCameraKeyframeValue as updateTimelineCameraKeyframeValue,
updateCameraKeyframeEasing as updateTimelineCameraKeyframeEasing,
setCameraKeyframeTimes as setTimelineCameraKeyframeTimes,
updateTimelineDuration,
updateTimelinePlaybackSettings,
normalizeTimelineDuration,
normalizeTimelineFps,
normalizeCameraTransition,
DEFAULT_TIMELINE_DURATION,
createKeyframeId,
type KeyframeTransformValue,
type CameraKeyframeValue,
type CameraTransition,
type EasingKind,
type Timeline,
} from "../schema/timeline";
import {
@@ -139,6 +146,8 @@ interface DirectorInternalState {
clipboard: DirectorClipboardEntry[];
clipboardPasteCount: number;
undoStack: DirectorState[];
/** 重做栈:撤销后把被撤销的状态压进来,新编辑发生时清空 */
redoStack: DirectorState[];
undoBatchDepth: number;
undoBatchSnapshot: DirectorState | null;
undoBatchHasTrackedChanges: boolean;
@@ -266,25 +275,31 @@ export interface DirectorActions {
removeKeyframe: (objectId: string, keyframeId: string) => void;
updateKeyframeTime: (objectId: string, keyframeId: string, time: number) => void;
updateKeyframeValue: (objectId: string, keyframeId: string, value: KeyframeTransformValue) => void;
updateKeyframeEasing: (objectId: string, keyframeId: string, easing: EasingKind) => void;
applyKeyframeToObjectTransform: (objectId: string, value: KeyframeTransformValue) => void;
// 相机运镜关键帧
addCameraKeyframe: (cameraId: string, time: number, value: CameraKeyframeValue) => string;
removeCameraKeyframe: (cameraId: string, keyframeId: string) => void;
updateCameraKeyframeTime: (cameraId: string, keyframeId: string, time: number) => void;
updateCameraKeyframeValue: (cameraId: string, keyframeId: string, value: CameraKeyframeValue) => void;
updateCameraKeyframeEasing: (cameraId: string, keyframeId: string, easing: EasingKind) => void;
/** 一次性覆盖整条运镜的所有关键帧时间(拖动「运镜时长」把手按比例缩放用) */
setCameraKeyframeTimes: (cameraId: string, times: number[]) => void;
applyCameraKeyframeToShot: (cameraId: string, value: CameraKeyframeValue) => void;
// 时长
setTimelineDuration: (seconds: number) => void;
/** 时间轴播放设置(每秒帧数 / 是否循环):设置弹窗里点「完成」时一次性写入 */
setTimelinePlaybackSettings: (patch: { fps?: number; loop?: boolean }) => void;
setTimelinePlaybackSettings: (patch: { fps?: number; loop?: boolean; cameraTransition?: CameraTransition }) => void;
/** 更新视频导出设置(分辨率 / 码率 / 编码档位),支持部分更新 */
updateExportSettings: (patch: Partial<ExportSettings>) => void;
clearAllKeyframes: () => void;
beginUndoBatch: () => void;
endUndoBatch: () => void;
copySelectedObjects: () => void;
pasteClipboardObjects: () => void;
undo: () => void;
/** 重做:把最近一次「撤销」回退掉,恢复被撤销的状态(新编辑会清空重做栈) */
redo: () => void;
openScopedScene: (scopeId: string | null | undefined) => void;
replaceProject: (project: DirectorProject) => void;
saveLatestSnapshot: () => void;
@@ -539,6 +554,7 @@ function withTimeline(project: DirectorProject): DirectorProject {
cameraTracks: timeline.cameraTracks ?? [],
fps: normalizeTimelineFps(timeline.fps),
loop: timeline.loop === true,
cameraTransition: normalizeCameraTransition(timeline.cameraTransition),
},
};
}
@@ -650,6 +666,8 @@ function migrateDirectorProject(project: DirectorProject): DirectorProject {
const withTimelineProject = withSceneDefaults(withCameraLookRotations(withTimeline(project)));
return {
...withTimelineProject,
// 旧工程(v2 早期)没有 exportSettings,补默认值,避免读取时 undefined
exportSettings: project.exportSettings ?? DEFAULT_EXPORT_SETTINGS,
objects: withTimelineProject.objects.map((object) => {
if (object.kind !== "character") return object;
@@ -791,6 +809,7 @@ function createRuntimeStateFromPersistedState(state: DirectorState): DirectorRun
clipboard: [],
clipboardPasteCount: 0,
undoStack: [],
redoStack: [],
undoBatchDepth: 0,
undoBatchSnapshot: null,
undoBatchHasTrackedChanges: false,
@@ -852,6 +871,7 @@ export function createDefaultDirectorProject({
activeCameraId: camera.id,
panoramaAssetId: null,
timeline: createDefaultTimeline(),
exportSettings: DEFAULT_EXPORT_SETTINGS,
};
}
@@ -1777,6 +1797,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
return {
...nextState,
undoStack: trackUndo ? currentState.undoStack : nextState.undoStack,
redoStack: currentState.redoStack ?? [],
undoBatchDepth: nextState.undoBatchDepth,
undoBatchSnapshot: nextState.undoBatchSnapshot,
undoBatchHasTrackedChanges: nextState.undoBatchHasTrackedChanges,
@@ -1784,13 +1805,17 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
}
const shouldCaptureUndoBatchSnapshot = isInsideUndoBatch && currentState.undoBatchSnapshot === null;
// 压入新的撤销快照(真正的用户编辑,非撤销批次内)时,清空重做栈 —— 重做链到此截断
const pushedNewUndoEntry = Boolean(previousSnapshot) && currentState.undoBatchDepth === 0;
const nextUndoStack =
previousSnapshot && currentState.undoBatchDepth === 0
? trimUndoStack([...currentState.undoStack, previousSnapshot])
: nextState.undoStack;
const nextRedoStack = pushedNewUndoEntry ? [] : (currentState.redoStack ?? []);
const runtimeState: DirectorRuntimeState = {
...nextState,
undoStack: nextUndoStack,
redoStack: nextRedoStack,
undoBatchSnapshot: shouldCaptureUndoBatchSnapshot
? createUndoStackEntry(currentState)
: nextState.undoBatchSnapshot,
@@ -1859,6 +1884,8 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
undoStack: shouldPushUndoEntry
? trimUndoStack([...currentState.undoStack, currentState.undoBatchSnapshot!])
: currentState.undoStack,
// 批次提交也是一次真实编辑,重做栈同样截断
redoStack: shouldPushUndoEntry ? [] : (currentState.redoStack ?? []),
undoBatchDepth: 0,
undoBatchSnapshot: null,
undoBatchHasTrackedChanges: false,
@@ -3235,6 +3262,14 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
timeline: updateTimelineKeyframeValue(state.project.timeline, objectId, keyframeId, value),
},
})),
updateKeyframeEasing: (objectId, keyframeId, easing) =>
commitMutation((state) => ({
...state,
project: {
...state.project,
timeline: updateTimelineKeyframeEasing(state.project.timeline, objectId, keyframeId, easing),
},
})),
applyKeyframeToObjectTransform: (objectId, value) =>
commitMutation((state) => ({
...state,
@@ -3281,6 +3316,14 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
timeline: updateTimelineCameraKeyframeValue(state.project.timeline, cameraId, keyframeId, value),
},
})),
updateCameraKeyframeEasing: (cameraId, keyframeId, easing) =>
commitMutation((state) => ({
...state,
project: {
...state.project,
timeline: updateTimelineCameraKeyframeEasing(state.project.timeline, cameraId, keyframeId, easing),
},
})),
setCameraKeyframeTimes: (cameraId, times) =>
commitMutation((state) => ({
...state,
@@ -3315,6 +3358,14 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
timeline: updateTimelinePlaybackSettings(state.project.timeline, patch),
},
})),
updateExportSettings: (patch) =>
commitMutation((state) => ({
...state,
project: {
...state.project,
exportSettings: { ...state.project.exportSettings, ...patch },
},
})),
clearAllKeyframes: () =>
commitMutation((state) => ({
...state,
@@ -3335,18 +3386,40 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
pasteClipboardObjects: () => commitMutation((state) => pasteClipboardEntries(state)),
undo: () => {
const currentState = get() as DirectorRuntimeState;
const previousState = currentState.undoStack[currentState.undoStack.length - 1];
if (!previousState) return;
const undoStack = currentState.undoStack;
if (undoStack.length === 0) return;
const previousState = undoStack[undoStack.length - 1];
// 被撤销的「当前状态」压进重做栈,供 redo 恢复(这正是之前缺失的能力)
const currentSnapshot = extractPersistedDirectorState(currentState);
const runtimeState = createRuntimeStateFromPersistedState(previousState);
set({
...runtimeState,
clipboard: currentState.clipboard,
clipboardPasteCount: currentState.clipboardPasteCount,
undoStack: currentState.undoStack.slice(0, -1),
undoStack: undoStack.slice(0, -1),
redoStack: trimUndoStack([...(currentState.redoStack ?? []), currentSnapshot]),
});
writePersistedDirectorState(previousState);
},
redo: () => {
const currentState = get() as DirectorRuntimeState;
const redoStack = currentState.redoStack ?? [];
if (redoStack.length === 0) return;
const nextState = redoStack[redoStack.length - 1];
// 被重做的「当前状态」压回撤销栈,重做链可继续来回切换
const currentSnapshot = extractPersistedDirectorState(currentState);
const runtimeState = createRuntimeStateFromPersistedState(nextState);
set({
...runtimeState,
clipboard: currentState.clipboard,
clipboardPasteCount: currentState.clipboardPasteCount,
undoStack: trimUndoStack([...currentState.undoStack, currentSnapshot]),
redoStack: redoStack.slice(0, -1),
});
writePersistedDirectorState(nextState);
},
openScopedScene: (scopeId) => {
const currentState = get() as DirectorRuntimeState;
setDirectorScenePersistenceScopeId(scopeId);

View File

@@ -760,7 +760,7 @@ textarea:focus-visible {
max-width: 100%;
}
.right-sidebar .right-inspector button.inspector-dropdown-trigger {
button.inspector-dropdown-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) 14px;
align-items: center;
@@ -785,20 +785,20 @@ textarea:focus-visible {
color 0.18s ease;
}
.right-sidebar .right-inspector button.inspector-dropdown-trigger:hover {
button.inspector-dropdown-trigger:hover {
border-color: rgb(var(--border-rgb) / 0.3);
color: rgb(var(--text-muted-rgb));
background: rgb(var(--text-rgb) / 0.1);
}
.right-sidebar .right-inspector button.inspector-dropdown-trigger:focus,
.right-sidebar .right-inspector button.inspector-dropdown-trigger:focus-visible {
button.inspector-dropdown-trigger:focus,
button.inspector-dropdown-trigger:focus-visible {
outline: none;
border-color: rgb(var(--border-rgb) / 0.35);
box-shadow: none;
}
.right-sidebar .right-inspector button.inspector-dropdown-trigger:active {
button.inspector-dropdown-trigger:active {
transform: none;
}
@@ -867,7 +867,7 @@ textarea:focus-visible {
color 0.16s ease;
}
.right-sidebar .right-inspector button.inspector-dropdown-option {
button.inspector-dropdown-option {
border: 0;
border-radius: 8px;
padding: 7px 12px;
@@ -883,8 +883,8 @@ textarea:focus-visible {
background: rgb(var(--text-rgb) / 0.05);
}
.right-sidebar .right-inspector button.inspector-dropdown-option:hover,
.right-sidebar .right-inspector button.inspector-dropdown-option:focus-visible {
button.inspector-dropdown-option:hover,
button.inspector-dropdown-option:focus-visible {
border: 0;
color: rgb(var(--text-rgb));
background: rgb(var(--text-rgb) / 0.05);
@@ -895,7 +895,7 @@ textarea:focus-visible {
background: transparent;
}
.right-sidebar .right-inspector button.inspector-dropdown-option.is-selected {
button.inspector-dropdown-option.is-selected {
color: rgb(var(--accent-rgb));
background: transparent;
}
@@ -3783,101 +3783,39 @@ textarea:focus-visible {
font-size: 11px;
font-weight: 600;
}
/* 自定义机位下拉(非原生 select):外层做定位锚点 */
.timeline-camera-select {
position: relative;
display: inline-flex;
}
.timeline-camera-select-trigger {
appearance: none;
-webkit-appearance: none;
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 0;
margin: 0;
border: 0;
background: transparent;
color: rgb(var(--text-rgb));
font: inherit;
font-weight: 600;
cursor: pointer;
outline: none;
}
.timeline-camera-select-trigger:focus-visible {
box-shadow: 0 0 0 1px rgb(var(--accent-rgb) / 0.5);
border-radius: 4px;
}
.timeline-camera-select-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-camera-select-chevron {
flex-shrink: 0;
color: rgb(var(--text-dim-rgb));
transition: transform 0.16s ease;
}
.timeline-camera-select-trigger[aria-expanded="true"] .timeline-camera-select-chevron {
transform: rotate(180deg);
}
/* 下拉面板:跟随主题配色,深色下选项文字正常可见 */
.timeline-camera-select-menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 60;
display: grid;
gap: 4px;
width: max-content;
min-width: 160px;
max-width: 240px;
max-height: 280px;
overflow-y: auto;
padding: 4px;
border: 1px solid rgb(var(--border-rgb) / 0.4);
border-radius: 8px;
background: rgb(var(--panel-rgb) / 0.96);
box-shadow: var(--ui-shadow-panel);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
}
.timeline-camera-select-option {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-height: 32px;
border: 0;
border-radius: 8px;
padding: 6px 10px;
background: transparent;
color: rgb(var(--text-muted-rgb));
box-shadow: none;
font-size: 12px;
font-weight: 500;
line-height: 17px;
text-align: left;
cursor: pointer;
transition:
background-color 0.16s ease,
color 0.16s ease;
}
.timeline-camera-select-option:hover,
.timeline-camera-select-option:focus-visible {
color: rgb(var(--text-rgb));
background: rgb(var(--text-rgb) / 0.05);
}
.timeline-camera-select-option.is-selected {
color: rgb(var(--accent-rgb));
}
.timeline-camera-select-option span {
/* 时间轴工具栏里的「当前机位」选择器:外层 timeline-camera-selector 已是统一边框容器
(图标 + 文案 + 下拉),内部 InspectorDropdown 不再单独画边框/背景,避免双重边框冲突,
仅保留触发文字 + 箭头并贴合外层样式 */
.timeline-camera-dropdown.inspector-dropdown {
width: auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-camera-dropdown .inspector-dropdown-trigger {
width: auto;
min-height: 0;
height: auto;
padding: 0 4px;
border: 0;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
box-shadow: none;
}
.timeline-camera-dropdown .inspector-dropdown-trigger:hover {
border: 0;
background: rgb(var(--text-rgb) / 0.08);
color: rgb(var(--text-rgb));
}
.timeline-camera-dropdown .inspector-dropdown-trigger:focus,
.timeline-camera-dropdown .inspector-dropdown-trigger:focus-visible {
outline: none;
border: 0;
box-shadow: none;
}
.timeline-camera-dropdown .inspector-dropdown-menu {
width: 220px;
max-width: min(240px, calc(100vw - 24px));
}
/* 时间轴设置弹窗:遮罩 + 卡片。草稿值只在点「完成」时才写回工程 */
@@ -3892,7 +3830,7 @@ textarea:focus-visible {
.timeline-settings-dialog {
display: grid;
gap: 14px;
width: 280px;
width: 560px;
padding: 18px;
border: 1px solid rgb(var(--border-rgb) / 0.4);
border-radius: 16px;
@@ -3992,6 +3930,17 @@ textarea:focus-visible {
color: rgb(var(--text-dim-rgb));
font-size: 11px;
}
.timeline-settings-divider {
height: 1px;
margin: 6px 0 2px;
background: rgb(var(--border-rgb) / 0.4);
}
.timeline-settings-subtitle {
margin: 4px 0 2px;
color: rgb(var(--text-muted-rgb));
font-size: 12px;
font-weight: 700;
}
.timeline-settings-toggle {
display: flex;
align-items: center;
@@ -4091,6 +4040,40 @@ textarea:focus-visible {
opacity: 0.42;
cursor: not-allowed;
}
/* ---- 选中关键帧时的缓动曲线选择 ---- */
.timeline-easing-control {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px 0 10px;
border: 1px solid rgb(var(--border-rgb) / 0.35);
border-radius: 9px;
background: rgb(var(--surface-rgb) / 0.5);
color: rgb(var(--text-muted-rgb));
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.timeline-easing-label {
pointer-events: none;
}
.timeline-easing-select {
appearance: none;
border: none;
background: transparent;
color: rgb(var(--text-rgb));
font-size: 12px;
font-weight: 600;
line-height: 1;
padding: 4px 2px;
cursor: pointer;
outline: none;
}
.timeline-easing-select:focus-visible {
color: rgb(var(--accent-rgb));
}
/* ---- 导出:一个按钮 + 按下后选择视角 ---- */
.timeline-export {
position: relative;
@@ -4798,6 +4781,18 @@ body.is-timeline-dragging * {
0 0 0 1px rgb(var(--panel-rgb) / 0.9),
0 0 0 3px rgb(242 169 0 / 0.28);
}
/* 非默认(非线性)缓动的关键帧:菱形角加一圈紫色描边,便于一眼看出哪段带了缓动 */
.timeline-keyframe.has-easing .timeline-keyframe-dot {
box-shadow:
0 0 0 1px rgb(var(--panel-rgb) / 0.75),
0 0 0 2px #c084fc;
}
.timeline-keyframe.has-easing.is-selected .timeline-keyframe-dot {
box-shadow:
0 0 0 1px rgb(var(--panel-rgb) / 0.9),
0 0 0 3px rgb(242 169 0 / 0.28),
0 0 0 4px #c084fc;
}
/* ===== 全屏画布 + 悬浮卡片 =====
画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。

View File

@@ -84,11 +84,11 @@ it("matches the provided right inspector layout dimensions and field styling", (
expect(css).toMatch(/\.inspector-text-input\s*\{[\s\S]*?width:\s*var\(--right-sidebar-content-width\);[\s\S]*?height:\s*40px;[\s\S]*?background:\s*rgb\(var\(--field-rgb\)\);[\s\S]*?border-radius:\s*8px;[\s\S]*?font-size:\s*12px;/);
expect(css).toMatch(/\.inspector-text-input:focus,\s*[\r\n]+\s*\.inspector-text-input:focus-visible\s*\{[\s\S]*?outline:\s*none;[\s\S]*?box-shadow:\s*0 0 0 1px rgb\(var\(--accent-rgb\) \/ 0\.45\);/);
expect(css).toMatch(/\.inspector-dropdown\s*\{[\s\S]*?position:\s*relative;[\s\S]*?width:\s*var\(--right-sidebar-content-width\);/);
expect(css).toMatch(/\.right-sidebar\s*\.right-inspector\s*button\.inspector-dropdown-trigger\s*\{[\s\S]*?display:\s*grid;[\s\S]*?grid-template-columns:\s*minmax\(0,\s*1fr\) 14px;[\s\S]*?width:\s*var\(--right-sidebar-content-width\);[\s\S]*?min-height:\s*36px;[\s\S]*?border:\s*1px solid rgb\(var\(--border-rgb\) \/ 0\.35\);[\s\S]*?border-radius:\s*8px;[\s\S]*?padding:\s*8px 12px;[\s\S]*?background:\s*rgb\(var\(--surface-rgb\)\);[\s\S]*?font-size:\s*12px;/);
expect(css).toMatch(/button\.inspector-dropdown-trigger\s*\{[\s\S]*?display:\s*grid;[\s\S]*?grid-template-columns:\s*minmax\(0,\s*1fr\) 14px;[\s\S]*?width:\s*var\(--right-sidebar-content-width\);[\s\S]*?min-height:\s*36px;[\s\S]*?border:\s*1px solid rgb\(var\(--border-rgb\) \/ 0\.35\);[\s\S]*?border-radius:\s*8px;[\s\S]*?padding:\s*8px 12px;[\s\S]*?background:\s*rgb\(var\(--surface-rgb\)\);[\s\S]*?font-size:\s*12px;/);
expect(css).toMatch(/\.inspector-dropdown-chevron\s*\{[\s\S]*?width:\s*14px;[\s\S]*?height:\s*14px;[\s\S]*?color:\s*rgb\(var\(--text-dim-rgb\)\);/);
expect(css).toMatch(/\.inspector-dropdown-menu\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?top:\s*calc\(100% \+ 8px\);[\s\S]*?width:\s*var\(--right-sidebar-content-width\);[\s\S]*?max-height:\s*288px;[\s\S]*?border:\s*1px solid rgb\(var\(--border-rgb\) \/ 0\.4\);[\s\S]*?border-radius:\s*8px;[\s\S]*?background:\s*rgb\(var\(--panel-rgb\) \/ 0\.78\);[\s\S]*?backdrop-filter:\s*blur\(32px\);/);
expect(css).toMatch(/\.inspector-dropdown-option\s*\{[\s\S]*?width:\s*calc\(100% - 8px\);[\s\S]*?min-height:\s*34px;[\s\S]*?margin:\s*0 4px;[\s\S]*?border-radius:\s*8px;[\s\S]*?padding:\s*7px 12px;[\s\S]*?font-size:\s*12px;[\s\S]*?color:\s*rgb\(var\(--text-muted-rgb\)\);/);
expect(css).toMatch(/\.right-sidebar\s*\.right-inspector\s*button\.inspector-dropdown-option\s*\{[\s\S]*?border:\s*0;[\s\S]*?border-radius:\s*8px;[\s\S]*?transform:\s*none;/);
expect(css).toMatch(/button\.inspector-dropdown-option\s*\{[\s\S]*?border:\s*0;[\s\S]*?border-radius:\s*8px;[\s\S]*?transform:\s*none;/);
expect(css).toMatch(/\.inspector-dropdown-option:hover,\s*[\r\n]+\s*\.inspector-dropdown-option:focus-visible\s*\{[\s\S]*?color:\s*rgb\(var\(--text-rgb\)\);[\s\S]*?background:\s*rgb\(var\(--text-rgb\) \/ 0\.05\);/);
expect(css).toMatch(/\.inspector-dropdown-option\.is-selected\s*\{[\s\S]*?color:\s*rgb\(var\(--accent-rgb\)\);[\s\S]*?background:\s*transparent;/);
expect(css).toMatch(/\.inspector-axis-group\s*\{[\s\S]*?gap:\s*10px;/);