389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
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);
|
||
}
|
||
}
|