diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index c816a19..1a4827d 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -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") diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index c171f6d..47934e0 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -361,9 +361,21 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId, ); } -// 对齐 api-bridge:阶段编号 / 状态分桶 / 友好标签 / pill 类 -const PROJ_STAGE_NO: Record = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 }; -function projStageNo(project: Project) { return project.status === "completed" ? 5 : (PROJ_STAGE_NO[project.current_stage] || 1); } +// 对齐 api-bridge / pipeline:阶段编号 / 状态分桶 / 友好标签 / pill 类 +// V1 雪藏「拼接导出」第 5 阶段(代码保留,V2 恢复):流程现为 4 步,export 归并到第 4(视频), +// 已完成项目落到第 4 阶段而非被藏的第 5。(与 pipeline.tsx STAGE_STEPS / projectStage 对齐) +const PROJ_STAGE_TOTAL = 4; +const PROJ_STAGE_NO: Record = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 }; +function projStageNo(project: Project) { return project.status === "completed" ? PROJ_STAGE_TOTAL : (PROJ_STAGE_NO[project.current_stage] || 1); } +// 进度格 class: +// · 已完成 → 含末格在内全部 done(绿),末格不再用闪烁的 cur +// · 失败 → 停在第 no 格标红(fail),之前的 done +// · 进行中 → 已过的格 done,当前格 cur(一闪一闪),未到的格留空 +function projProgClass(project: Project, i: number, no: number): string { + if (project.status === "completed") return i <= no ? "done" : ""; + if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : ""; + return i < no ? "done" : i === no ? "cur" : ""; +} function projBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; } function projStatusLabel(project: Project) { return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record)[project.status] || "进行中"; @@ -644,8 +656,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op {(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}
-
{[1, 2, 3, 4, 5].map((i) => )}
- {no}/5 +
{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => )}
+ {no}/{PROJ_STAGE_TOTAL}
{projStatusLabel(project)} @@ -683,8 +695,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
{productTitle(project.product)} · {projShots(project)} 镜
-
{[1, 2, 3, 4, 5].map((i) => )}
- {no}/5 +
{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => )}
+ {no}/{PROJ_STAGE_TOTAL}
{projStatusLabel(project)}{projDate(project)}