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:
@@ -120,6 +120,49 @@ def promote_base_asset_stage_if_ready(project: Project) -> bool:
|
|||||||
return True
|
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):
|
class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||||
queryset = Project.objects.select_related("product", "timeline").prefetch_related(
|
queryset = Project.objects.select_related("product", "timeline").prefetch_related(
|
||||||
"stages",
|
"stages",
|
||||||
@@ -159,6 +202,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
adopted_script = proj.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
adopted_script = proj.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||||
if adopted_script is not None:
|
if adopted_script is not None:
|
||||||
self._sync_video_segments_to_script(proj, adopted_script)
|
self._sync_video_segments_to_script(proj, adopted_script)
|
||||||
|
# 自愈完成态:历史项目「全部片段已出片」却卡在 videoing(导出藏起来后没有环节翻状态),
|
||||||
|
# 进详情时收口成 completed;若有段被重跑则退回 videoing。
|
||||||
|
settle_video_completion(proj)
|
||||||
return super().retrieve(request, *args, **kwargs)
|
return super().retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
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(
|
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
|
asset_id=version.asset_id, trim_start_ms=0, trim_end_ms=None
|
||||||
)
|
)
|
||||||
|
# 采用历史版后该段为已出片 → 若全部段就绪,收口项目为 completed
|
||||||
|
settle_video_completion(project)
|
||||||
return Response(ProjectSerializer(project).data)
|
return Response(ProjectSerializer(project).data)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="generate-storyboard")
|
@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)
|
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.error("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True)
|
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)
|
return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="poll-video-segment")
|
@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)
|
version = poll_video_segment(video_segment=segment, user=request.user)
|
||||||
if version is None:
|
if version is None:
|
||||||
return Response({"status": segment.status}, status=status.HTTP_202_ACCEPTED)
|
return Response({"status": segment.status}, status=status.HTTP_202_ACCEPTED)
|
||||||
|
# 这一段刚出片 → 若是最后一段,收口整个项目为 completed(视频是末阶段)
|
||||||
|
settle_video_completion(project)
|
||||||
return Response(VideoSegmentVersionSerializer(version).data)
|
return Response(VideoSegmentVersionSerializer(version).data)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="submit-export")
|
@action(detail=True, methods=["post"], url_path="submit-export")
|
||||||
|
|||||||
@@ -361,9 +361,21 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对齐 api-bridge:阶段编号 / 状态分桶 / 友好标签 / pill 类
|
// 对齐 api-bridge / pipeline:阶段编号 / 状态分桶 / 友好标签 / pill 类
|
||||||
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
|
// V1 雪藏「拼接导出」第 5 阶段(代码保留,V2 恢复):流程现为 4 步,export 归并到第 4(视频),
|
||||||
function projStageNo(project: Project) { return project.status === "completed" ? 5 : (PROJ_STAGE_NO[project.current_stage] || 1); }
|
// 已完成项目落到第 4 阶段而非被藏的第 5。(与 pipeline.tsx STAGE_STEPS / projectStage 对齐)
|
||||||
|
const PROJ_STAGE_TOTAL = 4;
|
||||||
|
const PROJ_STAGE_NO: Record<string, number> = { 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 projBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
|
||||||
function projStatusLabel(project: Project) {
|
function projStatusLabel(project: Project) {
|
||||||
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
|
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
|
||||||
@@ -644,8 +656,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
|||||||
<td><span className="muted">{(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
|
<td><span className="muted">{(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={project.status === "failed" && i === no ? "fail" : i < no ? "done" : i === no ? "cur" : ""} />)}</div>
|
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
|
||||||
<span className="muted-2 mono" style={{ fontSize: "12px" }}>{no}/5</span>
|
<span className="muted-2 mono" style={{ fontSize: "12px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
|
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
|
||||||
@@ -683,8 +695,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
|||||||
<div className="card-sub">{productTitle(project.product)} · {projShots(project)} 镜</div>
|
<div className="card-sub">{productTitle(project.product)} · {projShots(project)} 镜</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={project.status === "failed" && i === no ? "fail" : i < no ? "done" : i === no ? "cur" : ""} />)}</div>
|
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
|
||||||
<span className="muted-2 mono" style={{ fontSize: "10.5px" }}>{no}/5</span>
|
<span className="muted-2 mono" style={{ fontSize: "10.5px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-foot"><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span><span className="card-time">{projDate(project)}</span></div>
|
<div className="card-foot"><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span><span className="card-time">{projDate(project)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user