704 lines
26 KiB
TypeScript
704 lines
26 KiB
TypeScript
"use client";
|
||
import { directorPoses, type DirectorPoseName } from "@/lib/video-canvas/director-poses";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { Camera, Plus, Upload, X, Trash2 } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { DirectorViewport, type DirectorViewportHandle } from "./DirectorViewport";
|
||
import {
|
||
readDirectorScene,
|
||
directorRatios,
|
||
directorImageSize,
|
||
type DirectorScene,
|
||
type DirectorSnapshot,
|
||
type DirectorObject,
|
||
type DirectorVector,
|
||
} from "@/lib/video-canvas/director-scene";
|
||
import { DirectorMotionPanel } from "./DirectorMotionPanel";
|
||
import {
|
||
sampleDirectorMotion,
|
||
mergeDirectorCameraPose,
|
||
type DirectorMotion,
|
||
} from "@/lib/video-canvas/director-motion";
|
||
async function upload(file: File) {
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
form.append(
|
||
"purpose",
|
||
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
|
||
);
|
||
const response = await fetch(
|
||
/\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
|
||
{
|
||
method: "POST",
|
||
body: form,
|
||
signal: AbortSignal.timeout(120000),
|
||
},
|
||
);
|
||
const result = await response.json();
|
||
const url = result.url || result.data?.url;
|
||
if (!response.ok || !url) throw Error(result.error || "上传失败");
|
||
return String(url);
|
||
}
|
||
export function DirectorStudioDialog({
|
||
initialScene,
|
||
onChange,
|
||
onClose,
|
||
onSend,
|
||
onSendMotion,
|
||
onSendVideo,
|
||
}: {
|
||
initialScene: unknown;
|
||
onChange: (scene: DirectorScene) => void;
|
||
onClose: () => void;
|
||
onSend: (snapshots: DirectorSnapshot[]) => void;
|
||
onSendMotion: (snapshot: DirectorSnapshot, motion: DirectorMotion) => void;
|
||
onSendVideo: (video: { url: string; width: number; height: number; duration: number }) => void;
|
||
}) {
|
||
const [scene, setScene] = useState(() => readDirectorScene(initialScene));
|
||
const current = useRef(scene);
|
||
current.current = scene;
|
||
const commit = (next: DirectorScene) => {
|
||
current.current = next;
|
||
setScene(next);
|
||
onChange(next);
|
||
};
|
||
const [objectSearch, setObjectSearch] = useState("");
|
||
const [selected, setSelected] = useState(scene.objects[0]?.id || "");
|
||
const [mode, setMode] = useState<"translate" | "rotate" | "scale">("translate");
|
||
const [cameraView, setCameraView] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const viewport = useRef<DirectorViewportHandle>(null);
|
||
const [previewTime, setPreviewTime] = useState<number | null>(null);
|
||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||
const recording = useRef<AbortController | null>(null);
|
||
useEffect(() => () => recording.current?.abort(), []);
|
||
const [exportedVideo, setExportedVideo] = useState<{
|
||
blob: Blob;
|
||
url: string;
|
||
width: number;
|
||
height: number;
|
||
duration: number;
|
||
} | null>(null);
|
||
useEffect(
|
||
() => () => {
|
||
if (exportedVideo) URL.revokeObjectURL(exportedVideo.url);
|
||
},
|
||
[exportedVideo],
|
||
);
|
||
const exportMotion = async () => {
|
||
if (busy) return;
|
||
setBusy(true);
|
||
setExportProgress(0);
|
||
const controller = new AbortController();
|
||
recording.current = controller;
|
||
try {
|
||
const motion = current.current.motion;
|
||
const dimensions = directorImageSize(current.current.ratio);
|
||
const blob = await viewport.current!.record(controller.signal, setExportProgress);
|
||
setExportedVideo({
|
||
blob,
|
||
url: URL.createObjectURL(blob),
|
||
...dimensions,
|
||
duration: motion.duration,
|
||
});
|
||
toast.success("真实运镜视频已导出,可下载或发送到画布");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "轨迹导出失败");
|
||
} finally {
|
||
recording.current = null;
|
||
setBusy(false);
|
||
setExportProgress(null);
|
||
}
|
||
};
|
||
const sendMotion = async () => {
|
||
if (busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const motion = current.current.motion;
|
||
const pose = sampleDirectorMotion(motion, 0);
|
||
if (!pose || motion.keyframes.length < 2) throw Error("至少需要两个关键帧");
|
||
const image = await viewport.current!.capture(pose);
|
||
const url = await upload(
|
||
new File([image.blob], `director-motion-${Date.now()}.png`, {
|
||
type: "image/png",
|
||
}),
|
||
);
|
||
const snapshot = {
|
||
id: crypto.randomUUID(),
|
||
title: "运镜首帧",
|
||
url,
|
||
width: image.width,
|
||
height: image.height,
|
||
cameraId: current.current.activeCameraId,
|
||
};
|
||
commit({
|
||
...current.current,
|
||
snapshots: [...current.current.snapshots, snapshot],
|
||
});
|
||
onSendMotion(snapshot, motion);
|
||
toast.success("首帧和运镜描述已发送,请在视频节点选择模型");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "发送失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
const fileInput = useRef<HTMLInputElement>(null);
|
||
const camera = scene.cameras.find((c) => c.id === scene.activeCameraId) || scene.cameras[0];
|
||
const object = scene.objects.find((o) => o.id === selected);
|
||
const patchObject = (id: string, patch: Partial<DirectorObject>) =>
|
||
commit({
|
||
...current.current,
|
||
objects: current.current.objects.map((o) => (o.id === id ? { ...o, ...patch } : o)),
|
||
});
|
||
const patchCamera = (patch: Partial<typeof camera>) =>
|
||
commit({
|
||
...current.current,
|
||
cameras: current.current.cameras.map((c) =>
|
||
c.id === current.current.activeCameraId ? mergeDirectorCameraPose(c, patch) : c,
|
||
),
|
||
});
|
||
const addObject = (kind: DirectorObject["kind"], name: string, url?: string) => {
|
||
const id = crypto.randomUUID();
|
||
commit({
|
||
...current.current,
|
||
objects: [
|
||
...current.current.objects,
|
||
{
|
||
id,
|
||
kind,
|
||
name,
|
||
url,
|
||
position: [current.current.objects.length * 1.2, 0.5, 0],
|
||
rotation: [0, 0, 0],
|
||
scale: [1, 1, 1],
|
||
},
|
||
],
|
||
});
|
||
setSelected(id);
|
||
};
|
||
const screenshot = async () => {
|
||
if (busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const image = await viewport.current!.capture();
|
||
const url = await upload(
|
||
new File([image.blob], `director-${Date.now()}.png`, {
|
||
type: "image/png",
|
||
}),
|
||
);
|
||
const active = current.current.cameras.find((c) => c.id === current.current.activeCameraId)!;
|
||
const snapshot = {
|
||
id: crypto.randomUUID(),
|
||
title: `${active.name}-截图${current.current.snapshots.length + 1}`,
|
||
url,
|
||
width: image.width,
|
||
height: image.height,
|
||
cameraId: active.id,
|
||
};
|
||
commit({
|
||
...current.current,
|
||
snapshots: [...current.current.snapshots, snapshot],
|
||
});
|
||
toast.success("真实机位截图已保存");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "截图失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
const field = "w-full rounded bg-white/10 px-2 py-1.5 text-sm outline-none";
|
||
const button =
|
||
"whitespace-nowrap rounded-lg border border-white/10 px-2 py-2 text-sm hover:bg-white/10 disabled:opacity-40";
|
||
return (
|
||
<div
|
||
role="dialog"
|
||
aria-label="3D导演台"
|
||
data-canvas-overlay="true"
|
||
className="fixed inset-0 z-[600] flex flex-col bg-[#141414] text-white"
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
>
|
||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-white/10 px-5">
|
||
<strong>3D导演台</strong>
|
||
<div className="flex gap-2">
|
||
<button
|
||
className={button}
|
||
aria-pressed={!cameraView}
|
||
disabled={busy}
|
||
onClick={() => setCameraView(false)}
|
||
>
|
||
导演视角
|
||
</button>
|
||
<button
|
||
className={button}
|
||
aria-pressed={cameraView}
|
||
disabled={busy}
|
||
onClick={() => setCameraView(true)}
|
||
>
|
||
机位视角
|
||
</button>
|
||
</div>
|
||
<button
|
||
aria-label="关闭导演台"
|
||
className={button}
|
||
onClick={() => {
|
||
recording.current?.abort();
|
||
onClose();
|
||
}}
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</header>
|
||
<div className="grid min-h-0 flex-1 grid-cols-[210px_minmax(0,1fr)_260px]">
|
||
<aside inert={busy} className="overflow-y-auto border-r border-white/10 p-4">
|
||
<div className="mb-3 text-sm text-white/60">场景对象</div>
|
||
<input
|
||
aria-label="搜索场景对象"
|
||
className={`${field} mb-3`}
|
||
placeholder="搜索场景对象"
|
||
value={objectSearch}
|
||
onChange={(e) => setObjectSearch(e.target.value)}
|
||
/>
|
||
<div className="space-y-2">
|
||
{scene.objects
|
||
.filter((o) => o.name.toLowerCase().includes(objectSearch.trim().toLowerCase()))
|
||
.map((o) => (
|
||
<button
|
||
className={`${button} w-full text-left ${selected === o.id ? "bg-white/15" : ""}`}
|
||
key={o.id}
|
||
onClick={() => setSelected(o.id)}
|
||
>
|
||
{o.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-5 flex flex-col gap-2">
|
||
<button className={button} onClick={() => addObject("actor", "基础人偶")}>
|
||
添加人偶
|
||
</button>
|
||
<button className={button} onClick={() => addObject("box", "立方体")}>
|
||
添加立方体
|
||
</button>
|
||
<button className={button} onClick={() => addObject("sphere", "球体")}>
|
||
添加球体
|
||
</button>
|
||
<button className={button} disabled={busy} onClick={() => fileInput.current?.click()}>
|
||
<Upload size={14} className="mr-2 inline" />
|
||
上传 GLB 模型
|
||
</button>
|
||
<button className={button} disabled title="尚未接入图片识别与场景重建">
|
||
AI 识图导入(未接通)
|
||
</button>
|
||
</div>
|
||
<p className="mt-4 text-xs leading-5 text-white/45">
|
||
左键旋转视角,右键平移,滚轮缩放。选择对象后使用变换轴调整。
|
||
</p>
|
||
</aside>
|
||
<main
|
||
inert={busy}
|
||
className="relative flex min-h-0 items-center justify-center overflow-hidden bg-[#080808]"
|
||
>
|
||
<div
|
||
className="h-full max-h-full w-full"
|
||
style={{ aspectRatio: scene.ratio.replace(":", " / ") }}
|
||
>
|
||
<DirectorViewport
|
||
ref={viewport}
|
||
scene={scene}
|
||
selected={selected}
|
||
mode={mode}
|
||
cameraView={cameraView}
|
||
previewTime={previewTime}
|
||
locked={busy}
|
||
onTransform={patchObject}
|
||
onCamera={(position, target) => patchCamera({ position, target })}
|
||
/>
|
||
</div>
|
||
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-1 rounded-xl bg-black/75 p-2">
|
||
{(["translate", "rotate", "scale"] as const).map((m, i) => (
|
||
<button
|
||
key={m}
|
||
className={`${button} ${mode === m ? "bg-white/20" : ""}`}
|
||
onClick={() => setMode(m)}
|
||
>
|
||
{["移动", "旋转", "缩放"][i]}
|
||
</button>
|
||
))}
|
||
<button className={button} disabled={busy} onClick={screenshot}>
|
||
<Camera className="mr-1 inline" size={16} />
|
||
{busy ? "处理中..." : "截图"}
|
||
</button>
|
||
</div>
|
||
</main>
|
||
<aside className="space-y-4 overflow-y-auto border-l border-white/10 p-4">
|
||
<fieldset disabled={busy || previewTime !== null} className="space-y-4">
|
||
<label className="block text-sm">
|
||
机位
|
||
<select
|
||
aria-label="机位"
|
||
className={`${field} mt-2`}
|
||
value={scene.activeCameraId}
|
||
onChange={(e) => commit({ ...current.current, activeCameraId: e.target.value })}
|
||
>
|
||
{scene.cameras.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="flex flex-wrap gap-1">
|
||
{[
|
||
["正面", [0, 2, 7]],
|
||
["侧面", [7, 2, 0]],
|
||
["俯视", [0, 8, 0.1]],
|
||
["正面特写", [0, 1.7, 2]],
|
||
["正面全景", [0, 2, 10]],
|
||
["侧面近景", [2, 1.5, 0]],
|
||
["背面", [0, 2, -7]],
|
||
["45°俯拍", [4, 6, 4]],
|
||
["低机位", [0, 0.3, 4]],
|
||
["左过肩", [-1, 1.8, -2]],
|
||
["右过肩", [1, 1.8, -2]],
|
||
["鸟瞰", [0, 14, 0.1]],
|
||
].map(([name, position]) => (
|
||
<button
|
||
className={button}
|
||
key={String(name)}
|
||
onClick={() => {
|
||
const id = crypto.randomUUID();
|
||
commit({
|
||
...current.current,
|
||
activeCameraId: id,
|
||
cameras: [
|
||
...current.current.cameras,
|
||
{
|
||
id,
|
||
name: `${name}机位${current.current.cameras.length + 1}`,
|
||
position: position as DirectorVector,
|
||
target: [0, 1, 0],
|
||
fov: 50,
|
||
},
|
||
],
|
||
});
|
||
}}
|
||
>
|
||
<Plus size={12} className="inline" />
|
||
{String(name)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="block text-sm">
|
||
视角 FOV
|
||
<input
|
||
aria-label="视角 FOV"
|
||
className={`${field} mt-2`}
|
||
type="number"
|
||
min={15}
|
||
max={90}
|
||
value={Number(camera.fov.toFixed(2))}
|
||
onChange={(e) => {
|
||
const n = Number(e.target.value);
|
||
if (n >= 15 && n <= 90) patchCamera({ fov: n });
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className="block text-sm">
|
||
截图比例
|
||
<select
|
||
aria-label="截图比例"
|
||
className={`${field} mt-2`}
|
||
value={scene.ratio}
|
||
onChange={(e) => commit({ ...current.current, ratio: e.target.value })}
|
||
>
|
||
{directorRatios.map((r) => (
|
||
<option key={r}>{r}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{(["position", "target"] as const).map((key, j) => (
|
||
<label className="block text-xs" key={key}>
|
||
{j === 0 ? "相机位置" : "相机注视点"}
|
||
<div className="mt-1 grid grid-cols-3 gap-1">
|
||
{camera[key].map((n, i) => (
|
||
<input
|
||
key={i}
|
||
aria-label={`相机-${key}-${"XYZ"[i]}`}
|
||
className={field}
|
||
type="number"
|
||
step="0.1"
|
||
value={Number(n.toFixed(2))}
|
||
onChange={(e) => {
|
||
const n = Number(e.target.value);
|
||
if (!Number.isFinite(n) || Math.abs(n) > 10000) return;
|
||
const values = [...camera[key]] as DirectorVector;
|
||
values[i] = n;
|
||
patchCamera({ [key]: values });
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</label>
|
||
))}
|
||
{object ? (
|
||
<section className="space-y-2 border-t border-white/10 pt-3">
|
||
<div className="flex items-center justify-between text-sm">
|
||
<input
|
||
aria-label="对象名称"
|
||
className={field}
|
||
value={object.name}
|
||
maxLength={80}
|
||
onChange={(e) => patchObject(object.id, { name: e.target.value })}
|
||
/>
|
||
<button
|
||
aria-label="删除所选场景对象"
|
||
onClick={() => {
|
||
commit({
|
||
...current.current,
|
||
objects: current.current.objects.filter((o) => o.id !== object.id),
|
||
});
|
||
setSelected("");
|
||
}}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-3 text-xs">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={object.visible !== false}
|
||
onChange={(e) => patchObject(object.id, { visible: e.target.checked })}
|
||
/>{" "}
|
||
显示
|
||
</label>
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={object.locked === true}
|
||
onChange={(e) => patchObject(object.id, { locked: e.target.checked })}
|
||
/>{" "}
|
||
锁定变换轴
|
||
</label>
|
||
{object.kind !== "model" && (
|
||
<label>
|
||
颜色{" "}
|
||
<input
|
||
aria-label="对象颜色"
|
||
type="color"
|
||
value={object.color || (object.kind === "actor" ? "#687bb0" : "#c3664c")}
|
||
onChange={(e) => patchObject(object.id, { color: e.target.value })}
|
||
/>
|
||
</label>
|
||
)}
|
||
</div>
|
||
{object.kind === "actor" && (
|
||
<label className="block text-xs">
|
||
人偶姿势
|
||
<select
|
||
aria-label="人偶姿势"
|
||
className={`${field} mt-1`}
|
||
value={object.pose || "站立"}
|
||
onChange={(e) =>
|
||
patchObject(object.id, { pose: e.target.value as DirectorPoseName })
|
||
}
|
||
>
|
||
{Object.keys(directorPoses).map((name) => (
|
||
<option key={name}>{name}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
<button
|
||
className={button}
|
||
onClick={() => {
|
||
const copy = {
|
||
...object,
|
||
id: crypto.randomUUID(),
|
||
name: `${object.name} 副本`,
|
||
position: [
|
||
object.position[0] + 1,
|
||
...object.position.slice(1),
|
||
] as DirectorVector,
|
||
};
|
||
commit({ ...current.current, objects: [...current.current.objects, copy] });
|
||
setSelected(copy.id);
|
||
}}
|
||
>
|
||
复制对象
|
||
</button>
|
||
<button
|
||
className={button}
|
||
onClick={() =>
|
||
patchCamera({
|
||
target: [object.position[0], object.position[1] + 1, object.position[2]],
|
||
})
|
||
}
|
||
>
|
||
相机对准所选对象
|
||
</button>
|
||
<label className="block text-xs">
|
||
等比缩放
|
||
<input
|
||
aria-label="等比缩放"
|
||
className={`${field} mt-1`}
|
||
type="number"
|
||
step="0.1"
|
||
min="0.01"
|
||
value={Number(object.scale[0].toFixed(2))}
|
||
onChange={(e) => {
|
||
const n = Number(e.target.value);
|
||
if (n > 0 && Number.isFinite(n))
|
||
patchObject(object.id, {
|
||
scale: object.scale.map(
|
||
(v) => (v * n) / object.scale[0],
|
||
) as DirectorVector,
|
||
});
|
||
}}
|
||
/>
|
||
</label>
|
||
{(["position", "rotation", "scale"] as const).map((key, j) => (
|
||
<div key={key}>
|
||
<div className="mb-1 text-xs text-white/50">
|
||
{["位置", "旋转(度)", "缩放"][j]}
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-1">
|
||
{object[key].map((v, i) => (
|
||
<input
|
||
key={i}
|
||
aria-label={`${object.name}-${key}-${"XYZ"[i]}`}
|
||
className={field}
|
||
type="number"
|
||
step="0.1"
|
||
value={Number((key === "rotation" ? (v * 180) / Math.PI : v).toFixed(2))}
|
||
onChange={(e) => {
|
||
const n = Number(e.target.value);
|
||
if (!Number.isFinite(n) || (key === "scale" && n <= 0)) return;
|
||
const values = [...object[key]] as DirectorVector;
|
||
values[i] = key === "rotation" ? (n * Math.PI) / 180 : n;
|
||
patchObject(object.id, { [key]: values });
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</section>
|
||
) : null}
|
||
</fieldset>
|
||
{exportedVideo ? (
|
||
<section className="space-y-2 border-t border-white/10 pt-3" aria-label="运镜导出结果">
|
||
<video
|
||
controls
|
||
src={exportedVideo.url}
|
||
className="w-full"
|
||
aria-label="真实运镜导出预览"
|
||
/>
|
||
<a
|
||
className={button}
|
||
href={exportedVideo.url}
|
||
download={`director-motion.${exportedVideo.blob.type.includes("mp4") ? "mp4" : "webm"}`}
|
||
>
|
||
下载运镜视频
|
||
</a>
|
||
<button
|
||
disabled={busy}
|
||
className={`${button} w-full`}
|
||
onClick={async () => {
|
||
setBusy(true);
|
||
try {
|
||
const ext = exportedVideo.blob.type.includes("mp4") ? "mp4" : "webm";
|
||
const url = await upload(
|
||
new File([exportedVideo.blob], `director-motion-${Date.now()}.${ext}`, {
|
||
type: exportedVideo.blob.type.split(";")[0],
|
||
}),
|
||
);
|
||
onSendVideo({ ...exportedVideo, url });
|
||
toast.success("运镜视频已发送到画布");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "上传失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}}
|
||
>
|
||
发送真实运镜视频到画布
|
||
</button>
|
||
</section>
|
||
) : null}
|
||
<section className="space-y-2 border-t border-white/10 pt-3">
|
||
<div className="text-sm">机位截图({scene.snapshots.length})</div>
|
||
{scene.snapshots.map((s) => (
|
||
<div key={s.id} className="rounded-lg border border-white/10 p-2">
|
||
<img src={s.url} alt={s.title} className="w-full rounded" />
|
||
<div className="mt-1 truncate text-xs">
|
||
{s.title} · {s.width}×{s.height}
|
||
</div>
|
||
<button
|
||
className={`${button} mt-2 w-full`}
|
||
onClick={() => {
|
||
onSend([s]);
|
||
toast.success("截图已发送到画布");
|
||
}}
|
||
>
|
||
发送到画布
|
||
</button>
|
||
</div>
|
||
))}
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
<details
|
||
open
|
||
className="max-h-[32vh] shrink-0 overflow-auto border-t border-white/15 bg-[#1c1c1c] px-4 pb-3"
|
||
>
|
||
<summary className="cursor-pointer py-2 text-sm">相机时间轴与运镜</summary>
|
||
<DirectorMotionPanel
|
||
compact
|
||
motion={scene.motion}
|
||
pose={camera}
|
||
disabled={busy}
|
||
onChange={(motion) => commit({ ...current.current, motion })}
|
||
onPose={patchCamera}
|
||
onPreview={setPreviewTime}
|
||
onExport={exportMotion}
|
||
onSend={sendMotion}
|
||
exportProgress={exportProgress}
|
||
onCancel={() => recording.current?.abort()}
|
||
onOverview={() => {
|
||
setCameraView(false);
|
||
viewport.current?.showPath();
|
||
}}
|
||
/>
|
||
</details>
|
||
<input
|
||
ref={fileInput}
|
||
className="hidden"
|
||
type="file"
|
||
accept=".glb,model/gltf-binary"
|
||
onChange={async (e) => {
|
||
const file = e.target.files?.[0];
|
||
e.target.value = "";
|
||
if (!file) return;
|
||
if (!/\.glb$/i.test(file.name) || file.size > 25 * 1024 * 1024) {
|
||
toast.error("请上传 25 MiB 以内的 GLB 模型");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
const header = new DataView(await file.slice(0, 12).arrayBuffer());
|
||
if (header.byteLength < 12 || header.getUint32(0, true) !== 0x46546c67)
|
||
throw Error("文件不是有效 GLB 模型");
|
||
const url = await upload(file);
|
||
addObject("model", file.name, url);
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "模型上传失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|