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(".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 { 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["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 { 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((resolve) => window.setTimeout(resolve, 4)); } // 让出主线程,保持界面可响应(进度画面快进效果) await new Promise((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 { 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((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 { 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); } }