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

119 lines
4.1 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 { 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>
);
}