feat: 新增导出设置、过渡与缓动及重做支持
This commit is contained in:
26
agents.md
Normal file
26
agents.md
Normal 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 在深色下选项文字/配色不可控)。
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
112
src/editor/components/InspectorDropdown.tsx
Normal file
112
src/editor/components/InspectorDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
// 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
// 该机位没有运镜轨道时,把机位「自身视角」直接写进渲染相机:
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ===== 全屏画布 + 悬浮卡片 =====
|
||||
画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。
|
||||
|
||||
@@ -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;/);
|
||||
|
||||
Reference in New Issue
Block a user