from django.db import models from apps.common.models import TeamOwnedModel, TimeStampedModel class Asset(TeamOwnedModel): class Type(models.TextChoices): IMAGE = "image", "Image" VIDEO = "video", "Video" AUDIO = "audio", "Audio" SUBTITLE = "subtitle", "Subtitle" DOCUMENT = "document", "Document" class Source(models.TextChoices): UPLOAD = "upload", "Upload" AI_GENERATED = "ai_generated", "AI Generated" EXPORTED = "exported", "Exported" SYSTEM = "system", "System" class Category(models.TextChoices): 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" # 分镜图(视频趴·送审) # 送审范围(火山人像审核):视频趴含人脸 = 角色定妆照 / 模特形象图 / 三视图 / 分镜图 / 场景 / 商品图。 # model_portrait(模特库形象图,尤其真人上传)与 person 同样是真人脸,必须可送审——否则新增模特后在 # 基础资产点「待审核」会被判「该素材无需审核」(ZWQ#6:AI 角色 person 可审、上传模特 model_portrait 报无需审核)。 # 场景图与商品图也可能出现真人(模特出镜/上身),不送审则视频路只能传原始直链 → 火山判「疑似真人」拒; # 送审后拿到 remote_id,视频路换 asset:// 素材库引用即可放行。图片趴(上身图/套图/创作)仍不送审。 REVIEW_CATEGORIES = ("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image") name = models.CharField(max_length=255) asset_type = models.CharField(max_length=24, choices=Type.choices) source = models.CharField(max_length=32, choices=Source.choices) category = models.CharField(max_length=32, choices=Category.choices, default=Category.UNCATEGORIZED) description = models.TextField(blank=True) origin_task = models.ForeignKey( "ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="generated_assets", ) metadata = models.JSONField(default=dict, blank=True) is_deleted = models.BooleanField(default=False) purged_at = models.DateTimeField(null=True, blank=True) # 是否进「资产库」列表:工作台生成的图(模特上身图/平台套图/自由创作)默认 False(只在工作台展示), # 用户「加入资产库」后才置 True 出现在资产库列表;「取消加入」置回 False 即从列表移出。 # 字段默认 True 是为了让历史数据 / 上传 / 项目内资产保持原样可见(只对新生成的工作台图显式置 False)。 in_library = models.BooleanField(default=True, db_default=True) # 火山人像素材库审核(仅真人 person 资产):"" = 未送审 / processing 审核中 / active 绿盾通过 / failed 红标(需改提示词重生) review_status = models.CharField(max_length=16, blank=True, db_default="") review_remote_id = models.CharField(max_length=128, blank=True, db_default="") review_error = models.TextField(blank=True, null=True, default="") # TEXT 不能有 DB 默认值(MySQL 1101),改用可空容忍「漏带字段」的插入 class Meta: indexes = [ # 资产库按团队 + 分类倒序时间翻页(products 详情素材趴 / 资产库) models.Index(fields=["team", "category", "-created_at"]), models.Index(fields=["team", "asset_type"]), models.Index(fields=["team", "is_deleted", "purged_at"]), ] def __str__(self) -> str: 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) purged_at = models.DateTimeField(null=True, blank=True) class Meta: indexes = [ # 模特库默认按团队 + 新→旧;官方模板优先靠前由序列化/查询控制 models.Index(fields=["team", "is_official", "-created_at"]), models.Index(fields=["team", "is_deleted", "purged_at"]), ] ordering = ["-created_at"] def __str__(self) -> str: return self.name class ModelLibraryTag(TimeStampedModel): """模特库筛选标签目录 · 控制前台芯片显隐 / 排序 / 展示名。 实际标签仍写在 Model.metadata.tags;本表只管「筛选条怎么展示」。 未入表的标签在 sync 时按出现次数决定默认显隐。 """ name = models.CharField(max_length=64, unique=True) label = models.CharField(max_length=64, blank=True) # 空则用 name visible = models.BooleanField(default=True) sort = models.IntegerField(default=0) class Meta: ordering = ["sort", "name"] def __str__(self) -> str: return self.label or self.name @property def display_name(self) -> str: return (self.label or self.name).strip() or self.name class AssetReviewGroup(TimeStampedModel): """一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。""" team = models.OneToOneField("accounts.Team", on_delete=models.CASCADE, related_name="review_group") remote_group_id = models.CharField(max_length=128) name = models.CharField(max_length=128, blank=True) def __str__(self) -> str: return f"{self.team_id}:{self.remote_group_id}" class AssetFile(TimeStampedModel): asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="files") object_key = models.CharField(max_length=512) bucket = models.CharField(max_length=128) content_type = models.CharField(max_length=128, blank=True) size_bytes = models.BigIntegerField(default=0) checksum = models.CharField(max_length=128, blank=True) width = models.PositiveIntegerField(null=True, blank=True) height = models.PositiveIntegerField(null=True, blank=True) duration_ms = models.PositiveIntegerField(null=True, blank=True) preview_url = models.URLField(blank=True) is_primary = models.BooleanField(default=True) class Meta: indexes = [ models.Index(fields=["bucket", "object_key"]), ] def __str__(self) -> str: return self.object_key class AssetTag(TimeStampedModel): team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="asset_tags") name = models.CharField(max_length=64) class Meta: unique_together = [("team", "name")] def __str__(self) -> str: return self.name class AssetTagging(TimeStampedModel): asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="taggings") tag = models.ForeignKey(AssetTag, on_delete=models.CASCADE, related_name="taggings") class Meta: unique_together = [("asset", "tag")] class AssetUsage(TimeStampedModel): asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="usages") project = models.ForeignKey( "projects.Project", on_delete=models.CASCADE, null=True, blank=True, related_name="asset_usages", ) usage_type = models.CharField(max_length=64) context = models.JSONField(default=dict, blank=True) class FreeAssetGroup(TeamOwnedModel): """自由创作·人物素材组(一角色一组)。 本质是「火山 Assets API 引用登记表」:组内素材登记到火山素材库拿 remote_asset_id, 生成时以 asset://{remote_asset_id} 引用(免重复上传 + 火山侧预审)。 与 AssetReviewGroup(一团队一审核组)职责不同,与资产库 Asset(TOS 文件资产)也不同,独立建表。 """ name = models.CharField(max_length=128) description = models.TextField(blank=True) remote_group_id = models.CharField(max_length=128) thumbnail_url = models.URLField(blank=True) is_deleted = models.BooleanField(default=False) class Meta: indexes = [ models.Index(fields=["team", "-created_at"]), ] ordering = ["-created_at"] def __str__(self) -> str: return f"free-group:{self.name}" class FreeAsset(TimeStampedModel): """自由创作·素材组内单素材(火山 Assets API 登记项)。""" class Type(models.TextChoices): IMAGE = "Image", "Image" VIDEO = "Video", "Video" AUDIO = "Audio", "Audio" class Status(models.TextChoices): PROCESSING = "processing", "Processing" # 火山侧处理/审核中 ACTIVE = "active", "Active" # 可用,可 asset:// 引用 FAILED = "failed", "Failed" # 审核/处理失败 group = models.ForeignKey(FreeAssetGroup, on_delete=models.CASCADE, related_name="assets") remote_asset_id = models.CharField(max_length=128) name = models.CharField(max_length=128, blank=True) url = models.URLField(max_length=1024, blank=True) # TOS 公读直链(火山从这里拉源文件) asset_type = models.CharField(max_length=16, choices=Type.choices, default=Type.IMAGE) thumbnail_url = models.URLField(max_length=1024, blank=True) duration = models.FloatField(null=True, blank=True) # 视频/音频时长(秒) status = models.CharField(max_length=16, choices=Status.choices, default=Status.PROCESSING) error_message = models.TextField(blank=True) class Meta: indexes = [ models.Index(fields=["group", "-created_at"]), models.Index(fields=["remote_asset_id"]), ] ordering = ["-created_at"] def __str__(self) -> str: return f"free-asset:{self.name or self.remote_asset_id}"