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

648 lines
22 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 { 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 [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 =
"rounded-lg border border-white/10 px-3 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>
<div className="space-y-2">
{scene.objects.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]],
].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={camera.fov}
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">
<span>{object.name}</span>
<button
aria-label="删除所选场景对象"
onClick={() => {
commit({
...current.current,
objects: current.current.objects.filter(
(o) => o.id !== object.id,
),
});
setSelected("");
}}
>
<Trash2 size={14} />
</button>
</div>
{(["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(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] = n;
patchObject(object.id, { [key]: values });
}}
/>
))}
</div>
</div>
))}
</section>
) : null}
</fieldset>
<DirectorMotionPanel
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();
}}
/>
{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>
<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>
);
}