145 lines
12 KiB
TypeScript
145 lines
12 KiB
TypeScript
"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>;
|
||
}
|