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:
zyc
2026-06-24 17:11:55 +08:00
co-authored by Claude Opus 4.8
parent d6cb280018
commit 658aaa222f
12 changed files with 936 additions and 243 deletions
+184 -143
View File
@@ -29,6 +29,8 @@ from apps.projects.models import (
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
Timeline,
VideoSegment,
@@ -1126,8 +1128,9 @@ def _segment_script_text(segment, entities=None, *, with_dialogue: bool = False,
return "\n".join(parts)
def build_storyboard_frame_prompt(project, version, segment) -> str:
"""单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)。"""
def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> str:
"""单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)。
extra_prompt = 整张风格提示词(原 StoryboardVersion.prompt,现存项目级)。"""
dur = segment.duration_seconds or 15
default = (
"{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n"
@@ -1141,7 +1144,7 @@ def build_storyboard_frame_prompt(project, version, segment) -> str:
场景上下文=_scene_context(project),
时长=dur,
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"),
补充=(("\n" + version.prompt.strip()) if version.prompt else ""),
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
)
return "\n".join(line for line in rendered.split("\n") if line.strip())
@@ -1178,21 +1181,59 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt:
return "\n".join(line for line in rendered.split("\n") if line.strip())
def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
"""异步故事板·提交:快速创建(或复用)一个未采用的版本,不在此处生图。逐帧生成交给 generate_storyboard_frame(轮询)。"""
def ensure_storyboard_shots(project) -> list:
"""确保「采用版分镜数」= StoryboardShot 数:每镜一个 shot(对标视频段)。
缺则按 sort_order 建、补绑 script_segment;多出且从没出过图的尾 shot 裁掉(已出图的不动)。"""
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if adopted_script is None:
return []
segs = list(adopted_script.segments.order_by("sort_order"))
shots = {s.sort_order: s for s in project.storyboard_shots.all()}
for seg in segs:
shot = shots.get(seg.sort_order)
if shot is None:
shots[seg.sort_order] = StoryboardShot.objects.create(
project=project, script_segment=seg, sort_order=seg.sort_order, prompt=seg.visual_prompt or "")
elif shot.script_segment_id != seg.id:
shot.script_segment = seg
shot.save(update_fields=["script_segment", "updated_at"])
target = len(segs)
for order, shot in list(shots.items()):
if order >= target and not shot.versions.exists():
shot.delete()
del shots[order]
return [shots[k] for k in sorted(shots)]
def submit_storyboard(*, project, user, prompt: str = "", shot_ids: list | None = None) -> list:
"""故事板·提交(分镜制,对标视频「开始生成/单段重跑」):确保每镜一个 shot,把目标场标 QUEUED,
真正出图由 poll_storyboard 起后台线程。返回受影响的 shots。
- shot_ids 给定 → 只(重)生成这些场(单场重跑);
- 不给 → 还没出片的场;若全部已出片 → 视为整批重跑,全部重出。
prompt = 整张风格提示词,存项目级 metadata,供每场出图带上(原 StoryboardVersion.prompt 的去处)。"""
adopted_script = project.script_versions.filter(is_adopted=True).first()
if adopted_script is None:
raise ValueError("script must be adopted before generating storyboard")
if get_default_model(ModelConfig.Capability.IMAGE) is None:
raise ValueError("no active image model configured")
# 复用尚未完成(未采用)的版本,避免重复提交产生多版本;否则新建
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first()
if version is None:
version = StoryboardVersion.objects.create(project=project, prompt=prompt)
elif prompt and version.prompt != prompt:
version.prompt = prompt
version.save(update_fields=["prompt", "updated_at"])
return version
if prompt:
meta = dict(project.metadata or {})
if meta.get("storyboard_prompt") != prompt:
meta["storyboard_prompt"] = prompt
project.metadata = meta
project.save(update_fields=["metadata", "updated_at"])
shots = ensure_storyboard_shots(project)
if shot_ids is not None:
want = {str(x) for x in shot_ids}
targets = [s for s in shots if str(s.id) in want]
else:
pending = [s for s in shots if s.status != StoryboardShot.Status.SUCCEEDED or s.adopted_version_id is None]
targets = pending if pending else shots # 全部已出片 = 整批重跑
for s in targets:
s.status = StoryboardShot.Status.QUEUED
s.error_message = ""
s.save(update_fields=["status", "error_message", "updated_at"])
return targets
_ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
@@ -1269,8 +1310,9 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
used.add(match.id)
url = _asset_preview_url(match.adopted_asset)
if url:
# 带上审核态/素材库 ID:视频路按需换成 asset:// 引用;图像生成路只读 url 忽略它们
# 带上审核态/素材库 ID/资产 ID:视频路按需换成 asset:// 引用、过审闸据此定位送审;图像生成路只读 url 忽略它们
out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type"),
"asset_id": match.adopted_asset.id,
"review_status": match.adopted_asset.review_status, "review_remote_id": match.adopted_asset.review_remote_id})
if len(out) >= 3: # 给商品留一个位置(总计最多 4 张)
break
@@ -1284,11 +1326,11 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
return out[:4]
def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str:
def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra_prompt: str = "") -> str:
"""参考图合成版(用户钦定格式):顶部 @图N 点名每张参考图(角色/商品/场景),再给导演故事板指令 + 分镜脚本。
@图N 顺序与传给 gpt-image-2 的参考图顺序严格一致(refs 即 image_edit 的 images 顺序)。"""
if not refs:
return build_storyboard_frame_prompt(project, version, segment)
return build_storyboard_frame_prompt(project, segment, extra_prompt)
setup = "".join(
f"@图{i + 1}{r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs)
)
@@ -1306,7 +1348,7 @@ def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dic
场景上下文=_scene_context(project),
时长=dur,
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"),
补充=(("\n" + version.prompt.strip()) if version.prompt else ""),
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
)
return "\n".join(line for line in rendered.split("\n") if line.strip())
@@ -1341,62 +1383,46 @@ def _call_image_with_retry(fn, *, attempts: int = 2, base_delay: float = 2.0):
raise last # 理论不可达(循环内已 return/raise)
def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
"""后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。"""
import threading # noqa: F401 — 仅标注此函数运行在独立线程
def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
"""后台线程:为一个 StoryboardShot 出一张图 → 落成一条 StoryboardShotVersion 并采用。HTTP 永远秒回。"""
from django.db import connections
from apps.accounts.models import User
try:
task = AITask.objects.select_related("model_config__provider").get(id=task_id)
version = StoryboardVersion.objects.select_related("project__team").get(id=version_id)
segment = ScriptSegment.objects.get(id=segment_id)
shot = StoryboardShot.objects.select_related("project__team", "script_segment").get(id=shot_id)
user = User.objects.get(id=user_id)
project = version.project
project = shot.project
segment = shot.script_segment
model_config = task.model_config
reservation = task.credit_reservation
extra_prompt = (project.metadata or {}).get("storyboard_prompt", "") or ""
task.status = AITask.Status.SUBMITTED
task.save(update_fields=["status", "updated_at"])
try:
provider = get_image_provider(model_config)
refs = _storyboard_reference_images(project, segment)
refs = _storyboard_reference_images(project, segment) if segment is not None else []
ref_urls = [r["url"] for r in refs]
if ref_urls and hasattr(provider, "image_edit"):
# gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品),
# 不能复用 request_payload['prompt'](那是建任务时写死的基础提示词,恒为真值会架空一致性约束)。
frame_prompt = build_storyboard_frame_prompt_refs(project, version, segment, refs)
# 中转站偶发 Read timeout 会掐掉单帧(4 镜出 3 张的根因)→ 瞬时错误有界重试
# gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品)
frame_prompt = build_storyboard_frame_prompt_refs(project, segment, refs, extra_prompt)
response = _call_image_with_retry(
lambda: provider.image_edit(
model=model_config.name,
prompt=frame_prompt,
images=ref_urls,
size="1024x1536",
)
lambda: provider.image_edit(model=model_config.name, prompt=frame_prompt, images=ref_urls, size="1024x1536")
)
else:
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
frame_prompt = (
build_storyboard_frame_prompt(project, segment, extra_prompt) if segment is not None
else (task.request_payload.get("prompt") or "")
)
response = _call_image_with_retry(
lambda: provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=frame_prompt,
)
lambda: provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=frame_prompt)
)
media = provider.extract_first_media_url(response)
# 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。
# 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」
# 为同一镜重复起线程 → 重复帧 + 重复扣费(实测 4 帧出 6 帧)。
asset = _store_generated_media(
team=project.team,
user=user,
project=project,
task=task,
media=media,
name=f"{project.name}-storyboard-{segment.sort_order + 1}",
category=Asset.Category.STORYBOARD,
asset_type=Asset.Type.IMAGE,
team=project.team, user=user, project=project, task=task, media=media,
name=f"{project.name}-storyboard-{shot.sort_order + 1}",
category=Asset.Category.STORYBOARD, asset_type=Asset.Type.IMAGE,
)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
@@ -1405,120 +1431,94 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
# 幂等守卫:该镜已有帧(任何残余竞态/双 poll)就不再建,保持一镜一帧
if not StoryboardFrame.objects.filter(storyboard=version, script_segment=segment).exists():
StoryboardFrame.objects.create(
storyboard=version,
script_segment=segment,
asset=asset,
sort_order=segment.sort_order,
prompt=segment.visual_prompt,
)
# 合规(期3):分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
# 落一条新版本并采用(反采用同 shot 其余版本)= 该场历史 +1,采用最新
version = StoryboardShotVersion.objects.create(
shot=shot, task=task, asset=asset,
prompt=(segment.visual_prompt if segment is not None else ""), is_adopted=True,
)
shot.versions.exclude(id=version.id).update(is_adopted=False)
StoryboardShot.objects.filter(id=shot.id).update(
adopted_version=version, status=StoryboardShot.Status.SUCCEEDED, error_message="", updated_at=timezone.now())
# 合规:分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
from apps.assets.review import submit_asset_for_review
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务+shot 失败供 poll 上报
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
# 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 错误供前端显示
StoryboardShot.objects.filter(id=shot.id).update(
status=StoryboardShot.Status.FAILED, error_message=str(exc), updated_at=timezone.now())
finally:
connections.close_all() # 释放该线程的 DB 连接
def generate_storyboard_frame(*, project, user) -> dict:
"""异步故事板·轮询(秒回):读取进度;若无帧在生成则后台起线程生成下一帧。永不阻塞在 ARK 调用上。
返回 {status: generating|succeeded|failed, done, total, version_id}。全部完成→采用版本"""
def poll_storyboard(*, project, user) -> dict:
"""异步故事板·轮询(秒回):为 QUEUED/在制 shot 起线程出图;报告进度。永不阻塞在 ARK 调用上。
返回 {status: generating|succeeded|failed, done, total}。done = 已出片(有采用版且 SUCCEEDED)的场数"""
import threading
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first()
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if version is None or adopted_script is None:
latest = project.storyboard_versions.order_by("-created_at").first()
n = latest.frames.count() if latest else 0
return {"status": "succeeded", "done": n, "total": n, "version_id": str(latest.id) if latest else ""}
segments = list(adopted_script.segments.all().order_by("sort_order"))
total = len(segments)
done_segment_ids = set(version.frames.values_list("script_segment_id", flat=True))
done = len(done_segment_ids)
if done >= total:
_finalize_storyboard(project, version)
return {"status": "succeeded", "done": total, "total": total, "version_id": str(version.id)}
# 每镜独立「占位锁」(该镜有 CREATED/RESERVED/SUBMITTED 的任务=在生成中)。
# 旧实现是版本级单锁、一次只生成一帧;改为缺哪几镜就同时起哪几镜的线程。
# ★ 实测注记(2026-06-10):4 线程并发时当前 Seedream 端点在服务侧排队,单帧 25s→83-105s,
# 整版总时长 ≈ 串行(117s)。瓶颈是 ARK 端点并发配额而非本机;并行无额外成本,
# 配额提升后自动受益。可用 settings.STORYBOARD_MAX_PARALLEL 调并发(1=回到串行)。
# 仅算「近 N 分钟内」的任务:线程意外中断留下的僵尸任务超时后不再占锁,允许重新发起。
# 窗口须 > worker 最坏运行时长(单帧 HTTP 超时 300s × 重试 2 次 ≈ 10min),否则 worker 正在
# 重试时其任务被误判为僵尸 → 同一镜重复起线程 + 重复扣费。故默认 12min(覆盖一次瞬时重试)。
from django.conf import settings as dj_settings
shots = list(project.storyboard_shots.select_related("script_segment").order_by("sort_order"))
if not shots:
return {"status": "succeeded", "done": 0, "total": 0}
total = len(shots)
done = sum(1 for s in shots if s.adopted_version_id is not None and s.status == StoryboardShot.Status.SUCCEEDED)
active = [s for s in shots if s.status in (StoryboardShot.Status.QUEUED, StoryboardShot.Status.RUNNING)]
if not active:
failed = [s for s in shots if s.status == StoryboardShot.Status.FAILED]
if failed:
return {"status": "failed", "done": done, "total": total, "error": failed[0].error_message or "storyboard shot failed"}
return {"status": "succeeded", "done": done, "total": total}
# 每镜独立「占位锁」:近 N 分钟内有 CREATED/RESERVED/SUBMITTED 任务的 shot = 在生成中(僵尸超时后释放)。
STORYBOARD_MAX_PARALLEL = int(getattr(dj_settings, "STORYBOARD_MAX_PARALLEL", 4))
stale_minutes = int(getattr(dj_settings, "STORYBOARD_INFLIGHT_STALE_MINUTES", 12))
stale_cutoff = timezone.now() - timedelta(minutes=stale_minutes)
inflight_segment_ids = {
inflight_shot_ids = {
str(v)
for v in AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
project=project, task_type=AITask.Type.STORYBOARD,
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
request_payload__storyboard_version=str(version.id),
created_at__gte=stale_cutoff,
).values_list("request_payload__storyboard_segment", flat=True)
).values_list("request_payload__storyboard_shot", flat=True)
if v
}
pending = [s for s in segments if s.id not in done_segment_ids]
# 单帧失败次数上限,避免持续失败时无限重试;任一镜到上限即整版上报失败
for segment in pending:
failed_for_segment = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id),
).count()
if failed_for_segment >= 2:
last = AITask.objects.filter(project=project, task_type=AITask.Type.STORYBOARD, status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id)).order_by("-created_at").first()
return {"status": "failed", "done": done, "total": total, "version_id": str(version.id),
"error": last.error_message if last else "storyboard frame failed"}
spawnable = [s for s in pending if str(s.id) not in inflight_segment_ids]
slots = max(0, STORYBOARD_MAX_PARALLEL - len(inflight_segment_ids))
model_config = get_default_model(ModelConfig.Capability.IMAGE)
for segment in spawnable[:slots]:
extra_prompt = (project.metadata or {}).get("storyboard_prompt", "") or ""
spawnable = [s for s in active if str(s.id) not in inflight_shot_ids]
slots = max(0, STORYBOARD_MAX_PARALLEL - len(inflight_shot_ids))
for shot in spawnable[:slots]:
segment = shot.script_segment
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
project=project, user=user, task_type=AITask.Type.STORYBOARD, model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": build_storyboard_frame_prompt(project, version, segment),
"storyboard_version": str(version.id),
"storyboard_segment": str(segment.id),
"model": model_config.name, "endpoint": model_config.endpoint,
"prompt": build_storyboard_frame_prompt(project, segment, extra_prompt) if segment is not None else "",
"storyboard_shot": str(shot.id),
},
)
StoryboardShot.objects.filter(id=shot.id).update(status=StoryboardShot.Status.RUNNING, updated_at=timezone.now())
threading.Thread(
target=_storyboard_frame_worker,
args=(str(task.id), str(version.id), str(segment.id), str(user.id)),
daemon=True,
target=_storyboard_shot_worker, args=(str(task.id), str(shot.id), str(user.id)), daemon=True
).start()
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
return {"status": "generating", "done": done, "total": total}
def _finalize_storyboard(project, version) -> None:
"""全部帧就绪:采用该版本(反采用其余版本)。项目阶段推进由视图负责(与原同步实现一致)"""
project.storyboard_versions.exclude(id=version.id).update(is_adopted=False)
def adopt_storyboard_shot_version(*, shot: StoryboardShot, version: StoryboardShotVersion) -> None:
"""采用某场的某个历史版本(对标 adopt-video-version):反采用同场其余版本,置该场为采用版+SUCCEEDED"""
shot.versions.exclude(id=version.id).update(is_adopted=False)
if not version.is_adopted:
version.is_adopted = True
version.save(update_fields=["is_adopted", "updated_at"])
shot.adopted_version = version
shot.status = StoryboardShot.Status.SUCCEEDED
shot.error_message = ""
shot.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
def _asset_preview_url(asset) -> str:
@@ -1561,21 +1561,17 @@ def _video_reference_images(project, video_segment) -> list[dict]:
_vord = {"character": 0, "scene": 1, "product": 2}
refs.sort(key=lambda r: _vord.get(r.get("type"), 9))
out = refs
# 末位追加本镜故事板(@图N 末位 = 分镜图)
version = (
project.storyboard_versions.filter(is_adopted=True).order_by("-created_at").first()
or project.storyboard_versions.order_by("-created_at").first()
# 末位追加本镜故事板分镜图(@图N 末位 = 分镜图):取该 sort_order 的 shot 的采用版资产
shot = (
project.storyboard_shots.filter(sort_order=video_segment.sort_order, adopted_version__isnull=False).select_related("adopted_version__asset").first()
or project.storyboard_shots.filter(adopted_version__isnull=False).select_related("adopted_version__asset").order_by("sort_order").first()
)
if version is not None:
frame = (
version.frames.filter(sort_order=video_segment.sort_order).first()
or version.frames.order_by("sort_order").first()
)
if frame is not None:
url = _asset_preview_url(frame.asset)
if url:
out.append({"url": url, "label": "分镜图", "type": "storyboard",
"review_status": frame.asset.review_status, "review_remote_id": frame.asset.review_remote_id})
if shot is not None and shot.adopted_version_id:
frame_asset = shot.adopted_version.asset
url = _asset_preview_url(frame_asset)
if url:
out.append({"url": url, "label": "分镜图", "type": "storyboard",
"review_status": frame_asset.review_status, "review_remote_id": frame_asset.review_remote_id})
if not out:
product_group = (
project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False)
@@ -1591,6 +1587,51 @@ def _video_reference_images(project, video_segment) -> list[dict]:
return out
def collect_video_review_blockers(project, only_segment: "VideoSegment | None" = None) -> list[dict]:
"""点「生成视频」前的过审闸:列出本次将生成的段里,含真人脸的参考图(人物立绘 + 该镜故事板分镜)
中**尚未过审(review_status != 'active')** 的项。火山视频对含真人脸的图,只接受素材库已过审的引用,
否则报 InputImageSensitiveContentDetected。这里在调火山前先拦,弹窗指明是哪一镜的哪个人物/分镜未过审。
only_segment 给定 → 只校验该段(单段重跑);为 None → 校验全部「未出片」段(整批生成)。
返回 [{video_segment_id, sort_order, scene_no, kind:'person'|'storyboard', name, asset_id, review_status}];
空列表 = 全部已过审,可放行。"""
segs = [only_segment] if only_segment is not None else list(project.video_segments.order_by("sort_order"))
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
shots_by_order = {
s.sort_order: s
for s in project.storyboard_shots.filter(adopted_version__isnull=False).select_related("adopted_version__asset")
}
blockers: list[dict] = []
for seg in segs:
# 整批生成只校验还没出片的段;单段重跑则无论状态都校验(用户主动要重出这一段)
if only_segment is None and seg.status == VideoSegment.Status.SUCCEEDED:
continue
scene_no = seg.sort_order + 1
scene = adopted_script.segments.filter(sort_order=seg.sort_order).first() if adopted_script else None
# 人物立绘(与视频实际取图同一套逻辑,保证「拦的」就是「会传给火山的」)
if scene is not None:
for ref in _storyboard_reference_images(project, scene):
if ref.get("type") != "character":
continue
if (ref.get("review_status") or "") != "active":
blockers.append({
"video_segment_id": str(seg.id), "sort_order": seg.sort_order, "scene_no": scene_no,
"kind": "person", "name": ref.get("label") or "人物",
"asset_id": str(ref.get("asset_id") or ""), "review_status": ref.get("review_status") or "",
})
# 该镜故事板分镜图(分镜图里也有同一张脸,是另一个需过审的资产)= 对应 shot 的采用版资产
shot = shots_by_order.get(seg.sort_order)
if shot is not None and shot.adopted_version_id:
frame_asset = shot.adopted_version.asset
if (frame_asset.review_status or "") != "active":
blockers.append({
"video_segment_id": str(seg.id), "sort_order": seg.sort_order, "scene_no": scene_no,
"kind": "storyboard", "name": f"{scene_no} 分镜图",
"asset_id": str(frame_asset.id), "review_status": frame_asset.review_status or "",
})
return blockers
def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
model_config = get_default_model(ModelConfig.Capability.VIDEO)
if model_config is None: