ai-chatbot/lib/video-canvas/director-recording.ts

125 lines
4.5 KiB
TypeScript

/** 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<Blob> {
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<void>((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<Blob>((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());
}
}