540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
"use client";
|
|
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,
|
|
) => 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;
|
|
grid: THREE.GridHelper;
|
|
path: THREE.Group;
|
|
objects: THREE.Group;
|
|
pending: number;
|
|
resize: () => void;
|
|
} | null>(null);
|
|
const [ready, setReady] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
useEffect(() => {
|
|
if (!host.current) return;
|
|
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("#c5cbd3");
|
|
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("#c5cbd3");
|
|
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.MeshStandardMaterial({ color: 0xb1b6be, roughness: 1 }),
|
|
);
|
|
floor.rotation.x = -Math.PI / 2;
|
|
floor.position.y = -0.02;
|
|
world.add(floor);
|
|
const grid = new THREE.GridHelper(30, 30, 0x596573, 0x929daa);
|
|
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,
|
|
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);
|
|
});
|
|
setReady(true);
|
|
return () => {
|
|
runtime.current = null;
|
|
observer.disconnect();
|
|
renderer.setAnimationLoop(null);
|
|
transform.dispose();
|
|
orbit.dispose();
|
|
dispose(world);
|
|
renderer.dispose();
|
|
renderer.domElement.remove();
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
const r = runtime.current;
|
|
if (!r || !ready) return;
|
|
let active = true;
|
|
r.transform.detach();
|
|
r.objects.children.slice().forEach((o) => {
|
|
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;
|
|
object.position.fromArray(item.position);
|
|
object.rotation.set(...item.rotation);
|
|
object.scale.fromArray(item.scale);
|
|
r.objects.add(object);
|
|
if (
|
|
item.id === latest.current.selected &&
|
|
!latest.current.cameraView &&
|
|
latest.current.previewTime === null
|
|
)
|
|
r.transform.attach(object);
|
|
};
|
|
for (const item of props.scene.objects) {
|
|
if (item.kind === "model" && item.url) {
|
|
r.pending++;
|
|
new GLTFLoader().load(
|
|
buildThreeAssetUrl(item.url),
|
|
(g) => {
|
|
if (active) {
|
|
const box = new THREE.Box3().setFromObject(g.scene);
|
|
const size = box.getSize(new THREE.Vector3());
|
|
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);
|
|
r.pending--;
|
|
setLoading(r.pending > 0);
|
|
} else dispose(g.scene);
|
|
},
|
|
undefined,
|
|
() => {
|
|
if (active) {
|
|
r.pending--;
|
|
setLoading(r.pending > 0);
|
|
setError("模型加载失败,请检查 GLB 文件及资源访问权限");
|
|
}
|
|
},
|
|
);
|
|
continue;
|
|
}
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: item.kind === "actor" ? 0x687bb0 : 0xc3664c,
|
|
roughness: 0.6,
|
|
});
|
|
let object: THREE.Object3D;
|
|
if (item.kind === "actor") {
|
|
const group = new THREE.Group();
|
|
const body = new THREE.Mesh(
|
|
new THREE.CapsuleGeometry(0.32, 0.7, 6, 16),
|
|
mat,
|
|
);
|
|
body.position.y = 1.05;
|
|
group.add(body);
|
|
const head = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.24, 24, 16),
|
|
mat.clone(),
|
|
);
|
|
head.position.y = 1.95;
|
|
group.add(head);
|
|
for (const x of [-0.18, 0.18]) {
|
|
const leg = new THREE.Mesh(
|
|
new THREE.CapsuleGeometry(0.12, 0.55, 4, 12),
|
|
mat.clone(),
|
|
);
|
|
leg.position.set(x, 0.42, 0);
|
|
group.add(leg);
|
|
}
|
|
object = group;
|
|
} 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;
|
|
};
|
|
}, [props.scene.objects, ready]);
|
|
useEffect(() => {
|
|
const r = runtime.current;
|
|
if (!r) return;
|
|
if (props.locked) {
|
|
r.orbit.enabled = false;
|
|
r.transform.detach();
|
|
return;
|
|
}
|
|
const camera =
|
|
props.scene.cameras.find((c) => c.id === props.scene.activeCameraId) ||
|
|
props.scene.cameras[0];
|
|
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 && !props.cameraView && props.previewTime === null)
|
|
r.transform.attach(selected);
|
|
else r.transform.detach();
|
|
}, [
|
|
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;
|
|
if (motion.keyframes.length < 2) return;
|
|
const points = Array.from(
|
|
{ length: 121 },
|
|
(_, i) =>
|
|
new THREE.Vector3(
|
|
...sampleDirectorMotion(motion, (motion.duration * i) / 120)!
|
|
.position,
|
|
),
|
|
);
|
|
r.path.add(
|
|
new THREE.Line(
|
|
new THREE.BufferGeometry().setFromPoints(points),
|
|
new THREE.LineBasicMaterial({ color: 0xfbbf24 }),
|
|
),
|
|
);
|
|
for (const k of motion.keyframes) {
|
|
const dot = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.08, 10, 8),
|
|
new THREE.MeshBasicMaterial({ color: 0xfbbf24 }),
|
|
);
|
|
dot.position.fromArray(k.position);
|
|
r.path.add(dot);
|
|
}
|
|
}, [props.scene.motion, ready]);
|
|
useEffect(() => {
|
|
const r = runtime.current;
|
|
if (!r) return;
|
|
r.path.visible =
|
|
!props.locked && !props.cameraView && props.previewTime === null;
|
|
if (props.locked || props.previewTime === null) return;
|
|
const pose = sampleDirectorMotion(props.scene.motion, props.previewTime);
|
|
if (!pose) return;
|
|
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.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),
|
|
);
|
|
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) => {
|
|
const r = runtime.current;
|
|
if (!r || !ready || r.pending || error)
|
|
throw Error(error || "3D 场景尚未准备好");
|
|
if (latest.current.scene.motion.keyframes.length < 2)
|
|
throw 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) => {
|
|
const pose = sampleDirectorMotion(motion, time)!;
|
|
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) {
|
|
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) => {
|
|
const r = runtime.current;
|
|
if (!r || !ready || r.pending || error)
|
|
throw 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;
|
|
if (pose) {
|
|
r.camera.position.fromArray(pose.position);
|
|
r.orbit.target.fromArray(pose.target);
|
|
r.camera.fov = pose.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(Error("截图失败"))),
|
|
"image/png",
|
|
),
|
|
);
|
|
return { blob, width, height };
|
|
} finally {
|
|
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],
|
|
);
|
|
return (
|
|
<div className="relative h-full w-full" data-testid="director-viewport">
|
|
<div ref={host} className="h-full w-full" />
|
|
{error ? (
|
|
<div
|
|
role="alert"
|
|
className="absolute inset-x-4 top-4 rounded bg-red-950/90 p-3 text-sm text-white"
|
|
>
|
|
{error}
|
|
</div>
|
|
) : loading ? (
|
|
<div className="absolute left-4 top-4 rounded bg-black/60 p-2 text-white">
|
|
正在加载模型...
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
},
|
|
);
|