Files
ttstd-3d-director/src/editor/io/videoExport.ts
2026-09-18 00:52:28 +08:00

389 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { flushSync } from "react-dom";
import { ArrayBufferTarget, Muxer } from "mp4-muxer";
import { Vector2, type PerspectiveCamera } from "three";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { getVideoExportContext } from "./videoExportBridge";
import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
import { getCameraViewSnapshotFromShot, type CameraViewSnapshot } from "../schema/cameraGeometry";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
import { normalizeTimelineFps } from "../schema/timeline";
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 = [
{ mimeType: "video/mp4;codecs=avc1", ext: "mp4" },
{ mimeType: "video/webm;codecs=vp9", ext: "webm" },
{ mimeType: "video/webm;codecs=vp8", ext: "webm" },
{ mimeType: "video/webm", ext: "webm" },
];
return (
candidates.find((item) => {
try {
return MediaRecorder.isTypeSupported(item.mimeType);
} catch {
return false;
}
}) ?? { mimeType: "", ext: "webm" }
);
}
function findViewportCanvas(): HTMLCanvasElement | null {
return document.querySelector<HTMLCanvasElement>(".director-canvas canvas");
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function waitNextFrame(): Promise<void> {
return new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
}
/**
* 机位没有运镜轨道时的兜底:把「这个机位自己的视角」直接写到导出相机上。
*
* 机位视角下 applyActiveCameraViewAtTime 只在有运镜轨道时才覆盖相机(无轨道直接 return false),
* 也就是说无轨道时导出画面完全取决于「此刻 R3F 的默认相机是谁」。默认相机本来应该是
* 机位视角挂载的那颗 drei 相机,但换相机要等 React/R3F 走一轮,稍有差池就会渲成
* 导演视角(默认更高、更远)—— 所以这里不再赌它,直接按机位自身的视角写死。
*/
function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: CameraViewSnapshot) {
camera.position.set(view.position[0], view.position[1], view.position[2]);
camera.lookAt(view.target[0], view.target[1], view.target[2]);
if (Math.abs(camera.fov - view.fov) > 0.001) {
camera.fov = view.fov;
}
camera.updateProjectionMatrix();
}
/**
* 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。
* 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响,
* 否则视口是扁的时导出也会跟着扁。
*/
export function computeExportSize(
aspectRatioId: ReturnType<typeof useDirectorStore.getState>["viewportAspectRatio"]
): { width: number; height: number } {
const aspect = getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9;
let width: number;
let height: number;
if (aspect >= 1) {
height = EXPORT_SHORT_EDGE;
width = Math.round(height * aspect);
} else {
width = EXPORT_SHORT_EDGE;
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) {
const config: VideoEncoderConfig = {
codec,
width,
height,
bitrate,
framerate: fps,
};
try {
const support = await VideoEncoder.isConfigSupported(config);
if (support.supported) return config;
} catch {
// 继续尝试下一个候选
}
}
return null;
}
/**
* 逐帧离线导出(快,不用实时播放一遍):
* - 渲染分辨率固定为视频比例(短边 720),不受视口布局影响 —— 时间轴拖多高画面都不会变扁
* - 每帧直接 setPlayhead(t) + 手动渲染 + WebCodecs 编码,渲染多快导多快
* - H.264 编码进 MP4(Chrome / Electron 均支持)
*/
async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
const bridge = getVideoExportContext();
if (!bridge) throw new Error("找不到视口渲染上下文");
const { gl, scene, camera } = bridge;
const canvas = gl.domElement;
const directorStore = useDirectorStore.getState();
const { width, height } = computeExportSize(directorStore.viewportAspectRatio);
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);
if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge");
// 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)。
// 播放状态不在这里恢复:exportVideo 统一收口成「导出后停住」。
const previousViewMode = directorStore.viewMode;
const previousPlayhead = usePlaybackStore.getState().playhead;
const previousSize = new Vector2();
gl.getSize(previousSize);
const previousPixelRatio = gl.getPixelRatio();
const previousAspect = camera.aspect;
// 兜底改写相机会动到位姿,导出结束要还回去(切回导演视角后不该变成机位的视角)
const previousCameraPosition = camera.position.clone();
const previousCameraQuaternion = camera.quaternion.clone();
const previousCameraFov = camera.fov;
const muxer = new Muxer({
target: new ArrayBufferTarget(),
video: { codec: "avc", width, height },
fastStart: "in-memory",
});
let encodeError: unknown = null;
const encoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (error) => {
encodeError = encodeError ?? error;
},
});
encoder.configure(encoderConfig);
// 切到导出状态:固定分辨率渲染(不改 CSS 尺寸,canvas 加 object-fit: contain 保持显示不变形)
usePlaybackStore.getState().setPlaying(false);
directorStore.setViewMode(view);
canvas.classList.add("is-video-exporting");
gl.setPixelRatio(1);
gl.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
// 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera)。
// 必须等一整帧:换相机要 React 重渲染 + R3F 换默认相机 + VideoExportBridge 重新注册,
// 只等一个宏任务不够(截图路径同样要等一帧)。导出循环里也每帧取最新引用兜底。
await waitNextFrame();
// 切完视角再取当前机位:setViewMode("camera") 会在 activeCameraId 为空时补上第一个机位,
// 所以不能用上面的 directorStore 快照(那时可能还是 null)。
const exportState = useDirectorStore.getState();
const activeCameraShot =
view === "camera"
? exportState.project.cameras.find((item) => item.id === exportState.project.activeCameraId) ?? null
: null;
const activeCameraView = activeCameraShot ? getCameraViewSnapshotFromShot(activeCameraShot) : null;
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 / fps, duration);
// 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例
const frameCamera = getVideoExportContext()?.camera ?? camera;
frameCamera.aspect = width / height;
frameCamera.updateProjectionMatrix();
// 同步推进 playhead 让 React 侧内容(角色动作等)立刻更新到 t
flushSync(() => {
usePlaybackStore.getState().setPlayhead(time);
});
// 手动应用物体 / 相机运镜插值(与预览的 useFrame 同步逻辑共用)
applyTimelineTransformsAtTime(
scene,
directorStore.project.timeline.tracks,
directorStore.project.timeline.cameraTracks,
time,
usePlaybackStore.getState().editingObjectId,
{ objects: directorStore.project.objects, duration }
);
// 有运镜轨道时两边共用同一份插值(预览 / 导出一致);返回 false 表示没覆盖相机
const appliedCameraTrack = applyActiveCameraViewAtTime({
camera: frameCamera,
cameraTracks: directorStore.project.timeline.cameraTracks,
activeCameraId: exportState.project.activeCameraId,
playhead: time,
viewMode: view,
editingObjectId: usePlaybackStore.getState().editingObjectId,
});
// 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机,
// 否则画面取决于「此刻默认相机是谁」,换相机稍有差池就渲成导演视角(更高、更远)。
if (!appliedCameraTrack && activeCameraView) {
applyCameraSnapshotToFrameCamera(frameCamera, activeCameraView);
}
// 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来,
// 否则导出的视频里会带着选中的 gizmo 三条轴。渲完 withViewportCaptureHelpersHidden 会原样恢复。
withViewportCaptureHelpersHidden(scene, () => gl.render(scene, frameCamera));
const frame = new VideoFrame(canvas, {
timestamp: frameIndex * frameDurationUs,
duration: frameDurationUs,
});
encoder.encode(frame, { keyFrame: frameIndex % (fps * 2) === 0 });
frame.close();
// 背压:编码队列太深时等一等,防止内存堆积
while (encoder.encodeQueueSize > 8) {
await new Promise<void>((resolve) => window.setTimeout(resolve, 4));
}
// 让出主线程,保持界面可响应(进度画面快进效果)
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
}
await encoder.flush();
muxer.finalize();
if (encodeError) throw encodeError;
const blob = new Blob([muxer.target.buffer], { type: "video/mp4" });
const viewLabel = view === "director" ? "上帝视角" : "机位视角";
downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.mp4`);
} finally {
encoder.close();
canvas.classList.remove("is-video-exporting");
gl.setPixelRatio(previousPixelRatio);
gl.setSize(previousSize.x, previousSize.y, false);
// 导出期间相机可能换过(切视角 → drei 挂载机位相机),两颗都按导出前的样子还原:
// - aspect 两颗都要还,否则切回导演视角后画面比例还停在导出的 16:9;
// - 位姿只在「导出用的就是导出前那颗相机」时还,否则等于把导演相机的位姿
// 写到机位相机上,机位视角就会被带歪。
const restoreCamera = getVideoExportContext()?.camera ?? camera;
restoreCamera.aspect = previousAspect;
restoreCamera.updateProjectionMatrix();
camera.aspect = previousAspect;
camera.updateProjectionMatrix();
if (restoreCamera === camera) {
restoreCamera.position.copy(previousCameraPosition);
restoreCamera.quaternion.copy(previousCameraQuaternion);
restoreCamera.fov = previousCameraFov;
restoreCamera.updateProjectionMatrix();
}
directorStore.setViewMode(previousViewMode);
usePlaybackStore.getState().setPlayhead(previousPlayhead);
}
}
/**
* 旧版实时录制(MediaRecorder 兜底路径):只在浏览器不支持 WebCodecs 时使用。
* 从 playhead 0 播放到时间轴末尾录屏,耗时 = 视频时长。
*/
async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void> {
const canvas = findViewportCanvas();
if (!canvas) throw new Error("找不到视口画布");
if (typeof MediaRecorder === "undefined") {
throw new Error("当前浏览器不支持视频录制(MediaRecorder),请用 Chrome/Edge/Firefox");
}
const { mimeType, ext } = pickMimeType();
const fps = 30;
const stream = canvas.captureStream(fps);
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) chunks.push(event.data);
};
const directorStore = useDirectorStore.getState();
const duration = directorStore.project.timeline.duration;
// 实时录屏兜底路径录的是「实时画面」,整段录制期间都要藏住编辑器辅助元素
//(gizmo 红绿蓝轴 / 网格 / 坐标轴等)—— 不能像逐帧路径那样只在某一帧渲染时隐藏。
const captureBridge = getVideoExportContext();
const restoreCaptureHelpers = captureBridge ? hideViewportCaptureHelpers(captureBridge.scene) : () => {};
// 切视角 + 复位指针(时间轴本身就是单次播放,到末尾会自己停住)
directorStore.setViewMode(view);
usePlaybackStore.getState().setPlayhead(0);
const stopped = new Promise<void>((resolve) => {
recorder.onstop = () => {
restoreCaptureHelpers();
resolve();
};
});
recorder.start();
// 等两帧让视角切换 + 首帧渲染,避免录到上一视角的残影
await waitNextFrame();
// 监听播放结束(播到末帧 → isPlaying 置 false)→ 停止录制
const unsubscribe = usePlaybackStore.subscribe((state) => {
if (!state.isPlaying) {
try {
if (recorder.state !== "inactive") recorder.stop();
} catch {
/* ignore */
}
}
});
usePlaybackStore.getState().setPlaying(true);
// 保底:duration + 余量后强制停(防止 advance 因帧率/时序没准确触发)
const fallbackTimer = window.setTimeout(() => {
try {
if (recorder.state !== "inactive") recorder.stop();
} catch {
/* ignore */
}
}, duration * 1000 + 600);
await stopped;
clearTimeout(fallbackTimer);
unsubscribe();
usePlaybackStore.getState().setPlaying(false);
const viewLabel = view === "director" ? "上帝视角" : "机位视角";
const blob = new Blob(chunks, { type: mimeType || "video/webm" });
downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.${ext}`);
}
/**
* 导出视频:优先 WebCodecs 逐帧快速导出(MP4,固定分辨率,不受视口布局影响);
* 浏览器不支持时回退 MediaRecorder 实时录屏(WebM/MP4,耗时 = 视频时长)。
*
* 不管哪条路径,导出期间都强制关掉「播放循环」——导出永远是「一次性播完」:
* 否则录屏兜底路径会录到回卷的一段,逐帧路径也会把「正在循环播放」的状态带回视口。
* 导出结束时一律停住(不恢复导出前的播放状态,否则开着循环就会一直播下去),
* 只把指针放回导出前的位置,循环设置原样还回去。
*/
export async function exportVideo(view: VideoExportView): Promise<void> {
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);
}
}