feat(core): 基础资产页按流程文档补完 step4(人物/场景详情+演员库+三视图绑定立绘)
人物/场景: - 卡片可点开详情弹窗:立绘大图(hover 右下角 下载/查看大图)+ 立绘版本切换 + 提示词重跑(覆盖卡片立绘,归入同角色)+ 应用到当前项目;场景详情不显示三视图 - 进入基础资产自动生成脚本提取的待生成人物/场景(带 loading),每项目一次 - 「替换」→ 演员库 replace 回填;人物区头部「演员库」入口 三视图:与「某一版立绘」1:1 绑定(metadata.triview_of=立绘组id) - 生成/重跑立绘 → 链式据该立绘 image_edit 出配套三视图(锁角色一致性) - 详情切换立绘版本 → 三视图跟着切;可据当前立绘再出多版,下方版本小图可切 - 后端 generate_person_triview + 端点 generate-triview,复用 model_library 三视图 SOP 商品三视图弹窗:记录版本、切换查看、重跑生成、采用此版本 演员库(新建 components/actor-library.tsx 覆盖层): - 平台预设演员 / 我的演员两 tab + 添加演员工作台(AI 生成 / 本地上传保存) - 替换基础资产卡:新增后端 attach-base-asset(把现有资产挂为候选并采用) 合并 dev 进来的火山真人审核绿/红盾到新的实体卡。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { Asset } from "../types";
|
||||
import { useBodyScrollLock, MediaLightbox } from "./overlays";
|
||||
|
||||
// 流程步骤4 · 演员库:平台预设演员 + 我的演员(本地上传/AI 生成)。
|
||||
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从演员库选一个回填到某基础资产卡)。
|
||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
const previewOf = (a: Asset): string => a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
|
||||
// 平台预设 = 模特库生成的(metadata.kind==="model")或系统来源;其余 person 资产归「我的演员」
|
||||
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model" || a.source === "system" || a.source === "ai_generated";
|
||||
|
||||
export function ActorLibrary({ open, mode, assets, onClose, onPick, onGenerate, onUpload, onRefresh }: {
|
||||
open: boolean;
|
||||
mode: "browse" | "replace";
|
||||
assets: Asset[];
|
||||
onClose: () => void;
|
||||
onPick?: (assetId: string) => void | Promise<unknown>;
|
||||
onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
||||
onUpload: (file: File) => Promise<unknown>;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"preset" | "mine">("preset");
|
||||
const [studio, setStudio] = useState(false); // 添加演员工作台
|
||||
const [studioMode, setStudioMode] = useState<"ai" | "upload">("ai");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
useBodyScrollLock(open);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// person 类资产 → 演员;按 preset / mine 分两 tab
|
||||
const people = useMemo(() => assets.filter((a) => a.category === "person" && previewOf(a)), [assets]);
|
||||
const list = people.filter((a) => (tab === "preset" ? isPreset(a) : !isPreset(a)));
|
||||
|
||||
async function genActor() {
|
||||
const p = prompt.trim() || "电商真人模特,自然光,干净背景,9:16 竖屏,正面半身";
|
||||
setBusy(true);
|
||||
try {
|
||||
await onGenerate(p);
|
||||
onRefresh();
|
||||
setStudio(false);
|
||||
setTab("mine");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
async function pickFile(file?: File | null) {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
onRefresh();
|
||||
setStudio(false);
|
||||
setTab("mine");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div className="actorlib-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="actorlib" role="dialog" aria-modal="true" aria-label="演员库">
|
||||
<div className="actorlib-h">
|
||||
<h2>{mode === "replace" ? "演员库 · 选择演员替换" : "演员库"}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点演员卡即替换当前立绘" : "平台预设演员 / 我的演员"}</span>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{studio ? (
|
||||
<div className="actorlib-body">
|
||||
<div className="actorlib-studio-h">
|
||||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setStudio(false)}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M19 12H5M12 19l-7-7 7-7" /></svg>
|
||||
返回演员库
|
||||
</button>
|
||||
<strong style={{ fontSize: 14 }}>添加人物工作台</strong>
|
||||
</div>
|
||||
<div className="actorlib-tabs" style={{ marginBottom: 14 }}>
|
||||
<button className={`al-tab${studioMode === "ai" ? " active" : ""}`} type="button" onClick={() => setStudioMode("ai")}>AI 生成</button>
|
||||
<button className={`al-tab${studioMode === "upload" ? " active" : ""}`} type="button" onClick={() => setStudioMode("upload")}>本地上传</button>
|
||||
</div>
|
||||
{studioMode === "ai" ? (
|
||||
<div className="actorlib-studio-ai">
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 描述演员形象(年龄 / 风格 / 妆造 / 背景)</div>
|
||||
<textarea className="asset-prompt-edit" rows={4} placeholder="如:25 岁都市白领女性,通勤淡妆,简约棚拍背景,9:16 竖屏正面半身" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genActor()}>
|
||||
{busy ? "生成中…" : "AI 生成演员并保存"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="actorlib-studio-upload">
|
||||
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
||||
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地人物图片上传保存"}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>// JPG / PNG / WEBP</div>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void pickFile(e.target.files?.[0]); e.currentTarget.value = ""; }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="actorlib-body">
|
||||
<div className="actorlib-toolbar">
|
||||
<div className="actorlib-tabs">
|
||||
<button className={`al-tab${tab === "preset" ? " active" : ""}`} type="button" onClick={() => setTab("preset")}>平台预设演员 · {people.filter(isPreset).length}</button>
|
||||
<button className={`al-tab${tab === "mine" ? " active" : ""}`} type="button" onClick={() => setTab("mine")}>我的演员 · {people.filter((a) => !isPreset(a)).length}</button>
|
||||
</div>
|
||||
<span className="spacer" style={{ flex: 1 }}></span>
|
||||
<button className="btn btn-primary btn-sm" type="button" onClick={() => { setStudio(true); setStudioMode("ai"); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M12 5v14M5 12h14" /></svg>
|
||||
添加演员
|
||||
</button>
|
||||
</div>
|
||||
{list.length ? (
|
||||
<div className="actorlib-grid">
|
||||
{list.map((a) => {
|
||||
const url = previewOf(a);
|
||||
return (
|
||||
<div className="actor-card" key={a.id}>
|
||||
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}
|
||||
role="button" tabIndex={0} title={mode === "replace" ? "选用此演员替换" : "查看大图"}
|
||||
onClick={() => { if (mode === "replace") { void onPick?.(a.id); } else if (url) { setPreview({ src: url, name: a.name }); } }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (mode === "replace") { void onPick?.(a.id); } else if (url) { setPreview({ src: url, name: a.name }); } } }}>
|
||||
{!url && <span className="ph-frame">{a.name}</span>}
|
||||
{mode === "replace" && <span className="actor-pick">选用</span>}
|
||||
</div>
|
||||
<div className="actor-name" title={a.name}>{a.name}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
|
||||
<span className="ph-frame">// {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user