feat(storyboard): 故事板改分镜制(单场独立重跑/历史)+ 视频过审闸 + 审核前端如实化
三块改动在 services/views/pipeline/App/api 等文件按行交织、且过审闸后端依赖故事板新 模型,无法拆成各自独立可编译的提交,故合并为一次: · 故事板分镜制:新增 StoryboardShot/StoryboardShotVersion(对标 VideoSegment/Version) + 数据迁移(采用版每帧转一 shot+已采用版本)。每镜一个 shot,各自历史版本,可单场 重跑、逐场切换/采用,新增「开始生成故事板/单场重跑/采用某版」端点。重跑非阻塞(后台 静默轮询出图,不锁其它场按钮);历史「查看」与「采用」分离(切版本不丢在制重跑)。 视频取分镜图/删分镜清理/过审闸全部改读 storyboard_shots。 · 视频生成前过审闸:点生成校验含真人脸的人物立绘/故事板分镜是否过审,未过审弹窗列出 哪一镜/哪个,可一键送审,挡住不生成(collect_video_review_blockers + precheck 端点)。 · 审核前端如实化:不再 `|| "processing"` 伪造审核中;送审失败经 onNotify 如实提示; 审核轮询扩到视频阶段,送审后能看着盾变绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ from .models import (
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
@@ -36,6 +38,8 @@ admin.site.register(ScriptSegment)
|
||||
admin.site.register(BaseAssetGroup)
|
||||
admin.site.register(StoryboardVersion)
|
||||
admin.site.register(StoryboardFrame)
|
||||
admin.site.register(StoryboardShot)
|
||||
admin.site.register(StoryboardShotVersion)
|
||||
admin.site.register(VideoSegment)
|
||||
admin.site.register(VideoSegmentVersion)
|
||||
admin.site.register(Timeline)
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-24 08:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0016_person_triview_16_9"),
|
||||
("assets", "0006_alter_asset_category_model"),
|
||||
("projects", "0004_project_projects_pr_team_id_e667f8_idx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="StoryboardShot",
|
||||
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)),
|
||||
("sort_order", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("not_started", "Not Started"),
|
||||
("queued", "Queued"),
|
||||
("running", "Running"),
|
||||
("succeeded", "Succeeded"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="not_started",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("prompt", models.TextField(blank=True)),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="storyboard_shots",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"script_segment",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="storyboard_shots",
|
||||
to="projects.scriptsegment",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["sort_order", "created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="StoryboardShotVersion",
|
||||
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)),
|
||||
("prompt", models.TextField(blank=True)),
|
||||
("is_adopted", models.BooleanField(default=False)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"asset",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="storyboard_shot_versions",
|
||||
to="assets.asset",
|
||||
),
|
||||
),
|
||||
(
|
||||
"shot",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="versions",
|
||||
to="projects.storyboardshot",
|
||||
),
|
||||
),
|
||||
(
|
||||
"task",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="storyboard_shot_versions",
|
||||
to="ai.aitask",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="storyboardshot",
|
||||
name="adopted_version",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="adopted_by_shots",
|
||||
to="projects.storyboardshotversion",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="storyboardshot",
|
||||
unique_together={("project", "sort_order")},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
"""把旧「整版制」故事板迁到新「分镜制」:每个项目只取采用版(无采用版则取最新版)的每一帧,
|
||||
建一个 StoryboardShot + 一条已采用 StoryboardShotVersion(沿用同一张图)。旧的其它整版历史丢弃。"""
|
||||
Project = apps.get_model("projects", "Project")
|
||||
StoryboardVersion = apps.get_model("projects", "StoryboardVersion")
|
||||
StoryboardShot = apps.get_model("projects", "StoryboardShot")
|
||||
StoryboardShotVersion = apps.get_model("projects", "StoryboardShotVersion")
|
||||
|
||||
for project in Project.objects.all().iterator():
|
||||
adopted = (
|
||||
StoryboardVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first()
|
||||
or StoryboardVersion.objects.filter(project=project).order_by("-created_at").first()
|
||||
)
|
||||
if adopted is None:
|
||||
continue
|
||||
seen_orders = set()
|
||||
for frame in adopted.frames.order_by("sort_order", "created_at"):
|
||||
if frame.sort_order in seen_orders: # 同序帧只保第一张,守 (project, sort_order) 唯一约束
|
||||
continue
|
||||
seen_orders.add(frame.sort_order)
|
||||
shot = StoryboardShot.objects.create(
|
||||
project=project,
|
||||
script_segment=frame.script_segment,
|
||||
sort_order=frame.sort_order,
|
||||
status="succeeded",
|
||||
prompt=frame.prompt or "",
|
||||
)
|
||||
version = StoryboardShotVersion.objects.create(
|
||||
shot=shot, asset=frame.asset, prompt=frame.prompt or "", is_adopted=True,
|
||||
)
|
||||
shot.adopted_version = version
|
||||
shot.save(update_fields=["adopted_version"])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
StoryboardShot = apps.get_model("projects", "StoryboardShot")
|
||||
StoryboardShot.objects.all().delete() # version 随 shot 级联删
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("projects", "0005_storyboardshot_storyboardshotversion_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -151,6 +151,54 @@ class StoryboardFrame(TimeStampedModel):
|
||||
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"
|
||||
|
||||
@@ -13,6 +13,8 @@ from .models import (
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
@@ -130,6 +132,54 @@ class StoryboardVersionSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
# ── 故事板分镜制(对标 VideoSegment/Version):每镜一个 shot,采用版缩略图 + 各自历史版本 ──
|
||||
class StoryboardShotSerializer(serializers.ModelSerializer):
|
||||
adopted_asset = serializers.SerializerMethodField()
|
||||
adopted_asset_url = serializers.SerializerMethodField()
|
||||
# 分镜图含人脸,后端自动送火山人像审核;暴露采用版审核态让前端挂盾牌
|
||||
review_status = serializers.SerializerMethodField()
|
||||
review_error = serializers.SerializerMethodField()
|
||||
versions = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = StoryboardShot
|
||||
fields = ["id", "script_segment", "sort_order", "status", "error_message", "prompt",
|
||||
"adopted_version", "adopted_asset", "adopted_asset_url", "review_status", "review_error", "versions"]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_adopted_asset(self, obj):
|
||||
v = obj.adopted_version
|
||||
return str(v.asset_id) if v and v.asset_id else None
|
||||
|
||||
def get_adopted_asset_url(self, obj) -> str:
|
||||
v = obj.adopted_version
|
||||
return _asset_preview_url(v.asset) if v is not None else ""
|
||||
|
||||
def get_review_status(self, obj) -> str:
|
||||
v = obj.adopted_version
|
||||
return (getattr(v.asset, "review_status", "") or "") if (v and v.asset_id) else ""
|
||||
|
||||
def get_review_error(self, obj) -> str:
|
||||
v = obj.adopted_version
|
||||
return (getattr(v.asset, "review_error", "") or "") if (v and v.asset_id) else ""
|
||||
|
||||
def get_versions(self, obj):
|
||||
versions = sorted(obj.versions.all(), key=lambda v: (v.created_at is None, v.created_at), reverse=True)
|
||||
return [
|
||||
{
|
||||
"id": str(v.id),
|
||||
"asset": str(v.asset_id) if v.asset_id else None,
|
||||
"asset_url": _asset_preview_url(v.asset) if v.asset_id else "",
|
||||
"prompt": v.prompt,
|
||||
"is_adopted": v.is_adopted,
|
||||
"review_status": (getattr(v.asset, "review_status", "") or "") if v.asset_id else "",
|
||||
"review_error": (getattr(v.asset, "review_error", "") or "") if v.asset_id else "",
|
||||
"created_at": v.created_at.isoformat() if v.created_at else "",
|
||||
}
|
||||
for v in versions
|
||||
]
|
||||
|
||||
|
||||
class VideoSegmentVersionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = VideoSegmentVersion
|
||||
@@ -289,7 +339,8 @@ class ProjectSerializer(serializers.ModelSerializer):
|
||||
video_segments = VideoSegmentSerializer(many=True, read_only=True)
|
||||
script_versions = ScriptVersionSerializer(many=True, read_only=True)
|
||||
base_asset_groups = BaseAssetGroupSerializer(many=True, read_only=True)
|
||||
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True)
|
||||
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) # 过渡期保留(旧整版,新生成不再写)
|
||||
storyboard_shots = StoryboardShotSerializer(many=True, read_only=True)
|
||||
timeline = TimelineSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -307,6 +358,7 @@ class ProjectSerializer(serializers.ModelSerializer):
|
||||
"script_versions",
|
||||
"base_asset_groups",
|
||||
"storyboard_versions",
|
||||
"storyboard_shots",
|
||||
"video_segments",
|
||||
"timeline",
|
||||
"created_at",
|
||||
|
||||
@@ -13,6 +13,10 @@ from apps.projects.models import (
|
||||
ProjectStage,
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
TimelineClip,
|
||||
@@ -470,6 +474,124 @@ class ProjectApiTests(TestCase):
|
||||
# 已出片的尾段保留 → 不裁到 1,停在 2
|
||||
self.assertEqual(project.video_segments.count(), 2)
|
||||
|
||||
def _mk_image_model(self):
|
||||
return ModelConfig.objects.create(
|
||||
provider=self.provider, name="gpt-image-2", display_name="img",
|
||||
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
|
||||
)
|
||||
|
||||
def _mk_shot(self, project, script, order, *, review_status="active"):
|
||||
"""建一个已出片的 StoryboardShot(+ 已采用版本,资产审核态可控),供过审/采用测试。"""
|
||||
seg = script.segments.filter(sort_order=order).first() or ScriptSegment.objects.create(
|
||||
script_version=script, sort_order=order, narration=f"n{order}", visual_prompt=f"v{order}")
|
||||
a = Asset.objects.create(team=self.team, created_by=self.user, name=f"sb{order}", asset_type="image",
|
||||
source="ai_generated", category=Asset.Category.STORYBOARD, review_status=review_status)
|
||||
shot = StoryboardShot.objects.create(project=project, script_segment=seg, sort_order=order,
|
||||
status=StoryboardShot.Status.SUCCEEDED)
|
||||
ver = StoryboardShotVersion.objects.create(shot=shot, asset=a, is_adopted=True)
|
||||
shot.adopted_version = ver
|
||||
shot.save(update_fields=["adopted_version"])
|
||||
return shot, ver, a
|
||||
|
||||
def test_submit_storyboard_creates_one_shot_per_segment_all_queued(self):
|
||||
"""开始生成故事板:确保每镜一个 shot、全部置 QUEUED(真正出图交给 poll)。"""
|
||||
from apps.ai.services import submit_storyboard
|
||||
self._mk_image_model()
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
for i in range(3):
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}")
|
||||
|
||||
targets = submit_storyboard(project=project, user=self.user, prompt="风格")
|
||||
|
||||
self.assertEqual(project.storyboard_shots.count(), 3)
|
||||
self.assertEqual(len(targets), 3)
|
||||
self.assertTrue(all(s.status == StoryboardShot.Status.QUEUED for s in project.storyboard_shots.all()))
|
||||
project.refresh_from_db()
|
||||
self.assertEqual((project.metadata or {}).get("storyboard_prompt"), "风格") # 整张风格存项目级
|
||||
|
||||
def test_submit_storyboard_single_shot_only_targets_that_shot(self):
|
||||
"""单场重跑:只把指定 shot 置 QUEUED,其余已出片的场不动。"""
|
||||
from apps.ai.services import submit_storyboard
|
||||
self._mk_image_model()
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
for i in range(2):
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}")
|
||||
shot0, _, _ = self._mk_shot(project, script, 0)
|
||||
shot1, _, _ = self._mk_shot(project, script, 1)
|
||||
|
||||
submit_storyboard(project=project, user=self.user, shot_ids=[str(shot1.id)])
|
||||
|
||||
shot0.refresh_from_db(); shot1.refresh_from_db()
|
||||
self.assertEqual(shot0.status, StoryboardShot.Status.SUCCEEDED) # 没动
|
||||
self.assertEqual(shot1.status, StoryboardShot.Status.QUEUED) # 只重跑这场
|
||||
|
||||
def test_adopt_storyboard_shot_version_switches_adopted(self):
|
||||
"""采用某场的历史版本:切换该场采用的分镜图。"""
|
||||
self._mk_image_model()
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
|
||||
shot, v1, _ = self._mk_shot(project, script, 0)
|
||||
a2 = Asset.objects.create(team=self.team, created_by=self.user, name="sb-v2", asset_type="image",
|
||||
source="ai_generated", category=Asset.Category.STORYBOARD)
|
||||
v2 = StoryboardShotVersion.objects.create(shot=shot, asset=a2, is_adopted=False)
|
||||
|
||||
res = self.client.post(
|
||||
f"/api/projects/{project.id}/adopt-storyboard-shot-version/",
|
||||
{"shot_id": str(shot.id), "version_id": str(v2.id)}, format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
shot.refresh_from_db(); v1.refresh_from_db(); v2.refresh_from_db()
|
||||
self.assertEqual(shot.adopted_version_id, v2.id)
|
||||
self.assertTrue(v2.is_adopted)
|
||||
self.assertFalse(v1.is_adopted)
|
||||
|
||||
def test_video_review_precheck_flags_unreviewed_storyboard_then_clears(self):
|
||||
"""生成视频前的过审闸:某镜故事板分镜未过审(非 active)→ precheck 列为 blocker;
|
||||
过审(active)后 → blockers 清空,可放行生成。"""
|
||||
project = Project.objects.create(
|
||||
team=self.team, created_by=self.user, product=self.product, name="P",
|
||||
current_stage=ProjectStage.Stage.VIDEO,
|
||||
)
|
||||
VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15)
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
|
||||
_, _, frame_asset = self._mk_shot(project, script, 0, review_status="")
|
||||
|
||||
res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
blockers = res.json()["blockers"]
|
||||
self.assertTrue(any(b["kind"] == "storyboard" and b["scene_no"] == 1 for b in blockers))
|
||||
|
||||
# 过审后再查 → 不再拦
|
||||
frame_asset.review_status = "active"
|
||||
frame_asset.save(update_fields=["review_status"])
|
||||
res2 = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
|
||||
self.assertEqual(res2.json()["blockers"], [])
|
||||
|
||||
def test_video_review_precheck_skips_succeeded_segments_in_batch(self):
|
||||
"""整批校验(不传 segment_id)只看未出片的段:已 succeeded 的段即便分镜未过审也不拦。"""
|
||||
project = Project.objects.create(
|
||||
team=self.team, created_by=self.user, product=self.product, name="P",
|
||||
current_stage=ProjectStage.Stage.VIDEO,
|
||||
)
|
||||
seg = VideoSegment.objects.create(project=project, sort_order=0, status=VideoSegment.Status.SUCCEEDED)
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
|
||||
self._mk_shot(project, script, 0, review_status="")
|
||||
|
||||
# 整批:succeeded 段跳过 → 不拦
|
||||
res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
|
||||
self.assertEqual(res.json()["blockers"], [])
|
||||
# 指定该段(单段重跑):无论 succeeded 都校验 → 拦
|
||||
res2 = self.client.post(
|
||||
f"/api/projects/{project.id}/video-review-precheck/",
|
||||
{"video_segment_id": str(seg.id)}, format="json",
|
||||
)
|
||||
self.assertTrue(len(res2.json()["blockers"]) >= 1)
|
||||
|
||||
def test_adopt_video_version_remaps_timeline_draft(self):
|
||||
"""切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。"""
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
|
||||
@@ -18,12 +18,13 @@ from apps.ai.script_agent import stream_script_agent
|
||||
from apps.ai.services import (
|
||||
DEFAULT_VOICEOVER_VOICE,
|
||||
VOICEOVER_VOICES,
|
||||
adopt_storyboard_shot_version,
|
||||
create_export_job,
|
||||
generate_base_asset,
|
||||
generate_person_triview,
|
||||
generate_storyboard_frame,
|
||||
get_default_model,
|
||||
get_inflight_extraction,
|
||||
poll_storyboard,
|
||||
poll_video_segment,
|
||||
regenerate_script_segment,
|
||||
submit_extract_entities,
|
||||
@@ -46,6 +47,8 @@ from .models import (
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
@@ -130,6 +133,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"base_asset_groups__candidate_assets__files",
|
||||
"storyboard_versions",
|
||||
"storyboard_versions__frames__asset__files",
|
||||
"storyboard_shots__adopted_version__asset__files",
|
||||
"storyboard_shots__versions__asset__files",
|
||||
"timeline__clips__asset__files",
|
||||
).all()
|
||||
serializer_class = ProjectSerializer
|
||||
@@ -511,6 +516,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
|
||||
next_order += 1
|
||||
|
||||
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
|
||||
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
|
||||
多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。"""
|
||||
if not script.is_adopted:
|
||||
return
|
||||
target = script.segments.count()
|
||||
shots = list(project.storyboard_shots.order_by("sort_order"))
|
||||
while len(shots) > target:
|
||||
tail = shots[-1]
|
||||
if tail.status == StoryboardShot.Status.NOT_STARTED and not tail.versions.exists():
|
||||
tail.delete()
|
||||
shots.pop()
|
||||
else:
|
||||
break
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="update-script-segment")
|
||||
def update_script_segment(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
@@ -575,6 +595,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
seg.sort_order = index
|
||||
seg.save(update_fields=["sort_order", "updated_at"])
|
||||
self._sync_video_segments_to_script(project, script)
|
||||
self._sync_storyboard_shots_to_script(project, script)
|
||||
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="delete-script-segment")
|
||||
@@ -605,6 +626,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if resort:
|
||||
ScriptSegment.objects.bulk_update(resort, ["sort_order"])
|
||||
self._sync_video_segments_to_script(project, script)
|
||||
self._sync_storyboard_shots_to_script(project, script)
|
||||
return Response(ScriptVersionSerializer(script).data)
|
||||
|
||||
# ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ──
|
||||
@@ -633,20 +655,48 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="generate-storyboard")
|
||||
def generate_storyboard_action(self, request, pk=None):
|
||||
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。"""
|
||||
"""开始生成故事板(全部场):确保每镜一个 shot 并标 QUEUED;逐场出图交给 poll-storyboard 起线程。
|
||||
全部已出片时再点 = 整批重跑(全部场重出)。不推进阶段。"""
|
||||
require_worker()
|
||||
project = self.get_object()
|
||||
storyboard = submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
|
||||
submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_202_ACCEPTED)
|
||||
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="rerun-storyboard-shot")
|
||||
def rerun_storyboard_shot_action(self, request, pk=None):
|
||||
"""单场重跑:只重出该 shot 的图 —— 新增一条历史版本并采用,其余场不动。出图交给 poll-storyboard。"""
|
||||
require_worker()
|
||||
project = self.get_object()
|
||||
shot_id = request.data.get("shot_id")
|
||||
if not project.storyboard_shots.filter(id=shot_id).exists():
|
||||
return Response({"detail": "shot not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""), shot_ids=[shot_id])
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="adopt-storyboard-shot-version")
|
||||
def adopt_storyboard_shot_version_action(self, request, pk=None):
|
||||
"""采用某场的某个历史版本(对标 adopt-video-version):切换该场采用的分镜图。"""
|
||||
project = self.get_object()
|
||||
shot = StoryboardShot.objects.filter(project=project, id=request.data.get("shot_id")).first()
|
||||
if shot is None:
|
||||
return Response({"detail": "shot not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
version = StoryboardShotVersion.objects.filter(shot=shot, id=request.data.get("version_id")).first()
|
||||
if version is None:
|
||||
return Response({"detail": "version not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
adopt_storyboard_shot_version(shot=shot, version=version)
|
||||
return Response(ProjectSerializer(self.get_object()).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="poll-storyboard")
|
||||
def poll_storyboard_action(self, request, pk=None):
|
||||
"""异步故事板·轮询:每次生成下一帧(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
|
||||
"""异步故事板·轮询:为在制场起线程出图(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
|
||||
project = self.get_object()
|
||||
result = generate_storyboard_frame(project=project, user=request.user)
|
||||
result = poll_storyboard(project=project, user=request.user)
|
||||
if result.get("status") == "succeeded":
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
@@ -677,6 +727,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
return Response(ProjectSerializer(project).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="video-review-precheck")
|
||||
def video_review_precheck(self, request, pk=None):
|
||||
"""点「生成视频」前的过审校验:返回未过审(非 active)的人物立绘 / 故事板分镜清单。
|
||||
body 可选 video_segment_id:只校验该段(单段重跑);不传 = 校验全部未出片段。
|
||||
blockers 空 = 全部已过审,前端放行生成。"""
|
||||
from apps.ai.services import collect_video_review_blockers
|
||||
|
||||
project = self.get_object()
|
||||
seg = None
|
||||
sid = request.data.get("video_segment_id")
|
||||
if sid:
|
||||
seg = project.video_segments.filter(id=sid).first()
|
||||
blockers = collect_video_review_blockers(project, only_segment=seg)
|
||||
return Response({"blockers": blockers})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="submit-video-segment")
|
||||
def submit_video_segment_action(self, request, pk=None):
|
||||
# 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。
|
||||
|
||||
Reference in New Issue
Block a user