feat: 解耦角色与模特并完善模特库

This commit is contained in:
hh
2026-07-13 11:02:30 +08:00
parent 564215dc53
commit fc0f0ca370
22 changed files with 1328 additions and 314 deletions
+13 -14
View File
@@ -29,7 +29,7 @@ import {
} from "lucide-react";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import { api } from "../api";
import { ActorLibrary } from "../components/actor-library";
import { ModelLibrary } from "../components/model-library";
import { SkeletonRows } from "../components/loading";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
@@ -664,8 +664,8 @@ export function ImageWorkbenchPage({
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
const [pickedIds, setPickedIds] = useState<string[]>([]);
// 模特库全屏弹窗(P0①:复用现成 ActorLibrary,mode=replace 选一个回填)
const [actorLibOpen, setActorLibOpen] = useState(false);
// 模特库全屏弹窗选一个模特回填
const [modelLibOpen, setModelLibOpen] = useState(false);
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
const [pickedModelName, setPickedModelName] = useState("");
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
@@ -749,7 +749,7 @@ export function ImageWorkbenchPage({
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ActorLibrary 弹窗同源,逻辑不动。 */
无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ModelLibrary 弹窗同源。 */
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
const loadModels = useCallback(() => {
api.listModels({ pageSize: 200 })
@@ -1854,7 +1854,7 @@ export function ImageWorkbenchPage({
const name = selected?.name || pickedModelName;
if (pickedIds[0]) {
return (
<button type="button" className="model-card selected" title="点击打开模特库更换模特" onClick={() => setActorLibOpen(true)}>
<button type="button" className="model-card selected" title="点击打开模特库更换模特" onClick={() => setModelLibOpen(true)}>
<div className="m-thumb">
{url ? (
<img className="m-thumb-img" src={url} alt={name} loading="lazy" />
@@ -1869,7 +1869,7 @@ export function ImageWorkbenchPage({
);
}
return (
<button type="button" className="model-card model-card-empty" title="从模特库选择模特" onClick={() => setActorLibOpen(true)}>
<button type="button" className="model-card model-card-empty" title="从模特库选择模特" onClick={() => setModelLibOpen(true)}>
<div className="m-thumb m-thumb-empty">
<Plus size={26} />
<span className="m-empty-label"></span>
@@ -2046,17 +2046,16 @@ export function ImageWorkbenchPage({
</section>
</div>
{/* P0①:模特库全屏弹窗(复用现成 ActorLibrary,mode=replace 选一个回填到「选择模特」) */}
<ActorLibrary
open={actorLibOpen}
{/* 模特库全屏弹窗:只选 Model,新增模特后刷新并回填。 */}
<ModelLibrary
open={modelLibOpen}
mode="replace"
assets={personAssets}
onClose={() => setActorLibOpen(false)}
onClose={() => setModelLibOpen(false)}
onPick={(assetId, assetName) => {
setPickedIds([assetId]);
setPickedModelName(assetName || "");
loadModels();
setActorLibOpen(false);
setModelLibOpen(false);
}}
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
onUpload={(file) => {
@@ -2064,7 +2063,7 @@ export function ImageWorkbenchPage({
fd.append("file", file);
fd.append("name", file.name);
fd.append("asset_type", "image");
fd.append("category", "person");
fd.append("category", "model_portrait");
return api.uploadAsset(fd).catch(() => null);
}}
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
@@ -2218,7 +2217,7 @@ function ProductLibraryModal({
onConfirm: () => void;
navigate?: (page: Page) => void;
}) {
// Esc 关闭 + body 滚动锁(ActorLibrary 一致体感)
// Esc 关闭 + body 滚动锁ModelLibrary 一致体感
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
+154 -32
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { User, X } from "lucide-react";
import { RefreshCw, Upload, User, X } from "lucide-react";
import { api } from "../api";
import type { ModelEntity } from "../types";
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
@@ -20,52 +20,165 @@ type Preview = { src: string; kind: "image"; name: string };
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
function ModelDetailModal({ model, close, onZoom }: { model: ModelEntity | null; close: () => void; onZoom: (p: Preview) => void }) {
function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
model: ModelEntity | null;
close: () => void;
onZoom: (p: Preview) => void;
onSaved: (model: ModelEntity) => void;
onNotify?: (type: "success" | "error", text: string) => void;
}) {
const open = !!model;
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [pendingPortrait, setPendingPortrait] = useState<File | null>(null);
const [pendingPortraitUrl, setPendingPortraitUrl] = useState("");
const pendingPortraitUrlRef = useRef("");
const portraitFileRef = useRef<HTMLInputElement | null>(null);
function clearPendingPortrait() {
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
pendingPortraitUrlRef.current = "";
setPendingPortrait(null);
setPendingPortraitUrl("");
}
function dismiss() {
clearPendingPortrait();
close();
}
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
const { mounted, show } = useOverlayTransition(open, saving ? undefined : dismiss);
useEffect(() => {
clearPendingPortrait();
if (!model) return;
setName(model.name);
setDescription(model.description || "");
setSaving(false);
}, [model?.id]);
useEffect(() => () => {
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
}, []);
if (!mounted || !model) return null;
const currentModel = model;
const portraitUrl = pendingPortraitUrl || model.portrait;
const isDirty = Boolean(pendingPortrait) || name.trim() !== model.name || description.trim() !== (model.description || "");
const zoomKey = (p: Preview) => (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onZoom(p); } };
async function save() {
if (currentModel.is_official || !name.trim() || saving) return;
setSaving(true);
try {
let updated: ModelEntity;
if (pendingPortrait) {
const form = new FormData();
form.append("file", pendingPortrait);
form.append("name", name.trim());
form.append("description", description.trim());
updated = await api.uploadModelPortrait(currentModel.id, form);
} else {
updated = await api.updateLibraryModel(currentModel.id, { name: name.trim(), description: description.trim() });
}
onSaved(updated);
clearPendingPortrait();
setName(updated.name);
setDescription(updated.description || "");
onNotify?.("success", "模特资料已保存");
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
function selectPortrait(file?: File) {
if (!file || currentModel.is_official || saving) return;
clearPendingPortrait();
const url = URL.createObjectURL(file);
pendingPortraitUrlRef.current = url;
setPendingPortrait(file);
setPendingPortraitUrl(url);
}
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className={`modal-bg${show ? " show" : ""}`} onClick={() => { if (!saving) dismiss(); }}>
<div className="modal model-detail-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><User size={16} /></div>
<div className="ti">
{model.name}
<span>{model.is_official ? "官方模板" : model.source === "upload" ? "真人上传" : "AI 生成"}</span>
<span> · {model.name}</span>
</div>
<button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
<button className="x modal-x" type="button" disabled={saving} onClick={dismiss} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b model-detail-b">
<div
className={`placeholder model-detail-portrait${model.portrait ? " has-mock-media" : ""}`}
style={model.portrait ? mediaStyle(model.portrait) : undefined}
role={model.portrait ? "button" : undefined}
tabIndex={model.portrait ? 0 : undefined}
onClick={model.portrait ? () => onZoom({ src: model.portrait, kind: "image", name: model.name }) : undefined}
onKeyDown={model.portrait ? zoomKey({ src: model.portrait, kind: "image", name: model.name }) : undefined}
>
{!model.portrait && <span className="ph-frame"></span>}
<div className="model-detail-left">
<div className="model-detail-section-h">
<strong></strong>
<div className="model-detail-section-actions">
<span className="mono">// PORTRAIT</span>
{!model.is_official && (
<button className="btn btn-sm" type="button" disabled={saving} onClick={() => portraitFileRef.current?.click()}>
<Upload size={13} />{pendingPortrait ? "重新选择" : model.portrait ? "替换形象图" : "上传形象图"}
</button>
)}
</div>
</div>
<input ref={portraitFileRef} type="file" accept="image/*" hidden onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} />
<div
className={`placeholder model-detail-portrait${portraitUrl ? " has-mock-media" : ""}`}
style={portraitUrl ? mediaStyle(portraitUrl) : undefined}
role={portraitUrl ? "button" : undefined}
tabIndex={portraitUrl ? 0 : undefined}
onClick={portraitUrl ? () => onZoom({ src: portraitUrl, kind: "image", name: pendingPortrait ? `${name || model.name} · 待保存形象图` : model.name }) : undefined}
onKeyDown={portraitUrl ? zoomKey({ src: portraitUrl, kind: "image", name: pendingPortrait ? `${name || model.name} · 待保存形象图` : model.name }) : undefined}
>
{!portraitUrl && <span className="ph-frame"></span>}
</div>
<div className="model-detail-tags mono">
{model.is_official && <span className="pill info"></span>}
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
{pendingPortrait && <span className="pill info"></span>}
</div>
</div>
<div className="model-detail-tags mono">
{model.is_official && <span className="pill info"></span>}
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
</div>
{model.description && <p className="model-detail-desc">{model.description}</p>}
<div className="model-detail-label mono">// 三视图</div>
<div
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
style={model.triview ? mediaStyle(model.triview) : undefined}
role={model.triview ? "button" : undefined}
tabIndex={model.triview ? 0 : undefined}
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
>
{!model.triview && <span className="ph-frame mono">// 无三视图</span>}
<div className="model-detail-right">
<div>
<div className="model-detail-section-h">
<strong></strong>
<div className="model-detail-section-actions">
<span className="mono">// 可选资产</span>
{!model.is_official && (
<button className="btn btn-sm" type="button" disabled title={model.portrait ? "真实生成与计费将在下一步接通" : "请先设置模特形象图"}>
<RefreshCw size={13} />{model.triview ? "重新生成 · 20 积分" : "生成三视图 · 20 积分"}
</button>
)}
</div>
</div>
<div
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
style={model.triview ? mediaStyle(model.triview) : undefined}
role={model.triview ? "button" : undefined}
tabIndex={model.triview ? 0 : undefined}
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
>
{!model.triview && <span className="ph-frame mono">// 暂无三视图 · 模特仍可正常使用</span>}
</div>
</div>
<div className="model-detail-form">
<div className="field">
<label className="field-label" htmlFor="model-edit-name"></label>
<input id="model-edit-name" className="input" maxLength={255} disabled={model.is_official || saving} value={name} onChange={(event) => setName(event.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="model-edit-description"> / </label>
<textarea id="model-edit-description" className="textarea" rows={4} disabled={model.is_official || saving} placeholder="补充模特特征、风格或生成提示词" value={description} onChange={(event) => setDescription(event.target.value)} />
</div>
<div className="field-hint mono">// 编辑模特资料不会回写已存在的视频项目角色</div>
</div>
</div>
</div>
<div className="modal-f">
{model.is_official && <span className="model-detail-readonly mono">// 官方模板仅供查看</span>}
<button className="btn" type="button" disabled={saving} onClick={dismiss}></button>
{!model.is_official && <button className="btn btn-primary" type="button" disabled={saving || !name.trim() || !isDirty} onClick={() => void save()}>{saving ? "保存中…" : "保存修改"}</button>}
</div>
</div>
</div>,
document.body,
@@ -203,7 +316,16 @@ export function ModelsPage({ onNotify }: { onNotify?: (type: "success" | "error"
</div>
)}
<ModelDetailModal model={detail} close={() => setDetail(null)} onZoom={setPreview} />
<ModelDetailModal
model={detail}
close={() => setDetail(null)}
onZoom={setPreview}
onSaved={(updated) => {
setItems((list) => list.map((item) => item.id === updated.id ? updated : item));
setDetail(updated);
}}
onNotify={onNotify}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
);
+87 -55
View File
@@ -7,7 +7,7 @@ import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
import { ActorLibrary } from "../components/actor-library";
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { IconKitSvg } from "../components/IconKitSvg";
@@ -472,16 +472,16 @@ export function PipelinePage(props: {
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise<unknown>;
onDeleteBaseAsset: (groupId: string) => void | Promise<unknown>;
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
// 流程步骤4 · 模特库:用现有模特替换角色参考图 / AI 生成模特 / 本地上传模特
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
onGenerateActor: (prompt: string) => Promise<{ assets: Asset[] } | null>;
// 本地上传演员:返回上传后的 Asset(供添加人物工作台进右侧栏)
onUploadActor: (file: File) => Promise<Asset | null>;
onGenerateModel: (prompt: string) => Promise<{ assets: Asset[] } | null>;
// 本地上传模特:返回形象 Asset供添加模特工作台保存。
onUploadModel: (file: File) => Promise<Asset | null>;
// 流程步骤4 · 据某一版立绘生成它配套的三视图(三视图与立绘 1:1 绑定)
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
// 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产
onRenameActor?: (assetId: string, name: string) => Promise<unknown>;
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
onRenameModel?: (assetId: string, name: string) => Promise<unknown>;
onGenerateStoryboard: (prompt: string) => void | Promise<unknown>;
// 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本
onRerunStoryboardShot?: (shotId: string, prompt?: string) => Promise<unknown>;
@@ -502,7 +502,7 @@ export function PipelinePage(props: {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout, onNotify,
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
@@ -636,8 +636,8 @@ export function PipelinePage(props: {
const [triPanelOpen, setTriPanelOpen] = useState(false);
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
// 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套。
// 人物不走这里——人物有专门的演员库(ActorLibrary,含模特库/三视图工作台),对人物是对的。
// 旧 bug:场景卡「替换」误用了演员库 → 列的是角色数据。现在场景改走本选图层。
// 角色不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。
// 旧 bug:场景卡「替换」误用了模特选择器 → 列的是角色数据。现在场景改走本选图层。
type AssetPick = {
title: string;
category: "product_image" | "scene";
@@ -784,12 +784,9 @@ export function PipelinePage(props: {
delBusy(busyKey);
}
}
// 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘(返回组的 adopted_asset)→ 据它生成新三视图
async function genPersonWithTri(prompt: string, label: string | undefined, busyKey: string) {
const g = await genBaseAsset("person", prompt, label, busyKey);
const newAsset = g?.adopted_asset;
if (newAsset) await genTriview(newAsset, `${busyKey}:tri`);
return g;
// 流程步骤4 · 生成人物立绘;三视图只在角色详情里手动生成。
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) {
return await genBaseAsset("person", prompt, label, busyKey);
}
// ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ──
// idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏
@@ -799,13 +796,13 @@ export function PipelinePage(props: {
const extractPollRef = useRef(0); // 提取轮询定时器句柄
const extractStartedRef = useRef(false); // 是否已有一条轮询在跑(防认领与手点双轮询)
type ExtractEntity = { id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number };
// 提取完成后(mode≠only)按返回实体循环出参考图(立绘/三视图/场景图)
// 提取完成后(mode≠only)按返回实体循环出参考图(立绘/场景图;三视图手动生成)
async function runGenForEntities(entities: ExtractEntity[], mode: "gen" | "full") {
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
for (const e of entities) {
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
if (e.type === "character") {
if (mode === "full") await genPersonWithTri(e.visual_prompt, e.name, bk); // 立绘 + 三视图
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘;三视图手动生成
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
} else {
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
@@ -843,7 +840,7 @@ export function PipelinePage(props: {
extractPollRef.current = window.setInterval(() => { void tick(); }, 3000);
void tick();
}
// only=只提取关键词(不出图) / gen=提取+生成角色场景(立绘+场景图) / full=提取+全套(立绘+三视图+场景图)
// only=只提取关键词(不出图) / gen=提取+生成角色场景(立绘+场景图) / full=提取+生成角色场景(三视图手动生成)
async function runExtract(mode: "only" | "gen" | "full") {
if (extractState === "running") return;
setExtractErr("");
@@ -945,12 +942,30 @@ export function PipelinePage(props: {
const [adPortraitId, setAdPortraitId] = useState<string | null>(null); // 当前查看的立绘 asset id(组内候选)
const [adTriId, setAdTriId] = useState<string | null>(null); // 当前查看的三视图 asset id
const [adPrompt, setAdPrompt] = useState("");
// 角色立绘升级为模特的状态按「形象资产 id」记录;后端精确查询,不能用全量列表猜测。
const [enrolledModelAssets, setEnrolledModelAssets] = useState<Set<string>>(() => new Set());
const [modelEnrollmentLoading, setModelEnrollmentLoading] = useState(false);
const [modelEnrollmentBusy, setModelEnrollmentBusy] = useState(false);
async function loadModelEnrollment(assetId: string | null | undefined) {
if (!assetId) return;
setModelEnrollmentLoading(true);
try {
const result = await api.listModels({ portraitAsset: assetId, pageSize: 1 });
if (result.results.length) setEnrolledModelAssets((known) => new Set(known).add(assetId));
} catch {
// 查询失败时保留可升级入口;提交接口本身仍是幂等的,不会重复建模特。
} finally {
setModelEnrollmentLoading(false);
}
}
useBodyScrollLock(Boolean(adDetail));
function openAssetDetail(kind: "person" | "scene", entity: AssetEntity) {
setAdDetail({ kind, key: entity.key });
setAdPortraitId(entity.group.adopted_asset ?? entity.versions.at(-1) ?? null);
const portraitAssetId = entity.group.adopted_asset ?? entity.versions.at(-1) ?? null;
setAdPortraitId(portraitAssetId);
setAdTriId(null); // 跟随当前立绘的最新三视图
setAdPrompt(entity.group.prompt ?? "");
if (kind === "person") void loadModelEnrollment(portraitAssetId);
}
// seed(未生成)卡也能点进详情:还没组,用脚本名(=key)+ 当前提示词开一个「空壳」详情。
// 进去先生成立绘;三视图按既有逻辑(无立绘则禁用)天然挡住。生成完刷新 → 同 key(=名字)自动切到真实体。
@@ -960,31 +975,30 @@ export function PipelinePage(props: {
setAdTriId(null);
setAdPrompt(promptValue);
}
// 流程步骤4 · 演员库覆盖层:replace 带替换目标(已生成卡=groupId;seed 占位卡还没组=kind+label);
// browse 带 studio 直接进「添加人物工作台」
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string; addNew?: boolean } | null>(null);
function openActorReplace(entity: AssetEntity) {
setActorLib({ mode: "replace", groupId: entity.group.id });
// 模特库覆盖层replace 带替换目标browse + studio 直接进“添加模特”。
const [modelLib, setModelLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string; addNew?: boolean } | null>(null);
function openModelReplace(entity: AssetEntity) {
setModelLib({ mode: "replace", groupId: entity.group.id });
}
// seed 占位卡替换」:还没 group,记 kind+label,选演员后由后端按 label 命中/建组再挂
function openActorReplaceSeed(kind: "person" | "scene", tag: string) {
setActorLib({ mode: "replace", seedKind: kind, seedLabel: tag });
// seed 占位卡替换”:还没组,选模特后由后端按 label 命中/建组再挂
function openModelReplaceSeed(kind: "person" | "scene", tag: string) {
setModelLib({ mode: "replace", seedKind: kind, seedLabel: tag });
}
// 场景替换」:走通用选图层(取团队场景图),而非人物专用的演员库(修复:场景替换误列角色数据)
// 场景替换走通用选图层取团队场景图),不使用模特库
function openSceneReplaceSeed(tag: string) {
setAssetPick({ title: "选择场景素材", category: "scene", target: { kind: "scene", label: tag }, uploadCategory: "scene", uploadName: `${tag || "场景"}·场景图` });
}
function openSceneReplaceEntity(groupId: string, label?: string) {
setAssetPick({ title: "选择场景素材", category: "scene", target: { group_id: groupId }, uploadCategory: "scene", uploadName: `${label || "场景"}·场景图` });
}
async function pickActor(assetId: string, assetName?: string) {
if (actorLib?.mode === "replace") {
if (actorLib.groupId) await onAttachBaseAsset({ group_id: actorLib.groupId }, assetId);
// 新增人物(无既有 group / 无脚本标签):用所选演员名当 label 建一个新人物组挂上
else if (actorLib.addNew && actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: (assetName || "").trim() }, assetId);
else if (actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: actorLib.seedLabel }, assetId);
async function pickModel(assetId: string, assetName?: string) {
if (modelLib?.mode === "replace") {
if (modelLib.groupId) await onAttachBaseAsset({ group_id: modelLib.groupId }, assetId);
// 新增角色(无既有 group / 无脚本标签):用所选模特名建立角色组并挂上形象资产。
else if (modelLib.addNew && modelLib.seedKind) await onAttachBaseAsset({ kind: modelLib.seedKind, label: (assetName || "").trim() }, assetId);
else if (modelLib.seedKind) await onAttachBaseAsset({ kind: modelLib.seedKind, label: modelLib.seedLabel }, assetId);
}
setActorLib(null);
setModelLib(null);
}
useEffect(() => {
if (!adDetail) return;
@@ -2441,7 +2455,7 @@ export function PipelinePage(props: {
const productName = productRecord?.title || "透真补水面膜";
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonWithTri/genBaseAsset 出图、直接扣用户费,
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonPortrait/genBaseAsset 出图、直接扣用户费,
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
@@ -3065,8 +3079,8 @@ export function PipelinePage(props: {
<div className="sec-h">
<h3>{KIND_LABEL[kind]} · {entities.length} </h3>
<span className="spacer"></span>
{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */}
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "replace", seedKind: "person", addNew: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
{/* 对齐设计稿:角色从模特库选择,场景直接生成一版 */}
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setModelLib({ mode: "replace", seedKind: "person", addNew: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
</div>
<div className="asset-grid-2">
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */}
@@ -3133,7 +3147,7 @@ export function PipelinePage(props: {
<div className="hstack"><strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openSeedDetail(kind, tag, promptValue)}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span></span></div>
<PromptBox key={seedKey} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => kind === "scene" ? openSceneReplaceSeed(tag) : openActorReplaceSeed(kind, tag)}></button>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => kind === "scene" ? openSceneReplaceSeed(tag) : openModelReplaceSeed(kind, tag)}></button>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
</div>
@@ -3196,7 +3210,7 @@ export function PipelinePage(props: {
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void genBaseAsset(kind, p, entity.name, entBK, ref); }}>{busy ? "生成中…" : "重跑"}</button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => kind === "scene" ? openSceneReplaceEntity(entity.group.id, entity.name) : openActorReplace(entity)}></button>
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => kind === "scene" ? openSceneReplaceEntity(entity.group.id, entity.name) : openModelReplace(entity)}></button>
</div>
</div>
</div>
@@ -4098,7 +4112,8 @@ export function PipelinePage(props: {
<div className="ad-thumbs">
{portraitVersions.map((assetId) => {
const u = candUrl(grp, assetId);
return <div className={`thumb${assetId === viewPortraitAsset ? " active" : ""}${u ? " has-mock-media" : ""}`} key={assetId} role="button" tabIndex={0} title="切换立绘版本(三视图跟着切)" style={u ? mediaStyle(u) : undefined} onClick={() => { setAdPortraitId(assetId); setAdTriId(null); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setAdPortraitId(assetId); setAdTriId(null); } }} />;
const selectPortrait = () => { setAdPortraitId(assetId); setAdTriId(null); if (isPerson) void loadModelEnrollment(assetId); };
return <div className={`thumb${assetId === viewPortraitAsset ? " active" : ""}${u ? " has-mock-media" : ""}`} key={assetId} role="button" tabIndex={0} title="切换立绘版本(三视图跟着切)" style={u ? mediaStyle(u) : undefined} onClick={selectPortrait} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectPortrait(); } }} />;
})}
</div>
)}
@@ -4160,7 +4175,7 @@ export function PipelinePage(props: {
</button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => {
if (adDetail.kind === "scene") { isSeed ? openSceneReplaceSeed(entity.name) : openSceneReplaceEntity(entity.group.id, entity.name); }
else { isSeed ? openActorReplaceSeed(adDetail.kind, entity.name) : openActorReplace(entity); }
else { isSeed ? openModelReplaceSeed(adDetail.kind, entity.name) : openModelReplace(entity); }
}}></button>
</div>
</div>
@@ -4171,6 +4186,25 @@ export function PipelinePage(props: {
<div className="ad-foot-stats">
{portraitUrl && <button className="ad-stat-btn" type="button" onClick={() => void downloadAssetImage(viewPortraitAsset, `${entity.name}-立绘`)}><svg 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-4M7 10l5 5 5-5M12 15V3" /></svg></button>}
</div>
{isPerson && <button
className="btn"
type="button"
disabled={loading || modelEnrollmentLoading || modelEnrollmentBusy || !viewPortraitAsset || enrolledModelAssets.has(viewPortraitAsset)}
title={enrolledModelAssets.has(viewPortraitAsset) ? "无需重复升级" : undefined}
onClick={async () => {
if (!viewPortraitAsset) return;
setModelEnrollmentBusy(true);
try {
await api.enrollModelFromAsset(viewPortraitAsset, entity.name);
setEnrolledModelAssets((known) => new Set(known).add(viewPortraitAsset));
onNotify?.("success", "已升级为模特,可在模特库中复用");
} catch (error) {
onNotify?.("error", error instanceof Error && error.message ? error.message : "升级为模特失败,请重试");
} finally {
setModelEnrollmentBusy(false);
}
}}
>{modelEnrollmentBusy ? "升级中…" : modelEnrollmentLoading ? "检查中…" : enrolledModelAssets.has(viewPortraitAsset) ? "该角色形象已在模特库" : "升级为模特"}</button>}
<button className="btn btn-primary" type="button" disabled={loading || !viewPortraitAsset} onClick={async () => {
// 应用当前角色 = 采用 + 自动送审:不再要用户应用完还得手动点灰盾送审(ZWQ#7)。
// 立绘(person)/三视图(tri_view)都属送审类;送审异步(processing→绿盾),失败由 submitAssetReview 自行提示。
@@ -4234,18 +4268,16 @@ export function PipelinePage(props: {
</div>
</div>
)}
{/* 流程步骤4 · 演员库:浏览 / 添加演员(AI生成·本地上传)/ 替换回填 */}
<ActorLibrary
open={Boolean(actorLib)}
mode={actorLib?.mode || "browse"}
initialStudio={actorLib?.studio}
assets={assets}
onClose={() => setActorLib(null)}
onPick={pickActor}
onGenerate={onGenerateActor}
onUpload={onUploadActor}
onGenerateTriview={onGenerateTriview}
onRename={onRenameActor}
{/* 流程步骤4 · 模特库:浏览 / 添加模特(AI 生成·本地上传/ 替换回填 */}
<ModelLibrary
open={Boolean(modelLib)}
mode={modelLib?.mode || "browse"}
initialStudio={modelLib?.studio}
onClose={() => setModelLib(null)}
onPick={pickModel}
onGenerate={onGenerateModel}
onUpload={onUploadModel}
onRename={onRenameModel}
onRefresh={() => void onRefreshProject()}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />