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

765 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
},
);