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:
@@ -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):
|
||||
|
||||
@@ -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 键的取值),供下拉「只列真有的」。
|
||||
|
||||
Reference in New Issue
Block a user