121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
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<HTMLCanvasElement>(".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<void> {
|
||
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<void> {
|
||
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<void>((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}`);
|
||
}
|