338 lines
15 KiB
Python
338 lines
15 KiB
Python
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)
|
|
|