ai-chatbot/components/canvas/tvstar/CharacterStudioPanel.tsx

145 lines
12 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, useState } from "react";
import { ArrowRight, Check, Loader2, Sparkles, Upload, X } from "lucide-react";
import { requestCreativeAnalysis, type CreativeAnalysisModel } from "./creativeAnalysisClient";
export type StudioCharacter = {
name: string;
description: string;
look?: string;
wardrobe?: string;
signatureAction?: string;
traits?: string[];
prompt: string;
};
const styles = ["电影写实", "国风动画", "二维动漫", "三维动画", "插画概念"];
const views = ["角色立绘", "脸部近景", "表情参考", "角色三视图"] as const;
export type StudioView = (typeof views)[number];
export function CharacterStudioPanel({
canvasId,
models,
onApply,
onClose,
onOpenLibrary,
onUpload,
}: {
canvasId: string;
models: CreativeAnalysisModel[];
onApply: (character: StudioCharacter, style: string, selectedViews: StudioView[], referenceUrl?: string) => Promise<boolean>;
onClose: () => void;
onOpenLibrary: () => void;
onUpload: (file: File) => Promise<string>;
}) {
const [description, setDescription] = useState("");
const [style, setStyle] = useState(styles[0]);
const [characters, setCharacters] = useState<StudioCharacter[]>([]);
const [selected, setSelected] = useState(0);
const [selectedViews, setSelectedViews] = useState<StudioView[]>([...views]);
const [referenceUrl, setReferenceUrl] = useState<string>();
const [referenceName, setReferenceName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [restoredDraftFor, setRestoredDraftFor] = useState("");
const character = characters[selected];
useEffect(() => {
try {
const draft = JSON.parse(localStorage.getItem(`tvstar:character-draft:${canvasId}`) || "null");
if (draft && typeof draft === "object") {
if (typeof draft.description === "string") setDescription(draft.description);
if (styles.includes(draft.style)) setStyle(draft.style);
if (Array.isArray(draft.characters)) setCharacters(draft.characters.filter((item: StudioCharacter) =>
item && typeof item.name === "string" && typeof item.prompt === "string").slice(0, 8));
if (Number.isInteger(draft.selected)) setSelected(Math.max(0, draft.selected));
if (Array.isArray(draft.selectedViews)) setSelectedViews(draft.selectedViews.filter((item: StudioView) =>
views.includes(item)));
if (typeof draft.referenceUrl === "string") setReferenceUrl(draft.referenceUrl);
if (typeof draft.referenceName === "string") setReferenceName(draft.referenceName);
}
} catch { /* a damaged local draft must not block the studio */ }
setRestoredDraftFor(canvasId);
}, [canvasId]);
useEffect(() => {
if (restoredDraftFor !== canvasId) return;
try { localStorage.setItem(`tvstar:character-draft:${canvasId}`, JSON.stringify({
description, style, characters, selected, selectedViews, referenceUrl, referenceName,
})); } catch { /* storage is optional */ }
}, [canvasId, characters, description, referenceName, referenceUrl, restoredDraftFor, selected, selectedViews, style]);
const resetDraft = () => {
setDescription(""); setStyle(styles[0]); setCharacters([]); setSelected(0);
setSelectedViews([...views]); setReferenceUrl(undefined); setReferenceName(""); setError("");
};
const analyze = async () => {
if (!description.trim() || busy) return;
setError(""); setBusy(true);
try {
const data = await requestCreativeAnalysis({
mode: "character-scene-extraction",
nodeTitle: "角色造型室",
models,
prompt: `视觉风格:${style}。请提取并具体定义角色身份、稳定的面部与服装特征,提供可复用的造型提示词。\n角色描述:${description.trim()}`,
imageUrls: referenceUrl ? [referenceUrl] : [],
});
const items = Array.isArray(data.characters) ? data.characters.filter((item): item is StudioCharacter => Boolean(item && typeof item === "object" && (item as StudioCharacter).name && (item as StudioCharacter).prompt)).slice(0, 8) : [];
if (!items.length) throw new Error("模型没有返回有效角色,请补充外观描述后重试");
setCharacters(items);
setSelected(0);
} catch (cause) { setError(cause instanceof Error ? cause.message : "角色分析失败"); }
finally { setBusy(false); }
};
const upload = async (file?: File) => {
if (!file || !file.type.startsWith("image/")) return;
setError(""); setBusy(true);
try { setReferenceUrl(await onUpload(file)); setReferenceName(file.name); }
catch (cause) { setError(cause instanceof Error ? cause.message : "参考图上传失败"); }
finally { setBusy(false); }
};
const toggleView = (view: StudioView) => setSelectedViews(current => current.includes(view) ? current.filter(item => item !== view) : [...current, view]);
const apply = async () => {
if (!character || !selectedViews.length || busy) return;
setError(""); setBusy(true);
try {
if (!await onApply(character, style, selectedViews, referenceUrl))
setError("角色视图任务未能启动,请检查模型与生成配置后重试");
} catch (cause) { setError(cause instanceof Error ? cause.message : "角色视图任务未能启动"); }
finally { setBusy(false); }
};
return <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/65 p-4" data-canvas-overlay="true" role="presentation">
<section aria-label="角色造型室" aria-modal="true" className="flex h-[min(720px,calc(100vh-32px))] w-[min(1060px,calc(100vw-32px))] flex-col overflow-hidden rounded-[22px] border border-white/10 bg-[#202020] text-white shadow-[0_30px_90px_rgba(0,0,0,.6)]" role="dialog">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-white/10 px-5"><div><h2 className="text-base font-medium">角色造型室</h2><p className="text-[11px] text-white/40">建立稳定角色设定,再送入画布生成视图</p></div><div className="flex items-center gap-2"><button className="rounded-lg px-2 py-1 text-xs text-white/50 hover:bg-white/10" disabled={busy} onClick={resetDraft}>新角色</button><button aria-label="关闭角色造型室" className="rounded-lg p-2 text-white/60 hover:bg-white/10" onClick={onClose}><X className="size-4" /></button></div></header>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(260px,340px)_1fr] max-md:grid-cols-1">
<div className="flex min-h-0 flex-col border-r border-white/10 p-5 max-md:border-b max-md:border-r-0">
<label className="mb-2 text-xs text-white/65" htmlFor="tvstar-character-description">角色描述</label>
<textarea className="min-h-44 w-full resize-none rounded-xl border border-white/10 bg-[#292929] p-3 text-[13px] leading-6 outline-none placeholder:text-white/30 focus:border-cyan-300/50" id="tvstar-character-description" onChange={event => setDescription(event.target.value)} placeholder="例如:二十多岁的机械师,短银发,左眼下有一颗痣,穿旧皮夹克,性格沉着…" value={description} />
<label className="mb-2 mt-4 text-xs text-white/65" htmlFor="tvstar-character-style">视觉风格</label>
<select className="h-10 rounded-xl border border-white/10 bg-[#292929] px-3 text-[13px] outline-none" id="tvstar-character-style" onChange={event => setStyle(event.target.value)} value={style}>{styles.map(item => <option key={item}>{item}</option>)}</select>
<label className="mt-4 flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-white/15 bg-white/[.03] px-3 py-3 text-xs text-white/60 hover:bg-white/[.06]"><Upload className="size-4" />{referenceName || "上传角色参考图(可选)"}<input accept="image/*" className="hidden" onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; void upload(file); }} type="file" /></label>
{referenceUrl ? <button className="mt-2 self-start text-[11px] text-white/40 hover:text-white" onClick={() => { setReferenceUrl(undefined); setReferenceName(""); }}>移除参考图</button> : null}
{error ? <p className="mt-3 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-200" role="alert">{error}</p> : null}
<div className="mt-auto space-y-2 pt-5"><button className="flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-white text-[13px] font-medium text-[#1f1f1f] disabled:opacity-40" disabled={!description.trim() || busy} onClick={() => void analyze()}>{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}{busy ? "正在分析角色…" : "分析角色设定"}</button><button className="flex h-9 w-full items-center justify-center gap-1 rounded-xl text-xs text-white/55 hover:bg-white/5" onClick={onOpenLibrary}>浏览现有角色库 <ArrowRight className="size-3" /></button></div>
</div>
<div className="min-h-0 overflow-y-auto p-5">
{character ? <>
<div className="mb-4 flex flex-wrap gap-2">{characters.map((item, index) => <button aria-pressed={selected === index} className={`rounded-lg px-3 py-1.5 text-xs ${selected === index ? "bg-white text-[#252525]" : "bg-white/10 text-white/65 hover:bg-white/15"}`} key={`${index}-${item.name}`} onClick={() => setSelected(index)}>{item.name}</button>)}</div>
<h3 className="mb-2 text-lg font-medium">{character.name}</h3><p className="text-[13px] leading-6 text-white/65">{character.description}</p>
<div className="mt-4 grid grid-cols-2 gap-2 text-xs max-sm:grid-cols-1">{[["外观锚点", character.look], ["服装与道具", character.wardrobe], ["代表动作", character.signatureAction], ["性格关键词", character.traits?.join(" · ")]].filter(([, value]) => value).map(([label, value]) => <div className="rounded-xl bg-white/5 p-3" key={label}><span className="mb-1 block text-white/40">{label}</span><span className="leading-5 text-white/80">{value}</span></div>)}</div>
<div className="mt-5"><h4 className="mb-3 text-xs text-white/55">选择要创建的角色视图</h4><div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">{views.map((view, index) => <button aria-pressed={selectedViews.includes(view)} className={`relative flex h-32 flex-col items-center justify-center rounded-xl border text-xs ${selectedViews.includes(view) ? "border-cyan-300/70 bg-cyan-300/10" : "border-white/10 bg-white/5"}`} key={view} onClick={() => toggleView(view)}><span className="mb-3 text-2xl text-white/30">{["◉", "◌", "☺", "◧"][index]}</span>{view}{selectedViews.includes(view) ? <Check className="absolute right-2 top-2 size-3 text-cyan-300" /> : null}</button>)}</div></div>
<div className="mt-5 rounded-xl border border-white/10 bg-white/5 p-3"><p className="mb-2 text-[11px] text-white/40">一致性提示词</p><p className="text-xs leading-5 text-white/70">{character.prompt}</p></div>
<button className="mt-4 h-10 rounded-xl bg-white px-5 text-[13px] font-medium text-[#252525] disabled:opacity-40" disabled={!selectedViews.length || busy} onClick={() => void apply()}>{busy ? "正在启动角色出图…" : `生成并应用至画布 · ${selectedViews.length} 个视图`}</button>
</> : <div className="flex h-full min-h-56 flex-col items-center justify-center text-center"><div className="mb-5 flex size-20 items-center justify-center rounded-full bg-gradient-to-br from-cyan-300/15 via-indigo-400/15 to-violet-400/15 text-3xl text-cyan-200">☺</div><p className="text-sm text-white/75">从一个角色想法开始</p><p className="mt-2 max-w-sm text-xs leading-5 text-white/40">分析身份、面部锚点、服装与动作,再创建立绘、近景、表情和三视图节点。</p></div>}
</div>
</div>
</section>
</div>;
}