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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user