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:
+184
-143
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user