视频导出、时间轴与动作姿势模块

- 视频导出:WebCodecs 逐帧快速导出 MP4(固定分辨率不随视口布局变形),MediaRecorder 兜底
- 时间轴:面板可拖动调高(持久化、固定上限)、关键帧轨道区内滚动、无横向滚动条
- 动作模块:走路/跑步步态曲线(对侧摆臂修复、跑步独立步频、跑姿屈膝曲线、参考校准参数)
- 姿势调节:两级折叠分组、动作偏移联动
- 删除对象时同步清理时间轴轨道

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LXXandClaude Fable 5 committed 2026-09-02 10:36:03 +08:00
1 parent 3b053c714c
commit 59e68e444a
29 files changed
+3564 -166

No files matched your search

+196 -6
View File
@@ -1,8 +1,18 @@
import { flushSync } from "react-dom";
import { ArrayBufferTarget, Muxer } from "mp4-muxer";
import { Vector2 } from "three";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { getVideoExportContext } from "./videoExportBridge";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
export type VideoExportView = "director" | "camera";
const EXPORT_FPS = 30;
/** 导出分辨率按「短边 = 720」基准缩放,画面比例取自 viewportAspectRatio(auto 时用视口当前比例) */
const EXPORT_SHORT_EDGE = 720;
function pickMimeType(): { mimeType: string; ext: string } {
if (typeof MediaRecorder === "undefined") return { mimeType: "", ext: "webm" };
const candidates = [
@@ -45,13 +55,181 @@ function waitNextFrame(): Promise<void> {
}
/**
* 录制视口画面为视频:
* - 切到指定视角(director=上帝视角 / camera=机位视角)
* - 从 playhead 0 播放到时间轴末尾(stopAtEnd,不循环)
* - 优先 MP4(Chrome 120+ / Safari),不支持时回退 WebM(Chrome / Firefox)
* - 录完自动下载
* 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。
* 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响,
* 否则视口是扁的时导出也会跟着扁。
*/
export async function exportVideo(view: VideoExportView): Promise<void> {
export function computeExportSize(
aspectRatioId: ReturnType<typeof useDirectorStore.getState>["viewportAspectRatio"]
): { width: number; height: number } {
const aspect = getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9;
let width: number;
let height: number;
if (aspect >= 1) {
height = EXPORT_SHORT_EDGE;
width = Math.round(height * aspect);
} else {
width = EXPORT_SHORT_EDGE;
height = Math.round(width / aspect);
}
return { width: width + (width % 2), height: height + (height % 2) };
}
async function pickVideoEncoderConfig(width: number, height: number) {
const candidates = [
{ codec: "avc1.640028" }, // High 4.0
{ codec: "avc1.4d0028" }, // Main 4.0
{ codec: "avc1.42001f" }, // Baseline 3.1
];
const bitrate = Math.round(width * height * EXPORT_FPS * 0.15); // ≈6Mbps @720p30
for (const { codec } of candidates) {
const config: VideoEncoderConfig = {
codec,
width,
height,
bitrate,
framerate: EXPORT_FPS,
};
try {
const support = await VideoEncoder.isConfigSupported(config);
if (support.supported) return config;
} catch {
// 继续尝试下一个候选
}
}
return null;
}
/**
* 逐帧离线导出(快,不用实时播放一遍):
* - 渲染分辨率固定为视频比例(短边 720),不受视口布局影响 —— 时间轴拖多高画面都不会变扁
* - 每帧直接 setPlayhead(t) + 手动渲染 + WebCodecs 编码,渲染多快导多快
* - H.264 编码进 MP4(Chrome / Electron 均支持)
*/
async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
const bridge = getVideoExportContext();
if (!bridge) throw new Error("找不到视口渲染上下文");
const { gl, scene, camera } = bridge;
const canvas = gl.domElement;
const directorStore = useDirectorStore.getState();
const { width, height } = computeExportSize(directorStore.viewportAspectRatio);
const duration = directorStore.project.timeline.duration;
const totalFrames = Math.max(1, Math.round(duration * EXPORT_FPS));
const encoderConfig = await pickVideoEncoderConfig(width, height);
if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge");
// 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)
const previousViewMode = directorStore.viewMode;
const previousPlayhead = usePlaybackStore.getState().playhead;
const previousPlaying = usePlaybackStore.getState().isPlaying;
const previousSize = new Vector2();
gl.getSize(previousSize);
const previousPixelRatio = gl.getPixelRatio();
const previousAspect = camera.aspect;
const muxer = new Muxer({
target: new ArrayBufferTarget(),
video: { codec: "avc", width, height },
fastStart: "in-memory",
});
let encodeError: unknown = null;
const encoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (error) => {
encodeError = encodeError ?? error;
},
});
encoder.configure(encoderConfig);
// 切到导出状态:固定分辨率渲染(不改 CSS 尺寸,canvas 加 object-fit: contain 保持显示不变形)
usePlaybackStore.getState().setPlaying(false);
directorStore.setViewMode(view);
canvas.classList.add("is-video-exporting");
gl.setPixelRatio(1);
gl.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
// 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera),
// 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
const frameDurationUs = Math.round(1_000_000 / EXPORT_FPS);
try {
for (let frameIndex = 0; frameIndex <= totalFrames; frameIndex += 1) {
if (encodeError) throw encodeError;
const time = Math.min(frameIndex / EXPORT_FPS, duration);
// 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例
const frameCamera = getVideoExportContext()?.camera ?? camera;
frameCamera.aspect = width / height;
frameCamera.updateProjectionMatrix();
// 同步推进 playhead 让 React 侧内容(角色动作等)立刻更新到 t
flushSync(() => {
usePlaybackStore.getState().setPlayhead(time);
});
// 手动应用物体 / 相机运镜插值(与预览的 useFrame 同步逻辑共用)
applyTimelineTransformsAtTime(
scene,
directorStore.project.timeline.tracks,
directorStore.project.timeline.cameraTracks,
time,
usePlaybackStore.getState().editingObjectId
);
applyActiveCameraViewAtTime({
camera: frameCamera,
cameraTracks: directorStore.project.timeline.cameraTracks,
activeCameraId: directorStore.project.activeCameraId,
playhead: time,
viewMode: view,
editingObjectId: usePlaybackStore.getState().editingObjectId,
});
gl.render(scene, frameCamera);
const frame = new VideoFrame(canvas, {
timestamp: frameIndex * frameDurationUs,
duration: frameDurationUs,
});
encoder.encode(frame, { keyFrame: frameIndex % (EXPORT_FPS * 2) === 0 });
frame.close();
// 背压:编码队列太深时等一等,防止内存堆积
while (encoder.encodeQueueSize > 8) {
await new Promise<void>((resolve) => window.setTimeout(resolve, 4));
}
// 让出主线程,保持界面可响应(进度画面快进效果)
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
}
await encoder.flush();
muxer.finalize();
if (encodeError) throw encodeError;
const blob = new Blob([muxer.target.buffer], { type: "video/mp4" });
const viewLabel = view === "director" ? "上帝视角" : "机位视角";
downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.mp4`);
} finally {
encoder.close();
canvas.classList.remove("is-video-exporting");
gl.setPixelRatio(previousPixelRatio);
gl.setSize(previousSize.x, previousSize.y, false);
const restoreCamera = getVideoExportContext()?.camera ?? camera;
restoreCamera.aspect = previousAspect;
restoreCamera.updateProjectionMatrix();
directorStore.setViewMode(previousViewMode);
usePlaybackStore.getState().setPlaying(previousPlaying);
usePlaybackStore.getState().setPlayhead(previousPlayhead);
}
}
/**
* 旧版实时录制(MediaRecorder 兜底路径):只在浏览器不支持 WebCodecs 时使用。
* 从 playhead 0 播放到时间轴末尾录屏,耗时 = 视频时长。
*/
async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void> {
const canvas = findViewportCanvas();
if (!canvas) throw new Error("找不到视口画布");
if (typeof MediaRecorder === "undefined") {
@@ -118,3 +296,15 @@ export async function exportVideo(view: VideoExportView): Promise<void> {
const blob = new Blob(chunks, { type: mimeType || "video/webm" });
downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.${ext}`);
}
/**
* 导出视频:优先 WebCodecs 逐帧快速导出(MP4,固定分辨率,不受视口布局影响);
* 浏览器不支持时回退 MediaRecorder 实时录屏(WebM/MP4,耗时 = 视频时长)。
*/
export async function exportVideo(view: VideoExportView): Promise<void> {
if (typeof VideoEncoder !== "undefined") {
await exportVideoFrameByFrame(view);
return;
}
await exportVideoWithMediaRecorder(view);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Object3D, PerspectiveCamera, WebGLRenderer } from "three";
/**
* 把 R3F <Canvas> 内部的渲染三件套(renderer / scene / camera)暴露给
* Canvas 外的视频导出逻辑(videoExport.ts),供逐帧离线渲染。
* 模式与 captureBridge.ts 一致:Canvas 内的桥组件注册,导出方读取。
*/
export interface VideoExportContext {
gl: WebGLRenderer;
scene: Object3D;
camera: PerspectiveCamera;
}
let videoExportContext: VideoExportContext | null = null;
export function setVideoExportContext(context: VideoExportContext | null) {
videoExportContext = context;
}
export function getVideoExportContext() {
return videoExportContext;
}