119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
"use client";
|
||
import { useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
|
||
import { insertReconstructedScene } from "@/lib/video-canvas/director-reconstruction";
|
||
export type ReconstructionTask = {
|
||
status: string;
|
||
error?: string | null;
|
||
result?: Record<string, unknown> | null;
|
||
};
|
||
export function DirectorReconstructionPanel({
|
||
scene,
|
||
onChange,
|
||
onSubmit,
|
||
task,
|
||
disabled,
|
||
}: {
|
||
scene: DirectorScene;
|
||
onChange: (s: DirectorScene) => void;
|
||
onSubmit: (input: { imageUrl: string; prompt: string }) => Promise<unknown>;
|
||
task?: ReconstructionTask;
|
||
disabled: boolean;
|
||
}) {
|
||
const [file, setFile] = useState<File | null>(null),
|
||
[prompt, setPrompt] = useState(""),
|
||
[busy, setBusy] = useState(false);
|
||
const waiting = task && ["pending", "running"].includes(task.status);
|
||
const result = task?.status === "succeeded" ? task.result : undefined;
|
||
return (
|
||
<fieldset
|
||
disabled={disabled || busy}
|
||
className="space-y-2 rounded border border-white/15 p-3 text-xs"
|
||
>
|
||
<legend>AI 识图重建布景</legend>
|
||
<p className="text-white/60">
|
||
从参考图识别人物、物体与机位,生成可编辑的简化几何布景;不包含精细网格和纹理。
|
||
</p>
|
||
<input
|
||
aria-label="重建参考图"
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||
/>
|
||
<textarea
|
||
aria-label="重建要求"
|
||
className="w-full rounded bg-[#303030] p-2"
|
||
value={prompt}
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
placeholder="需要保留的主体、构图与大致尺寸"
|
||
maxLength={2000}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={!file || !!waiting}
|
||
className="rounded bg-amber-400 px-3 py-2 text-black disabled:opacity-40"
|
||
onClick={async () => {
|
||
if (!file) return;
|
||
if (file.size > 20 * 1024 * 1024) {
|
||
toast.error("参考图需小于 20 MiB");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
form.append("purpose", "director-studio");
|
||
const response = await fetch("/api/files/upload", {
|
||
method: "POST",
|
||
body: form,
|
||
signal: AbortSignal.timeout(120000),
|
||
});
|
||
const data = await response.json();
|
||
const url = data.url || data.data?.url;
|
||
if (!response.ok || !url) throw Error(data.error || "参考图上传失败");
|
||
await onSubmit({ imageUrl: url, prompt });
|
||
toast.success("重建任务已提交,结果将保存为独立导演台节点");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "重建失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}}
|
||
>
|
||
{busy ? "正在提交…" : waiting ? "正在重建…" : "识图重建"}
|
||
</button>
|
||
{task?.error ? <p role="alert">{task.error}</p> : null}
|
||
{result?.directorScene ? (
|
||
<div>
|
||
<p className="my-2">{String(result.summary || "布景已生成")}</p>
|
||
{[false, true].map((replace) => (
|
||
<button
|
||
type="button"
|
||
key={String(replace)}
|
||
className="mr-2 rounded border border-white/20 px-2 py-1"
|
||
onClick={() => {
|
||
try {
|
||
onChange(
|
||
insertReconstructedScene(
|
||
scene,
|
||
readDirectorScene(result.directorScene),
|
||
replace,
|
||
() => crypto.randomUUID(),
|
||
),
|
||
);
|
||
toast.success(replace ? "已替换场景对象" : "已插入场景对象");
|
||
} catch (e) {
|
||
toast.error((e as Error).message);
|
||
}
|
||
}}
|
||
>
|
||
{replace ? "替换场景对象" : "插入当前场景"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</fieldset>
|
||
);
|
||
}
|