feat(timeline): 支持拖拽调整时长与运镜/轨迹关键帧时间
This commit is contained in:
46
src/editor/io/videoExport.test.ts
Normal file
46
src/editor/io/videoExport.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user