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

This commit is contained in:
tongtong committed 2026-09-19 04:41:48 +08:00
1 parent 482d649f84
commit 2c59709f6b
18 files changed
+1174 -351

No files matched your search

+37 -18
View File
@@ -9,12 +9,10 @@ import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../r
import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, type CameraViewSnapshot } from "../schema/cameraGeometry";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
import { getActiveCameraIdAtTime, normalizeTimelineFps } from "../schema/timeline";
import { EXPORT_CODEC_OPTIONS, EXPORT_QUALITY_OPTIONS, type ExportCodecPreference, type ExportSettings } from "../schema/directorProject";
export type VideoExportView = "director" | "camera";
/** 导出分辨率按「短边 = 720」基准缩放,画面比例取自 viewportAspectRatio(auto 时用视口当前比例) */
const EXPORT_SHORT_EDGE = 720;
function pickMimeType(): { mimeType: string; ext: string } {
if (typeof MediaRecorder === "undefined") return { mimeType: "", ext: "webm" };
const candidates = [
@@ -74,34 +72,51 @@ function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: Camer
}
/**
* 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。
* 按视频比例(viewportAspectRatio)计算导出尺寸,短边取自导出设置(默认 720),宽高取偶(编码器要求)。
* 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响,
* 否则视口是扁的时导出也会跟着扁。
*/
export function computeExportSize(
aspectRatioId: ReturnType<typeof useDirectorStore.getState>["viewportAspectRatio"]
aspectRatioId: ReturnType<typeof useDirectorStore.getState>["viewportAspectRatio"],
resolutionShortEdge: number
): { width: number; height: number } {
const aspect = getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9;
let width: number;
let height: number;
if (aspect >= 1) {
height = EXPORT_SHORT_EDGE;
height = resolutionShortEdge;
width = Math.round(height * aspect);
} else {
width = EXPORT_SHORT_EDGE;
width = resolutionShortEdge;
height = Math.round(width / aspect);
}
return { width: width + (width % 2), height: height + (height % 2) };
}
async function pickVideoEncoderConfig(width: number, height: number, fps: number) {
const candidates = [
{ codec: "avc1.640028" }, // High 4.0
{ codec: "avc1.4d0028" }, // Main 4.0
{ codec: "avc1.42001f" }, // Baseline 3.1
];
const bitrate = Math.round(width * height * fps * 0.15); // ≈6Mbps @720p30
for (const { codec } of candidates) {
/** 按画质档位算目标码率(custom 时直接用手填值)。统一 H.264,这里只影响码率大小。 */
function computeExportBitrate(width: number, height: number, fps: number, settings: ExportSettings): number {
if (settings.quality === "custom") return Math.max(100_000, Math.round(settings.customBitrate));
const opt = EXPORT_QUALITY_OPTIONS.find((item) => item.value === settings.quality);
const multiplier = opt?.bitrateMultiplier ?? 0.15;
return Math.round(width * height * fps * multiplier);
}
/** 编码档位 → 实际要尝试的 H.264 codec 串列表(auto 时从高规格往下兜底) */
function buildCodecCandidates(pref: ExportCodecPreference): string[] {
const chosen = EXPORT_CODEC_OPTIONS.find((item) => item.value === pref);
if (chosen?.codec) return [chosen.codec];
return EXPORT_CODEC_OPTIONS.filter((item) => item.codec).map((item) => item.codec as string);
}
async function pickVideoEncoderConfig(
width: number,
height: number,
fps: number,
settings: ExportSettings
): Promise<VideoEncoderConfig | null> {
const bitrate = computeExportBitrate(width, height, fps, settings);
const candidates = buildCodecCandidates(settings.codec);
for (const codec of candidates) {
const config: VideoEncoderConfig = {
codec,
width,
@@ -121,7 +136,8 @@ async function pickVideoEncoderConfig(width: number, height: number, fps: number
/**
* 逐帧离线导出(快,不用实时播放一遍):
* - 渲染分辨率固定为视频比例(短边 720),不受视口布局影响 —— 时间轴拖多高画面都不会变扁
* - 渲染分辨率 / 码率 / 编码档位取自工程里的导出设置,且固定为视频比例,不受视口布局影响
* —— 时间轴拖多高画面都不会变扁
* - 每帧直接 setPlayhead(t) + 手动渲染 + WebCodecs 编码,渲染多快导多快
* - H.264 编码进 MP4(Chrome / Electron 均支持)
*/
@@ -132,13 +148,14 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
const canvas = gl.domElement;
const directorStore = useDirectorStore.getState();
const { width, height } = computeExportSize(directorStore.viewportAspectRatio);
const exportSettings = directorStore.project.exportSettings;
const { width, height } = computeExportSize(directorStore.viewportAspectRatio, exportSettings.resolutionShortEdge);
const duration = directorStore.project.timeline.duration;
// 帧率取自工程设置(时间轴设置里的「每秒帧数」),导出与读数保持一致
const fps = normalizeTimelineFps(directorStore.project.timeline.fps);
const totalFrames = Math.max(1, Math.round(duration * fps));
const encoderConfig = await pickVideoEncoderConfig(width, height, fps);
const encoderConfig = await pickVideoEncoderConfig(width, height, fps, exportSettings);
if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge");
// 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)。
@@ -227,6 +244,8 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
playhead: time,
viewMode: view,
editingObjectId: usePlaybackStore.getState().editingObjectId,
transition: directorStore.project.timeline.cameraTransition,
fallbackCameraId: directorStore.project.activeCameraId,
});
// 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机,