From d2aa26248dc81c18fd8abc0bc21f6e6c2c12d342 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 24 Jun 2026 14:33:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(video):=20=E8=A7=86=E9=A2=91=E7=89=87?= =?UTF-8?q?=E6=AE=B5=E6=95=B0=E5=AF=B9=E9=BD=90=E9=87=87=E7=94=A8=E7=89=88?= =?UTF-8?q?=E5=88=86=E9=95=9C=E6=95=B0,=E4=BF=AE=E3=80=8C=E5=88=A0?= =?UTF-8?q?=E5=88=86=E9=95=9C=E5=90=8E=E8=A7=86=E9=A2=91=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E4=BB=8D=E6=98=BE=E7=A4=BA=E6=97=A7=E6=95=B0=E9=87=8F=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目创建固定铺 4 段视频片段,而 _sync_video_segments_to_script 仅在 增删分镜且该脚本 is_adopted 时才收口。用户通常「采用前」就增删分镜, 那些编辑被 is_adopted 闸跳过;adopt_script 自身又不收口 → 视频步骤 一直停在 4 段,与故事板/采用版分镜对不上。 - adopt_script:采用时按这版分镜数收口视频片段 - poll/skip-storyboard:进入视频阶段前再收口一次(安全网) - retrieve:视频阶段详情加载时自愈历史脏项目(只裁从未生成过的尾段) - 已生成/生成中的段绝不动;新增 3 个回归测试 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/projects/tests.py | 68 +++++++++++++++++++++++++++++ core/backend/apps/projects/views.py | 28 ++++++++++++ 2 files changed, 96 insertions(+) diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index 88f78fb..8a4f74e 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -402,6 +402,74 @@ class ProjectApiTests(TestCase): {"cast": [], "scenes": [], "cast_prompts": {}, "scene_prompts": {}}, ) + def test_adopt_script_syncs_video_segments_to_shot_count(self): + """采用脚本时把视频片段数对齐到分镜数:用户在「采用前」删掉一镜(4→3), + 采用后视频步骤的片段数必须跟着变 3,而不是停在项目创建固定铺的 4 段。""" + # 项目创建 → 固定 4 段视频片段 + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + for i in range(4): + VideoSegment.objects.create(project=project, sort_order=i, target_duration_seconds=15) + # 草稿脚本只剩 3 镜(模拟用户建了 4 镜又删 1,且尚未采用) + script = ScriptVersion.objects.create(project=project, is_adopted=False) + for i in range(3): + ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}") + self.assertEqual(project.video_segments.count(), 4) + + response = self.client.post( + f"/api/projects/{project.id}/adopt-script/", + {"script_version_id": str(script.id)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + # 采用后:多出的「从未生成过」尾段被裁掉,片段数 = 分镜数 + self.assertEqual(project.video_segments.count(), 3) + + def test_retrieve_self_heals_stale_video_segment_count(self): + """历史脏项目(已在视频阶段、片段数比采用版分镜多)在详情加载时自愈到分镜数。""" + project = Project.objects.create( + team=self.team, created_by=self.user, product=self.product, name="P", + current_stage=ProjectStage.Stage.VIDEO, + ) + for i in range(4): + VideoSegment.objects.create(project=project, sort_order=i, target_duration_seconds=15) + 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}") + + response = self.client.get(f"/api/projects/{project.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(project.video_segments.count(), 3) + self.assertEqual(len(response.data["video_segments"]), 3) + + def test_adopt_script_keeps_generated_segment_even_if_excess(self): + """收口绝不动已生成的段:即便采用版分镜少于已出片的片段数,已采用版本的尾段保留。""" + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + segs = [VideoSegment.objects.create(project=project, sort_order=i, target_duration_seconds=15) for i in range(2)] + # 第 2 段(sort_order=1)已出片(有 adopted_version)→ 不可裁 + asset = Asset.objects.create( + team=self.team, created_by=self.user, name="clip", + asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP, + ) + ver = VideoSegmentVersion.objects.create(video_segment=segs[1], asset=asset, is_adopted=True) + segs[1].adopted_version = ver + segs[1].status = VideoSegment.Status.SUCCEEDED + segs[1].save(update_fields=["adopted_version", "status"]) + # 采用版只有 1 镜 + script = ScriptVersion.objects.create(project=project, is_adopted=False) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v") + + response = self.client.post( + f"/api/projects/{project.id}/adopt-script/", + {"script_version_id": str(script.id)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + # 已出片的尾段保留 → 不裁到 1,停在 2 + self.assertEqual(project.video_segments.count(), 2) + 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") diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 21ac532..a057543 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -140,6 +140,22 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): # 列表用轻量序列化(只给列表/仪表盘/侧栏要的字段);详情/写操作用全量 ProjectSerializer return ProjectListSerializer if self.action == "list" else ProjectSerializer + def retrieve(self, request, *args, **kwargs): + # 详情加载(=进入流水线页)时自愈视频片段数:历史项目在「采用前增删分镜」未同步, + # 或项目创建固定铺的 4 段从未被收口,会让视频步骤的片段数与故事板/采用版分镜对不上。 + # 按采用版分镜数收口一次(幂等;只裁从未生成过的尾段,生成中/已出片的段绝不动)。 + # 用轻量查询判定 + 同步,再交给 super 做重 prefetch 序列化(避免重查询跑两遍)。 + proj = ( + Project.objects.filter(pk=kwargs.get("pk"), **{self.team_field: self.get_team()}) + .only("id", "current_stage") + .first() + ) + if proj is not None and proj.current_stage == ProjectStage.Stage.VIDEO: + adopted_script = proj.script_versions.filter(is_adopted=True).order_by("-created_at").first() + if adopted_script is not None: + self._sync_video_segments_to_script(proj, adopted_script) + return super().retrieve(request, *args, **kwargs) + def get_queryset(self): # 列表:轻查询(只 select_related product + 计数注解),不做详情那串 12 个重 prefetch # ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。 @@ -285,6 +301,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): ScriptVersion.objects.filter(project=project).update(is_adopted=False) script.is_adopted = True script.save(update_fields=["is_adopted", "updated_at"]) + # 采用脚本时把视频片段数对齐到这版分镜数:用户常在「采用前」就增删分镜, + # 那些编辑因 _sync 的 is_adopted 闸而未同步到 VideoSegment(项目创建时固定铺了 4 段), + # 不在此收口的话视频步骤会一直停在 4 段,与故事板/分镜数对不上。已生成的段绝不动。 + self._sync_video_segments_to_script(project, script) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) stage.status = ProjectStage.Status.SUCCEEDED stage.save(update_fields=["status", "updated_at"]) @@ -631,6 +651,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) stage.status = ProjectStage.Status.SUCCEEDED stage.save(update_fields=["status", "updated_at"]) + # 进入视频阶段前再收口一次视频片段数,确保与采用版分镜数一致(已生成的段不动) + adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first() + if adopted_script is not None: + self._sync_video_segments_to_script(project, adopted_script) project.current_stage = ProjectStage.Stage.VIDEO project.status = Project.Status.VIDEOING project.save(update_fields=["current_stage", "status", "updated_at"]) @@ -644,6 +668,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) stage.status = ProjectStage.Status.SKIPPED stage.save(update_fields=["status", "updated_at"]) + # 跳过故事板直接进视频时,同样把视频片段数对齐到采用版分镜数(已生成的段不动) + adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first() + if adopted_script is not None: + self._sync_video_segments_to_script(project, adopted_script) project.current_stage = ProjectStage.Stage.VIDEO project.status = Project.Status.VIDEOING project.save(update_fields=["current_stage", "status", "updated_at"])