import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; export type VideoExportView = "director" | "camera"; 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())) ); } /** * 录制视口画面为视频: * - 切到指定视角(director=上帝视角 / camera=机位视角) * - 从 playhead 0 播放到时间轴末尾(stopAtEnd,不循环) * - 优先 MP4(Chrome 120+ / Safari),不支持时回退 WebM(Chrome / Firefox) * - 录完自动下载 */ export async function exportVideo(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; // 切视角 + 复位指针 + 录制模式(到末尾停住不循环) directorStore.setViewMode(view); const playback = usePlaybackStore.getState(); playback.setStopAtEnd(true); playback.setPlayhead(0); const stopped = new Promise((resolve) => { recorder.onstop = () => resolve(); }); recorder.start(); // 等两帧让视角切换 + 首帧渲染,避免录到上一视角的残影 await waitNextFrame(); // 监听播放结束(stopAtEnd → 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().setStopAtEnd(false); 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}`); }