Files
yingqing/core/backend/apps/projects/models.py
T
seaislee1209andClaude Opus 4.8 3d38c173e6 feat(core): 角色对白(剧情向按需)+ 聊天精准改一镜 + 模型下拉移位 + 全流程e2e + 交叉验证修复
新能力(均已端到端验证):
- 角色对白 dialogue:[{speaker,line}](speaker=null 即旁白)。默认口播,用户在聊天提要求才出多角色对白(剧情向);narration 保留扁平拼接兼容下游字幕/配音。skill 同步:默认不强制对白。
- 聊天精准改一镜:聊天说「第N镜改XX」→ agent 读全脚本上下文、只重写那一镜(target_index + 后端 _merge_single_segment 强制保其余镜原样),保衔接。
- 前端:分镜卡渲染对白/镜型/露出;模型下拉从顶部移到输入框下方小按钮(对齐 ChatGPT/Lovart)。

全流程 e2e 真跑通:商品→脚本→基础资产(gpt-image-2)→故事板(@图N 多图合成锁脸锁商品)→视频(Seedance 6.4分钟出片,带音效+人声)。

对抗式交叉验证(3 审查员)修复:
- 改稿用基准稿时长 effective_duration(prompt head + normalize + merge),避免请求默认 60 把 90s/6镜稿尾镜截掉/单镜改空转。
- target_index 越界服务端早返回报错、不计费;模型未产出目标镜时抛错释放额度(不静默空转)。
- 前端「第N镜」正则收窄为 镜|场(去掉会误判「第3个卖点」的 个|段)。
- 回归:测试 setUp 停用 seed 中转站文本模型,保证命中可 mock 的 provider(18/18 过)。

CLAUDE.md 加「AI 生成 Agent 化架构」一节供后续维护。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 05:09:51 +08:00

246 lines
10 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")
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)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["team", "current_stage"]),
]
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 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"]
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)