from django.db import models from apps.common.models import TeamOwnedModel, TimeStampedModel class Project(TeamOwnedModel): class Status(models.TextChoices): DRAFT = "draft", "Draft" SCRIPTING = "scripting", "Scripting" ASSETING = "asseting", "Asseting" STORYBOARDING = "storyboarding", "Storyboarding" VIDEOING = "videoing", "Videoing" EXPORTING = "exporting", "Exporting" COMPLETED = "completed", "Completed" FAILED = "failed", "Failed" name = models.CharField(max_length=255) product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="projects") status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT) current_stage = models.CharField(max_length=32, default="script") # ⚠️ 废弃休眠字段:从未有过写入路径与运行语义,单位未定义(积分制 rescale 有意未缩放它)。 # 勿接入额度管控/展示;项目级限额请用 QuotaPolicy.project_limit。留列仅为免迁移。 budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) failure_reason = 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", "status"]), models.Index(fields=["team", "current_stage"]), # 项目列表默认按团队 + 更新时间倒序 models.Index(fields=["team", "-updated_at"]), models.Index(fields=["team", "is_deleted", "purged_at"]), ] def __str__(self) -> str: return self.name class ProjectStage(TimeStampedModel): class Stage(models.TextChoices): SCRIPT = "script", "Script" BASE_ASSETS = "base_assets", "Base Assets" STORYBOARD = "storyboard", "Storyboard" VIDEO = "video", "Video" EXPORT = "export", "Export" class Status(models.TextChoices): NOT_STARTED = "not_started", "Not Started" DRAFT = "draft", "Draft" QUEUED = "queued", "Queued" RUNNING = "running", "Running" SUCCEEDED = "succeeded", "Succeeded" FAILED = "failed", "Failed" SKIPPED = "skipped", "Skipped" NEEDS_REVIEW = "needs_review", "Needs Review" project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="stages") stage = models.CharField(max_length=32, choices=Stage.choices) status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED) started_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) error_message = models.TextField(blank=True) metadata = models.JSONField(default=dict, blank=True) class Meta: unique_together = [("project", "stage")] ordering = ["created_at"] class ScriptVersion(TimeStampedModel): project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions") task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions") title = models.CharField(max_length=128, blank=True) content = models.TextField() source = models.CharField(max_length=32, default="ai") is_adopted = models.BooleanField(default=False) metadata = models.JSONField(default=dict, blank=True) class ScriptSegment(TimeStampedModel): script_version = models.ForeignKey(ScriptVersion, on_delete=models.CASCADE, related_name="segments") sort_order = models.PositiveIntegerField(default=0) duration_seconds = models.PositiveIntegerField(default=15) narration = models.TextField(blank=True) visual_prompt = models.TextField(blank=True) product_points = models.JSONField(default=list, blank=True) # ScriptDraft 结构化契约字段(对话式脚本 agent 产出): role = models.CharField(max_length=16, blank=True) # 钩子|痛点|卖点|CTA speaker = models.CharField(max_length=32, blank=True) # 指向某 entity id;画外旁白为空 product_exposure = models.CharField(max_length=64, blank=True) # 手持/特写/使用中… entity_refs = models.JSONField(default=list, blank=True) # 本镜引用的 entity id 列表(→ 故事板 @图N) # 角色对白(剧情向):[{speaker, line}],speaker=entity id 即角色台词、null 即旁白。 # 默认空 = 纯口播/旁白(用 narration);用户在聊天里明确要对白时 agent 才填。narration 仍存扁平拼接兼容下游。 dialogue = models.JSONField(default=list, blank=True) class Meta: ordering = ["sort_order", "created_at"] class ScriptTemplate(TeamOwnedModel): """「套路模板」:把一版脚本里可复用的部分抽出来存团队库,换个商品重开项目时套用。 只存**套路**不存**文案**:outline 里每镜只留作用/时长/商品露出/说话方式, 旧商品的口播词一律不进模板,避免换商品重跑时把上一个品牌漏出去。 """ name = models.CharField(max_length=128) presentation_format = models.CharField(max_length=32, blank=True) # oral|drama|vlog video_structure = models.CharField(max_length=32, blank=True) # pain|contrast|review|scene persona = models.CharField(max_length=32, blank=True) # 人物设定 key(对齐向导 WIZ_PERSONAS) total_duration = models.PositiveIntegerField(default=0) # [{index, role, duration, product_exposure, delivery}] · 镜头节奏 + 脚本结构 outline = models.JSONField(default=list, blank=True) # 结尾转化方式的**写法参考**(可能带旧品牌词,套用时提示模型换成新商品) cta = models.TextField(blank=True) # 场景风格名(人物场景风格的场景侧;人物侧由 persona 承载) scenes = models.JSONField(default=list, blank=True) source_project = models.ForeignKey( Project, on_delete=models.SET_NULL, null=True, blank=True, related_name="script_templates" ) source_script = models.ForeignKey( "ScriptVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="templates" ) usage_count = models.PositiveIntegerField(default=0) class Meta: ordering = ["-created_at"] constraints = [ models.UniqueConstraint(fields=["team", "name"], name="uniq_script_template_name_per_team"), ] indexes = [models.Index(fields=["team", "-created_at"])] def __str__(self) -> str: return self.name class BaseAssetGroup(TimeStampedModel): class Kind(models.TextChoices): PRODUCT = "product", "Product" PERSON = "person", "Person" SCENE = "scene", "Scene" project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="base_asset_groups") kind = models.CharField(max_length=32, choices=Kind.choices) task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="base_asset_groups") prompt = models.TextField(blank=True) adopted_asset = models.ForeignKey( "assets.Asset", on_delete=models.SET_NULL, null=True, blank=True, related_name="adopted_base_groups", ) candidate_assets = models.ManyToManyField("assets.Asset", blank=True, related_name="candidate_base_groups") version = models.PositiveIntegerField(default=1) metadata = models.JSONField(default=dict, blank=True) class Meta: indexes = [models.Index(fields=["project", "kind"])] class StoryboardVersion(TimeStampedModel): project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="storyboard_versions") task = models.ForeignKey( "ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="storyboard_versions", ) prompt = models.TextField(blank=True) is_adopted = models.BooleanField(default=False) metadata = models.JSONField(default=dict, blank=True) class StoryboardFrame(TimeStampedModel): storyboard = models.ForeignKey(StoryboardVersion, on_delete=models.CASCADE, related_name="frames") script_segment = models.ForeignKey( ScriptSegment, on_delete=models.SET_NULL, null=True, blank=True, related_name="storyboard_frames", ) asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="storyboard_frames") sort_order = models.PositiveIntegerField(default=0) prompt = models.TextField(blank=True) class Meta: ordering = ["sort_order", "created_at"] # ── 故事板「分镜制」(对标视频 VideoSegment/VideoSegmentVersion)── # 一镜 = 一个 StoryboardShot(有自己的采用版 + 状态);每次出图 = 一条 StoryboardShotVersion(历史版本)。 # 取代旧的「整版制」StoryboardVersion/StoryboardFrame:单场可独立重跑、单场各有历史、可逐场切换采用。 class StoryboardShot(TimeStampedModel): class Status(models.TextChoices): NOT_STARTED = "not_started", "Not Started" QUEUED = "queued", "Queued" RUNNING = "running", "Running" SUCCEEDED = "succeeded", "Succeeded" FAILED = "failed", "Failed" project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="storyboard_shots") script_segment = models.ForeignKey( ScriptSegment, on_delete=models.SET_NULL, null=True, blank=True, related_name="storyboard_shots", ) sort_order = models.PositiveIntegerField(default=0) status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED) prompt = models.TextField(blank=True) adopted_version = models.ForeignKey( "projects.StoryboardShotVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="adopted_by_shots", ) error_message = models.TextField(blank=True) class Meta: unique_together = [("project", "sort_order")] ordering = ["sort_order", "created_at"] class StoryboardShotVersion(TimeStampedModel): shot = models.ForeignKey(StoryboardShot, on_delete=models.CASCADE, related_name="versions") task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="storyboard_shot_versions") asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="storyboard_shot_versions") prompt = models.TextField(blank=True) is_adopted = models.BooleanField(default=False) metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["-created_at"] class VideoSegment(TimeStampedModel): class Status(models.TextChoices): NOT_STARTED = "not_started", "Not Started" QUEUED = "queued", "Queued" RUNNING = "running", "Running" SUCCEEDED = "succeeded", "Succeeded" FAILED = "failed", "Failed" project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="video_segments") script_segment = models.ForeignKey( ScriptSegment, on_delete=models.SET_NULL, null=True, blank=True, related_name="video_segments", ) sort_order = models.PositiveIntegerField(default=0) target_duration_seconds = models.PositiveIntegerField(default=15) status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED) adopted_version = models.ForeignKey( "projects.VideoSegmentVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="adopted_by_segments", ) error_message = models.TextField(blank=True) class Meta: unique_together = [("project", "sort_order")] ordering = ["sort_order", "created_at"] class VideoSegmentVersion(TimeStampedModel): video_segment = models.ForeignKey(VideoSegment, on_delete=models.CASCADE, related_name="versions") task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_versions") asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="video_segment_versions") prompt = models.TextField(blank=True) is_adopted = models.BooleanField(default=False) metadata = models.JSONField(default=dict, blank=True) class Timeline(TimeStampedModel): project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="timeline") name = models.CharField(max_length=255, blank=True) aspect_ratio = models.CharField(max_length=16, default="9:16") resolution = models.CharField(max_length=32, default="1080x1920") duration_seconds = models.PositiveIntegerField(default=60) metadata = models.JSONField(default=dict, blank=True) class TimelineClip(TimeStampedModel): timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="clips") asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="timeline_clips") sort_order = models.PositiveIntegerField(default=0) start_ms = models.PositiveIntegerField(default=0) duration_ms = models.PositiveIntegerField(default=15000) trim_start_ms = models.PositiveIntegerField(default=0) trim_end_ms = models.PositiveIntegerField(null=True, blank=True) class Meta: ordering = ["sort_order", "created_at"] class SubtitleTrack(TimeStampedModel): timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="subtitle_tracks") content = models.JSONField(default=list, blank=True) style = models.JSONField(default=dict, blank=True) enabled = models.BooleanField(default=True) class BgmTrack(TimeStampedModel): timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="bgm_tracks") asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="bgm_tracks") volume = models.PositiveIntegerField(default=60) start_ms = models.PositiveIntegerField(default=0) class ExportJob(TimeStampedModel): class Status(models.TextChoices): DRAFT = "draft", "Draft" QUEUED = "queued", "Queued" RUNNING = "running", "Running" SUCCEEDED = "succeeded", "Succeeded" FAILED = "failed", "Failed" timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="export_jobs") status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT) task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="export_jobs") output_asset = models.ForeignKey("assets.Asset", on_delete=models.SET_NULL, null=True, blank=True, related_name="export_jobs") progress = models.PositiveIntegerField(default=0) error_message = models.TextField(blank=True) metadata = models.JSONField(default=dict, blank=True)