fix(pipeline): 视频为末步时收口项目完成态 + 列表 5→4 步同步

V1 雪藏「拼接导出」第 5 阶段后,完成态(COMPLETED)只在 export.py 拼接成片时
才置,导致视频全部出片的项目永远卡在 videoing,列表末格一直闪烁。

- 后端:新增 settle_video_completion(幂等)+ VIDEO_IS_FINAL_STAGE 开关。
  全部片段出片→项目 completed、video 阶段标成功;某段被重跑→退回 videoing。
  挂 retrieve(自愈历史卡住项目)/poll-video-segment/submit-video-segment/
  adopt-video-version 四入口;收口直接查库,不吃 prefetch 旧缓存致误判。
  V2 恢复导出把 VIDEO_IS_FINAL_STAGE 置 False,完成判定交回 export.py。
- 前端:项目列表/网格进度从 5 步同步成 4 步(PROJ_STAGE_TOTAL=4),
  export 阶段并入第 4;已完成项目末格走 done(绿)不再用闪烁的 cur。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-24 17:32:12 +08:00
co-authored by Claude Opus 4.8
parent 658aaa222f
commit 8222a46202
2 changed files with 71 additions and 7 deletions
+52
View File
@@ -120,6 +120,49 @@ def promote_base_asset_stage_if_ready(project: Project) -> bool:
return True
# V1 雪藏「拼接导出」第 5 阶段(前端 STAGE_STEPS 同步隐藏)→ 视频是当前最后一步。
# 因此「全部片段出片」即视为项目完成,不再依赖导出任务(原来只有 export.py 在拼接成片后
# 才置 COMPLETED,导出一藏起来就没有任何环节会把 videoing 翻成 completed)。
# V2 恢复导出时把此常量置 False:完成重新交回 export.py。
VIDEO_IS_FINAL_STAGE = True
def settle_video_completion(project: Project) -> bool:
"""视频阶段收口(幂等):
· 全部片段已出片(SUCCEEDED + 有采用版)→ 项目 COMPLETED、VIDEO 阶段标成功;
· 否则若项目却仍标着 COMPLETED(用户重跑了某一镜)→ 退回 VIDEOING。
仅当视频为末阶段时生效;非视频阶段不动。返回是否发生状态变更。"""
if not VIDEO_IS_FINAL_STAGE:
return False
if project.current_stage != ProjectStage.Stage.VIDEO:
return False
# 直接查库,不用 project.video_segments 的 prefetch 缓存:调用方常在刚 save 完某段后调用,
# 缓存里仍是旧状态会误判(漏判完成 / 误判未完成)。
segments = list(
VideoSegment.objects.filter(project=project).values_list("status", "adopted_version_id")
)
if not segments:
return False
all_done = all(
status_ == VideoSegment.Status.SUCCEEDED and adopted_id is not None
for status_, adopted_id in segments
)
if all_done and project.status != Project.Status.COMPLETED:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.VIDEO)
if stage.status != ProjectStage.Status.SUCCEEDED:
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.status = Project.Status.COMPLETED
project.save(update_fields=["status", "updated_at"])
return True
if not all_done and project.status == Project.Status.COMPLETED:
# 重跑某段后又回到在制 → 从「已完成」退回「视频生成中」(用户重跑第 3 镜的情形)
project.status = Project.Status.VIDEOING
project.save(update_fields=["status", "updated_at"])
return True
return False
class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Project.objects.select_related("product", "timeline").prefetch_related(
"stages",
@@ -159,6 +202,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
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)
# 自愈完成态:历史项目「全部片段已出片」却卡在 videoing(导出藏起来后没有环节翻状态),
# 进详情时收口成 completed;若有段被重跑则退回 videoing。
settle_video_completion(proj)
return super().retrieve(request, *args, **kwargs)
def get_queryset(self):
@@ -651,6 +697,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
timeline.clips.filter(asset_id__in=segment_asset_ids).exclude(asset_id=version.asset_id).update(
asset_id=version.asset_id, trim_start_ms=0, trim_end_ms=None
)
# 采用历史版后该段为已出片 → 若全部段就绪,收口项目为 completed
settle_video_completion(project)
return Response(ProjectSerializer(project).data)
@action(detail=True, methods=["post"], url_path="generate-storyboard")
@@ -760,6 +808,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
except Exception: # noqa: BLE001
logger.error("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True)
# 重跑某段 → 该段回到在制;若项目此前已 completed,退回 videoing
settle_video_completion(project)
return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="poll-video-segment")
@@ -769,6 +819,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
version = poll_video_segment(video_segment=segment, user=request.user)
if version is None:
return Response({"status": segment.status}, status=status.HTTP_202_ACCEPTED)
# 这一段刚出片 → 若是最后一段,收口整个项目为 completed(视频是末阶段)
settle_video_completion(project)
return Response(VideoSegmentVersionSerializer(version).data)
@action(detail=True, methods=["post"], url_path="submit-export")