diff --git a/core/backend/airshelf/urls.py b/core/backend/airshelf/urls.py index ebffa16..0447e7e 100644 --- a/core/backend/airshelf/urls.py +++ b/core/backend/airshelf/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path("api/auth/", include("apps.accounts.urls")), path("api/products/", include("apps.products.urls")), path("api/assets/", include("apps.assets.urls")), + path("api/models/", include("apps.assets.model_urls")), path("api/projects/", include("apps.projects.urls")), path("api/billing/", include("apps.billing.urls")), path("api/ai/", include("apps.ai.urls")), diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 08de406..88053b2 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -289,9 +289,9 @@ _REVIEW_STATUSES = {"", "processing", "active", "failed"} @api_view(["GET"]) @permission_classes([IsPlatformAdmin]) def admin_asset_reviews(request): - """跨团队真人(person)资产审核队列。?review_status=none|processing|active|failed 过滤(none=未送审)。""" + """跨团队送审资产审核队列(角色定妆照/三视图/分镜图)。?review_status=none|processing|active|failed 过滤(none=未送审)。""" qs = ( - Asset.objects.filter(category=Asset.Category.PERSON, is_deleted=False) + Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, is_deleted=False) .select_related("team") .prefetch_related("files") .order_by("-created_at") @@ -314,7 +314,7 @@ def admin_asset_reviews(request): def admin_asset_reviews_submit(request): """批量送审(也用于失败重试):对给定 person 资产逐个 submit_asset_for_review。""" ids = request.data.get("asset_ids") or [] - assets = list(Asset.objects.filter(id__in=ids, category=Asset.Category.PERSON, is_deleted=False)) + assets = list(Asset.objects.filter(id__in=ids, category__in=Asset.REVIEW_CATEGORIES, is_deleted=False)) for asset in assets: submit_asset_for_review(asset) statuses = {str(a.id): a.review_status for a in Asset.objects.filter(id__in=ids)} @@ -333,7 +333,7 @@ def admin_asset_reviews_submit(request): def admin_asset_reviews_poll(request): """轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。""" ids = request.data.get("asset_ids") - qs = Asset.objects.filter(category=Asset.Category.PERSON, review_status="processing", is_deleted=False) + qs = Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False) if ids: qs = qs.filter(id__in=ids) statuses = {} diff --git a/core/backend/apps/assets/management/__init__.py b/core/backend/apps/assets/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/backend/apps/assets/management/commands/__init__.py b/core/backend/apps/assets/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/backend/apps/assets/management/commands/backfill_models.py b/core/backend/apps/assets/management/commands/backfill_models.py new file mode 100644 index 0000000..6ff2d9f --- /dev/null +++ b/core/backend/apps/assets/management/commands/backfill_models.py @@ -0,0 +1,145 @@ +"""把历史「成套模特(形象图 + 三视图)」归集成 Model 实体,顺带报告/清理孤儿错图。 + +两条历史来源都收编: + 1) 模特库生成(model_library.generate_model):metadata.kind="model" 的 PERSON 资产, + 成对的 view=frontal / view=three_view 共享同一 brief、同团队 → 按 brief 配对。 + 2) 项目立绘流(run_triview_task):三视图资产带 metadata.triview_of=<立绘 asset id> → 立绘=形象图、它=三视图。 + +幂等(局部):已被某个 Model 引用过的 portrait/triview 资产会跳过,同一张图不会被重复归组。 +注意这是 **一次性数据工具**:同名前缀若有多余的重生版本(例如同项目多次出图),再次运行会把剩余版本另配成新组。 +所以跑一次即可,别反复跑。默认 **dry-run** 只打印计划;--apply 才真正写库。清理孤儿用 --soft-delete-orphans(只置 is_deleted,可逆)。 +""" +from collections import defaultdict + +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.assets.models import Asset, Model + + +class Command(BaseCommand): + help = "把历史成套模特(形象图+三视图)归集成 Model 实体;报告并可软删孤儿错图。" + + def add_arguments(self, parser): + parser.add_argument("--apply", action="store_true", help="真正写库(默认 dry-run 只打印)") + parser.add_argument( + "--soft-delete-orphans", + action="store_true", + help="把无法配对、且无文件的 model 类孤儿资产置 is_deleted=True(可逆)", + ) + + def handle(self, *args, **opts): + apply = opts["apply"] + soft_del = opts["soft_delete_orphans"] + self.stdout.write(self.style.WARNING("=== backfill_models %s ===" % ("APPLY" if apply else "DRY-RUN"))) + + # 已被 Model 引用的资产(幂等跳过) + used = set() + for pid, tid in Model.objects.values_list("portrait_asset_id", "triview_asset_id"): + if pid: + used.add(str(pid)) + if tid: + used.add(str(tid)) + + plans: list[dict] = [] # {name, portrait, triview, source} + + # —— 来源 2:项目立绘流(triview_of) —— + triviews = Asset.objects.filter(metadata__triview_of__isnull=False, is_deleted=False) + for tv in triviews: + portrait_id = str((tv.metadata or {}).get("triview_of") or "") + if not portrait_id or str(tv.id) in used or portrait_id in used: + continue + portrait = Asset.objects.filter(id=portrait_id, is_deleted=False).first() + if portrait is None: + continue + plans.append( + {"name": (portrait.name or "模特").split("·")[0][:255], "portrait": portrait, "triview": tv, "source": "project"} + ) + used.update({str(tv.id), portrait_id}) + + # —— 来源 1:模特库生成(kind=model,按 team+brief 配对 frontal/three_view) —— + groups: dict[tuple, dict[str, list[Asset]]] = defaultdict(lambda: {"frontal": [], "three_view": []}) + models_qs = Asset.objects.filter(metadata__kind="model", is_deleted=False).order_by("created_at") + for a in models_qs: + if str(a.id) in used: + continue + view = (a.metadata or {}).get("view") + if view in ("frontal", "three_view"): + groups[(a.team_id, (a.metadata or {}).get("brief", ""))][view].append(a) + for (team_id, brief), bucket in groups.items(): + frontals, tvs = bucket["frontal"], bucket["three_view"] + for portrait, triview in zip(frontals, tvs): + if str(portrait.id) in used or str(triview.id) in used: + continue + plans.append( + {"name": (portrait.name or brief or "模特").split("·")[0][:255], "portrait": portrait, "triview": triview, "source": "model_lib"} + ) + used.update({str(portrait.id), str(triview.id)}) + + # —— 来源 3:项目立绘流按命名后缀配对(立绘 "X-person" ↔ 三视图 "X-三视图",同前缀同团队) —— + # 历史项目流的 person 资产没写 triview_of,但命名成对;按名前缀收编成套的。 + PORT_SUF = ("-person", "-立绘") + TRI_SUF = ("-三视图", "·三视图") + + def _strip(name: str): + for s in TRI_SUF: + if name.endswith(s): + return name[: -len(s)], "tri" + for s in PORT_SUF: + if name.endswith(s): + return name[: -len(s)], "port" + return None, None + + named: dict[tuple, dict[str, Asset]] = defaultdict(dict) + for a in Asset.objects.filter(category="person", is_deleted=False).order_by("created_at"): + if str(a.id) in used: + continue + prefix, role = _strip(a.name or "") + if role is None: + continue + slot = named[(a.team_id, prefix)] + slot.setdefault(role, a) # 同前缀多版本取最早一张,避免错配 + for (team_id, prefix), slot in named.items(): + portrait, triview = slot.get("port"), slot.get("tri") + if not portrait or not triview or str(portrait.id) in used or str(triview.id) in used: + continue + plans.append({"name": prefix.strip(" ·-")[:255] or "模特", "portrait": portrait, "triview": triview, "source": "named"}) + used.update({str(portrait.id), str(triview.id)}) + + # —— 孤儿:model 类资产没进任何配对(疑似早期错图 / 半成品) —— + orphans = [ + a for a in models_qs + if str(a.id) not in used and (a.metadata or {}).get("view") in ("frontal", "three_view") + ] + no_file_orphans = [a for a in orphans if not a.files.exists()] + + # —— 打印计划 —— + self.stdout.write(f" 待建 Model:{len(plans)} 组") + for p in plans: + self.stdout.write(f" [{p['source']}] {p['name']} portrait={p['portrait'].id} triview={p['triview'].id}") + self.stdout.write(f" 孤儿(model 类未配对):{len(orphans)};其中无文件:{len(no_file_orphans)}") + + if not apply: + self.stdout.write(self.style.WARNING("DRY-RUN 结束,未写库。加 --apply 执行。")) + return + + created = 0 + with transaction.atomic(): + for p in plans: + Model.objects.create( + team_id=p["portrait"].team_id, + created_by_id=p["portrait"].created_by_id, + name=p["name"], + source=Model.Source.AI, + portrait_asset=p["portrait"], + triview_asset=p["triview"], + metadata={"backfill": p["source"]}, + ) + created += 1 + soft_deleted = 0 + if soft_del: + for a in no_file_orphans: + a.is_deleted = True + a.save(update_fields=["is_deleted", "updated_at"]) + soft_deleted += 1 + self.stdout.write(self.style.SUCCESS(f"完成:建 {created} 个 Model;软删孤儿 {soft_deleted if soft_del else 0} 张。")) diff --git a/core/backend/apps/assets/migrations/0006_alter_asset_category_model.py b/core/backend/apps/assets/migrations/0006_alter_asset_category_model.py new file mode 100644 index 0000000..4831bb6 --- /dev/null +++ b/core/backend/apps/assets/migrations/0006_alter_asset_category_model.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.15 on 2026-06-20 14:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0005_invitation_kind_alter_invitation_team'), + ('assets', '0005_asset_assets_asse_team_id_4b1b57_idx_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='asset', + name='category', + field=models.CharField(choices=[('person', 'Person'), ('scene', 'Scene'), ('product_image', 'Product Image'), ('video_clip', 'Video Clip'), ('final_video', 'Final Video'), ('upload', 'Upload'), ('uncategorized', 'Uncategorized'), ('model_portrait', 'Model Portrait'), ('tri_view', 'Tri View'), ('voice', 'Voice'), ('model_tryon', 'Model Try-on'), ('platform_kit', 'Platform Kit'), ('free_create', 'Free Create'), ('storyboard', 'Storyboard')], default='uncategorized', max_length=32), + ), + migrations.CreateModel( + name='Model', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ('is_official', models.BooleanField(default=False)), + ('source', models.CharField(choices=[('ai', 'AI Generated'), ('upload', 'Upload')], default='ai', max_length=16)), + ('description', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('is_deleted', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('portrait_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_portrait', to='assets.asset')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='accounts.team')), + ('triview_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_triview', to='assets.asset')), + ('voice_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_voice', to='assets.asset')), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['team', 'is_official', '-created_at'], name='assets_mode_team_id_0caf77_idx')], + }, + ), + ] diff --git a/core/backend/apps/assets/model_urls.py b/core/backend/apps/assets/model_urls.py new file mode 100644 index 0000000..25118d7 --- /dev/null +++ b/core/backend/apps/assets/model_urls.py @@ -0,0 +1,9 @@ +"""模特库路由(顶级实体,挂在 /api/models/)。与 AssetViewSet(/api/assets/)分开,避免 "" 前缀 pk 冲突。""" +from rest_framework.routers import DefaultRouter + +from .views import ModelLibraryViewSet + +router = DefaultRouter() +router.register("", ModelLibraryViewSet, basename="model") + +urlpatterns = router.urls diff --git a/core/backend/apps/assets/models.py b/core/backend/apps/assets/models.py index 25e4f96..a05719a 100644 --- a/core/backend/apps/assets/models.py +++ b/core/backend/apps/assets/models.py @@ -18,13 +18,24 @@ class Asset(TeamOwnedModel): SYSTEM = "system", "System" class Category(models.TextChoices): - PERSON = "person", "Person" + PERSON = "person", "Person" # 视频趴·角色定妆照(送审) SCENE = "scene", "Scene" PRODUCT_IMAGE = "product_image", "Product Image" VIDEO_CLIP = "video_clip", "Video Clip" FINAL_VIDEO = "final_video", "Final Video" UPLOAD = "upload", "Upload" UNCATEGORIZED = "uncategorized", "Uncategorized" + # ↓ 模特库 + 资产模型重构(2026-06-20)新增,逐期接线。 + MODEL_PORTRAIT = "model_portrait", "Model Portrait" # 模特形象图(图片域·不送审) + TRI_VIEW = "tri_view", "Tri View" # 三视图(送审) + VOICE = "voice", "Voice" # 模特声线(占位·尾期) + MODEL_TRYON = "model_tryon", "Model Try-on" # 模特上身图(图片趴·不送审) + PLATFORM_KIT = "platform_kit", "Platform Kit" # 平台套图(图片趴) + FREE_CREATE = "free_create", "Free Create" # 自由创作(图片趴) + STORYBOARD = "storyboard", "Storyboard" # 分镜图(视频趴·送审) + + # 送审范围(火山人像审核):视频趴含人脸 = 角色定妆照 / 三视图 / 分镜图。图片趴(上身图/套图/创作)不送审。 + REVIEW_CATEGORIES = ("person", "tri_view", "storyboard") name = models.CharField(max_length=255) asset_type = models.CharField(max_length=24, choices=Type.choices) @@ -56,6 +67,45 @@ class Asset(TeamOwnedModel): return self.name +class Model(TeamOwnedModel): + """模特(顶级实体 = 模特库)· 团队级、可复用。 + + 一个完整模特 = 形象图 + 三视图 + 声线(声线尾期做)。图片线(上身图)+ 视频线(角色)都能引用它。 + 来源:官方预制(is_official=True,打「官方模板」标签)/ 商家自建(AI 生成或真人上传)。 + portrait/triview/voice 指向具体 Asset(复用资产表,不重复存文件)。 + """ + + class Source(models.TextChoices): + AI = "ai", "AI Generated" + UPLOAD = "upload", "Upload" + + name = models.CharField(max_length=255) + is_official = models.BooleanField(default=False) # 官方模板标签 + source = models.CharField(max_length=16, choices=Source.choices, default=Source.AI) + portrait_asset = models.ForeignKey( + Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_portrait" + ) + triview_asset = models.ForeignKey( + Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_triview" + ) + voice_asset = models.ForeignKey( # 声线,尾期接线,先留空 + Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_voice" + ) + description = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + indexes = [ + # 模特库默认按团队 + 新→旧;官方模板优先靠前由序列化/查询控制 + models.Index(fields=["team", "is_official", "-created_at"]), + ] + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + class AssetReviewGroup(TimeStampedModel): """一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。""" diff --git a/core/backend/apps/assets/review.py b/core/backend/apps/assets/review.py index 74ca02a..dff4ec1 100644 --- a/core/backend/apps/assets/review.py +++ b/core/backend/apps/assets/review.py @@ -61,7 +61,7 @@ def get_or_create_team_group(team) -> AssetReviewGroup: def submit_asset_for_review(asset: Asset) -> None: """真人资产静默送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。""" - if not assets_client.is_enabled() or asset.category != Asset.Category.PERSON: + if not assets_client.is_enabled() or asset.category not in Asset.REVIEW_CATEGORIES: return url = _asset_url(asset) if not url: @@ -112,7 +112,7 @@ def poll_team_reviews(team) -> dict: """轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。""" out: dict[str, str] = {} pending = Asset.objects.filter( - team=team, category=Asset.Category.PERSON, review_status="processing", is_deleted=False + team=team, category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False ) for asset in pending: out[str(asset.id)] = poll_asset_review(asset) diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py index db8ccd3..13258ce 100644 --- a/core/backend/apps/assets/serializers.py +++ b/core/backend/apps/assets/serializers.py @@ -1,7 +1,7 @@ from django.conf import settings from rest_framework import serializers -from .models import Asset, AssetFile +from .models import Asset, AssetFile, Model from .storage import TosStorage @@ -105,3 +105,71 @@ class AssetUploadSerializer(serializers.Serializer): category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD) description = serializers.CharField(required=False, allow_blank=True) + +def _asset_preview(asset) -> str: + """取资产主文件的公读直链(模特卡缩略图用)。无文件返回 ""。""" + if asset is None: + return "" + f = asset.files.filter(is_primary=True).first() or asset.files.first() + if f is None: + return "" + if f.preview_url: + return f.preview_url + if not f.object_key or not settings.TOS.get("endpoint"): + return "" + try: + return _tos().public_url(object_key=f.object_key, bucket=f.bucket or None) + except Exception: + return "" + + +class ModelLibrarySerializer(serializers.ModelSerializer): + """模特库卡片:实体字段 + 形象图/三视图缩略图直链 + 是否已挂声线。""" + + portrait = serializers.SerializerMethodField() + triview = serializers.SerializerMethodField() + has_voice = serializers.SerializerMethodField() + + class Meta: + model = Model + fields = [ + "id", + "name", + "is_official", + "source", + "description", + "metadata", + "portrait_asset", + "triview_asset", + "voice_asset", + "portrait", + "triview", + "has_voice", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "is_official", "source", "created_at", "updated_at"] + + def get_portrait(self, obj): + return _asset_preview(obj.portrait_asset) + + def get_triview(self, obj): + return _asset_preview(obj.triview_asset) + + def get_has_voice(self, obj): + return bool(obj.voice_asset_id) + + def validate(self, attrs): + """防越权:引用的形象图/三视图/声线资产必须属于当前团队。""" + from apps.common.api import get_current_team + + request = self.context.get("request") + if request is None: + return attrs + team = get_current_team(request.user) + for field in ("portrait_asset", "triview_asset", "voice_asset"): + asset = attrs.get(field) + if asset is not None and asset.team_id != team.id: + raise serializers.ValidationError({field: "资产不属于当前团队"}) + return attrs + diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py new file mode 100644 index 0000000..b1814cd --- /dev/null +++ b/core/backend/apps/assets/tests.py @@ -0,0 +1,110 @@ +from io import BytesIO +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import TestCase +from rest_framework.test import APIClient + +from apps.accounts.models import Team, TeamMember, User + +from .models import Asset, Model + + +def _mk_team(username, team_name): + user = User.objects.create_user(username=username, password="x") + team = Team.objects.create(name=team_name, owner=user) + TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER) + return user, team + + +class ModelLibraryApiTests(TestCase): + """模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。""" + + def setUp(self): + self.user, self.teamA = _mk_team("ua", "TeamA") + self.other, self.teamB = _mk_team("ub", "TeamB") + self.client = APIClient() + self.client.force_authenticate(self.user) + + self.mine = Model.objects.create(team=self.teamA, name="我的模特") + self.official = Model.objects.create(team=self.teamB, name="官方小美", is_official=True) + self.bs_other = Model.objects.create(team=self.teamB, name="别队私有") # B 的私有,A 看不到 + + def test_list_returns_mine_plus_official(self): + ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]} + self.assertIn(str(self.mine.id), ids) + self.assertIn(str(self.official.id), ids) # 官方跨团队可见 + self.assertNotIn(str(self.bs_other.id), ids) # 别队私有不可见 + + def test_tab_official_and_mine(self): + off = {m["id"] for m in self.client.get("/api/models/?tab=official").json()["results"]} + self.assertEqual(off, {str(self.official.id)}) + mine = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]} + self.assertEqual(mine, {str(self.mine.id)}) + + def test_official_flag_in_payload(self): + row = next(m for m in self.client.get("/api/models/").json()["results"] if m["id"] == str(self.official.id)) + self.assertTrue(row["is_official"]) + + def test_delete_is_soft(self): + res = self.client.delete(f"/api/models/{self.mine.id}/") + self.assertEqual(res.status_code, 204) + self.mine.refresh_from_db() + self.assertTrue(self.mine.is_deleted) + ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]} + self.assertNotIn(str(self.mine.id), ids) + + def test_official_cannot_be_deleted(self): + res = self.client.delete(f"/api/models/{self.official.id}/") + self.assertEqual(res.status_code, 403) + self.official.refresh_from_db() + self.assertFalse(self.official.is_deleted) + + def test_cannot_delete_other_team_model(self): + res = self.client.delete(f"/api/models/{self.bs_other.id}/") + self.assertIn(res.status_code, (403, 404)) # 别队私有:不在可见 qs → 404,或命中守卫 → 403 + self.bs_other.refresh_from_db() + self.assertFalse(self.bs_other.is_deleted) + + def test_create_rejects_cross_team_asset(self): + b_asset = Asset.objects.create(team=self.teamB, name="别队图", asset_type="image", source="upload") + res = self.client.post("/api/models/", {"name": "盗用", "portrait_asset": str(b_asset.id)}, format="json") + self.assertEqual(res.status_code, 400) + + def test_upload_real_person_creates_asset_and_model(self): + stored = SimpleNamespace(object_key="teams/x/models/a.png", bucket="b", content_type="image/png", size_bytes=10) + with patch("apps.assets.views.TosStorage") as Tos: + Tos.return_value.upload_fileobj.return_value = stored + f = BytesIO(b"img") + f.name = "lady.png" + res = self.client.post("/api/models/upload/", {"name": "上传妹", "file": f}, format="multipart") + self.assertEqual(res.status_code, 201) + body = res.json() + self.assertEqual(body["source"], "upload") + model = Model.objects.get(id=body["id"]) + self.assertIsNotNone(model.portrait_asset) + self.assertEqual(model.portrait_asset.category, Asset.Category.MODEL_PORTRAIT) + + +class ReviewScopeTests(TestCase): + """送审范围 = 角色定妆照(person)/ 三视图(tri_view)/ 分镜图(storyboard);图片趴不送审。""" + + def test_review_categories_constant(self): + self.assertEqual(Asset.REVIEW_CATEGORIES, ("person", "tri_view", "storyboard")) + + def test_poll_team_reviews_only_scans_review_categories(self): + from apps.assets import review + + _, team = _mk_team("uc", "TeamC") + keep = {} + for cat in ("person", "tri_view", "storyboard"): + a = Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated", + category=cat, review_status="processing") + keep[cat] = str(a.id) + # 图片趴 / 场景:不应进送审队列 + for cat in ("model_tryon", "model_portrait", "scene"): + Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated", + category=cat, review_status="processing") + with patch("apps.assets.review.assets_client.is_enabled", return_value=False): + out = review.poll_team_reviews(team) + self.assertEqual(set(out.keys()), set(keep.values())) diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 06aeac2..83fc19c 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -7,16 +7,17 @@ from django.db.models import Q from django.http import StreamingHttpResponse from rest_framework import status from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.pagination import PageNumberPagination -from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet from apps.common.api import TeamScopedViewSetMixin, get_current_team -from .models import Asset, AssetFile -from .serializers import AssetSerializer, AssetUploadSerializer +from .models import Asset, AssetFile, Model +from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer from .storage import TosStorage @@ -141,6 +142,93 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): return response +class ModelLibraryViewSet(ModelViewSet): + """模特库(顶级实体)· 团队级 + 官方模板跨团队可见。 + list 返回「本团队模特 ∪ 官方模板」;?tab=official 只看官方、?tab=mine 只看自建。 + 真人上传走 upload action(传图 → 建 model_portrait 资产 + Model 实体)。删除 = 软删(官方不可删)。""" + + serializer_class = ModelLibrarySerializer + pagination_class = AssetPagination + parser_classes = [JSONParser, MultiPartParser, FormParser] + search_fields = ["name", "description"] + + def get_team(self): + return get_current_team(self.request.user) + + def get_queryset(self): + team = self.get_team() + qs = ( + Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False) + .prefetch_related("portrait_asset__files", "triview_asset__files") + ) + tab = self.request.query_params.get("tab") + if tab == "official": + qs = qs.filter(is_official=True) + elif tab == "mine": + qs = qs.filter(team=team, is_official=False) + if self.request.query_params.get("q"): + q = self.request.query_params["q"] + qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q)) + # 官方模板靠前,再按新→旧 + return qs.order_by("-is_official", "-created_at") + + def perform_create(self, serializer): + serializer.save(team=self.get_team(), created_by=self.request.user) + + def perform_destroy(self, instance): + if instance.is_official: + raise PermissionDenied("官方模特不可删除") + if instance.team_id != self.get_team().id: + raise PermissionDenied("无权删除其他团队的模特") + instance.is_deleted = True + instance.save(update_fields=["is_deleted", "updated_at"]) + + @action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser]) + def upload(self, request): + """真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。""" + upload = request.FILES.get("file") + if upload is None: + raise ValidationError({"file": "请上传一张模特形象图"}) + team = self.get_team() + name = (request.data.get("name") or Path(upload.name).stem or "模特")[:255] + asset_id = uuid.uuid4() + suffix = Path(upload.name).suffix.lower() or ".png" + object_key = f"teams/{team.id}/models/{asset_id}{suffix}" + with transaction.atomic(): + stored = TosStorage().upload_fileobj( + fileobj=upload.file, + object_key=object_key, + content_type=upload.content_type or "image/png", + ) + portrait = Asset.objects.create( + id=asset_id, + team=team, + created_by=request.user, + name=f"{name}·形象图", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.UPLOAD, + category=Asset.Category.MODEL_PORTRAIT, + metadata={"kind": "model", "view": "frontal", "source": "upload"}, + ) + AssetFile.objects.create( + asset=portrait, + object_key=stored.object_key, + bucket=stored.bucket, + content_type=stored.content_type, + size_bytes=stored.size_bytes, + is_primary=True, + ) + model = Model.objects.create( + team=team, + created_by=request.user, + name=name, + source=Model.Source.UPLOAD, + portrait_asset=portrait, + metadata={"source": "upload"}, + ) + return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED) + + class AssetUploadView(APIView): parser_classes = [MultiPartParser, FormParser] diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 1646db0..24d3c7a 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -41,6 +41,7 @@ import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./r import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config"; import { AdminApp } from "./routes/admin/admin-app"; import { TrashPage } from "./routes/trash"; +import { ModelsPage } from "./routes/models"; import { money } from "./routes/stage-config"; const crumbLabels: Partial> = { @@ -743,6 +744,8 @@ export function App() { ); + case "models": + return ; case "library": return action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />; case "trash": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 51b0b9d..8f6cbf0 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -21,6 +21,7 @@ import type { LoginSession, Invitation, ModelConfig, + ModelEntity, Notification, NotificationList, Paginated, @@ -478,6 +479,22 @@ export const api = { updateAsset(id: string, payload: { name?: string; description?: string }) { return request(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, + // 模特库:列出(本团队 ∪ 官方模板);tab=official 只看官方、mine 只看自建 + listModels(params: { tab?: "official" | "mine"; q?: string; pageSize?: number } = {}) { + const qs = new URLSearchParams(); + if (params.tab) qs.set("tab", params.tab); + if (params.q) qs.set("q", params.q); + qs.set("page_size", String(params.pageSize ?? 200)); + return request>(`/api/models/?${qs.toString()}`); + }, + // 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体 + uploadModel(formData: FormData) { + return request("/api/models/upload/", { method: "POST", body: formData }); + }, + // 删除模特(软删;官方模特不可删) + deleteModel(id: string) { + return request(`/api/models/${id}/`, { method: "DELETE" }); + }, billingSummary() { return request("/api/billing/summary/"); }, diff --git a/core/frontend/src/components/IconKitSvg.tsx b/core/frontend/src/components/IconKitSvg.tsx index 261b17b..70cfce8 100644 --- a/core/frontend/src/components/IconKitSvg.tsx +++ b/core/frontend/src/components/IconKitSvg.tsx @@ -4,6 +4,7 @@ const iconPaths: Record = { layoutDashboard: '', trash: '', package: '', + model: '', clapperboard: '', sparkles: '', images: '', diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index dbf238f..24703b1 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -190,6 +190,7 @@ type NavDef = { id: string; page: Page; label: string; icon: string; badge?: num const NAV: NavDef[] = [ { id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" }, { id: "products", page: "products", label: "商品库", icon: "package" }, + { id: "models", page: "models", label: "模特库", icon: "model" }, { id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" }, { id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" }, { id: "library", page: "library", label: "资产库", icon: "library" }, @@ -204,6 +205,7 @@ const PAGE_TO_NAV: Partial> = { products: "products", productDetail: "products", productCreateUpload: "products", + models: "models", projects: "projects", projectWizard: "projects", pipeline: "projects", diff --git a/core/frontend/src/models-page.css b/core/frontend/src/models-page.css new file mode 100644 index 0000000..664ac8a --- /dev/null +++ b/core/frontend/src/models-page.css @@ -0,0 +1,98 @@ +/* 模特库 · 顶级实体(形象图 + 三视图)· 仅用 design-restraint token,不写裸 hex */ +.models-empty { min-height: 240px; flex-direction: column; gap: 8px; } +.models-empty-hint { font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; } + +.models-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; +} + +.model-card { + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + overflow: hidden; + transition: border-color .15s; +} +.model-card:hover { border-color: var(--border-loud); } + +/* 形象图(9:16 氛围正面图)· 主视觉 */ +.model-portrait { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + border-radius: 0; +} + +/* 官方模板标签:复用 .pill.info(单橙锚点),定位左上 */ +.model-official { + position: absolute; + top: 8px; + left: 8px; + pointer-events: none; +} + +/* 删除按钮:默认半隐,hover 卡片才显;crimson 危险态 */ +.model-del-btn { + position: absolute; + top: 6px; + right: 6px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--r-sm); + background: var(--black-alpha-48); + color: var(--accent-white); + font-size: 16px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity .15s, background .15s; +} +.model-card:hover .model-del-btn { opacity: 1; } +.model-del-btn:hover { background: var(--accent-crimson); } + +.model-confirm { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--black-alpha-56); +} +.model-del { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); } +.model-del:hover { filter: brightness(1.06); } + +.model-body { padding: 10px 12px 12px; } +.model-name { + font-size: 13.5px; + font-weight: 500; + color: var(--accent-black); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 三视图(16:9 白底)· 成套证明 */ +.model-triview { + width: 100%; + aspect-ratio: 16 / 9; + margin-top: 8px; + border-radius: var(--r-sm); +} +.model-triview .ph-frame { font-size: 11px; } + +.model-tags { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + font-size: 11.5px; + color: var(--black-alpha-48); + letter-spacing: .02em; +} diff --git a/core/frontend/src/routes/models.tsx b/core/frontend/src/routes/models.tsx new file mode 100644 index 0000000..64bf27c --- /dev/null +++ b/core/frontend/src/routes/models.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent, CSSProperties } from "react"; +import { api } from "../api"; +import type { ModelEntity } from "../types"; +import "../models-page.css"; + +const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties); + +type Tab = "all" | "official" | "mine"; +const TABS: { k: Tab; label: string }[] = [ + { k: "all", label: "全部" }, + { k: "official", label: "官方模板" }, + { k: "mine", label: "我的模特" } +]; + +// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。 +// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。 +export function ModelsPage() { + const [tab, setTab] = useState("all"); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [confirmId, setConfirmId] = useState(null); + const fileRef = useRef(null); + + useEffect(() => { + let alive = true; + setLoading(true); + api + .listModels({ tab: tab === "all" ? undefined : tab }) + .then((r) => { if (alive) setItems(r.results); }) + .catch(() => { if (alive) setItems([]); }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [tab]); + + async function onPick(e: ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setUploading(true); + try { + const form = new FormData(); + form.append("file", file); + form.append("name", file.name.replace(/\.[^.]+$/, "")); + const created = await api.uploadModel(form); + setItems((list) => (tab === "official" ? list : [created, ...list])); + } finally { + setUploading(false); + } + } + + async function remove(id: string) { + try { + await api.deleteModel(id); + setItems((list) => list.filter((m) => m.id !== id)); + } finally { + setConfirmId(null); + } + } + + return ( +
+
+
+

模特库

+
+ // 团队级可复用 · 形象图 + 三视图 + · + 图片创作与视频项目都能引用 +
+
+
+ + +
+
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {loading ? ( +
// 加载中…
+ ) : items.length === 0 ? ( +
+ // 还没有模特 + 点右上「真人上传」加一个,或在图片/视频流程里生成 +
+ ) : ( +
+ {items.map((m) => ( +
+
+ {!m.portrait && 无图} + {m.is_official && 官方模板} + {!m.is_official && + (confirmId === m.id ? ( +
+ + +
+ ) : ( + + ))} +
+
+
{m.name}
+
+ {!m.triview && // 无三视图} +
+
+ {m.source === "upload" ? "真人上传" : "AI 生成"} + · + {m.triview ? "三视图 ✓" : "三视图 —"} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index 64df92b..a086357 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -7,6 +7,7 @@ import { MessageSquare, Package, Settings, + UserRound, Users, Wallet, WandSparkles @@ -17,6 +18,7 @@ export type Page = | "products" | "productDetail" | "productCreateUpload" + | "models" | "projects" | "projectWizard" | "pipeline" @@ -57,6 +59,7 @@ export type NavItem = { page: Page; label: string; icon: typeof Home; badge?: st export const mainNav: NavItem[] = [ { page: "dashboard", label: "工作台", icon: Home }, { page: "products", label: "商品库", icon: Package }, + { page: "models", label: "模特库", icon: UserRound }, { page: "projects", label: "视频项目", icon: FolderKanban }, { page: "pipeline", label: "生产管线", icon: WandSparkles }, { page: "library", label: "资产库", icon: Library }, @@ -76,6 +79,7 @@ export const routeLabels: Record = { products: "商品库", productDetail: "商品详情", productCreateUpload: "上传创建商品", + models: "模特库", projects: "视频项目", projectWizard: "新建视频项目", pipeline: "生产管线", @@ -129,6 +133,7 @@ export function resolveRoute(): ResolvedRoute { if (path.startsWith("/products/")) { return { page: "productDetail", authMode: "login", productId: decodeURIComponent(path.slice("/products/".length)), hash }; } + if (path === "/models") return { page: "models", authMode: "login", hash }; if (path === "/projects") return { page: "projects", authMode: "login", hash }; if (path === "/projects/new") return { page: "projectWizard", authMode: "login", hash }; if (path === "/pipeline") { @@ -163,6 +168,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) { return "/products/new"; case "productDetail": return options.productId ? `/products/${encodeURIComponent(options.productId)}` : "/products"; + case "models": + return "/models"; case "projects": return "/projects"; case "projectWizard": diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 215eac3..a82d97c 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -230,6 +230,24 @@ export type Asset = { updated_at: string; }; +// 模特库实体(顶级,团队级可复用)= 形象图 + 三视图 + 声线(声线尾期)。官方预制打 is_official 标签。 +export type ModelEntity = { + id: string; + name: string; + is_official: boolean; + source: "ai" | "upload"; + description: string; + metadata?: Record; + portrait_asset: string | null; + triview_asset: string | null; + voice_asset: string | null; + portrait: string; // 形象图缩略图直链 + triview: string; // 三视图缩略图直链 + has_voice: boolean; + created_at: string; + updated_at: string; +}; + export type ProjectStage = { id: string; stage: string; diff --git a/core/qa/visual-parity/_models-p1.mjs b/core/qa/visual-parity/_models-p1.mjs new file mode 100644 index 0000000..b71ae5e --- /dev/null +++ b/core/qa/visual-parity/_models-p1.mjs @@ -0,0 +1,54 @@ +// 期1 模特库走查:登录 → /models → 断言卡片/官方标签/tab,逐态截图,抓 console/pageerror。 +import { chromium } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; + +const BASE = process.env.BASE || "http://localhost:5188"; +const OUT = path.resolve("../../../_qa_shots/models-p1"); +fs.mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch({ headless: true }); +const r = { consoleErrors: [], steps: {} }; +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); +p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => r.consoleErrors.push("PAGEERR:" + e.message)); + +// 登录 +await p.goto(BASE + "/login", { waitUntil: "load" }); +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 }); + +// 左菜单是否有「模特库」入口 +r.steps.navHasModels = await p.locator("nav a, aside a, .sidebar a, button").filter({ hasText: "模特库" }).count(); + +// 进模特库 +await p.goto(BASE + "/models", { waitUntil: "load" }); +await p.waitForTimeout(2800); +r.steps.h1 = await p.locator("h1").first().innerText().catch(() => ""); +r.steps.tabCount = await p.locator(".tabs .tab").count(); +r.steps.cardCountAll = await p.locator(".model-card").count(); +r.steps.officialPill = await p.locator(".model-official").count(); +r.steps.triviewThumbs = await p.locator(".model-triview").count(); +r.steps.uploadBtn = await p.locator(".actions button", { hasText: /上传/ }).count(); +await p.screenshot({ path: path.join(OUT, "models-all.png"), fullPage: true }); + +// 官方模板 tab +await p.locator(".tabs .tab", { hasText: "官方模板" }).click(); +await p.waitForTimeout(2200); +r.steps.cardCountOfficial = await p.locator(".model-card").count(); +r.steps.officialPillInTab = await p.locator(".model-official").count(); +await p.screenshot({ path: path.join(OUT, "models-official.png"), fullPage: true }); + +// 我的模特 tab +await p.locator(".tabs .tab", { hasText: "我的模特" }).click(); +await p.waitForTimeout(2200); +r.steps.cardCountMine = await p.locator(".model-card").count(); +await p.screenshot({ path: path.join(OUT, "models-mine.png"), fullPage: true }); + +await browser.close(); +console.log(JSON.stringify(r, null, 2)); +fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2)); diff --git a/docs/todo/UI还原核对-2026-06-20.md b/docs/todo/UI还原核对-2026-06-20.md index ac562f2..4dcd0fd 100644 --- a/docs/todo/UI还原核对-2026-06-20.md +++ b/docs/todo/UI还原核对-2026-06-20.md @@ -133,7 +133,29 @@ --- -# ⛔ Wave 4(后端分类改造)— 待你拍 2 个板才动 +# ★★ 资产模型重定义(2026-06-20 用户盘点,W4 以此为准 —— 已不止是"改分类") + +这不是简单给 category 改名,是把「模特」从一张图升级成**可复用的实体/库**。三大块: + +**① 模特(顶级独立类 = 模特库)** +- 一个完整模特 = **形象图 + 三视图 + 声线**(三件套合成一个模特) +- 模特库**独立成块**(像商品库),**图片线 + 视频线都能调**(=演员库);商家**真人模特可上传**入库 +- 现状问题:模特库现在埋在 model-photo.html 的深层嵌套弹窗里(actor-library),UI 当初没想清楚 → 要提到顶级 + +**② 图片趴(3 类,均为「组」)** +- **模特上身图**(组):模特+商品宣传图,一组多张 +- **平台套图**(组):电商头图/轮播主图,各平台尺寸不同,一组 N 张(平台规则后续加) +- **自由创作**:杂图都归这 + +**③ 视频趴** +- **模特图**:关联模特库 → 引用库中某模特的脸 → 生成符合本脚本剧情的演员**定妆照** → 再出三视图;也可直接 AI 生成角色 +- **分镜图**(组):每个视频任务脚本 = 一组分镜图 + 一组视频素材 + +**结构含义(待规划):** 模特要从「项目内的 person 基础资产」升级为「团队级、跨流程复用的模特实体」;上身图/套图/分镜/视频都是「组」;视频流程的模特图引用模特库。→ 这是**多阶段功能**,不是一次 relabel。审核范围不变(视频趴人脸:模特定妆照/三视图/分镜送审)。 + +--- + +# ⛔ Wave 4(原"后端分类改造")— 已升级为「资产模型重定义」,需先出设计稿 摸完代码发现:**火山人像内容审核(P0 法务红线)绑死在 `category=person`**(assets/review.py:64、adminpanel 审核队列)。现在三视图算 person → **会送审**。若按新分类把三视图/模特上身图从 person 拆走,它们就**不再过内容审核**。这是合规决策,不能我自己定。 diff --git a/docs/todo/模特库与资产模型-设计稿-2026-06-20.md b/docs/todo/模特库与资产模型-设计稿-2026-06-20.md new file mode 100644 index 0000000..34104e9 --- /dev/null +++ b/docs/todo/模特库与资产模型-设计稿-2026-06-20.md @@ -0,0 +1,116 @@ +# 模特库 + 资产模型重构 · 设计稿 + +> 2026-06-20 · 与用户对齐后产出 · **先拍这版图纸,再分 3 期施工**。 +> 现状全是测试数据、平台未上线 → 数据可随意动(归类 / 删错图)。 + +--- + +## 0. 一句话目标 +把「模特」从"一张图"升级成**团队级、可复用的实体(模特库)**;图片生成线 + 视频项目线都能调它;资产按"图片趴 / 视频趴"两大块清晰分类。 + +--- + +## 1. 资产模型(实体定义) + +### ① 模特(顶级实体 = 模特库) +一个完整模特 = **形象图 + 三视图 + 声线**(声线最后做)。 +- **团队级、可复用**,不绑某个项目;图片线 + 视频线都能引用。 +- 来源:**官方预制**(打「官方模板」标签)/ **商家自建**(AI 生成或上传真人)。 +- 模特库 = **左侧顶级菜单**(像商品库),不再埋在弹窗里。 + +### ② 图片趴(3 类,均为「组」) +| 类 | 说明 | 形态 | +|---|---|---| +| 模特上身图 | 模特 + 商品 宣传图(引用模特库的模特) | 一组多张 | +| 平台套图 | 电商头图/轮播主图,各平台尺寸不同 | 一组 N 张(平台规则后续加) | +| 自由创作 | 杂图都归这 | 单张/多张 | + +### ③ 视频趴 +| 类 | 说明 | 形态 | +|---|---|---| +| 角色(原「人物」改名) | 见下方"角色链路" | 项目内,引用模特库 | +| 分镜图 | 每个视频任务脚本 = 一组分镜图 + 一组视频素材 | 组 | + +**角色链路(视频趴):** +- **A 引用模特库**:选库里某模特当参考图 + 叠加**符合剧情的服装样貌描述** → 生成该模特的**定妆照** → 再出三视图。 +- **B 直接新生成**:不用库里的,新生成一个 → **自动入模特库**(要删去模特库删)。 +- → "角色"卡需小改以承载这两条来法。 + +--- + +## 2. 后端模型设计(提案,期 1 细化) + +**新增 `Model`(模特)实体 · 团队级:** +``` +Model: team, name, is_official(官方模板), source(ai/upload), + portrait_asset(形象图 FK), triview_asset(三视图 FK), + voice_asset(声线 FK, 可空·后做), metadata, created_by +``` +**`Asset.Category` 枚举一次加全(逐期接线):** +- model_portrait(模特形象图)· tri_view(三视图)· voice(声线占位) +- model_tryon(模特上身图)· platform_kit(平台套图)· free_create(自由创作) +- storyboard(分镜图) +- 保留:product_image / scene / video_clip / final_video / upload / uncategorized + +**「组」落库:** 复用/扩展现有 BaseAssetGroup 或加 group_id+task 关联(期 2/3 定);上身图组/套图组/分镜组/视频组同源思路。 + +**审核范围(不变,已拍板):** 送审 = 视频趴人脸 = **{模特定妆照(person), tri_view(三视图), storyboard(分镜图)}**;图片趴(上身图/套图/创作)**不送审**。`review.py` + adminpanel 队列过滤 `person → person+tri_view+storyboard`。 + +--- + +## 3. 页面 / 导航 +- **新增顶级「模特库」**(左菜单):官方模板 tab / 我的模特 tab / 真人上传入口。 +- **图片生成页**:三类入口(上身图/套图/自由创作),上身图接模特库。 +- **视频项目·资产趴**:"人物"→"角色",角色卡承载 A/B 两条来法。 +- **深埋的 actor-library 弹窗**:本轮先不动(逻辑顺),做到对应页时再收编。 + +--- + +## ★ 施工纪律(每一块/每个 Phase 都严格走这一圈,缺一不可) + +> 这是硬流程,不是口号。任何一块没走完整圈,不算完、不 commit、不进下一块。 + +1. **写(开发)** — 实现这一块;后端先于前端;独立子项可派 sub-agent 并行(worktree 隔离防冲突)。 +2. **审(自审)** — 回头**批判性**读自己刚写的 diff(或派 sub-agent 当审查者):逻辑 / 边界(空值·0·越权·并发)/ 安全(密钥不回传·权限不漏)/ **回归风险** / 有没有跑题。发现问题 → 修 → 再审,**审干净才往下**。 +3. **改(修)** — 上面审出来的、下面测出来的,全修掉。 +4. **测(三层全绿,缺一不可)**: + - **后端单测**:`DB_ENGINE=sqlite python manage.py test apps.<相关app>`;每个新端点 ≥ 正常 + 权限拒绝 + 边界。 + - **无头浏览器 e2e**:Playwright 真点击走完整流程 + locator 断言 + **抓 console/pageerror 必须为 0**;按需起 worker 真跑异步(出图/审核)。 + - **类型/构建**:`npx tsc --noEmit && npm run build` 全绿。 +5. **无头 + 截图核对** — 关键画面**截图**,逐张对照设计稿 / 预期,**视觉达标**才算过;不达标 → 回第 3 步继续迭代。 +6. **直到完美验收** — 三层全绿 + 截图达标 + 该块业务链路走通,才算这块完成。 +7. **全绿才本地 commit**(消息规范);写进度;**进下一块**。 +8. **回归红线**:开工前记下进场既有失败基线,只保证**不新增** fail;既有链路零回归。 +9. **全程本地、绝不 push**;不碰 master/main、不 `--no-verify`、不绕 hook。 + +— 即「**写 → 审 → 改 → 测 → 无头 → 截图 → 不达标继续迭代 → 完美验收 → commit**」,一圈不少。 + +## 4. 分 3 期开发(每期都走上面这一圈;不 push) + +### 期 1 · 模特库地基 +- 后端:`Model` 实体 + 迁移;`Asset.Category` 枚举加全;审核过滤改 person+tri_view+storyboard;后端单测。 +- 数据:把**历史已成套模特(形象图+三视图)归集成 Model 实体**当测试数据;**删早期错图**理干净。 +- 前端:左菜单 +「模特库」页(官方模板标签 / 我的模特 / 真人上传)。 +- 验收:模特库页能看到归好的成套模特、官方标签;无头截图 + 0 console error。 + +### 期 2 · 图片趴三类 +- 模特上身图(组,引用模特库)/ 平台套图(组)/ 自由创作 —— 落库 + 图片生成页接线。 +- 前端标签表全对(上身图/套图/创作各归各类)。 +- 验收:三类各生成→归类正确;截图核对。 + +### 期 3 · 视频趴(角色 + 分镜组) +- "人物"→"角色"改名 + 角色卡小改(承载 A 引用库 / B 新生成自动入库)。 +- 分镜图成组(每脚本一组分镜 + 一组视频素材),理顺现有链路。 +- 资产库"成片"→"素材";最终成片类隐藏。 +- 验收:角色两条来法走通、分镜组正确;截图 + 链路零回归。 + +### 尾 · 声线(最后) +- 模特声线生成 + 模特实体挂声线 + 参考元素加"声音"选项。 + +--- + +## 5. 待定 / 后续 +- 平台套图各平台尺寸规则(逐平台加)。 +- 角色卡 UI 细节(做到期 3 再逐稿对)。 +- 声线打包方式(挂在模特实体下)。 +- 这些页用户还要逐页再过一遍。