优化脚本

This commit is contained in:
Azmat@qq.com
2026-08-28 14:45:38 +08:00
parent 849b14a0f6
commit c0a0b9603b
37 changed files with 541 additions and 1893 deletions
+9 -114
View File
@@ -27,17 +27,14 @@ from apps.ai.script_agent import (
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
adopt_storyboard_shot_version,
create_export_job,
generate_base_asset,
generate_person_triview,
get_default_model,
get_inflight_extraction,
poll_storyboard,
poll_video_segment,
regenerate_script_segment,
submit_extract_entities,
submit_storyboard,
submit_video_segment,
synthesize_project_voiceover,
)
@@ -60,10 +57,6 @@ from .models import (
ScriptSegment,
ScriptTemplate,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
@@ -78,14 +71,12 @@ from .serializers import (
QuickCreateJobSerializer,
ScriptTemplateSerializer,
ScriptVersionSerializer,
StoryboardVersionSerializer,
VideoSegmentVersionSerializer,
is_playable_video,
)
from .services.export import run_export_job_in_thread
from .services.pipeline import (
adopt_script_version,
finish_storyboard_stage,
initialize_project_pipeline,
sync_video_segments_to_script,
)
@@ -182,8 +173,8 @@ def promote_base_asset_stage_if_ready(project: Project) -> bool:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.STORYBOARD
project.status = Project.Status.STORYBOARDING
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
return True
@@ -197,7 +188,7 @@ VIDEO_IS_FINAL_STAGE = True
def _ensure_video_stage(project: Project) -> None:
"""幂等地把项目推进到「视频」阶段(生成视频片段时调用)。current_stage 没到 VIDEO 时,
settle_video_completion 会直接返回 → 全段出片也不收口、卡在故事板格(ZWQ#15)。"""
settle_video_completion 会直接返回 → 全段出片也不收口、卡在上一格(ZWQ#15)。"""
if project.current_stage != ProjectStage.Stage.VIDEO:
project.current_stage = ProjectStage.Stage.VIDEO
if project.status != Project.Status.COMPLETED:
@@ -252,10 +243,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"base_asset_groups",
"base_asset_groups__adopted_asset__files",
"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
@@ -304,7 +291,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
def retrieve(self, request, *args, **kwargs):
# 详情加载(=进入流水线页)时自愈视频片段数:历史项目在「采用前增删分镜」未同步,
# 或项目创建固定铺的 4 段从未被收口,会让视频步骤的片段数与故事板/采用版分镜对不上。
# 或项目创建固定铺的 4 段从未被收口,会让视频步骤的片段数与采用版分镜对不上。
# 按采用版分镜数收口一次(幂等;只裁从未生成过的尾段,生成中/已出片的段绝不动)。
# 用轻量查询判定 + 同步,再交给 super 做重 prefetch 序列化(避免重查询跑两遍)。
proj = (
@@ -326,7 +313,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if adopted_script is not None:
self._sync_video_segments_to_script(proj, adopted_script)
# 自愈卡阶段:历史项目「视频片段全部出片并采用」却因 current_stage 没推到 VIDEO 而永远停在
# 「3/4 故事板」、不收口(ZWQ#15)。进详情时若检测到全段已出片,就补推到视频阶段再收口。
# 停在上一格、不收口(ZWQ#15)。进详情时若检测到全段已出片,就补推到视频阶段再收口。
segs = list(VideoSegment.objects.filter(project=proj).values_list("status", "adopted_version_id"))
if segs and all(s == VideoSegment.Status.SUCCEEDED and a is not None for s, a in segs):
_ensure_video_stage(proj)
@@ -343,7 +330,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
.order_by("-updated_at")
)
# 列表:轻查询(只 select_related product + 计数注解),不做详情那串 12 个重 prefetch
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
# ——原列表把每个项目的 阶段/片段/时间线/资产文件全拉出,20 个项目实测 ~2s。
if self.action == "list":
qs = (
Project.objects.select_related("product", "product__cover_asset", "timeline", "quick_create_job")
@@ -725,7 +712,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"project__product__cover_asset__files",
"project__script_versions",
"project__base_asset_groups",
"project__storyboard_shots__adopted_version__asset__files",
"project__video_segments__adopted_version__asset__files",
"project__timeline__export_jobs__output_asset__files",
)
@@ -1256,21 +1242,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
已出片的段不改时长——改了会跟已渲染的成片对不上。"""
sync_video_segments_to_script(project, script)
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()
@@ -1341,7 +1312,6 @@ 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")
@@ -1353,15 +1323,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
script = segment.script_version
# 允许删空:用户可以把镜头全删了(再整体重新生成或手动加),不再强制「至少保留一个分镜」。
# 删本场脚本时,它对应的故事板分镜帧也一起删 —— frame.script_segment 是 SET_NULL,
# 不主动删会留下孤儿帧,故事板里仍显示这一(已删的)场。各版本受影响的帧统一收口后重排 sort_order。
affected_storyboards = list(StoryboardVersion.objects.filter(frames__script_segment=segment).distinct())
StoryboardFrame.objects.filter(script_segment=segment).delete()
for sb in affected_storyboards:
for index, frame in enumerate(sb.frames.order_by("sort_order")):
if frame.sort_order != index:
frame.sort_order = index
frame.save(update_fields=["sort_order", "updated_at"])
segment.delete()
# 删后重排 sort_order:收集变动的镜一次性 bulk_update,替代逐条 save(远程库少跑 N 个往返 → 删除快很多)
resort = []
@@ -1372,7 +1333,6 @@ 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 · 视频版本采用(详情弹窗里切历史版) ──
@@ -1402,74 +1362,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
settle_video_completion(project)
return Response(ProjectSerializer(project).data)
@action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None):
"""开始生成故事板(全部场):确保每镜一个 shot 并标 QUEUED;逐场出图交给 poll-storyboard 起线程。
全部已出片时再点 = 整批重跑(全部场重出)。不推进阶段。"""
require_worker()
project = self.get_object()
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({"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 阶段。"""
project = self.get_object()
result = poll_storyboard(project=project, user=request.user)
if result.get("status") == "succeeded":
finish_storyboard_stage(project)
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
return Response(result, status=http_status)
@action(detail=True, methods=["post"], url_path="skip-storyboard")
@transaction.atomic
def skip_storyboard(self, request, pk=None):
project = self.get_object()
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"])
return Response(ProjectSerializer(project).data)
@action(detail=True, methods=["post"], url_path="video-review-precheck")
def video_review_precheck(self, request, pk=None):
"""点「生成视频」前的过审校验:返回未过审(非 active)的人物立绘 / 故事板分镜清单。
"""点「生成视频」前的过审校验:返回未过审(非 active)的人物立绘清单。
body 可选 video_segment_id:只校验该段(单段重跑);不传 = 校验全部未出片段。
blockers 空 = 全部已过审,前端放行生成。"""
from apps.ai.services import collect_video_review_blockers
@@ -1511,9 +1406,9 @@ 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)
# 生成视频即代表已进入视频阶段:某些路径(跳过故事板/异常)可能没把 current_stage 推到 VIDEO,
# 生成视频即代表已进入视频阶段:某些路径(异常/老数据)可能没把 current_stage 推到 VIDEO,
# 而 settle_video_completion 只在 current_stage==VIDEO 时收口 → 不补推的话,全段出片后项目会永远停在
# 「3/4 故事板」、永不 completed(ZWQ#15)。这里幂等补推。
# 停在上一格、永不 completed(ZWQ#15)。这里幂等补推。
_ensure_video_stage(project)
# 重跑某段 → 该段回到在制;若项目此前已 completed,退回 videoing
settle_video_completion(project)