feat(video): 期3 视频趴 — 人物→角色 + 引用模特库/自动入库 + 分镜合规归类 + 资产库素材

后端:
- B 路:视频流程新生成角色立绘+三视图成套 → 自动入模特库(run_triview_task,幂等 additive)
- 合规闭合:三视图 person→tri_view、分镜图 scene→storyboard,两者均加 on_commit 送审(此前只有角色立绘送审,三视图/分镜图漏审 = 合规缺口)
- 资产库 _tab_q:成片 finals→素材 materials(所有视频排除最终成片;最终成片隐藏不列)
- 单测 TriviewAutoEnrollTests:自动入库 + 幂等 + 三视图落审核范围

前端:
- 视频项目资产趴「人物」→「角色」(KIND_LABEL,区块名/新增/空态/详情弹窗一处全改;向导人设 persona 不动)
- A 路:演员库收编模特库 —— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),选一个即用其形象图作角色参考
- 资产库 library:人物→角色、成片 tab→素材、分类标签补全(模特上身图/平台套图/自由创作/视频素材/最终成片)
- vite.config:端口与后端代理目标可用 VITE_PORT/VITE_API_TARGET 覆盖(默认不变),避免多实例撞车

验收:后端单测全绿(基线既有 7 失败零新增);tsc+build 全绿;无头 0 console error
(资产库角色/素材改名、最终成片隐藏;pipeline 资产趴显角色;演员库模特库 tab 含 5 模特)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-21 19:27:23 +08:00
co-authored by Claude Opus 4.8
parent c8f3f91d38
commit c6e9c7e77b
8 changed files with 192 additions and 27 deletions
+29 -9
View File
@@ -2,15 +2,29 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { CSSProperties } from "react";
import { api } from "../api";
import type { Asset } from "../types";
import type { Asset, ModelEntity } from "../types";
import { useBodyScrollLock, MediaLightbox } from "./overlays";
// 流程步骤4 · 演员库:平台预设演员 + 我的演员(本地上传/AI 生成)。
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从演员库选一个回填到某基础资产卡)。
// 流程步骤4 · 演员库(期3 收编):角色可「引用模特库」—— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),
// 选一个即用其形象图作该角色的参考/回填;「我的演员」= 本项目未入库的 person 资产。
// 两种用法: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";
// 模特库实体 → Asset 形(id=形象图资产 id,选中即作角色参考图);标 kind=model 归「模特库」tab
const modelToAsset = (m: ModelEntity): Asset => ({
id: m.portrait_asset as string,
name: m.name,
asset_type: "image",
source: "ai_generated",
category: "person",
description: "",
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [],
created_at: m.created_at,
updated_at: m.updated_at
});
// 模特库 = 模特库实体(metadata.kind==="model");其余 person 资产归「我的演员」
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model";
export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPick, onGenerate, onUpload, onGenerateTriview, onRename, onRefresh }: {
open: boolean;
@@ -57,8 +71,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
// 否则平台预设/我的演员全空。打开时拉一页(团队 person 资产),保存后 reload 让新人物即时入列。
const [fetched, setFetched] = useState<Asset[]>([]);
const reload = useCallback(async () => {
const res = await api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null);
setFetched(res?.results ?? []);
// 期3:同时拉「项目 person 资产」+「模特库实体」。模特放后面 → 按 id 去重时模特胜出,
// 同一张形象图(既是 person 资产又被模特库引用)归「模特库」tab,可作角色参考。
const [persons, models] = await Promise.all([
api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null),
api.listModels({ pageSize: 200 }).catch(() => null)
]);
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
setFetched([...(persons?.results ?? []), ...mapped]);
}, []);
useEffect(() => { if (open) void reload(); }, [open, reload]);
@@ -228,7 +248,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
<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 === "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>
@@ -266,7 +286,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
</>
) : (
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
<span className="ph-frame">// {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"}</span>
<span className="ph-frame">// {tab === "preset" ? "模特库暂无模特 · 去「模特库」页添加" : "还没有自己的演员 · 点右上「添加演员」"}</span>
</div>
)}
</div>