feat(timeline): 支持拖拽调整时长与运镜/轨迹关键帧时间

This commit is contained in:
2026-09-17 17:30:22 +08:00
parent bab92c05b8
commit 8d67f17a84
21 changed files with 2556 additions and 194 deletions

View File

@@ -0,0 +1,46 @@
import { beforeEach, expect, it } from "vitest";
import { usePlaybackStore } from "../runtime/playbackStore";
import { exportVideo } from "./videoExport";
beforeEach(() => {
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, loop: false, editingObjectId: null });
});
/**
* jsdom 里既没有视口画布也没有 WebCodecs,导出会立刻失败 ——
* 但这里要测的正是「导出期间 / 导出之后」的播放状态收口,与导出是否成功无关。
*/
it("turns the playback loop off while exporting and puts it back afterwards", async () => {
usePlaybackStore.getState().setLoop(true);
const loopHistory: boolean[] = [];
const unsubscribe = usePlaybackStore.subscribe((state) => loopHistory.push(state.loop));
await expect(exportVideo("director")).rejects.toThrow();
unsubscribe();
// 导出一开始就关掉循环:导出永远只播一遍,不会录到回卷的一段
expect(loopHistory[0]).toBe(false);
// 导出结束后循环设置原样还回去
expect(usePlaybackStore.getState().loop).toBe(true);
});
it("stops playback after an export instead of resuming an endless loop", async () => {
// 导出前正在播放,而且开着循环 —— 这就是「播完一直循环播放」的现场
usePlaybackStore.getState().setLoop(true);
usePlaybackStore.getState().setPlaying(true);
await expect(exportVideo("director")).rejects.toThrow();
const state = usePlaybackStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.loop).toBe(true);
});
it("keeps the playhead where the export found it", async () => {
usePlaybackStore.getState().setPlayhead(2.5);
await expect(exportVideo("director")).rejects.toThrow();
expect(usePlaybackStore.getState().playhead).toBe(2.5);
});

View File

@@ -6,10 +6,10 @@ import { usePlaybackStore } from "../runtime/playbackStore";
import { getVideoExportContext } from "./videoExportBridge";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
import { normalizeTimelineFps } from "../schema/timeline";
export type VideoExportView = "director" | "camera";
const EXPORT_FPS = 30;
/** 导出分辨率按「短边 = 720」基准缩放,画面比例取自 viewportAspectRatio(auto 时用视口当前比例) */
const EXPORT_SHORT_EDGE = 720;
@@ -75,20 +75,20 @@ export function computeExportSize(
return { width: width + (width % 2), height: height + (height % 2) };
}
async function pickVideoEncoderConfig(width: number, height: number) {
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 * EXPORT_FPS * 0.15); // ≈6Mbps @720p30
const bitrate = Math.round(width * height * fps * 0.15); // ≈6Mbps @720p30
for (const { codec } of candidates) {
const config: VideoEncoderConfig = {
codec,
width,
height,
bitrate,
framerate: EXPORT_FPS,
framerate: fps,
};
try {
const support = await VideoEncoder.isConfigSupported(config);
@@ -115,15 +115,17 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
const directorStore = useDirectorStore.getState();
const { width, height } = computeExportSize(directorStore.viewportAspectRatio);
const duration = directorStore.project.timeline.duration;
const totalFrames = Math.max(1, Math.round(duration * EXPORT_FPS));
// 帧率取自工程设置(时间轴设置里的「每秒帧数」),导出与读数保持一致
const fps = normalizeTimelineFps(directorStore.project.timeline.fps);
const totalFrames = Math.max(1, Math.round(duration * fps));
const encoderConfig = await pickVideoEncoderConfig(width, height);
const encoderConfig = await pickVideoEncoderConfig(width, height, fps);
if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge");
// 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)
// 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)。
// 播放状态不在这里恢复:exportVideo 统一收口成「导出后停住」。
const previousViewMode = directorStore.viewMode;
const previousPlayhead = usePlaybackStore.getState().playhead;
const previousPlaying = usePlaybackStore.getState().isPlaying;
const previousSize = new Vector2();
gl.getSize(previousSize);
const previousPixelRatio = gl.getPixelRatio();
@@ -156,11 +158,11 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
// 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
const frameDurationUs = Math.round(1_000_000 / EXPORT_FPS);
const frameDurationUs = Math.round(1_000_000 / fps);
try {
for (let frameIndex = 0; frameIndex <= totalFrames; frameIndex += 1) {
if (encodeError) throw encodeError;
const time = Math.min(frameIndex / EXPORT_FPS, duration);
const time = Math.min(frameIndex / fps, duration);
// 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例
const frameCamera = getVideoExportContext()?.camera ?? camera;
frameCamera.aspect = width / height;
@@ -194,7 +196,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
timestamp: frameIndex * frameDurationUs,
duration: frameDurationUs,
});
encoder.encode(frame, { keyFrame: frameIndex % (EXPORT_FPS * 2) === 0 });
encoder.encode(frame, { keyFrame: frameIndex % (fps * 2) === 0 });
frame.close();
// 背压:编码队列太深时等一等,防止内存堆积
@@ -221,7 +223,6 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
restoreCamera.aspect = previousAspect;
restoreCamera.updateProjectionMatrix();
directorStore.setViewMode(previousViewMode);
usePlaybackStore.getState().setPlaying(previousPlaying);
usePlaybackStore.getState().setPlayhead(previousPlayhead);
}
}
@@ -298,11 +299,25 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void
/**
* 导出视频:优先 WebCodecs 逐帧快速导出(MP4,固定分辨率,不受视口布局影响);
* 浏览器不支持时回退 MediaRecorder 实时录屏(WebM/MP4,耗时 = 视频时长)。
*
* 不管哪条路径,导出期间都强制关掉「播放循环」——导出永远是「一次性播完」:
* 否则录屏兜底路径会录到回卷的一段,逐帧路径也会把「正在循环播放」的状态带回视口。
* 导出结束时一律停住(不恢复导出前的播放状态,否则开着循环就会一直播下去),
* 只把指针放回导出前的位置,循环设置原样还回去。
*/
export async function exportVideo(view: VideoExportView): Promise<void> {
if (typeof VideoEncoder !== "undefined") {
await exportVideoFrameByFrame(view);
return;
const previousLoop = usePlaybackStore.getState().loop;
usePlaybackStore.getState().setLoop(false);
try {
if (typeof VideoEncoder !== "undefined") {
await exportVideoFrameByFrame(view);
return;
}
await exportVideoWithMediaRecorder(view);
} finally {
const playback = usePlaybackStore.getState();
playback.setLoop(previousLoop);
playback.setPlaying(false);
}
await exportVideoWithMediaRecorder(view);
}