refactor(io): 机位视角导出改用监视器画布渲染

This commit is contained in:
2026-09-20 08:39:07 +08:00
parent 7e28c73643
commit f778f05957
7 changed files with 191 additions and 101 deletions

View File

@@ -0,0 +1,26 @@
import type { PerspectiveCamera, Scene, WebGLRenderer } from "three";
/**
* 把「镜头监视器」那块独立 Canvas 的渲染三件套(renderer / scene / camera)暴露给视频导出。
*
* 机位视角导出直接在这块画布的 gl 上、用它自己的 scene / camera、按导出分辨率逐帧渲染 ——
* 与监视器物理上就是同一份渲染(同一颗 gl、同一个场景、同一套景深管线),
* 天然逐像素一致,也省得把主视口的场景辅助元素再隐藏一遍(监视器场景本就只有成片内容)。
*
* 模式与 videoExportBridge / captureBridge 一致:Canvas 内的桥组件注册,导出方读取。
*/
export interface MonitorRenderContext {
gl: WebGLRenderer;
scene: Scene;
camera: PerspectiveCamera;
}
let monitorRenderContext: MonitorRenderContext | null = null;
export function setMonitorRenderContext(context: MonitorRenderContext | null) {
monitorRenderContext = context;
}
export function getMonitorRenderContext(): MonitorRenderContext | null {
return monitorRenderContext;
}

View File

@@ -22,6 +22,12 @@ vi.mock("./videoExportBridge", () => ({
getVideoExportContext: () => contextRef.current,
}));
// 机位视角导出改为「从监视器那块画布出片」,这里让监视器渲染上下文同样指向这套假上下文:
// 于是机位视角导出用的就是同一颗(被记录的)gl / scene / camera。
vi.mock("./monitorRenderBridge", () => ({
getMonitorRenderContext: () => contextRef.current,
}));
// 相机视角导出会走 renderSceneWithBokeh(真实 EffectComposer / BokehPass 在 jsdom 下无 WebGL)。
// 桩里直接委托给 gl.render,既能避开 WebGL,又保留「记录每帧相机位姿」的测试意图
// (createFakeBridge 只在 gl.render 时把相机位姿写入 bridge.rendered)。

View File

@@ -5,6 +5,7 @@ import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import type { Scene } from "three";
import { getVideoExportContext } from "./videoExportBridge";
import { getMonitorRenderContext, type MonitorRenderContext } from "./monitorRenderBridge";
import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime, resolveLensAtTime } from "../runtime/playbackTransforms";
import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, resolveLens, type CameraViewSnapshot } from "../schema/cameraGeometry";
@@ -56,6 +57,21 @@ function waitNextFrame(): Promise<void> {
);
}
/**
* 机位视角导出从「镜头监视器」那块画布出片:需要等它进入自渲染模式并注册好渲染上下文。
* 监视器的自渲染 Canvas 是条件挂载的(机位视角平时显示的是主视口镜像),
* 导出标志置位 + React 重渲染 + WebGL 上下文就绪需要几帧,这里轮询等待。
*/
async function waitForMonitorRenderContext(timeoutMs = 4000): Promise<MonitorRenderContext | null> {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const context = getMonitorRenderContext();
if (context) return context;
await waitNextFrame();
}
return getMonitorRenderContext();
}
/**
* 机位没有运镜轨道时的兜底:把「这个机位自己的视角」直接写到导出相机上。
*
@@ -149,10 +165,17 @@ async function pickVideoEncoderConfig(
* - H.264 编码进 MP4(Chrome / Electron 均支持)
*/
async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
const bridge = getVideoExportContext();
if (!bridge) throw new Error("找不到视口渲染上下文");
// 机位视角「直接用监视器那块画布出片」:同一颗 gl / 同一个场景(本就只有成片内容、没有编辑器辅助)/
// 同一颗相机 / 同一套景深管线 —— 导出的画面与监视器所见就是同一份渲染,天然逐像素一致。
// 上帝视角仍走主视口(监视器只显示机位画面)。
const bridge = view === "camera" ? await waitForMonitorRenderContext() : getVideoExportContext();
if (!bridge) {
throw new Error(view === "camera" ? "找不到镜头监视器的渲染上下文" : "找不到视口渲染上下文");
}
const { gl, scene, camera } = bridge;
const canvas = gl.domElement;
// 机位视角的渲染源是监视器自己那块画布,不必去动主视口的视角
const switchesViewportView = view !== "camera";
const directorStore = useDirectorStore.getState();
const exportSettings = directorStore.project.exportSettings;
@@ -213,7 +236,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
// 切到导出状态:固定分辨率渲染(不改 CSS 尺寸,canvas 加 object-fit: contain 保持显示不变形)
usePlaybackStore.getState().setPlaying(false);
directorStore.setViewMode(view);
if (switchesViewportView) directorStore.setViewMode(view);
canvas.classList.add("is-video-exporting");
gl.setPixelRatio(1);
gl.setSize(width, height, false);
@@ -235,7 +258,8 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
if (encodeError) throw encodeError;
const time = Math.min(frameIndex / fps, duration);
// 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例
const frameCamera = getVideoExportContext()?.camera ?? camera;
const frameCamera =
(view === "camera" ? getMonitorRenderContext()?.camera : getVideoExportContext()?.camera) ?? camera;
frameCamera.aspect = width / height;
frameCamera.updateProjectionMatrix();
@@ -349,11 +373,14 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
// - aspect 两颗都要还,否则切回导演视角后画面比例还停在导出的 16:9;
// - 位姿只在「导出用的就是导出前那颗相机」时还,否则等于把导演相机的位姿
// 写到机位相机上,机位视角就会被带歪。
const restoreCamera = getVideoExportContext()?.camera ?? camera;
const restoreCamera =
(view === "camera" ? getMonitorRenderContext()?.camera : getVideoExportContext()?.camera) ?? camera;
restoreCamera.aspect = previousAspect;
restoreCamera.updateProjectionMatrix();
camera.aspect = previousAspect;
camera.updateProjectionMatrix();
if (restoreCamera !== camera) {
camera.aspect = previousAspect;
camera.updateProjectionMatrix();
}
if (restoreCamera === camera) {
restoreCamera.position.copy(previousCameraPosition);
@@ -362,7 +389,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
restoreCamera.updateProjectionMatrix();
}
directorStore.setViewMode(previousViewMode);
if (switchesViewportView) directorStore.setViewMode(previousViewMode);
usePlaybackStore.getState().setPlayhead(previousPlayhead);
}
}