765 lines
29 KiB
TypeScript
765 lines
29 KiB
TypeScript
"use client";
|
||
import { sampleDirectorObjects } from "@/lib/video-canvas/director-object-tracks";
|
||
import { resolveDirectorCamera, directorBodies } from "@/lib/video-canvas/director-extensions";
|
||
import { directorPoses } from "@/lib/video-canvas/director-poses";
|
||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||
import * as THREE from "three";
|
||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||
import {
|
||
directorImageSize,
|
||
type DirectorScene,
|
||
type DirectorObject,
|
||
} from "@/lib/video-canvas/director-scene";
|
||
import { sampleDirectorMotion, type DirectorPose } from "@/lib/video-canvas/director-motion";
|
||
import { recordDirectorVideo } from "@/lib/video-canvas/director-recording";
|
||
export type DirectorViewportHandle = {
|
||
showPath: () => void;
|
||
record: (signal: AbortSignal, onProgress: (time: number) => void) => Promise<Blob>;
|
||
capture: (
|
||
pose?: DirectorPose,
|
||
time?: number,
|
||
) => Promise<{ blob: Blob; width: number; height: number }>;
|
||
};
|
||
type Props = {
|
||
scene: DirectorScene;
|
||
selected: string;
|
||
mode: "translate" | "rotate" | "scale";
|
||
cameraView: boolean;
|
||
previewTime: number | null;
|
||
locked: boolean;
|
||
onTransform: (id: string, patch: Partial<DirectorObject>) => void;
|
||
onCamera: (position: [number, number, number], target: [number, number, number]) => void;
|
||
};
|
||
function dispose(root: THREE.Object3D) {
|
||
root.traverse((o) => {
|
||
if (o instanceof THREE.Mesh || o instanceof THREE.Line) {
|
||
o.geometry.dispose();
|
||
for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
|
||
for (const value of Object.values(m)) if (value instanceof THREE.Texture) value.dispose();
|
||
m.dispose();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||
function DirectorViewport(props, ref) {
|
||
const host = useRef<HTMLDivElement>(null);
|
||
const latest = useRef(props);
|
||
latest.current = props;
|
||
const runtime = useRef<{
|
||
renderer: THREE.WebGLRenderer;
|
||
world: THREE.Scene;
|
||
camera: THREE.PerspectiveCamera;
|
||
orbit: OrbitControls;
|
||
transform: TransformControls;
|
||
helper: THREE.Object3D;
|
||
floor: THREE.Mesh;
|
||
grid: THREE.GridHelper;
|
||
path: THREE.Group;
|
||
objects: THREE.Group;
|
||
pending: number;
|
||
resize: () => void;
|
||
} | null>(null);
|
||
const [ready, setReady] = useState(false);
|
||
const [rendererRevision, setRendererRevision] = useState(0);
|
||
const [resourceRevision, setResourceRevision] = useState(0);
|
||
const [error, setError] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [backgroundPending, setBackgroundPending] = useState(false);
|
||
const [backgroundError, setBackgroundError] = useState("");
|
||
useEffect(() => {
|
||
const r = runtime.current;
|
||
if (!r) return;
|
||
r.grid.visible = props.scene.gridVisible !== false;
|
||
r.floor.visible = props.scene.groundVisible !== false;
|
||
}, [props.scene.gridVisible, props.scene.groundVisible, ready]);
|
||
const applyObjectTime = (time: number | null) => {
|
||
const objects = sampleDirectorObjects(latest.current.scene, time),
|
||
r = runtime.current;
|
||
if (r)
|
||
for (const item of objects) {
|
||
const mesh = r.objects.children.find((o) => o.name === item.id);
|
||
if (mesh) {
|
||
mesh.position.fromArray(item.position);
|
||
mesh.rotation.set(...item.rotation);
|
||
mesh.scale.fromArray(item.scale);
|
||
mesh.visible = item.visible !== false;
|
||
}
|
||
}
|
||
return { ...latest.current.scene, objects };
|
||
};
|
||
useEffect(() => {
|
||
if (!host.current) return;
|
||
setError("");
|
||
let renderer: THREE.WebGLRenderer;
|
||
try {
|
||
renderer = new THREE.WebGLRenderer({
|
||
antialias: true,
|
||
preserveDrawingBuffer: true,
|
||
});
|
||
} catch {
|
||
setError("无法初始化 3D 视图,请检查浏览器 WebGL 支持");
|
||
return;
|
||
}
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
renderer.setClearColor("#080808");
|
||
host.current.appendChild(renderer.domElement);
|
||
renderer.domElement.setAttribute("aria-label", "3D 导演台场景");
|
||
Object.assign(renderer.domElement.style, {
|
||
position: "absolute",
|
||
left: "50%",
|
||
top: "50%",
|
||
transform: "translate(-50%, -50%)",
|
||
});
|
||
const world = new THREE.Scene();
|
||
world.background = new THREE.Color(props.scene.skyColor || "#080808");
|
||
world.add(new THREE.HemisphereLight(0xffffff, 0x647080, 2.4));
|
||
const light = new THREE.DirectionalLight(0xffffff, 3);
|
||
light.position.set(4, 8, 5);
|
||
world.add(light);
|
||
const floor = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(100, 100),
|
||
new THREE.MeshBasicMaterial({ color: 0x0b0f15 }),
|
||
);
|
||
floor.rotation.x = -Math.PI / 2;
|
||
floor.position.y = -0.02;
|
||
world.add(floor);
|
||
const grid = new THREE.GridHelper(30, 30, 0x21445b, 0x183346);
|
||
world.add(grid);
|
||
const path = new THREE.Group();
|
||
world.add(path);
|
||
const camera = new THREE.PerspectiveCamera(50, 1, 0.05, 500);
|
||
camera.position.set(4, 3, 7);
|
||
const orbit = new OrbitControls(camera, renderer.domElement);
|
||
orbit.target.set(0, 1, 0);
|
||
orbit.minDistance = 0.3;
|
||
orbit.maxDistance = 80;
|
||
const transform = new TransformControls(camera, renderer.domElement);
|
||
const helper = transform.getHelper();
|
||
world.add(helper);
|
||
const objects = new THREE.Group();
|
||
world.add(objects);
|
||
transform.addEventListener("dragging-changed", (e) => {
|
||
orbit.enabled =
|
||
!e.value && !latest.current.cameraView && latest.current.previewTime === null;
|
||
});
|
||
transform.addEventListener("mouseUp", () => {
|
||
const o = transform.object;
|
||
if (o)
|
||
latest.current.onTransform(o.name, {
|
||
position: o.position.toArray(),
|
||
rotation: [o.rotation.x, o.rotation.y, o.rotation.z],
|
||
scale: o.scale.toArray(),
|
||
});
|
||
});
|
||
orbit.addEventListener("end", () => {
|
||
if (latest.current.previewTime !== null || latest.current.cameraView) return;
|
||
latest.current.onCamera(camera.position.toArray(), orbit.target.toArray());
|
||
});
|
||
|
||
const resize = () => {
|
||
if (!host.current) return;
|
||
const { width, height } = host.current.getBoundingClientRect();
|
||
const size = directorImageSize(latest.current.scene.ratio);
|
||
const aspect = size.width / size.height;
|
||
const fitWidth = Math.max(1, Math.min(width, height * aspect));
|
||
renderer.setSize(fitWidth, fitWidth / aspect);
|
||
camera.aspect = aspect;
|
||
camera.updateProjectionMatrix();
|
||
};
|
||
const state = {
|
||
renderer,
|
||
world,
|
||
camera,
|
||
orbit,
|
||
transform,
|
||
helper,
|
||
floor,
|
||
grid,
|
||
path,
|
||
objects,
|
||
pending: 0,
|
||
resize,
|
||
};
|
||
runtime.current = state;
|
||
const observer = new ResizeObserver(resize);
|
||
observer.observe(host.current);
|
||
resize();
|
||
renderer.setAnimationLoop(() => {
|
||
if (orbit.enabled) orbit.update();
|
||
renderer.render(world, camera);
|
||
});
|
||
const contextLost = (event: Event) => {
|
||
event.preventDefault();
|
||
renderer.setAnimationLoop(null);
|
||
setReady(false);
|
||
setError("3D 显示连接已中断,请点击重试恢复场景");
|
||
};
|
||
const contextRestored = () => setRendererRevision((value) => value + 1);
|
||
renderer.domElement.addEventListener("webglcontextlost", contextLost);
|
||
renderer.domElement.addEventListener("webglcontextrestored", contextRestored);
|
||
setReady(true);
|
||
return () => {
|
||
renderer.domElement.removeEventListener("webglcontextlost", contextLost);
|
||
renderer.domElement.removeEventListener("webglcontextrestored", contextRestored);
|
||
runtime.current = null;
|
||
observer.disconnect();
|
||
renderer.setAnimationLoop(null);
|
||
transform.dispose();
|
||
orbit.dispose();
|
||
dispose(world);
|
||
renderer.dispose();
|
||
renderer.domElement.remove();
|
||
};
|
||
}, [rendererRevision]);
|
||
useEffect(() => {
|
||
const r = runtime.current;
|
||
if (!r || !ready) return;
|
||
let active = true;
|
||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||
r.transform.detach();
|
||
const desired = new Set(props.scene.objects.map((o) => o.id));
|
||
r.objects.children.slice().forEach((o) => {
|
||
if (!desired.has(o.name)) {
|
||
dispose(o);
|
||
r.objects.remove(o);
|
||
}
|
||
});
|
||
setError("");
|
||
r.pending = 0;
|
||
const attach = (object: THREE.Object3D, item: DirectorObject) => {
|
||
if (!active) {
|
||
dispose(object);
|
||
return;
|
||
}
|
||
object.name = item.id;
|
||
// A GLB may finish loading after playback has paused at a nonzero time.
|
||
const sampled =
|
||
latest.current.previewTime === null
|
||
? item
|
||
: sampleDirectorObjects(latest.current.scene, latest.current.previewTime).find(
|
||
(o) => o.id === item.id,
|
||
) || item;
|
||
object.visible = sampled.visible !== false;
|
||
object.userData.directorSignature = JSON.stringify([
|
||
item.kind,
|
||
item.url,
|
||
item.pose,
|
||
item.color,
|
||
item.body,
|
||
]);
|
||
object.position.fromArray(sampled.position);
|
||
object.rotation.set(...sampled.rotation);
|
||
object.scale.fromArray(sampled.scale);
|
||
r.objects.add(object);
|
||
if (
|
||
item.id === latest.current.selected &&
|
||
!item.locked &&
|
||
item.visible !== false &&
|
||
!latest.current.cameraView &&
|
||
latest.current.previewTime === null
|
||
)
|
||
r.transform.attach(object);
|
||
};
|
||
for (const item of props.scene.objects) {
|
||
const existing = r.objects.children.find((o) => o.name === item.id);
|
||
if (
|
||
existing &&
|
||
existing.userData.directorSignature ===
|
||
JSON.stringify([item.kind, item.url, item.pose, item.color, item.body])
|
||
) {
|
||
attach(existing, item);
|
||
continue;
|
||
}
|
||
if (existing) {
|
||
dispose(existing);
|
||
r.objects.remove(existing);
|
||
}
|
||
if (item.kind === "model" && item.url) {
|
||
r.pending++;
|
||
let settled = false;
|
||
const finish = (message?: string) => {
|
||
if (!active || settled) return false;
|
||
settled = true;
|
||
clearTimeout(timeout);
|
||
r.pending--;
|
||
setLoading(r.pending > 0);
|
||
if (message) setError(`${item.name}:${message}`);
|
||
return true;
|
||
};
|
||
const timeout = setTimeout(() => finish("模型加载超过 30 秒,请检查网络后重试"), 30000);
|
||
timers.push(timeout);
|
||
new GLTFLoader().load(
|
||
buildThreeAssetUrl(item.url),
|
||
(g) => {
|
||
if (!active || settled) {
|
||
dispose(g.scene);
|
||
return;
|
||
}
|
||
try {
|
||
const box = new THREE.Box3().setFromObject(g.scene);
|
||
const size = box.getSize(new THREE.Vector3());
|
||
if (
|
||
box.isEmpty() ||
|
||
!size.toArray().every(Number.isFinite) ||
|
||
size.length() < 0.000001
|
||
) {
|
||
throw new Error("模型没有可显示的有效几何内容");
|
||
}
|
||
const scale = 2 / Math.max(size.x, size.y, size.z, 0.001);
|
||
g.scene.scale.setScalar(scale);
|
||
const normalized = new THREE.Group();
|
||
normalized.add(g.scene);
|
||
const fitted = new THREE.Box3().setFromObject(normalized);
|
||
const center = fitted.getCenter(new THREE.Vector3());
|
||
g.scene.position.set(-center.x, -fitted.min.y, -center.z);
|
||
attach(normalized, item);
|
||
finish();
|
||
} catch (failure) {
|
||
dispose(g.scene);
|
||
finish(failure instanceof Error ? failure.message : "模型内容读取失败");
|
||
}
|
||
},
|
||
undefined,
|
||
() => finish("模型加载失败,请检查 GLB 文件及资源访问权限"),
|
||
);
|
||
continue;
|
||
}
|
||
const mat = new THREE.MeshStandardMaterial({
|
||
color: item.color || (item.kind === "actor" ? "#687bb0" : "#c3664c"),
|
||
roughness: 0.6,
|
||
});
|
||
let object: THREE.Object3D;
|
||
if (item.kind === "actor") {
|
||
const group = new THREE.Group();
|
||
const pose = directorPoses[item.pose || "站立"] || directorPoses["站立"];
|
||
const radians = THREE.MathUtils.degToRad;
|
||
const hips = new THREE.Group();
|
||
hips.position.y = 1 + ("height" in pose ? pose.height : 0);
|
||
group.add(hips);
|
||
const torso = new THREE.Group();
|
||
torso.rotation.x = radians(pose.lean);
|
||
hips.add(torso);
|
||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.65, 0.28), mat);
|
||
body.position.y = 0.38;
|
||
torso.add(body);
|
||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.2, 20, 16), mat.clone());
|
||
head.position.y = 0.95;
|
||
torso.add(head);
|
||
const limb = (
|
||
parent: THREE.Object3D,
|
||
position: [number, number, number],
|
||
angle: number,
|
||
bend: number,
|
||
length: number,
|
||
radius: number,
|
||
spread = 0,
|
||
) => {
|
||
const root = new THREE.Group();
|
||
root.position.set(...position);
|
||
root.rotation.x = radians(angle);
|
||
root.rotation.z = radians(spread);
|
||
parent.add(root);
|
||
const upper = new THREE.Mesh(
|
||
new THREE.CapsuleGeometry(radius, length - radius * 2, 4, 12),
|
||
mat.clone(),
|
||
);
|
||
upper.position.y = -length / 2;
|
||
root.add(upper);
|
||
const joint = new THREE.Group();
|
||
joint.position.y = -length;
|
||
joint.rotation.x = radians(bend);
|
||
root.add(joint);
|
||
const lower = new THREE.Mesh(
|
||
new THREE.CapsuleGeometry(radius * 0.85, length - radius * 2, 4, 12),
|
||
mat.clone(),
|
||
);
|
||
lower.position.y = -length / 2;
|
||
joint.add(lower);
|
||
};
|
||
for (let i = 0; i < 2; i++) {
|
||
const side = i === 0 ? -1 : 1;
|
||
limb(
|
||
torso,
|
||
[side * 0.33, 0.65, 0],
|
||
pose.arms[i],
|
||
-pose.elbows[i],
|
||
0.34,
|
||
0.07,
|
||
("spread" in pose ? pose.spread : 5) * side,
|
||
);
|
||
limb(hips, [side * 0.15, 0, 0], pose.legs[i], pose.knees[i], 0.46, 0.1);
|
||
}
|
||
const [width, height, headScale] =
|
||
directorBodies[item.body || "标准"] || directorBodies["标准"];
|
||
group.scale.set(width, height, width);
|
||
head.scale.setScalar(headScale);
|
||
const root = new THREE.Group();
|
||
root.add(group);
|
||
object = root;
|
||
} else {
|
||
object = new THREE.Mesh(
|
||
item.kind === "sphere"
|
||
? new THREE.SphereGeometry(0.5, 32, 20)
|
||
: new THREE.BoxGeometry(1, 1, 1),
|
||
mat,
|
||
);
|
||
}
|
||
attach(object, item);
|
||
}
|
||
setLoading(r.pending > 0);
|
||
return () => {
|
||
active = false;
|
||
timers.forEach(clearTimeout);
|
||
};
|
||
}, [props.scene.objects, ready, resourceRevision]);
|
||
useEffect(() => {
|
||
const r = runtime.current;
|
||
if (!r) return;
|
||
let active = true;
|
||
let texture: THREE.Texture | undefined;
|
||
setBackgroundError("");
|
||
r.world.background = new THREE.Color(props.scene.skyColor || "#080808");
|
||
if (!props.scene.backgroundUrl) {
|
||
setBackgroundPending(false);
|
||
return;
|
||
}
|
||
setBackgroundPending(true);
|
||
const timeout = setTimeout(() => {
|
||
if (active) {
|
||
active = false;
|
||
setBackgroundPending(false);
|
||
setBackgroundError("全景背景加载超时,请重新选择背景");
|
||
}
|
||
}, 20000);
|
||
new THREE.TextureLoader().load(
|
||
buildThreeAssetUrl(props.scene.backgroundUrl),
|
||
(loaded) => {
|
||
clearTimeout(timeout);
|
||
if (!active) {
|
||
loaded.dispose();
|
||
return;
|
||
}
|
||
texture = loaded;
|
||
loaded.mapping = THREE.EquirectangularReflectionMapping;
|
||
loaded.colorSpace = THREE.SRGBColorSpace;
|
||
r.world.background = loaded;
|
||
setBackgroundPending(false);
|
||
},
|
||
undefined,
|
||
() => {
|
||
clearTimeout(timeout);
|
||
if (active) {
|
||
setBackgroundPending(false);
|
||
setBackgroundError("全景背景加载失败,请检查图片或切回默认背景");
|
||
}
|
||
},
|
||
);
|
||
return () => {
|
||
active = false;
|
||
clearTimeout(timeout);
|
||
if (r.world.background === texture) r.world.background = new THREE.Color(latest.current.scene.skyColor || "#080808");
|
||
texture?.dispose();
|
||
};
|
||
}, [props.scene.backgroundUrl, props.scene.skyColor, ready, resourceRevision]);
|
||
useEffect(() => {
|
||
const r = runtime.current;
|
||
if (!r) return;
|
||
if (props.locked) {
|
||
r.orbit.enabled = false;
|
||
r.transform.detach();
|
||
return;
|
||
}
|
||
const camera = resolveDirectorCamera(props.scene);
|
||
if (props.previewTime === null) {
|
||
r.camera.position.fromArray(camera.position);
|
||
r.camera.fov = camera.fov;
|
||
r.camera.updateProjectionMatrix();
|
||
r.orbit.target.fromArray(camera.target);
|
||
r.orbit.update();
|
||
}
|
||
r.orbit.enabled = !props.cameraView && props.previewTime === null;
|
||
r.transform.setMode(props.mode);
|
||
const selected = r.objects.getObjectByName(props.selected);
|
||
if (
|
||
selected &&
|
||
selected.visible &&
|
||
!props.scene.objects.find((o) => o.id === props.selected)?.locked &&
|
||
!props.cameraView &&
|
||
props.previewTime === null
|
||
)
|
||
r.transform.attach(selected);
|
||
else r.transform.detach();
|
||
}, [
|
||
props.scene.objects,
|
||
props.scene.cameras,
|
||
props.scene.activeCameraId,
|
||
props.selected,
|
||
props.mode,
|
||
props.cameraView,
|
||
props.previewTime === null,
|
||
props.locked,
|
||
ready,
|
||
]);
|
||
useEffect(() => {
|
||
const r = runtime.current;
|
||
if (!r) return;
|
||
dispose(r.path);
|
||
r.path.clear();
|
||
const motion = props.scene.motion;
|
||
const tracks = [
|
||
...(motion.keyframes.length >= 2
|
||
? [
|
||
{
|
||
color: 0xfbbf24,
|
||
keys: motion.keyframes,
|
||
points: Array.from(
|
||
{ length: 121 },
|
||
(_, i) =>
|
||
new THREE.Vector3(
|
||
...sampleDirectorMotion(motion, (motion.duration * i) / 120)!.position,
|
||
),
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
...Object.entries(props.scene.objectTracks || {})
|
||
.filter(([, t]) => !t.muted && t.keyframes.length >= 2)
|
||
.map(([id, t]) => ({
|
||
color: 0x67e8f9,
|
||
keys: t.keyframes,
|
||
points: Array.from(
|
||
{ length: 121 },
|
||
(_, i) =>
|
||
new THREE.Vector3(
|
||
...sampleDirectorObjects(props.scene, (motion.duration * i) / 120).find(
|
||
(o) => o.id === id,
|
||
)!.position,
|
||
),
|
||
),
|
||
})),
|
||
];
|
||
for (const track of tracks) {
|
||
r.path.add(
|
||
new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints(track.points),
|
||
new THREE.LineBasicMaterial({ color: track.color }),
|
||
),
|
||
);
|
||
for (const k of track.keys) {
|
||
const dot = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.08, 10, 8),
|
||
new THREE.MeshBasicMaterial({ color: track.color }),
|
||
);
|
||
dot.position.fromArray(k.position);
|
||
r.path.add(dot);
|
||
}
|
||
}
|
||
}, [props.scene.motion, props.scene.objectTracks, props.scene.objects, ready]);
|
||
useEffect(() => {
|
||
const r = runtime.current;
|
||
if (!r) return;
|
||
r.path.visible = !props.locked && !props.cameraView && props.previewTime === null;
|
||
if (props.locked) return;
|
||
const sampledScene = applyObjectTime(props.previewTime);
|
||
if (props.previewTime === null) return;
|
||
const sampled = sampleDirectorMotion(props.scene.motion, props.previewTime);
|
||
const pose = resolveDirectorCamera(sampledScene, sampled || undefined);
|
||
r.camera.position.fromArray(pose.position);
|
||
r.orbit.target.fromArray(pose.target);
|
||
r.camera.fov = pose.fov;
|
||
r.camera.updateProjectionMatrix();
|
||
r.camera.lookAt(r.orbit.target);
|
||
r.transform.detach();
|
||
}, [
|
||
props.previewTime,
|
||
props.scene.motion,
|
||
props.scene.cameras,
|
||
props.scene.objects,
|
||
props.scene.objectTracks,
|
||
props.cameraView,
|
||
props.locked,
|
||
ready,
|
||
]);
|
||
useEffect(() => {
|
||
runtime.current?.resize();
|
||
}, [props.scene.ratio, ready]);
|
||
useImperativeHandle(
|
||
ref,
|
||
() => ({
|
||
showPath: () => {
|
||
const r = runtime.current;
|
||
if (!r) return;
|
||
const points = latest.current.scene.motion.keyframes.map(
|
||
(k) => new THREE.Vector3(...k.position),
|
||
);
|
||
for (const track of Object.values(latest.current.scene.objectTracks || {}))
|
||
if (!track.muted)
|
||
for (const key of track.keyframes) points.push(new THREE.Vector3(...key.position));
|
||
if (!points.length) return;
|
||
points.push(new THREE.Vector3(0, 1, 0));
|
||
const box = new THREE.Box3().setFromPoints(points),
|
||
center = box.getCenter(new THREE.Vector3());
|
||
const radius = Math.max(3, box.getSize(new THREE.Vector3()).length());
|
||
const position = center
|
||
.clone()
|
||
.add(new THREE.Vector3(1, 0.8, 1).normalize().multiplyScalar(radius * 1.4));
|
||
latest.current.onCamera(position.toArray(), center.toArray());
|
||
},
|
||
record: async (signal, onProgress) => {
|
||
if (backgroundPending || backgroundError)
|
||
throw new Error(backgroundError || "全景背景仍在加载,请稍后导出");
|
||
const r = runtime.current;
|
||
if (!r || !ready || r.pending || error) throw new Error(error || "3D 场景尚未准备好");
|
||
if (
|
||
latest.current.scene.motion.keyframes.length < 2 &&
|
||
!Object.values(latest.current.scene.objectTracks || {}).some(
|
||
(t) => !t.muted && t.keyframes.length >= 2,
|
||
)
|
||
)
|
||
throw new Error("相机或对象轨道至少需要两个关键帧");
|
||
const motion = latest.current.scene.motion;
|
||
const size = r.renderer.getSize(new THREE.Vector2()),
|
||
pixelRatio = r.renderer.getPixelRatio();
|
||
const position = r.camera.position.clone(),
|
||
target = r.orbit.target.clone(),
|
||
fov = r.camera.fov,
|
||
aspect = r.camera.aspect;
|
||
const visibility = [r.helper.visible, r.grid.visible, r.path.visible],
|
||
enabled = r.orbit.enabled;
|
||
const dimensions = directorImageSize(latest.current.scene.ratio);
|
||
r.helper.visible = false;
|
||
r.grid.visible = false;
|
||
r.path.visible = false;
|
||
r.orbit.enabled = false;
|
||
r.renderer.setPixelRatio(1);
|
||
r.renderer.setSize(dimensions.width, dimensions.height, false);
|
||
r.camera.aspect = dimensions.width / dimensions.height;
|
||
try {
|
||
return await recordDirectorVideo({
|
||
canvas: r.renderer.domElement,
|
||
duration: motion.duration,
|
||
signal,
|
||
onProgress,
|
||
render: (time) => {
|
||
if (r.renderer.getContext().isContextLost())
|
||
throw new Error("3D 显示连接中断,已取消视频导出");
|
||
const pose = resolveDirectorCamera(
|
||
applyObjectTime(time),
|
||
sampleDirectorMotion(motion, time) || undefined,
|
||
);
|
||
r.camera.position.fromArray(pose.position);
|
||
r.orbit.target.fromArray(pose.target);
|
||
r.camera.fov = pose.fov;
|
||
r.camera.updateProjectionMatrix();
|
||
r.camera.lookAt(r.orbit.target);
|
||
r.renderer.render(r.world, r.camera);
|
||
},
|
||
});
|
||
} finally {
|
||
if (runtime.current === r) {
|
||
applyObjectTime(latest.current.previewTime);
|
||
r.renderer.setPixelRatio(pixelRatio);
|
||
r.renderer.setSize(size.x, size.y);
|
||
r.camera.position.copy(position);
|
||
r.orbit.target.copy(target);
|
||
r.camera.fov = fov;
|
||
r.camera.aspect = aspect;
|
||
r.camera.updateProjectionMatrix();
|
||
r.orbit.enabled = enabled;
|
||
r.orbit.update();
|
||
[r.helper.visible, r.grid.visible, r.path.visible] = visibility;
|
||
}
|
||
}
|
||
},
|
||
capture: async (pose, time) => {
|
||
if (backgroundPending || backgroundError)
|
||
throw new Error(backgroundError || "全景背景仍在加载,请稍后截图");
|
||
const r = runtime.current;
|
||
if (!r || !ready || r.pending || error) throw new Error(error || "3D 场景尚未准备好");
|
||
const { width, height } = directorImageSize(latest.current.scene.ratio);
|
||
const previousSize = r.renderer.getSize(new THREE.Vector2());
|
||
const pixelRatio = r.renderer.getPixelRatio();
|
||
const aspect = r.camera.aspect;
|
||
const position = r.camera.position.clone(),
|
||
target = r.orbit.target.clone(),
|
||
fov = r.camera.fov;
|
||
const visibility = [r.helper.visible, r.grid.visible, r.path.visible];
|
||
const enabled = r.orbit.enabled;
|
||
r.orbit.enabled = false;
|
||
const capturedScene = applyObjectTime(time ?? latest.current.previewTime);
|
||
if (pose) {
|
||
const resolvedPose = resolveDirectorCamera(capturedScene, pose);
|
||
r.camera.position.fromArray(resolvedPose.position);
|
||
r.orbit.target.fromArray(resolvedPose.target);
|
||
r.camera.fov = resolvedPose.fov;
|
||
r.camera.lookAt(r.orbit.target);
|
||
}
|
||
r.path.visible = false;
|
||
r.helper.visible = false;
|
||
r.grid.visible = false;
|
||
r.renderer.setPixelRatio(1);
|
||
r.renderer.setSize(width, height, false);
|
||
r.camera.aspect = width / height;
|
||
r.camera.updateProjectionMatrix();
|
||
r.renderer.render(r.world, r.camera);
|
||
try {
|
||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||
r.renderer.domElement.toBlob(
|
||
(b) => (b ? resolve(b) : reject(new Error("截图失败"))),
|
||
"image/png",
|
||
),
|
||
);
|
||
return { blob, width, height };
|
||
} finally {
|
||
applyObjectTime(latest.current.previewTime);
|
||
r.renderer.setPixelRatio(pixelRatio);
|
||
r.renderer.setSize(previousSize.x, previousSize.y);
|
||
r.camera.aspect = aspect;
|
||
r.camera.updateProjectionMatrix();
|
||
r.camera.position.copy(position);
|
||
r.orbit.target.copy(target);
|
||
r.camera.fov = fov;
|
||
r.camera.updateProjectionMatrix();
|
||
r.orbit.update();
|
||
[r.helper.visible, r.grid.visible, r.path.visible] = visibility;
|
||
r.orbit.enabled = enabled;
|
||
}
|
||
},
|
||
}),
|
||
[ready, error, backgroundPending, backgroundError],
|
||
);
|
||
return (
|
||
<div className="relative h-full w-full" data-testid="director-viewport">
|
||
<div ref={host} className="h-full w-full" />
|
||
{error || backgroundError ? (
|
||
<div
|
||
role="alert"
|
||
className="absolute inset-x-4 top-4 rounded bg-red-950/90 p-3 text-sm text-white"
|
||
>
|
||
{error || backgroundError}
|
||
<button
|
||
type="button"
|
||
className="ml-3 rounded border border-white/30 px-2 py-1"
|
||
onClick={() => {
|
||
if (ready) setResourceRevision((value) => value + 1);
|
||
else setRendererRevision((value) => value + 1);
|
||
}}
|
||
>
|
||
重试 3D 加载
|
||
</button>
|
||
</div>
|
||
) : loading || backgroundPending ? (
|
||
<div className="absolute left-4 top-4 rounded bg-black/60 p-2 text-white">
|
||
{backgroundPending ? "正在加载全景背景…" : "正在加载模型…"}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
},
|
||
);
|