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:
|
||||
|
||||
@@ -9,6 +9,8 @@ from .models import (
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
@@ -36,6 +38,8 @@ admin.site.register(ScriptSegment)
|
||||
admin.site.register(BaseAssetGroup)
|
||||
admin.site.register(StoryboardVersion)
|
||||
admin.site.register(StoryboardFrame)
|
||||
admin.site.register(StoryboardShot)
|
||||
admin.site.register(StoryboardShotVersion)
|
||||
admin.site.register(VideoSegment)
|
||||
admin.site.register(VideoSegmentVersion)
|
||||
admin.site.register(Timeline)
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-24 08:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0016_person_triview_16_9"),
|
||||
("assets", "0006_alter_asset_category_model"),
|
||||
("projects", "0004_project_projects_pr_team_id_e667f8_idx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="StoryboardShot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("sort_order", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("not_started", "Not Started"),
|
||||
("queued", "Queued"),
|
||||
("running", "Running"),
|
||||
("succeeded", "Succeeded"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="not_started",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("prompt", models.TextField(blank=True)),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="storyboard_shots",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"script_segment",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="storyboard_shots",
|
||||
to="projects.scriptsegment",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["sort_order", "created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="StoryboardShotVersion",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("prompt", models.TextField(blank=True)),
|
||||
("is_adopted", models.BooleanField(default=False)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"asset",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="storyboard_shot_versions",
|
||||
to="assets.asset",
|
||||
),
|
||||
),
|
||||
(
|
||||
"shot",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="versions",
|
||||
to="projects.storyboardshot",
|
||||
),
|
||||
),
|
||||
(
|
||||
"task",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="storyboard_shot_versions",
|
||||
to="ai.aitask",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="storyboardshot",
|
||||
name="adopted_version",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="adopted_by_shots",
|
||||
to="projects.storyboardshotversion",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="storyboardshot",
|
||||
unique_together={("project", "sort_order")},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
"""把旧「整版制」故事板迁到新「分镜制」:每个项目只取采用版(无采用版则取最新版)的每一帧,
|
||||
建一个 StoryboardShot + 一条已采用 StoryboardShotVersion(沿用同一张图)。旧的其它整版历史丢弃。"""
|
||||
Project = apps.get_model("projects", "Project")
|
||||
StoryboardVersion = apps.get_model("projects", "StoryboardVersion")
|
||||
StoryboardShot = apps.get_model("projects", "StoryboardShot")
|
||||
StoryboardShotVersion = apps.get_model("projects", "StoryboardShotVersion")
|
||||
|
||||
for project in Project.objects.all().iterator():
|
||||
adopted = (
|
||||
StoryboardVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first()
|
||||
or StoryboardVersion.objects.filter(project=project).order_by("-created_at").first()
|
||||
)
|
||||
if adopted is None:
|
||||
continue
|
||||
seen_orders = set()
|
||||
for frame in adopted.frames.order_by("sort_order", "created_at"):
|
||||
if frame.sort_order in seen_orders: # 同序帧只保第一张,守 (project, sort_order) 唯一约束
|
||||
continue
|
||||
seen_orders.add(frame.sort_order)
|
||||
shot = StoryboardShot.objects.create(
|
||||
project=project,
|
||||
script_segment=frame.script_segment,
|
||||
sort_order=frame.sort_order,
|
||||
status="succeeded",
|
||||
prompt=frame.prompt or "",
|
||||
)
|
||||
version = StoryboardShotVersion.objects.create(
|
||||
shot=shot, asset=frame.asset, prompt=frame.prompt or "", is_adopted=True,
|
||||
)
|
||||
shot.adopted_version = version
|
||||
shot.save(update_fields=["adopted_version"])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
StoryboardShot = apps.get_model("projects", "StoryboardShot")
|
||||
StoryboardShot.objects.all().delete() # version 随 shot 级联删
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("projects", "0005_storyboardshot_storyboardshotversion_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -151,6 +151,54 @@ class StoryboardFrame(TimeStampedModel):
|
||||
ordering = ["sort_order", "created_at"]
|
||||
|
||||
|
||||
# ── 故事板「分镜制」(对标视频 VideoSegment/VideoSegmentVersion)──
|
||||
# 一镜 = 一个 StoryboardShot(有自己的采用版 + 状态);每次出图 = 一条 StoryboardShotVersion(历史版本)。
|
||||
# 取代旧的「整版制」StoryboardVersion/StoryboardFrame:单场可独立重跑、单场各有历史、可逐场切换采用。
|
||||
class StoryboardShot(TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
NOT_STARTED = "not_started", "Not Started"
|
||||
QUEUED = "queued", "Queued"
|
||||
RUNNING = "running", "Running"
|
||||
SUCCEEDED = "succeeded", "Succeeded"
|
||||
FAILED = "failed", "Failed"
|
||||
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="storyboard_shots")
|
||||
script_segment = models.ForeignKey(
|
||||
ScriptSegment,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="storyboard_shots",
|
||||
)
|
||||
sort_order = models.PositiveIntegerField(default=0)
|
||||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED)
|
||||
prompt = models.TextField(blank=True)
|
||||
adopted_version = models.ForeignKey(
|
||||
"projects.StoryboardShotVersion",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="adopted_by_shots",
|
||||
)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("project", "sort_order")]
|
||||
ordering = ["sort_order", "created_at"]
|
||||
|
||||
|
||||
class StoryboardShotVersion(TimeStampedModel):
|
||||
shot = models.ForeignKey(StoryboardShot, on_delete=models.CASCADE, related_name="versions")
|
||||
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="storyboard_shot_versions")
|
||||
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="storyboard_shot_versions")
|
||||
prompt = models.TextField(blank=True)
|
||||
is_adopted = models.BooleanField(default=False)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class VideoSegment(TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
NOT_STARTED = "not_started", "Not Started"
|
||||
|
||||
@@ -13,6 +13,8 @@ from .models import (
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
@@ -130,6 +132,54 @@ class StoryboardVersionSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
# ── 故事板分镜制(对标 VideoSegment/Version):每镜一个 shot,采用版缩略图 + 各自历史版本 ──
|
||||
class StoryboardShotSerializer(serializers.ModelSerializer):
|
||||
adopted_asset = serializers.SerializerMethodField()
|
||||
adopted_asset_url = serializers.SerializerMethodField()
|
||||
# 分镜图含人脸,后端自动送火山人像审核;暴露采用版审核态让前端挂盾牌
|
||||
review_status = serializers.SerializerMethodField()
|
||||
review_error = serializers.SerializerMethodField()
|
||||
versions = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = StoryboardShot
|
||||
fields = ["id", "script_segment", "sort_order", "status", "error_message", "prompt",
|
||||
"adopted_version", "adopted_asset", "adopted_asset_url", "review_status", "review_error", "versions"]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_adopted_asset(self, obj):
|
||||
v = obj.adopted_version
|
||||
return str(v.asset_id) if v and v.asset_id else None
|
||||
|
||||
def get_adopted_asset_url(self, obj) -> str:
|
||||
v = obj.adopted_version
|
||||
return _asset_preview_url(v.asset) if v is not None else ""
|
||||
|
||||
def get_review_status(self, obj) -> str:
|
||||
v = obj.adopted_version
|
||||
return (getattr(v.asset, "review_status", "") or "") if (v and v.asset_id) else ""
|
||||
|
||||
def get_review_error(self, obj) -> str:
|
||||
v = obj.adopted_version
|
||||
return (getattr(v.asset, "review_error", "") or "") if (v and v.asset_id) else ""
|
||||
|
||||
def get_versions(self, obj):
|
||||
versions = sorted(obj.versions.all(), key=lambda v: (v.created_at is None, v.created_at), reverse=True)
|
||||
return [
|
||||
{
|
||||
"id": str(v.id),
|
||||
"asset": str(v.asset_id) if v.asset_id else None,
|
||||
"asset_url": _asset_preview_url(v.asset) if v.asset_id else "",
|
||||
"prompt": v.prompt,
|
||||
"is_adopted": v.is_adopted,
|
||||
"review_status": (getattr(v.asset, "review_status", "") or "") if v.asset_id else "",
|
||||
"review_error": (getattr(v.asset, "review_error", "") or "") if v.asset_id else "",
|
||||
"created_at": v.created_at.isoformat() if v.created_at else "",
|
||||
}
|
||||
for v in versions
|
||||
]
|
||||
|
||||
|
||||
class VideoSegmentVersionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = VideoSegmentVersion
|
||||
@@ -289,7 +339,8 @@ class ProjectSerializer(serializers.ModelSerializer):
|
||||
video_segments = VideoSegmentSerializer(many=True, read_only=True)
|
||||
script_versions = ScriptVersionSerializer(many=True, read_only=True)
|
||||
base_asset_groups = BaseAssetGroupSerializer(many=True, read_only=True)
|
||||
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True)
|
||||
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) # 过渡期保留(旧整版,新生成不再写)
|
||||
storyboard_shots = StoryboardShotSerializer(many=True, read_only=True)
|
||||
timeline = TimelineSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -307,6 +358,7 @@ class ProjectSerializer(serializers.ModelSerializer):
|
||||
"script_versions",
|
||||
"base_asset_groups",
|
||||
"storyboard_versions",
|
||||
"storyboard_shots",
|
||||
"video_segments",
|
||||
"timeline",
|
||||
"created_at",
|
||||
|
||||
@@ -13,6 +13,10 @@ from apps.projects.models import (
|
||||
ProjectStage,
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
StoryboardShotVersion,
|
||||
StoryboardVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
TimelineClip,
|
||||
@@ -470,6 +474,124 @@ class ProjectApiTests(TestCase):
|
||||
# 已出片的尾段保留 → 不裁到 1,停在 2
|
||||
self.assertEqual(project.video_segments.count(), 2)
|
||||
|
||||
def _mk_image_model(self):
|
||||
return ModelConfig.objects.create(
|
||||
provider=self.provider, name="gpt-image-2", display_name="img",
|
||||
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
|
||||
)
|
||||
|
||||
def _mk_shot(self, project, script, order, *, review_status="active"):
|
||||
"""建一个已出片的 StoryboardShot(+ 已采用版本,资产审核态可控),供过审/采用测试。"""
|
||||
seg = script.segments.filter(sort_order=order).first() or ScriptSegment.objects.create(
|
||||
script_version=script, sort_order=order, narration=f"n{order}", visual_prompt=f"v{order}")
|
||||
a = Asset.objects.create(team=self.team, created_by=self.user, name=f"sb{order}", asset_type="image",
|
||||
source="ai_generated", category=Asset.Category.STORYBOARD, review_status=review_status)
|
||||
shot = StoryboardShot.objects.create(project=project, script_segment=seg, sort_order=order,
|
||||
status=StoryboardShot.Status.SUCCEEDED)
|
||||
ver = StoryboardShotVersion.objects.create(shot=shot, asset=a, is_adopted=True)
|
||||
shot.adopted_version = ver
|
||||
shot.save(update_fields=["adopted_version"])
|
||||
return shot, ver, a
|
||||
|
||||
def test_submit_storyboard_creates_one_shot_per_segment_all_queued(self):
|
||||
"""开始生成故事板:确保每镜一个 shot、全部置 QUEUED(真正出图交给 poll)。"""
|
||||
from apps.ai.services import submit_storyboard
|
||||
self._mk_image_model()
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
for i in range(3):
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}")
|
||||
|
||||
targets = submit_storyboard(project=project, user=self.user, prompt="风格")
|
||||
|
||||
self.assertEqual(project.storyboard_shots.count(), 3)
|
||||
self.assertEqual(len(targets), 3)
|
||||
self.assertTrue(all(s.status == StoryboardShot.Status.QUEUED for s in project.storyboard_shots.all()))
|
||||
project.refresh_from_db()
|
||||
self.assertEqual((project.metadata or {}).get("storyboard_prompt"), "风格") # 整张风格存项目级
|
||||
|
||||
def test_submit_storyboard_single_shot_only_targets_that_shot(self):
|
||||
"""单场重跑:只把指定 shot 置 QUEUED,其余已出片的场不动。"""
|
||||
from apps.ai.services import submit_storyboard
|
||||
self._mk_image_model()
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
for i in range(2):
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}")
|
||||
shot0, _, _ = self._mk_shot(project, script, 0)
|
||||
shot1, _, _ = self._mk_shot(project, script, 1)
|
||||
|
||||
submit_storyboard(project=project, user=self.user, shot_ids=[str(shot1.id)])
|
||||
|
||||
shot0.refresh_from_db(); shot1.refresh_from_db()
|
||||
self.assertEqual(shot0.status, StoryboardShot.Status.SUCCEEDED) # 没动
|
||||
self.assertEqual(shot1.status, StoryboardShot.Status.QUEUED) # 只重跑这场
|
||||
|
||||
def test_adopt_storyboard_shot_version_switches_adopted(self):
|
||||
"""采用某场的历史版本:切换该场采用的分镜图。"""
|
||||
self._mk_image_model()
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
|
||||
shot, v1, _ = self._mk_shot(project, script, 0)
|
||||
a2 = Asset.objects.create(team=self.team, created_by=self.user, name="sb-v2", asset_type="image",
|
||||
source="ai_generated", category=Asset.Category.STORYBOARD)
|
||||
v2 = StoryboardShotVersion.objects.create(shot=shot, asset=a2, is_adopted=False)
|
||||
|
||||
res = self.client.post(
|
||||
f"/api/projects/{project.id}/adopt-storyboard-shot-version/",
|
||||
{"shot_id": str(shot.id), "version_id": str(v2.id)}, format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
shot.refresh_from_db(); v1.refresh_from_db(); v2.refresh_from_db()
|
||||
self.assertEqual(shot.adopted_version_id, v2.id)
|
||||
self.assertTrue(v2.is_adopted)
|
||||
self.assertFalse(v1.is_adopted)
|
||||
|
||||
def test_video_review_precheck_flags_unreviewed_storyboard_then_clears(self):
|
||||
"""生成视频前的过审闸:某镜故事板分镜未过审(非 active)→ precheck 列为 blocker;
|
||||
过审(active)后 → blockers 清空,可放行生成。"""
|
||||
project = Project.objects.create(
|
||||
team=self.team, created_by=self.user, product=self.product, name="P",
|
||||
current_stage=ProjectStage.Stage.VIDEO,
|
||||
)
|
||||
VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15)
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
|
||||
_, _, frame_asset = self._mk_shot(project, script, 0, review_status="")
|
||||
|
||||
res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
blockers = res.json()["blockers"]
|
||||
self.assertTrue(any(b["kind"] == "storyboard" and b["scene_no"] == 1 for b in blockers))
|
||||
|
||||
# 过审后再查 → 不再拦
|
||||
frame_asset.review_status = "active"
|
||||
frame_asset.save(update_fields=["review_status"])
|
||||
res2 = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
|
||||
self.assertEqual(res2.json()["blockers"], [])
|
||||
|
||||
def test_video_review_precheck_skips_succeeded_segments_in_batch(self):
|
||||
"""整批校验(不传 segment_id)只看未出片的段:已 succeeded 的段即便分镜未过审也不拦。"""
|
||||
project = Project.objects.create(
|
||||
team=self.team, created_by=self.user, product=self.product, name="P",
|
||||
current_stage=ProjectStage.Stage.VIDEO,
|
||||
)
|
||||
seg = VideoSegment.objects.create(project=project, sort_order=0, status=VideoSegment.Status.SUCCEEDED)
|
||||
script = ScriptVersion.objects.create(project=project, is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
|
||||
self._mk_shot(project, script, 0, review_status="")
|
||||
|
||||
# 整批:succeeded 段跳过 → 不拦
|
||||
res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
|
||||
self.assertEqual(res.json()["blockers"], [])
|
||||
# 指定该段(单段重跑):无论 succeeded 都校验 → 拦
|
||||
res2 = self.client.post(
|
||||
f"/api/projects/{project.id}/video-review-precheck/",
|
||||
{"video_segment_id": str(seg.id)}, format="json",
|
||||
)
|
||||
self.assertTrue(len(res2.json()["blockers"]) >= 1)
|
||||
|
||||
def test_adopt_video_version_remaps_timeline_draft(self):
|
||||
"""切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。"""
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
|
||||
@@ -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