"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; 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) => 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( function DirectorViewport(props, ref) { const host = useRef(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[] = []; 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((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 (
{error || backgroundError ? (
{error || backgroundError}
) : loading || backgroundPending ? (
{backgroundPending ? "正在加载全景背景…" : "正在加载模型…"}
) : null}
); }, );