ai-chatbot/components/canvas/director/DirectorViewport.tsx

568 lines
21 KiB
TypeScript

"use client";
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) => 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();
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;
object.visible = item.visible !== false;
object.userData.directorSignature = JSON.stringify([
item.kind,
item.url,
item.pose,
item.color,
]);
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 &&
!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])
) {
attach(existing, item);
continue;
}
if (existing) {
dispose(existing);
r.objects.remove(existing);
}
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.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);
}
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 &&
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;
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>
);
},
);