feat(library): 资产库重做为「成品库」— 图片成品三类 + 视频成品按项目素材包

定义:资产 = 平台产出的成品。原料(商品/模特)归各自的库、半成品(角色/三视图/场景/分镜)归项目,
资产库只装成品 —— tab 从 9 个收成 5 个。

- 后端 /api/assets/video-packs/:视频成品按项目素材包(与「导出全部」同源,取 VideoSegment.adopted_version
  真实视频资产,给 TOS 直链 url 供 <video> 播放/取首帧);summary 改只数成品(tryon/kits/creations/others)
- 资产库 _tab_q 加 others(我的上传+未归类兜底);角色/场景/商品图/素材 tab 移除(各回各家)
- 前端 library:tab→模特上身图/平台套图/自由创作/视频成品/其他;视频成品=项目包卡(<video>首帧封面)
  → 点开弹窗看该项目所有片段(可播);图片成品/其他复用原网格
- 单测 VideoPacksTests:按项目分组 + 软删片段排除

验收:后端单测绿(基线 4 失败零新增);tsc+build 绿;无头 0 报错(5 tab + 视频包弹窗 3 段)
注:图片成品「按生成批次成组」待图片趴有真实数据时接线(当前 demo 已清空)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-21 20:54:42 +08:00
parent a39d2b8603
commit 4571991deb
7 changed files with 216 additions and 22 deletions
+39 -3
View File
@@ -93,8 +93,8 @@ class AssetSoftDeleteTests(TestCase):
self.user, self.team = _mk_team("usd", "TeamSD")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.live = Asset.objects.create(team=self.team, name="留存图", asset_type="image", source="ai_generated", category=Asset.Category.PERSON)
self.dead = Asset.objects.create(team=self.team, name="废图", asset_type="image", source="ai_generated", category=Asset.Category.PERSON, is_deleted=True)
self.live = Asset.objects.create(team=self.team, name="留存图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON)
self.dead = Asset.objects.create(team=self.team, name="废图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON, is_deleted=True)
def test_list_excludes_soft_deleted(self):
ids = {a["id"] for a in self.client.get("/api/assets/?page_size=50").json()["results"]}
@@ -102,7 +102,43 @@ class AssetSoftDeleteTests(TestCase):
self.assertNotIn(str(self.dead.id), ids)
def test_summary_excludes_soft_deleted(self):
self.assertEqual(self.client.get("/api/assets/summary/").json()["people"], 1)
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
class VideoPacksTests(TestCase):
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
def setUp(self):
from apps.products.models import Product
from apps.projects.models import Project, VideoSegment, VideoSegmentVersion
from .models import AssetFile
self.user, self.team = _mk_team("uvp", "TeamVP")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
self.proj = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
self.assets = []
for i in range(2):
a = Asset.objects.create(team=self.team, name=f"clip{i}", asset_type="video", source="ai_generated", category=Asset.Category.VIDEO_CLIP)
AssetFile.objects.create(asset=a, object_key=f"k{i}.mp4", bucket="b", content_type="video/mp4", is_primary=True)
seg = VideoSegment.objects.create(project=self.proj, sort_order=i)
ver = VideoSegmentVersion.objects.create(video_segment=seg, asset=a, is_adopted=True)
seg.adopted_version = ver
seg.save(update_fields=["adopted_version"])
self.assets.append(a)
def test_packs_grouped_by_project(self):
data = self.client.get("/api/assets/video-packs/").json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["project_name"], "项目甲")
self.assertEqual(len(data[0]["clips"]), 2)
self.assertIn("url", data[0]["clips"][0])
def test_soft_deleted_clip_excluded(self):
Asset.objects.filter(id=self.assets[0].id).update(is_deleted=True)
data = self.client.get("/api/assets/video-packs/").json()
self.assertEqual(len(data[0]["clips"]), 1)
class ReviewScopeTests(TestCase):
+44 -1
View File
@@ -54,6 +54,8 @@ def _tab_q(tab: str) -> Q:
return Q(category="upload")
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
return ~Q(category="final_video") & Q(asset_type="video")
if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底)
return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video"))
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
return ~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video")
return Q()
@@ -110,9 +112,50 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
def summary(self, request):
"""资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False)
tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "materials", "uploads", "unclassified"]
# 资产库成品化:对外只数成品三类 + 其他(角色/场景/商品图各回各库,视频成品走 video-packs)
tabs = ["tryon", "kits", "creations", "others"]
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
@action(detail=False, methods=["get"], url_path="video-packs")
def video_packs(self, request):
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
取 VideoSegment.adopted_version 的真实视频资产)。片段给 TOS 直链 url 供 <video> 播放/取首帧。"""
from apps.assets.serializers import _asset_preview
from apps.projects.models import Project
team = self.get_team()
projects = (
Project.objects.filter(team=team)
.select_related("product__cover_asset")
.prefetch_related("product__cover_asset__files")
.order_by("-updated_at")
)
out = []
for proj in projects:
segs = (
proj.video_segments.filter(adopted_version__isnull=False)
.select_related("adopted_version__asset")
.prefetch_related("adopted_version__asset__files")
.order_by("sort_order")
)
clips = []
for seg in segs:
a = getattr(seg.adopted_version, "asset", None)
if a is None or a.is_deleted:
continue
clips.append({"id": str(a.id), "name": a.name, "url": _asset_preview(a)})
if not clips:
continue
product = proj.product
out.append({
"project_id": str(proj.id),
"project_name": proj.name,
"product_cover": _asset_preview(getattr(product, "cover_asset", None)) if product else "",
"clips": clips,
})
out.sort(key=lambda p: len(p["clips"]), reverse=True)
return Response(out)
@action(detail=False, methods=["get"])
def facets(self, request):
"""某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键的取值),供下拉「只列真有的」。
+4
View File
@@ -436,6 +436,10 @@ export const api = {
assetSummary() {
return request<Record<string, number>>("/api/assets/summary/");
},
// 视频成品:按项目素材包分组(每项目一包,含片段列表)
videoPacks() {
return request<import("./types").VideoPack[]>("/api/assets/video-packs/");
},
// 某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键取值),供下拉「只列真有的」
assetFacets(tab?: string, metaKeys: string[] = []) {
const qs = new URLSearchParams();
+19
View File
@@ -212,3 +212,22 @@ body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important;
.upload-modal .modal-f { align-items: center; }
.upload-foot-meta { flex: 1; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.upload-foot-meta .accent { color: var(--heat); font-weight: 600; }
/* ── 资产库成品化:视频成品「项目素材包」 ── */
.library-page .packs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
.library-page .pack-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; }
.library-page .pack-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.library-page .pack-thumb { position: relative; width: 100%; aspect-ratio: 9 / 16; max-height: 280px; }
.library-page .pack-thumb video, .library-page .pack-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
.library-page .pack-count { position: absolute; left: 8px; top: 8px; padding: 2px 8px; border-radius: var(--r-pill); background: var(--black-alpha-56); color: var(--accent-white); font-size: 11px; letter-spacing: .02em; }
/* 素材包弹窗(portal 到 body) */
.pack-modal-bg { position: fixed; inset: 0; z-index: 1200; background: rgba(0, 0, 0, .5); display: flex; align-items: center; justify-content: center; padding: 32px; }
.pack-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(900px, 92vw); max-height: 86vh; display: flex; flex-direction: column; overflow: hidden; }
.pack-modal-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border-faint); }
.pack-modal-h h2 { font-size: 16px; font-weight: 600; color: var(--accent-black); }
.pack-modal-h .x { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--black-alpha-48); border-radius: var(--r-sm); cursor: pointer; flex-shrink: 0; }
.pack-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }
.pack-clip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; padding: 18px 20px; overflow-y: auto; }
.pack-clip video { width: 100%; aspect-ratio: 9 / 16; object-fit: cover; border-radius: var(--r-sm); background: #000; display: block; }
.pack-clip-name { font-size: 11px; color: var(--black-alpha-48); margin-top: 4px; text-align: center; letter-spacing: .02em; }
+77 -18
View File
@@ -34,27 +34,20 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
{ value: "subtitle", label: "字幕", accept: ".srt,.vtt,.ass", hint: "SRT / VTT / ASS" }
];
// 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作
// 期3:人物→角色;成片 tab→素材(视频素材,排除最终成片;最终成片隐藏不列)
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "materials" | "uploads" | "unclassified";
// 资产库 = 成品库(商品/模特/半成品各回各家):
// 图片成品 tryon=模特上身图 / kits=平台套图 / creations=自由创作;视频成品 videopacks=按项目素材包;others=其他兜底
type LibTab = "tryon" | "kits" | "creations" | "videopacks" | "others";
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
{ key: "people", label: "角色" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" },
{ key: "materials", label: "素材" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
{ key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" }
];
// 对齐 api-bridge:工具栏 chip 按 tab 显隐
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "gender", label: "性别", tabs: ["people"] },
{ key: "age", label: "年龄段", tabs: ["people"] },
{ key: "role", label: "角色标签", tabs: ["people"] },
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
{ key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] },
{ key: "project", label: "关联项目", tabs: ["materials"] },
{ key: "duration", label: "时长", tabs: ["materials"] },
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] }
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
{ key: "kind", label: "资产类型", tabs: ["others"] },
{ key: "source", label: "来源", tabs: ["tryon", "kits", "creations", "others"] }
];
// metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
@@ -396,7 +389,7 @@ function UploadModal({ open, close, onSubmit }: {
}
export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
const [tab, setTab] = useState<LibTab>("people");
const [tab, setTab] = useState<LibTab>("tryon");
const [query, setQuery] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [openChip, setOpenChip] = useState("");
@@ -428,7 +421,10 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, materials: 0, uploads: 0, unclassified: 0 });
const [counts, setCounts] = useState<Record<LibTab, number>>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 });
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
@@ -448,6 +444,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 列表数据(分页 + 过滤)
const [reloadFlag, setReloadFlag] = useState(0);
useEffect(() => {
if (tab === "videopacks") { setLoading(false); return; } // 视频成品走素材包,不拉扁平资产
let alive = true;
setLoading(true);
api.assetsPage({
@@ -467,6 +464,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
// 视频成品素材包:进页/刷新就拉一次,顺带把项目包数喂给 tab 徽标
useEffect(() => {
let alive = true;
api.videoPacks().then((ps) => { if (alive) { setPacks(ps); setCounts((prev) => ({ ...prev, videopacks: ps.length })); } }).catch(() => { if (alive) setPacks([]); });
return () => { alive = false; };
}, [reloadFlag]);
useEffect(() => {
let alive = true;
api.assetFacets(tab, metaKeys).then((f) => { if (alive) setFacets(f); }).catch(() => {});
@@ -523,7 +526,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 角色 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-materials">{counts.materials}</span> 素材</span></div>
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} · 视频 {counts.videopacks} 包</span></div>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
@@ -543,6 +546,34 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
))}
</div>
{tab === "videopacks" ? (
<div className="packs-grid">
{packs.length === 0 ? (
<div className="empty-filter">// 还没有视频成品 · 去视频项目生成片段</div>
) : (
packs.map((pack) => {
const first = pack.clips[0];
return (
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenPack(pack); } }}>
<div className="placeholder asset-thumb pack-thumb">
{first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
) : pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : (
<span className="ph-frame"></span>
)}
<span className="pack-count mono">{pack.clips.length} </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono"></div></div>
</article>
);
})
)}
</div>
) : (
<>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
@@ -696,6 +727,34 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
)}
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
)}
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
{openPack && createPortal(
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="视频素材包" onClick={(e) => { if (e.target === e.currentTarget) setOpenPack(null); }}>
<div className="pack-modal">
<div className="pack-modal-h">
<div>
<h2>{openPack.project_name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 视频素材包 · {openPack.clips.length} 段</span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenPack(null)}>
<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>
<div className="pack-clip-grid">
{openPack.clips.map((c, i) => (
<div className="pack-clip" key={c.id}>
<video src={c.url} controls muted playsInline preload="metadata" />
<div className="pack-clip-name mono"> {i + 1}</div>
</div>
))}
</div>
</div>
</div>,
document.body
)}
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
+8
View File
@@ -248,6 +248,14 @@ export type ModelEntity = {
updated_at: string;
};
// 视频成品「项目素材包」:某项目产出的全部视频片段归一个包(资产库视频成品按此展示)
export type VideoPack = {
project_id: string | null;
project_name: string;
product_cover: string; // 商品主图(封面兜底)
clips: Array<{ id: string; name: string; url: string }>;
};
export type ProjectStage = {
id: string;
stage: string;
+25
View File
@@ -0,0 +1,25 @@
import { chromium } from "playwright";
import fs from "node:fs"; import path from "node:path";
const BASE="http://localhost:5200"; const OUT=path.resolve("../../../_qa_shots/lib-v2"); fs.mkdirSync(OUT,{recursive:true});
const b=await chromium.launch({headless:true}); const r={consoleErrors:[]};
const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage();
p.on("console",m=>{if(m.type()==="error")r.consoleErrors.push(m.text())});
p.on("pageerror",e=>r.consoleErrors.push("PE:"+e.message));
await p.goto(BASE+"/login"); await p.waitForTimeout(400);
await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026");
await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000});
await p.goto(BASE+"/library"); await p.waitForTimeout(2800);
r.tabs=(await p.locator(".tab").allInnerTexts().catch(()=>[])).join(" | ");
await p.screenshot({path:path.join(OUT,"lib-default.png"),fullPage:true});
// 视频成品
await p.locator(".tab",{hasText:"视频成品"}).click(); await p.waitForTimeout(2500);
r.packCards=await p.locator(".pack-card").count();
await p.screenshot({path:path.join(OUT,"lib-videopacks.png"),fullPage:true});
// 点开包
if(r.packCards>0){
await p.locator(".pack-card").first().click(); await p.waitForTimeout(1800);
r.packModalOpen=await p.locator(".pack-modal").count();
r.clipCount=await p.locator(".pack-clip").count();
await p.screenshot({path:path.join(OUT,"lib-pack-modal.png"),fullPage:true});
}
await b.close(); console.log(JSON.stringify(r,null,2));