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:
@@ -18,12 +18,13 @@ from apps.ai.script_agent import stream_script_agent
|
||||
from apps.ai.services import (
|
||||
DEFAULT_VOICEOVER_VOICE,
|
||||
VOICEOVER_VOICES,
|
||||
adopt_storyboard_shot_version,
|
||||
create_export_job,
|
||||
generate_base_asset,
|
||||
generate_person_triview,
|
||||
generate_storyboard_frame,
|
||||
get_default_model,
|
||||
get_inflight_extraction,
|
||||
poll_storyboard,
|
||||
poll_video_segment,
|
||||
regenerate_script_segment,
|
||||
submit_extract_entities,
|
||||
@@ -46,6 +47,8 @@ from .models import (
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
@@ -130,6 +133,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"base_asset_groups__candidate_assets__files",
|
||||
"storyboard_versions",
|
||||
"storyboard_versions__frames__asset__files",
|
||||
"storyboard_shots__adopted_version__asset__files",
|
||||
"storyboard_shots__versions__asset__files",
|
||||
"timeline__clips__asset__files",
|
||||
).all()
|
||||
serializer_class = ProjectSerializer
|
||||
@@ -511,6 +516,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
|
||||
next_order += 1
|
||||
|
||||
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
|
||||
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
|
||||
多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。"""
|
||||
if not script.is_adopted:
|
||||
return
|
||||
target = script.segments.count()
|
||||
shots = list(project.storyboard_shots.order_by("sort_order"))
|
||||
while len(shots) > target:
|
||||
tail = shots[-1]
|
||||
if tail.status == StoryboardShot.Status.NOT_STARTED and not tail.versions.exists():
|
||||
tail.delete()
|
||||
shots.pop()
|
||||
else:
|
||||
break
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="update-script-segment")
|
||||
def update_script_segment(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
@@ -575,6 +595,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
seg.sort_order = index
|
||||
seg.save(update_fields=["sort_order", "updated_at"])
|
||||
self._sync_video_segments_to_script(project, script)
|
||||
self._sync_storyboard_shots_to_script(project, script)
|
||||
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="delete-script-segment")
|
||||
@@ -605,6 +626,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if resort:
|
||||
ScriptSegment.objects.bulk_update(resort, ["sort_order"])
|
||||
self._sync_video_segments_to_script(project, script)
|
||||
self._sync_storyboard_shots_to_script(project, script)
|
||||
return Response(ScriptVersionSerializer(script).data)
|
||||
|
||||
# ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ──
|
||||
@@ -633,20 +655,48 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="generate-storyboard")
|
||||
def generate_storyboard_action(self, request, pk=None):
|
||||
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。"""
|
||||
"""开始生成故事板(全部场):确保每镜一个 shot 并标 QUEUED;逐场出图交给 poll-storyboard 起线程。
|
||||
全部已出片时再点 = 整批重跑(全部场重出)。不推进阶段。"""
|
||||
require_worker()
|
||||
project = self.get_object()
|
||||
storyboard = submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
|
||||
submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_202_ACCEPTED)
|
||||
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="rerun-storyboard-shot")
|
||||
def rerun_storyboard_shot_action(self, request, pk=None):
|
||||
"""单场重跑:只重出该 shot 的图 —— 新增一条历史版本并采用,其余场不动。出图交给 poll-storyboard。"""
|
||||
require_worker()
|
||||
project = self.get_object()
|
||||
shot_id = request.data.get("shot_id")
|
||||
if not project.storyboard_shots.filter(id=shot_id).exists():
|
||||
return Response({"detail": "shot not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""), shot_ids=[shot_id])
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="adopt-storyboard-shot-version")
|
||||
def adopt_storyboard_shot_version_action(self, request, pk=None):
|
||||
"""采用某场的某个历史版本(对标 adopt-video-version):切换该场采用的分镜图。"""
|
||||
project = self.get_object()
|
||||
shot = StoryboardShot.objects.filter(project=project, id=request.data.get("shot_id")).first()
|
||||
if shot is None:
|
||||
return Response({"detail": "shot not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
version = StoryboardShotVersion.objects.filter(shot=shot, id=request.data.get("version_id")).first()
|
||||
if version is None:
|
||||
return Response({"detail": "version not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
adopt_storyboard_shot_version(shot=shot, version=version)
|
||||
return Response(ProjectSerializer(self.get_object()).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="poll-storyboard")
|
||||
def poll_storyboard_action(self, request, pk=None):
|
||||
"""异步故事板·轮询:每次生成下一帧(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
|
||||
"""异步故事板·轮询:为在制场起线程出图(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
|
||||
project = self.get_object()
|
||||
result = generate_storyboard_frame(project=project, user=request.user)
|
||||
result = poll_storyboard(project=project, user=request.user)
|
||||
if result.get("status") == "succeeded":
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
@@ -677,6 +727,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
return Response(ProjectSerializer(project).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="video-review-precheck")
|
||||
def video_review_precheck(self, request, pk=None):
|
||||
"""点「生成视频」前的过审校验:返回未过审(非 active)的人物立绘 / 故事板分镜清单。
|
||||
body 可选 video_segment_id:只校验该段(单段重跑);不传 = 校验全部未出片段。
|
||||
blockers 空 = 全部已过审,前端放行生成。"""
|
||||
from apps.ai.services import collect_video_review_blockers
|
||||
|
||||
project = self.get_object()
|
||||
seg = None
|
||||
sid = request.data.get("video_segment_id")
|
||||
if sid:
|
||||
seg = project.video_segments.filter(id=sid).first()
|
||||
blockers = collect_video_review_blockers(project, only_segment=seg)
|
||||
return Response({"blockers": blockers})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="submit-video-segment")
|
||||
def submit_video_segment_action(self, request, pk=None):
|
||||
# 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。
|
||||
|
||||
Reference in New Issue
Block a user