Files
ttstd-3d-director/src/editor/io/videoExport.ts
LKH 3b053c714c init
all

视频

视频

视频

视频

视频

init
2026-07-29 09:17:00 +08:00

121 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
}