/** Record the rendered camera path, never an AI approximation. */ export async function recordDirectorVideo({ canvas, duration, render, signal, onProgress, }: { canvas: HTMLCanvasElement; duration: number; render: (time: number) => void; signal: AbortSignal; onProgress?: (time: number) => void; }): Promise { const mimeType = [ "video/mp4", "video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", ].find((t) => typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t)); if (!mimeType || !canvas.captureStream) throw Error("当前浏览器不支持视频录制,请更换支持视频录制的浏览器"); if (signal.aborted || document.hidden) throw Error("请保持当前页面可见后导出"); // Let WebGL present the first frame before opening the encoder stream. render(0); await new Promise((resolve, reject) => { let frame = 0; const finish = (error?: Error) => { cancelAnimationFrame(frame); clearTimeout(timeout); signal.removeEventListener("abort", cancel); document.removeEventListener("visibilitychange", hidden); if (error) reject(error); else resolve(); }; const cancel = () => finish(Error("已取消轨迹视频导出")); const hidden = () => { if (document.hidden) finish(Error("导出时切换了页面,请回到当前页面重新导出")); }; const timeout = setTimeout(() => finish(Error("画面准备超时,请保持页面可见后重试")), 2000); signal.addEventListener("abort", cancel, { once: true }); document.addEventListener("visibilitychange", hidden); frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => finish()); }); }); if (signal.aborted || document.hidden) throw Error("请保持当前页面可见后导出"); const stream = canvas.captureStream(30); try { return await new Promise((resolve, reject) => { const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 6_000_000, }); const chunks: Blob[] = []; let frame = 0, started = 0, failure: Error | undefined, finished = false; const cleanup = () => { cancelAnimationFrame(frame); clearTimeout(timeout); signal.removeEventListener("abort", cancel); document.removeEventListener("visibilitychange", visibility); }; const stop = (error?: Error) => { if (finished) return; finished = true; failure = error; cleanup(); if (recorder.state !== "inactive") recorder.stop(); else reject(error || Error("视频录制未启动")); }; const cancel = () => stop(Error("已取消轨迹视频导出")); const visibility = () => { if (document.hidden) stop(Error("导出时切换了页面,请回到当前页面重新导出")); }; const timeout = setTimeout( () => stop(Error("轨迹导出超时,请重试")), duration * 1000 + 15000, ); recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); }; recorder.onerror = () => stop(Error("浏览器视频编码失败")); recorder.onstop = () => { cleanup(); if (failure) reject(failure); else if (!chunks.length) reject(Error("未生成有效视频")); else resolve(new Blob(chunks, { type: recorder.mimeType })); }; signal.addEventListener("abort", cancel, { once: true }); document.addEventListener("visibilitychange", visibility); const tick = (now: number) => { if (finished) return; if (!started) started = now; const t = Math.min(duration, (now - started) / 1000); try { render(t); onProgress?.(t); } catch (e) { stop(e instanceof Error ? e : Error("轨迹渲染失败")); return; } // MediaRecorder consumes the canvas asynchronously. Stopping in the rendering // callback drops the final pose; allow two presentation frames to flush it. if (t >= duration) frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => stop()); }); else frame = requestAnimationFrame(tick); }; try { render(0); recorder.start(100); frame = requestAnimationFrame(tick); } catch (e) { stop(e instanceof Error ? e : Error("视频编码失败")); } }); } finally { stream.getTracks().forEach((t) => t.stop()); } }