125 lines
4.5 KiB
TypeScript
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());
|
|
}
|
|
}
|