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, ScriptSegment,
ScriptVersion, ScriptVersion,
StoryboardFrame, StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion, StoryboardVersion,
Timeline, Timeline,
VideoSegment, VideoSegment,
@@ -1126,8 +1128,9 @@ def _segment_script_text(segment, entities=None, *, with_dialogue: bool = False,
return "\n".join(parts) return "\n".join(parts)
def build_storyboard_frame_prompt(project, version, segment) -> str: def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> str:
"""单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)。""" """单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)。
extra_prompt = 整张风格提示词(原 StoryboardVersion.prompt,现存项目级)。"""
dur = segment.duration_seconds or 15 dur = segment.duration_seconds or 15
default = ( default = (
"{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n"
@@ -1141,7 +1144,7 @@ def build_storyboard_frame_prompt(project, version, segment) -> str:
场景上下文=_scene_context(project), 场景上下文=_scene_context(project),
时长=dur, 时长=dur,
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"), 脚本=(_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()) 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()) return "\n".join(line for line in rendered.split("\n") if line.strip())
def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion: def ensure_storyboard_shots(project) -> list:
"""异步故事板·提交:快速创建(或复用)一个未采用的版本,不在此处生图。逐帧生成交给 generate_storyboard_frame(轮询)。""" """确保「采用版分镜数」= StoryboardShot 数:每镜一个 shot(对标视频段)。
缺则按 sort_order 建、补绑 script_segment;多出且从没出过图的尾 shot 裁掉(已出图的不动)。"""
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() 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: if adopted_script is None:
raise ValueError("script must be adopted before generating storyboard") raise ValueError("script must be adopted before generating storyboard")
if get_default_model(ModelConfig.Capability.IMAGE) is None: if get_default_model(ModelConfig.Capability.IMAGE) is None:
raise ValueError("no active image model configured") raise ValueError("no active image model configured")
# 复用尚未完成(未采用)的版本,避免重复提交产生多版本;否则新建 if prompt:
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first() meta = dict(project.metadata or {})
if version is None: if meta.get("storyboard_prompt") != prompt:
version = StoryboardVersion.objects.create(project=project, prompt=prompt) meta["storyboard_prompt"] = prompt
elif prompt and version.prompt != prompt: project.metadata = meta
version.prompt = prompt project.save(update_fields=["metadata", "updated_at"])
version.save(update_fields=["prompt", "updated_at"]) shots = ensure_storyboard_shots(project)
return version 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": "商品"} _ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
@@ -1269,8 +1310,9 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
used.add(match.id) used.add(match.id)
url = _asset_preview_url(match.adopted_asset) url = _asset_preview_url(match.adopted_asset)
if url: 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"), 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}) "review_status": match.adopted_asset.review_status, "review_remote_id": match.adopted_asset.review_remote_id})
if len(out) >= 3: # 给商品留一个位置(总计最多 4 张) if len(out) >= 3: # 给商品留一个位置(总计最多 4 张)
break break
@@ -1284,11 +1326,11 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
return out[:4] 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 点名每张参考图(角色/商品/场景),再给导演故事板指令 + 分镜脚本。
@图N 顺序与传给 gpt-image-2 的参考图顺序严格一致(refs 即 image_edit 的 images 顺序)。""" @图N 顺序与传给 gpt-image-2 的参考图顺序严格一致(refs 即 image_edit 的 images 顺序)。"""
if not refs: if not refs:
return build_storyboard_frame_prompt(project, version, segment) return build_storyboard_frame_prompt(project, segment, extra_prompt)
setup = "".join( setup = "".join(
f"@图{i + 1}{r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs) 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), 场景上下文=_scene_context(project),
时长=dur, 时长=dur,
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"), 脚本=(_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()) 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) raise last # 理论不可达(循环内已 return/raise)
def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None: def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
"""后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。""" """后台线程:为一个 StoryboardShot 出一张图 → 落成一条 StoryboardShotVersion 并采用。HTTP 永远秒回。"""
import threading # noqa: F401 — 仅标注此函数运行在独立线程
from django.db import connections from django.db import connections
from apps.accounts.models import User from apps.accounts.models import User
try: try:
task = AITask.objects.select_related("model_config__provider").get(id=task_id) task = AITask.objects.select_related("model_config__provider").get(id=task_id)
version = StoryboardVersion.objects.select_related("project__team").get(id=version_id) shot = StoryboardShot.objects.select_related("project__team", "script_segment").get(id=shot_id)
segment = ScriptSegment.objects.get(id=segment_id)
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
project = version.project project = shot.project
segment = shot.script_segment
model_config = task.model_config model_config = task.model_config
reservation = task.credit_reservation reservation = task.credit_reservation
extra_prompt = (project.metadata or {}).get("storyboard_prompt", "") or ""
task.status = AITask.Status.SUBMITTED task.status = AITask.Status.SUBMITTED
task.save(update_fields=["status", "updated_at"]) task.save(update_fields=["status", "updated_at"])
try: try:
provider = get_image_provider(model_config) 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] ref_urls = [r["url"] for r in refs]
if ref_urls and hasattr(provider, "image_edit"): if ref_urls and hasattr(provider, "image_edit"):
# gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品), # gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品)
# 不能复用 request_payload['prompt'](那是建任务时写死的基础提示词,恒为真值会架空一致性约束)。 frame_prompt = build_storyboard_frame_prompt_refs(project, segment, refs, extra_prompt)
frame_prompt = build_storyboard_frame_prompt_refs(project, version, segment, refs)
# 中转站偶发 Read timeout 会掐掉单帧(4 镜出 3 张的根因)→ 瞬时错误有界重试
response = _call_image_with_retry( response = _call_image_with_retry(
lambda: provider.image_edit( lambda: provider.image_edit(model=model_config.name, prompt=frame_prompt, images=ref_urls, size="1024x1536")
model=model_config.name,
prompt=frame_prompt,
images=ref_urls,
size="1024x1536",
)
) )
else: 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( response = _call_image_with_retry(
lambda: provider.image_generation( lambda: provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=frame_prompt)
model=model_config.name,
endpoint=model_config.endpoint,
prompt=frame_prompt,
)
) )
media = provider.extract_first_media_url(response) media = provider.extract_first_media_url(response)
# 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。
# 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」
# 为同一镜重复起线程 → 重复帧 + 重复扣费(实测 4 帧出 6 帧)。
asset = _store_generated_media( asset = _store_generated_media(
team=project.team, team=project.team, user=user, project=project, task=task, media=media,
user=user, name=f"{project.name}-storyboard-{shot.sort_order + 1}",
project=project, category=Asset.Category.STORYBOARD, asset_type=Asset.Type.IMAGE,
task=task,
media=media,
name=f"{project.name}-storyboard-{segment.sort_order + 1}",
category=Asset.Category.STORYBOARD,
asset_type=Asset.Type.IMAGE,
) )
with transaction.atomic(): with transaction.atomic():
task.status = AITask.Status.SUCCEEDED 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.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
# 幂等守卫:该镜已有帧(任何残余竞态/双 poll)就不再建,保持一镜一帧 # 落一条新版本并采用(反采用同 shot 其余版本)= 该场历史 +1,采用最新
if not StoryboardFrame.objects.filter(storyboard=version, script_segment=segment).exists(): version = StoryboardShotVersion.objects.create(
StoryboardFrame.objects.create( shot=shot, task=task, asset=asset,
storyboard=version, prompt=(segment.visual_prompt if segment is not None else ""), is_adopted=True,
script_segment=segment, )
asset=asset, shot.versions.exclude(id=version.id).update(is_adopted=False)
sort_order=segment.sort_order, StoryboardShot.objects.filter(id=shot.id).update(
prompt=segment.visual_prompt, adopted_version=version, status=StoryboardShot.Status.SUCCEEDED, error_message="", updated_at=timezone.now())
) # 合规:分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
# 合规(期3):分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
from apps.assets.review import submit_asset_for_review from apps.assets.review import submit_asset_for_review
transaction.on_commit(lambda a=asset: submit_asset_for_review(a)) 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.status = AITask.Status.FAILED
task.error_message = str(exc) task.error_message = str(exc)
task.completed_at = timezone.now() task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc)) 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: finally:
connections.close_all() # 释放该线程的 DB 连接 connections.close_all() # 释放该线程的 DB 连接
def generate_storyboard_frame(*, project, user) -> dict: def poll_storyboard(*, project, user) -> dict:
"""异步故事板·轮询(秒回):读取进度;若无帧在生成则后台起线程生成下一帧。永不阻塞在 ARK 调用上。 """异步故事板·轮询(秒回):为 QUEUED/在制 shot 起线程出图;报告进度。永不阻塞在 ARK 调用上。
返回 {status: generating|succeeded|failed, done, total, version_id}。全部完成→采用版本""" 返回 {status: generating|succeeded|failed, done, total}。done = 已出片(有采用版且 SUCCEEDED)的场数"""
import threading 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 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)) STORYBOARD_MAX_PARALLEL = int(getattr(dj_settings, "STORYBOARD_MAX_PARALLEL", 4))
stale_minutes = int(getattr(dj_settings, "STORYBOARD_INFLIGHT_STALE_MINUTES", 12)) stale_minutes = int(getattr(dj_settings, "STORYBOARD_INFLIGHT_STALE_MINUTES", 12))
stale_cutoff = timezone.now() - timedelta(minutes=stale_minutes) stale_cutoff = timezone.now() - timedelta(minutes=stale_minutes)
inflight_segment_ids = { inflight_shot_ids = {
str(v) str(v)
for v in AITask.objects.filter( for v in AITask.objects.filter(
project=project, project=project, task_type=AITask.Type.STORYBOARD,
task_type=AITask.Type.STORYBOARD,
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED], status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
request_payload__storyboard_version=str(version.id),
created_at__gte=stale_cutoff, created_at__gte=stale_cutoff,
).values_list("request_payload__storyboard_segment", flat=True) ).values_list("request_payload__storyboard_shot", flat=True)
if v 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) 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( task = create_ai_task(
project=project, project=project, user=user, task_type=AITask.Type.STORYBOARD, model_config=model_config,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
request_payload={ request_payload={
"model": model_config.name, "model": model_config.name, "endpoint": model_config.endpoint,
"endpoint": model_config.endpoint, "prompt": build_storyboard_frame_prompt(project, segment, extra_prompt) if segment is not None else "",
"prompt": build_storyboard_frame_prompt(project, version, segment), "storyboard_shot": str(shot.id),
"storyboard_version": str(version.id),
"storyboard_segment": str(segment.id),
}, },
) )
StoryboardShot.objects.filter(id=shot.id).update(status=StoryboardShot.Status.RUNNING, updated_at=timezone.now())
threading.Thread( threading.Thread(
target=_storyboard_frame_worker, target=_storyboard_shot_worker, args=(str(task.id), str(shot.id), str(user.id)), daemon=True
args=(str(task.id), str(version.id), str(segment.id), str(user.id)),
daemon=True,
).start() ).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: def adopt_storyboard_shot_version(*, shot: StoryboardShot, version: StoryboardShotVersion) -> None:
"""全部帧就绪:采用该版本(反采用其余版本)。项目阶段推进由视图负责(与原同步实现一致)""" """采用某场的某个历史版本(对标 adopt-video-version):反采用同场其余版本,置该场为采用版+SUCCEEDED"""
project.storyboard_versions.exclude(id=version.id).update(is_adopted=False) shot.versions.exclude(id=version.id).update(is_adopted=False)
if not version.is_adopted: if not version.is_adopted:
version.is_adopted = True version.is_adopted = True
version.save(update_fields=["is_adopted", "updated_at"]) 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: 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} _vord = {"character": 0, "scene": 1, "product": 2}
refs.sort(key=lambda r: _vord.get(r.get("type"), 9)) refs.sort(key=lambda r: _vord.get(r.get("type"), 9))
out = refs out = refs
# 末位追加本镜故事板(@图N 末位 = 分镜图) # 末位追加本镜故事板分镜图(@图N 末位 = 分镜图):取该 sort_order 的 shot 的采用版资产
version = ( shot = (
project.storyboard_versions.filter(is_adopted=True).order_by("-created_at").first() project.storyboard_shots.filter(sort_order=video_segment.sort_order, adopted_version__isnull=False).select_related("adopted_version__asset").first()
or project.storyboard_versions.order_by("-created_at").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: if shot is not None and shot.adopted_version_id:
frame = ( frame_asset = shot.adopted_version.asset
version.frames.filter(sort_order=video_segment.sort_order).first() url = _asset_preview_url(frame_asset)
or version.frames.order_by("sort_order").first() if url:
) out.append({"url": url, "label": "分镜图", "type": "storyboard",
if frame is not None: "review_status": frame_asset.review_status, "review_remote_id": frame_asset.review_remote_id})
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: if not out:
product_group = ( product_group = (
project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False) 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 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: def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
model_config = get_default_model(ModelConfig.Capability.VIDEO) model_config = get_default_model(ModelConfig.Capability.VIDEO)
if model_config is None: if model_config is None:
+4
View File
@@ -9,6 +9,8 @@ from .models import (
ScriptSegment, ScriptSegment,
ScriptVersion, ScriptVersion,
StoryboardFrame, StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion, StoryboardVersion,
SubtitleTrack, SubtitleTrack,
Timeline, Timeline,
@@ -36,6 +38,8 @@ admin.site.register(ScriptSegment)
admin.site.register(BaseAssetGroup) admin.site.register(BaseAssetGroup)
admin.site.register(StoryboardVersion) admin.site.register(StoryboardVersion)
admin.site.register(StoryboardFrame) admin.site.register(StoryboardFrame)
admin.site.register(StoryboardShot)
admin.site.register(StoryboardShotVersion)
admin.site.register(VideoSegment) admin.site.register(VideoSegment)
admin.site.register(VideoSegmentVersion) admin.site.register(VideoSegmentVersion)
admin.site.register(Timeline) admin.site.register(Timeline)
@@ -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),
]
+48
View File
@@ -151,6 +151,54 @@ class StoryboardFrame(TimeStampedModel):
ordering = ["sort_order", "created_at"] 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 VideoSegment(TimeStampedModel):
class Status(models.TextChoices): class Status(models.TextChoices):
NOT_STARTED = "not_started", "Not Started" NOT_STARTED = "not_started", "Not Started"
+53 -1
View File
@@ -13,6 +13,8 @@ from .models import (
ScriptSegment, ScriptSegment,
ScriptVersion, ScriptVersion,
StoryboardFrame, StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion, StoryboardVersion,
SubtitleTrack, SubtitleTrack,
Timeline, Timeline,
@@ -130,6 +132,54 @@ class StoryboardVersionSerializer(serializers.ModelSerializer):
read_only_fields = fields 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 VideoSegmentVersionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = VideoSegmentVersion model = VideoSegmentVersion
@@ -289,7 +339,8 @@ class ProjectSerializer(serializers.ModelSerializer):
video_segments = VideoSegmentSerializer(many=True, read_only=True) video_segments = VideoSegmentSerializer(many=True, read_only=True)
script_versions = ScriptVersionSerializer(many=True, read_only=True) script_versions = ScriptVersionSerializer(many=True, read_only=True)
base_asset_groups = BaseAssetGroupSerializer(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) timeline = TimelineSerializer(read_only=True)
class Meta: class Meta:
@@ -307,6 +358,7 @@ class ProjectSerializer(serializers.ModelSerializer):
"script_versions", "script_versions",
"base_asset_groups", "base_asset_groups",
"storyboard_versions", "storyboard_versions",
"storyboard_shots",
"video_segments", "video_segments",
"timeline", "timeline",
"created_at", "created_at",
+122
View File
@@ -13,6 +13,10 @@ from apps.projects.models import (
ProjectStage, ProjectStage,
ScriptSegment, ScriptSegment,
ScriptVersion, ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack, SubtitleTrack,
Timeline, Timeline,
TimelineClip, TimelineClip,
@@ -470,6 +474,124 @@ class ProjectApiTests(TestCase):
# 已出片的尾段保留 → 不裁到 1,停在 2 # 已出片的尾段保留 → 不裁到 1,停在 2
self.assertEqual(project.video_segments.count(), 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): 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") project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
+71 -6
View File
@@ -18,12 +18,13 @@ from apps.ai.script_agent import stream_script_agent
from apps.ai.services import ( from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE, DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES, VOICEOVER_VOICES,
adopt_storyboard_shot_version,
create_export_job, create_export_job,
generate_base_asset, generate_base_asset,
generate_person_triview, generate_person_triview,
generate_storyboard_frame,
get_default_model, get_default_model,
get_inflight_extraction, get_inflight_extraction,
poll_storyboard,
poll_video_segment, poll_video_segment,
regenerate_script_segment, regenerate_script_segment,
submit_extract_entities, submit_extract_entities,
@@ -46,6 +47,8 @@ from .models import (
ScriptSegment, ScriptSegment,
ScriptVersion, ScriptVersion,
StoryboardFrame, StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion, StoryboardVersion,
SubtitleTrack, SubtitleTrack,
Timeline, Timeline,
@@ -130,6 +133,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"base_asset_groups__candidate_assets__files", "base_asset_groups__candidate_assets__files",
"storyboard_versions", "storyboard_versions",
"storyboard_versions__frames__asset__files", "storyboard_versions__frames__asset__files",
"storyboard_shots__adopted_version__asset__files",
"storyboard_shots__versions__asset__files",
"timeline__clips__asset__files", "timeline__clips__asset__files",
).all() ).all()
serializer_class = ProjectSerializer 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)) segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
next_order += 1 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") @action(detail=True, methods=["post"], url_path="update-script-segment")
def update_script_segment(self, request, pk=None): def update_script_segment(self, request, pk=None):
project = self.get_object() project = self.get_object()
@@ -575,6 +595,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
seg.sort_order = index seg.sort_order = index
seg.save(update_fields=["sort_order", "updated_at"]) seg.save(update_fields=["sort_order", "updated_at"])
self._sync_video_segments_to_script(project, script) 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) return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="delete-script-segment") @action(detail=True, methods=["post"], url_path="delete-script-segment")
@@ -605,6 +626,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if resort: if resort:
ScriptSegment.objects.bulk_update(resort, ["sort_order"]) ScriptSegment.objects.bulk_update(resort, ["sort_order"])
self._sync_video_segments_to_script(project, script) self._sync_video_segments_to_script(project, script)
self._sync_storyboard_shots_to_script(project, script)
return Response(ScriptVersionSerializer(script).data) return Response(ScriptVersionSerializer(script).data)
# ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ── # ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ──
@@ -633,20 +655,48 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="generate-storyboard") @action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None): def generate_storyboard_action(self, request, pk=None):
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。""" """开始生成故事板(全部场):确保每镜一个 shot 并标 QUEUED;逐场出图交给 poll-storyboard 起线程。
全部已出片时再点 = 整批重跑(全部场重出)不推进阶段"""
require_worker() require_worker()
project = self.get_object() 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, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.RUNNING stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"]) 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") @action(detail=True, methods=["post"], url_path="poll-storyboard")
def poll_storyboard_action(self, request, pk=None): def poll_storyboard_action(self, request, pk=None):
"""异步故事板·轮询:每次生成下一帧(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。""" """异步故事板·轮询:为在制场起线程出图(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
project = self.get_object() 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": if result.get("status") == "succeeded":
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED stage.status = ProjectStage.Status.SUCCEEDED
@@ -677,6 +727,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project.save(update_fields=["current_stage", "status", "updated_at"]) project.save(update_fields=["current_stage", "status", "updated_at"])
return Response(ProjectSerializer(project).data) 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") @action(detail=True, methods=["post"], url_path="submit-video-segment")
def submit_video_segment_action(self, request, pk=None): def submit_video_segment_action(self, request, pk=None):
# 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。 # 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。
+30 -13
View File
@@ -357,6 +357,25 @@ export function App() {
} }
}, [activeProjectId]); }, [activeProjectId]);
// 静默轮询故事板分镜生成(对标 pollVideosQuiet):有 queued/running 的场就驱动后端出图并刷新,不占全局 loading、不弹 toast。
// 这样单场重跑不会把别的场按钮也锁住——生成在后台跑,UI 只按每场自身状态转圈。
const pollStoryboardQuiet = useCallback(async () => {
if (!activeProjectId) return;
let detail = projectDetailRef.current;
if (!detail || detail.id !== activeProjectId) {
detail = await api.project(activeProjectId).catch(() => null);
if (!detail) return;
setProjectDetail(detail);
}
const active = (detail.storyboard_shots ?? []).filter((s) => ["queued", "running"].includes(s.status));
if (active.length === 0) return;
await api.pollStoryboard(activeProjectId).catch(() => undefined);
const next = await api.project(activeProjectId).catch(() => null);
if (next && next.id === activeProjectIdRef.current) {
setProjectDetail((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
}
}, [activeProjectId]);
// 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。 // 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。
const refreshExport = useCallback(async () => { const refreshExport = useCallback(async () => {
if (!activeProjectId) return; if (!activeProjectId) return;
@@ -866,7 +885,9 @@ export function App() {
notice={notice} notice={notice}
unreadCount={unreadCount} unreadCount={unreadCount}
avatarChar={avatarChar} avatarChar={avatarChar}
logout={logout} onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")} logout={logout}
onNotify={(type, text) => setNotice({ type, text })}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新", { liteRefresh: true })} onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新", { liteRefresh: true })}
onAddShot={(afterSegmentId, content) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId, ...content }), "分镜已添加", { liteRefresh: true })} onAddShot={(afterSegmentId, content) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId, ...content }), "分镜已添加", { liteRefresh: true })}
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除", { liteRefresh: true })} onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除", { liteRefresh: true })}
@@ -883,19 +904,15 @@ export function App() {
return assetId ? { adopted_asset: assetId } : null; return assetId ? { adopted_asset: assetId } : null;
}} }}
onGenerateStoryboard={(prompt) => onGenerateStoryboard={(prompt) =>
action(async () => { // 只「提交」(秒回);出图由后台 pollStoryboardQuiet 驱动 —— 不占全局 loading,不锁其它场按钮(对标视频「开始生成」)
// 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待 action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已开始生成")
await api.generateStoryboard(pipelineProject.id, { prompt });
for (let i = 0; i < 60; i += 1) {
const res = await api.pollStoryboard(pipelineProject.id);
if (res.status === "succeeded") return true;
if (res.status === "failed") throw new Error(res.error || "故事板生成失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 4000));
}
// 轮询窗口耗尽仍未完成:如实报超时,不能让 action 弹「已生成」的假成功 toast
throw new Error("故事板生成超时,请稍后刷新查看或重试");
}, "故事板已生成")
} }
onRerunStoryboardShot={(shotId, prompt) =>
// 单场重跑也只「提交」(秒回);后台轮询出图。不阻塞 → 可同时重跑别的场
action(() => api.rerunStoryboardShot(pipelineProject.id, shotId, prompt), "本场已开始重跑")
}
onAdoptStoryboardShotVersion={(shotId, versionId) => action(() => api.adoptStoryboardShotVersion(pipelineProject.id, shotId, versionId), "已采用该版本")}
onPollStoryboardQuiet={pollStoryboardQuiet}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")} onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")} onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")} onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
+15
View File
@@ -405,9 +405,24 @@ export const api = {
skipStoryboard(projectId: string) { skipStoryboard(projectId: string) {
return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" }); return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" });
}, },
// 单场重跑:只重出该 shot 的分镜图(新增一条历史版本并采用)
rerunStoryboardShot(projectId: string, shotId: string, prompt?: string) {
return request(`/api/projects/${projectId}/rerun-storyboard-shot/`, { method: "POST", body: JSON.stringify({ shot_id: shotId, prompt: prompt || "" }) });
},
// 采用某场的某个历史版本(切换该场分镜图)
adoptStoryboardShotVersion(projectId: string, shotId: string, versionId: string) {
return request<Project>(`/api/projects/${projectId}/adopt-storyboard-shot-version/`, { method: "POST", body: JSON.stringify({ shot_id: shotId, version_id: versionId }) });
},
submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) { submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) {
return request<Project>(`/api/projects/${projectId}/submit-video-segment/`, { method: "POST", body: JSON.stringify(payload) }); return request<Project>(`/api/projects/${projectId}/submit-video-segment/`, { method: "POST", body: JSON.stringify(payload) });
}, },
// 点生成视频前的过审闸:列出未过审的人物立绘/故事板分镜。video_segment_id 给定=只校验该段,不给=全部未出片段。
videoReviewPrecheck(projectId: string, videoSegmentId?: string) {
return request<{ blockers: Array<{ video_segment_id: string; sort_order: number; scene_no: number; kind: "person" | "storyboard"; name: string; asset_id: string; review_status: string }> }>(
`/api/projects/${projectId}/video-review-precheck/`,
{ method: "POST", body: JSON.stringify(videoSegmentId ? { video_segment_id: videoSegmentId } : {}) },
);
},
pollVideo(projectId: string, video_segment_id: string) { pollVideo(projectId: string, video_segment_id: string) {
return request(`/api/projects/${projectId}/poll-video-segment/`, { return request(`/api/projects/${projectId}/poll-video-segment/`, {
method: "POST", method: "POST",
+197 -80
View File
@@ -2,7 +2,7 @@ import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useR
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react"; import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { Play } from "lucide-react"; import { Play } from "lucide-react";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types"; import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
import type { Notice, Page } from "./route-config"; import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config"; import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell"; import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
@@ -457,6 +457,7 @@ export function PipelinePage(props: {
unreadCount: number; unreadCount: number;
avatarChar: string; avatarChar: string;
logout: () => void; logout: () => void;
onNotify?: (type: "success" | "error", text: string) => void;
scriptModelName: string; scriptModelName: string;
textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise<unknown>; textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>; onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
@@ -482,6 +483,10 @@ export function PipelinePage(props: {
// 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产 // 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产
onRenameActor?: (assetId: string, name: string) => Promise<unknown>; onRenameActor?: (assetId: string, name: string) => Promise<unknown>;
onGenerateStoryboard: (prompt: string) => void | Promise<unknown>; onGenerateStoryboard: (prompt: string) => void | Promise<unknown>;
// 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本
onRerunStoryboardShot?: (shotId: string, prompt?: string) => Promise<unknown>;
onAdoptStoryboardShotVersion?: (shotId: string, versionId: string) => void | Promise<unknown>;
onPollStoryboardQuiet?: () => void | Promise<void>;
onSkipStoryboard: () => Promise<unknown>; onSkipStoryboard: () => Promise<unknown>;
onSubmitVideo: (segmentId: string, prompt: string) => void | Promise<unknown>; onSubmitVideo: (segmentId: string, prompt: string) => void | Promise<unknown>;
onSubmitAllVideos: (prompt: string) => void | Promise<unknown>; onSubmitAllVideos: (prompt: string) => void | Promise<unknown>;
@@ -495,9 +500,9 @@ export function PipelinePage(props: {
onSubmitExport: (payload?: TimelineSavePayload) => void; onSubmitExport: (payload?: TimelineSavePayload) => void;
}) { }) {
const { const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout, project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout, onNotify,
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover, textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard, onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props; } = props;
@@ -772,6 +777,24 @@ export function PipelinePage(props: {
if (missing.length) setRefGate({ missing, proceed: action }); if (missing.length) setRefGate({ missing, proceed: action });
else action(); else action();
} }
// ── 流程步骤5 · 生成视频前的「过审闸」:含真人脸的人物立绘/故事板分镜必须先过审(火山要素材库已过审引用),
// 否则火山直接报 InputImageSensitiveContentDetected。未过审 → 弹窗指明哪一镜/哪个人物/哪张分镜,挡住不生成。
type ReviewBlocker = { video_segment_id: string; sort_order: number; scene_no: number; kind: "person" | "storyboard"; name: string; asset_id: string; review_status: string };
const [reviewGate, setReviewGate] = useState<{ blockers: ReviewBlocker[]; submitting: boolean } | null>(null);
useBodyScrollLock(Boolean(reviewGate));
// 过审闸:有未过审项 → 弹窗(去送审/等过审),不放行;全过审 → 执行生成。segmentId 给定=只校验该段。
async function guardReview(segmentId: string | null, action: () => void) {
const r = await api.videoReviewPrecheck(project.id, segmentId || undefined).catch(() => null);
const blockers = r?.blockers ?? [];
if (blockers.length) setReviewGate({ blockers, submitting: false });
else action();
}
// 视频生成统一闸:先查参考图齐不齐(refGate),齐了再查过审(reviewGate),都过才真生成。
function guardVideoGen(segs: Array<{ entity_refs?: string[] }>, segmentId: string | null, action: () => void) {
const missing = missingRefsFor(segs);
if (missing.length) { setRefGate({ missing, proceed: () => void guardReview(segmentId, action) }); return; }
void guardReview(segmentId, action);
}
// 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签) // 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签)
async function downloadAssetImage(assetId: string, name: string) { async function downloadAssetImage(assetId: string, name: string) {
try { try {
@@ -834,20 +857,47 @@ export function PipelinePage(props: {
}, [adDetail]); }, [adDetail]);
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。 // 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ── // ── Stage 3:分镜制(对标视频段)—— 每镜一个 StoryboardShot,各自采用版图 + 历史版本,可单场重跑/切版 ──
const storyboards = project.storyboard_versions ?? []; const sbShots = [...(project.storyboard_shots ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
const [sbViewId, setSbViewId] = useState<string | null>(null);
const displayedStoryboard = storyboards.find((s) => s.id === sbViewId) || adoptedStoryboard;
const sbFrames = [...(displayedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const [sbSelected, setSbSelected] = useState(0); const [sbSelected, setSbSelected] = useState(0);
const sbActiveFrame = sbFrames[Math.min(sbSelected, Math.max(0, sbFrames.length - 1))] || null; const sbActiveShot = sbShots[Math.min(sbSelected, Math.max(0, sbShots.length - 1))] || null;
// 「查看的版本」与「采用的版本」分离(对标视频详情弹窗):点历史缩略图只切预览(纯本地态),
// 绝不动后端状态 → 重跑在制时切版本不会把 RUNNING 状态冲掉、不丢任务;采用要点显式「采用此版本」。
const [sbViewVerId, setSbViewVerId] = useState<string | null>(null);
// 切换场 / 切项目 / 采用版变化(重跑落地或手动采用)时复位预览到「采用版」,这样重跑出片后自动看到新图
useEffect(() => { setSbViewVerId(null); }, [sbActiveShot?.id, sbActiveShot?.adopted_version]);
const sbAnyImage = sbShots.some((s) => Boolean(s.adopted_asset_url || s.adopted_asset));
// 全部场都出片(有采用版)= 故事板完成,可进视频;在制中(queued/running)用于转圈veil
const sbAllDone = sbShots.length > 0 && sbShots.every((s) => Boolean(s.adopted_version));
const sbAnyGenerating = sbShots.some((s) => ["queued", "running"].includes(s.status));
// 故事板按「采用版脚本」逐镜出图 → 还没出图时,先按采用版镜头数铺等量空占位(场1…场N), // 故事板按「采用版脚本」逐镜出图 → 还没出图时,先按采用版镜头数铺等量空占位(场1…场N),
// 让用户一眼看出「这里本该有几张」,出图后逐个填真图。无采用版脚本则为 0(显示「暂无」)。 // 让用户一眼看出「这里本该有几张」,出图后逐个填真图。无采用版脚本则为 0(显示「暂无」)。
const sbAdoptedScript = scripts.find((s) => s.is_adopted) || null; const sbAdoptedScript = scripts.find((s) => s.is_adopted) || null;
const sbExpectedShots = sbAdoptedScript ? (sbAdoptedScript.segments?.length ?? 0) : 0; const sbExpectedShots = sbAdoptedScript ? (sbAdoptedScript.segments?.length ?? 0) : 0;
// 整张故事板生成中:不全屏遮挡,只在每张分镜占位上转个 spinner(样式同基础资产趴) // 整张/全部生成中:不全屏遮挡,只在每张分镜占位上转个 spinner(样式同基础资产趴)
const [sbGenerating, setSbGenerating] = useState(false); const [sbGenerating, setSbGenerating] = useState(false);
// 单场重跑的乐观态:点下立刻给该场转圈(撑到状态回传 queued/running 接管)
const [sbBusyShots, setSbBusyShots] = useState<Set<string>>(() => new Set());
const clearSbBusy = (id: string) => setSbBusyShots((s) => { if (!s.has(id)) return s; const n = new Set(s); n.delete(id); return n; });
// 单场重跑:乐观置忙 + 调后端 → 由 poll 出图;状态进 queued/running 后由状态接管,失败/超时兜底解禁
function rerunStoryboardShotOptimistic(shotId: string) {
setSbBusyShots((s) => new Set(s).add(shotId));
Promise.resolve(onRerunStoryboardShot?.(shotId, storyboardPrompt || SB_PROMPT_DEFAULT))
.then((res) => { if (res == null) clearSbBusy(shotId); })
.catch(() => clearSbBusy(shotId));
window.setTimeout(() => clearSbBusy(shotId), 90000);
}
// 进入 succeeded/failed 终态的场,从乐观忙集合摘除(此后由真实状态驱动)
const sbStatusKey = sbShots.map((s) => `${s.id}:${s.status}`).join("|");
useEffect(() => {
setSbBusyShots((prev) => {
if (prev.size === 0) return prev;
const next = new Set(prev);
for (const s of sbShots) if (next.has(s.id) && ["succeeded", "failed"].includes(s.status)) next.delete(s.id);
return next.size === prev.size ? prev : next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sbStatusKey]);
// 没生成故事板时点「确认故事板」→ 弹气泡提示先去生成(3.5s 自动收起) // 没生成故事板时点「确认故事板」→ 弹气泡提示先去生成(3.5s 自动收起)
const [sbConfirmHint, setSbConfirmHint] = useState(false); const [sbConfirmHint, setSbConfirmHint] = useState(false);
useEffect(() => { useEffect(() => {
@@ -942,14 +992,19 @@ export function PipelinePage(props: {
setReviewBusyId(id); setReviewBusyId(id);
try { try {
const r = await api.submitAssetReview(id); const r = await api.submitAssetReview(id);
setReviews((m) => ({ ...m, [id]: r.review_status || "processing" })); // 用后端真态,别再 `|| "processing"` 伪造审核中 —— 没真送出去就该回灰盾,而不是骗一个刷新即消失的「审核中」
} catch { /* 失败保持灰盾可再点 */ } finally { setReviews((m) => ({ ...m, [id]: r.review_status || "" }));
} catch (e) {
// 送审没成(503=审核服务不可用等):如实提示,灰盾保持可重试,不再静默吞掉
onNotify?.("error", e instanceof Error && e.message ? e.message : "审核服务暂不可用,请稍后重试");
} finally {
setReviewBusyId(null); setReviewBusyId(null);
} }
} }
// 在基础资产趴(stage 2)/ 故事板趴(stage 3)定时轮询审核状态,刷新徽章(processing → active绿 / failed红) // 在基础资产趴(stage 2)/ 故事板趴(stage 3)/ 视频趴(stage 4)定时轮询审核状态,刷新徽章
// (processing → active绿 / failed红)。stage4 也轮询:过审闸里「一键送审」后,要在视频阶段就能看到盾变绿再生成。
useEffect(() => { useEffect(() => {
if (viewStage !== 2 && viewStage !== 3) return; if (viewStage !== 2 && viewStage !== 3 && viewStage !== 4) return;
let alive = true; let alive = true;
let sawProcessing = false; let sawProcessing = false;
let timer = 0; let timer = 0;
@@ -969,6 +1024,14 @@ export function PipelinePage(props: {
timer = window.setInterval(tick, 8000); timer = window.setInterval(tick, 8000);
return () => { alive = false; window.clearInterval(timer); }; return () => { alive = false; window.clearInterval(timer); };
}, [viewStage, project.id]); }, [viewStage, project.id]);
// 故事板有在制场(queued/running)时,后台每 5s 静默轮询驱动出图 + 刷新(对标视频段轮询)。
// 不占全局 loading → 单场重跑不会锁住其它场的按钮,可并行重跑多场。
useEffect(() => {
if (viewStage !== 3 || !sbAnyGenerating) return;
const timer = window.setInterval(() => { void onPollStoryboardQuiet?.(); }, 5000);
void onPollStoryboardQuiet?.();
return () => window.clearInterval(timer);
}, [viewStage, sbAnyGenerating, onPollStoryboardQuiet]);
// 外部 hash 变化(浏览器前进/后退、地址栏改 #stage-N)也要切阶段——镜像有 hashchange 监听,这里补齐 // 外部 hash 变化(浏览器前进/后退、地址栏改 #stage-N)也要切阶段——镜像有 hashchange 监听,这里补齐
useEffect(() => { useEffect(() => {
function onHashChange() { function onHashChange() {
@@ -1359,12 +1422,9 @@ export function PipelinePage(props: {
}; };
}, [gutterDragging]); }, [gutterDragging]);
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成可直接指导视频的分镜图"; const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成可直接指导视频的分镜图";
const [storyboardPrompt, setStoryboardPrompt] = useState(SB_PROMPT_DEFAULT); // 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
// 切换查看的故事板版本时,提示词编辑框重新种子为该版本的提示词 const sbSavedPrompt = (project.metadata as Record<string, unknown> | undefined)?.storyboard_prompt as string | undefined;
useEffect(() => { const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
setStoryboardPrompt(displayedStoryboard?.prompt || SB_PROMPT_DEFAULT);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedStoryboard?.id]);
const videoPrompt = "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感"; const videoPrompt = "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感";
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version)); const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
@@ -2970,30 +3030,41 @@ export function PipelinePage(props: {
); );
})()} })()}
{/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */} {/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
{viewStage === 3 && ( {viewStage === 3 && (() => {
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
let cum = 0;
const sceneTimes = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; });
const shotImg = (s?: StoryboardShot | null) => frameUrl(s ? { asset: s.adopted_asset ?? "", asset_url: s.adopted_asset_url } : null);
const shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status);
const cardCount = sbShots.length || sbExpectedShots;
const activeBusy = sbActiveShot ? shotBusy(sbActiveShot) : sbGenerating;
const activeVers = [...(sbActiveShot?.versions ?? [])];
// 当前「查看」的版本(纯预览,默认=采用版);切换它只动本地预览,不动后端、不丢重跑
const sbViewedVer = activeVers.find((v) => v.id === sbViewVerId)
|| activeVers.find((v) => v.is_adopted || v.id === sbActiveShot?.adopted_version) || null;
const sbViewedIsAdopted = !sbViewedVer || sbViewedVer.is_adopted || sbViewedVer.id === sbActiveShot?.adopted_version;
const mainUrl = sbViewedVer ? (sbViewedVer.asset_url || assetUrl(sbViewedVer.asset)) : shotImg(sbActiveShot);
const mainAid = sbViewedVer?.asset || sbActiveShot?.adopted_asset || "";
return (
<section className="stage active" data-stage-pane="3"> <section className="stage active" data-stage-pane="3">
<div className="stage-storyboard"> <div className="stage-storyboard">
<div className="sb-canvas"> <div className="sb-canvas">
<div className="sb-scenes-col" id="sb-scenes-row"> <div className="sb-scenes-col" id="sb-scenes-row">
{sbFrames.length ? (() => { {sbShots.length ? sbShots.map((shot, idx) => {
// 每场显示时间区间(累加脚本镜时长 → 「0~5s」),对齐 HTML;无脚本时回退序号 const url = shotImg(shot);
let cum = 0; const aid = shot.adopted_asset || "";
const times = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; }); return (
return sbFrames.map((frame, idx) => { <div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={shot.id} data-sid={shot.id} onClick={() => setSbSelected(idx)}>
const url = frameUrl(frame); <div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
return ( <span className="ph-frame"> {idx + 1}</span>
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}> {shotBusy(shot) && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}> {aid && <span className="sb-frame-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[aid] || shot.review_status || "") as ReviewStatus} error={shot.review_error} onSubmit={() => void submitAssetReview(aid)} busy={reviewBusyId === aid} /></span>}
<span className="ph-frame"> {idx + 1}</span>
{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{frame.asset && <span className="sb-frame-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[frame.asset] || frame.review_status || "") as ReviewStatus} error={frame.review_error} onSubmit={() => void submitAssetReview(frame.asset)} busy={reviewBusyId === frame.asset} /></span>}
</div>
<div className="nm"> {idx + 1}</div>
<div className="sub">{times[idx] || `#${frame.sort_order + 1}`}</div>
</div> </div>
); <div className="nm"> {idx + 1}</div>
}); <div className="sub">{sceneTimes[idx] || `#${shot.sort_order + 1}`}</div>
})() : sbExpectedShots ? ( </div>
);
}) : sbExpectedShots ? (
/* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */ /* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */
Array.from({ length: sbExpectedShots }, (_, idx) => ( Array.from({ length: sbExpectedShots }, (_, idx) => (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={`ph-${idx}`} onClick={() => setSbSelected(idx)}> <div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={`ph-${idx}`} onClick={() => setSbSelected(idx)}>
@@ -3005,12 +3076,13 @@ export function PipelinePage(props: {
) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>} ) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
</div> </div>
{(() => { {(() => {
const url = frameUrl(sbActiveFrame); const url = mainUrl;
const aid = mainAid;
return ( return (
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => setPreview({ src: url, kind: "image", name: `${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `${sbSelected + 1}` }); } } : undefined}> <div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => setPreview({ src: url, kind: "image", name: `${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `${sbSelected + 1}` }); } } : undefined}>
<span className="ph-frame">{sbActiveFrame ? `${sbSelected + 1}` : sbExpectedShots ? `${sbSelected + 1} · 待生成` : "// 故事板未生成"}</span> <span className="ph-frame">{url ? `${sbSelected + 1}${sbViewedIsAdopted ? "" : " · 预览历史版"}` : sbExpectedShots ? `${sbSelected + 1} · 待生成` : "// 故事板未生成"}</span>
{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>} {activeBusy && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{sbActiveFrame?.asset && <span className="sb-main-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[sbActiveFrame.asset] || sbActiveFrame.review_status || "") as ReviewStatus} error={sbActiveFrame.review_error} onSubmit={() => void submitAssetReview(sbActiveFrame.asset)} busy={reviewBusyId === sbActiveFrame.asset} /></span>} {aid && <span className="sb-main-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[aid] || sbViewedVer?.review_status || sbActiveShot?.review_status || "") as ReviewStatus} error={sbViewedVer?.review_error || sbActiveShot?.review_error} onSubmit={() => void submitAssetReview(aid)} busy={reviewBusyId === aid} /></span>}
</div> </div>
); );
})()} })()}
@@ -3019,65 +3091,70 @@ export function PipelinePage(props: {
<div className="sb-side"> <div className="sb-side">
<div className="pane" style={{ padding: "18px" }}> <div className="pane" style={{ padding: "18px" }}>
<div className="hstack" style={{ marginBottom: "10px" }}> <div className="hstack" style={{ marginBottom: "10px" }}>
<strong style={{ fontSize: "14px" }}> · <span id="sb-side-scene">{sbActiveFrame ? `${sbSelected + 1}` : "—"}</span></strong> <strong style={{ fontSize: "14px" }}> · <span id="sb-side-scene">{sbShots.length ? `${sbSelected + 1}` : "—"}</span></strong>
<span className="spacer"></span> <span className="spacer"></span>
{displayedStoryboard {sbActiveShot
? (displayedStoryboard.is_adopted ? (activeBusy
? <span className="pill ok"><span className="dot"></span></span> ? <span className="pill neutral"><span className="dot"></span></span>
: <span className="pill neutral"><span className="dot"></span></span>) : sbActiveShot.status === "failed"
? <span className="pill bad"><span className="dot"></span></span>
: sbActiveShot.adopted_version
? <span className="pill ok"><span className="dot"></span></span>
: <span className="pill neutral"><span className="dot"></span></span>)
: <span className="pill neutral"><span className="dot"></span></span>} : <span className="pill neutral"><span className="dot"></span></span>}
</div> </div>
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}> · image-2 (/)</div> <div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}> · ,,</div>
<div className="sb-rerun-note"> <div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 整张风格提示词(重跑时生效,可编辑)</div>
<span className="warn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4M12 17h.01" /></svg>
</span>
<div className="note-copy"><strong></strong> · , <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 </a> ,</div>
</div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 整张提示词(重跑时生效,可编辑)</div>
{/* key 跟版本走:切版本重新种子;onChange 实时回写 state → 整张重跑用的就是你编辑后的文本 */}
<PromptBox <PromptBox
key={displayedStoryboard?.id || "no-version"}
className="prompt-edit" className="prompt-edit"
id="sb-prompt-edit" id="sb-prompt-edit"
stop={false} stop={false}
value={displayedStoryboard?.prompt || SB_PROMPT_DEFAULT} value={storyboardPrompt}
onChange={(v) => setStoryboardPrompt(v.trim())} onChange={(v) => setStoryboardPrompt(v.trim())}
/> />
<div className="sb-stage-actions"> <div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => guardGen(shots, () => { setSbGenerating(true); void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false)); })}> <button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={sbAnyGenerating} onClick={() => guardGen(shots, () => { setSbGenerating(true); void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false)); })}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
{adoptedStoryboard ? "整张重跑" : "生成故事板"} {sbAnyImage ? "全部重跑" : "开始生成故事板"}
</button> </button>
{sbActiveShot && (
<button className="btn btn-sm" type="button" disabled={activeBusy} title="只重出当前这一场(新增一条该场历史版本)" onClick={() => guardGen(shots.filter((s) => s.sort_order === sbActiveShot.sort_order), () => rerunStoryboardShotOptimistic(sbActiveShot.id))}>
{activeBusy ? <><span className="spinner btn-spin" aria-hidden="true" /></> : `↻ 重跑本场`}
</button>
)}
<span className="spacer"></span> <span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>~¥0.45/</span> <span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>~¥0.45/</span>
</div> </div>
<div className="sb-history"> <div className="sb-history">
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div> <div className="sb-history-h">// 本场历史版本(<span id="sb-history-ct">{activeVers.length}</span>)· 点击预览</div>
<div className="sb-history-row" id="sb-history-row"> <div className="sb-history-row" id="sb-history-row">
{storyboards.length ? storyboards.map((ver) => { {activeVers.length ? activeVers.map((ver) => {
const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]); const cover = frameUrl({ asset: ver.asset ?? "", asset_url: ver.asset_url });
const isAdopted = ver.is_adopted || ver.id === sbActiveShot?.adopted_version;
const isViewed = sbViewedVer?.id === ver.id;
// 点击只切「预览」(本地态),不动后端 → 重跑在制时切版本不丢任务。采用要点下方按钮。
return ( return (
<div className={`sb-history-thumb${ver.id === displayedStoryboard?.id ? " current" : ""}`} key={ver.id} data-vi={ver.id} role="button" tabIndex={0} title="点击查看该版本" onClick={() => { setSbViewId(ver.id); setSbSelected(0); }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewId(ver.id); setSbSelected(0); } }}> <div className={`sb-history-thumb${isViewed ? " current" : ""}`} key={ver.id} data-vi={ver.id} role="button" tabIndex={0} title={isAdopted ? "当前采用版(点击预览)" : "点击预览此版本"} onClick={() => setSbViewVerId(ver.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewVerId(ver.id); } }}>
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div> <div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{isAdopted ? "采用" : "历史"}</span></div>
<div className="ts">{(ver.created_at || "").slice(11, 16) || "--:--"}</div> <div className="ts">{(ver.created_at || "").slice(11, 16) || "--:--"}</div>
</div> </div>
); );
}) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无历史</span>} }) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 本场暂无历史</span>}
</div> </div>
{/* 预览的是非采用版 → 显式「采用此版本」(对标视频详情弹窗的采用按钮);采用不影响在制重跑 */}
{sbViewedVer && !sbViewedIsAdopted && sbActiveShot && (
<button className="btn btn-sm btn-primary" type="button" style={{ marginTop: "8px" }} onClick={() => void onAdoptStoryboardShotVersion?.(sbActiveShot.id, sbViewedVer.id)}></button>
)}
</div> </div>
<div className="divider" style={{ marginTop: "16px" }}></div> <div className="divider" style={{ marginTop: "16px" }}></div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div> <div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets"> <div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
{(() => { {(() => {
// 与 Stage 2 同源:用 metadata.label 取名(不依赖全局 assets 列表),排除三视图组并按名字去重,
// 否则同一角色的立绘组+三视图组+多版本会各算一个,且名字会退化成「人物(人物)」。
const bound = [ const bound = [
...groupsByKind("product").filter((g) => g.adopted_asset).map((g) => ({ id: g.id, name: (g.metadata?.label || "").trim() || KIND_LABEL.product, kind: "product" as const, url: groupMainUrl(g) })), ...groupsByKind("product").filter((g) => g.adopted_asset).map((g) => ({ id: g.id, name: (g.metadata?.label || "").trim() || KIND_LABEL.product, kind: "product" as const, url: groupMainUrl(g) })),
...buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "person" as const, url: groupMainUrl(e.group) })), ...buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "person" as const, url: groupMainUrl(e.group) })),
...buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "scene" as const, url: groupMainUrl(e.group) })), ...buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "scene" as const, url: groupMainUrl(e.group) })),
]; ];
// 圆点占位 → 已引用资产缩略图(一眼看出绑了哪张),无图时回退圆点
return bound.length ? bound.map((b) => ( return bound.length ? bound.map((b) => (
<span className="asset-tag" key={b.id}>{b.url ? <span className="asset-thumb" style={{ backgroundImage: `url(${b.url})` }} /> : <span className="dotc"></span>}{b.name}({KIND_LABEL[b.kind]})</span> <span className="asset-tag" key={b.id}>{b.url ? <span className="asset-thumb" style={{ backgroundImage: `url(${b.url})` }} /> : <span className="dotc"></span>}{b.name}({KIND_LABEL[b.kind]})</span>
)) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无绑定资产</span>; )) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无绑定资产</span>;
@@ -3088,26 +3165,26 @@ export function PipelinePage(props: {
</div> </div>
<div className="stage-foot"> <div className="stage-foot">
<div className="info"><span className="mono">[ image-2 · {sbFrames.length ? `${sbFrames.length}` : sbExpectedShots ? `待生成 ${sbExpectedShots}` : "0 场"} · , ]</span></div> <div className="info"><span className="mono">[ image-2 · {cardCount ? `${cardCount}` : "0 场"} · , ]</span></div>
<div className="hstack"> <div className="hstack">
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button> <button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
{/* 不再允许跳过故事板:统一「确认故事板」。没生成故事板时点它 → 弹气泡提示先去生成,不放行 */}
<div className="sb-confirm-wrap"> <div className="sb-confirm-wrap">
{sbConfirmHint && ( {sbConfirmHint && (
<div className="sb-confirm-pop" role="tooltip"> <div className="sb-confirm-pop" role="tooltip">
<span className="pop-h"> <span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
</span> </span>
<span className="pop-body"> <b></b>,</span> <span className="pop-body"> <b></b>,</span>
</div> </div>
)} )}
<button className="btn btn-primary btn-lg" type="button" disabled={loading} onClick={() => { if (!adoptedStoryboard) { setSbConfirmHint(true); return; } goStage(4); }}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button> <button className="btn btn-primary btn-lg" type="button" disabled={loading} onClick={() => { if (!sbAllDone) { setSbConfirmHint(true); return; } goStage(4); }}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
)} );
})()}
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */} {/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
{viewStage === 4 && (() => { {viewStage === 4 && (() => {
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0; const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
@@ -3128,7 +3205,7 @@ export function PipelinePage(props: {
</div> </div>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div> <div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span> <span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardGen(shots, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" /></> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button> <button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardVideoGen(shots, null, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" /></> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}> <button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
@@ -3169,7 +3246,7 @@ export function PipelinePage(props: {
</div> </div>
<div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{seg.error_message ? ` · ${seg.error_message}` : ""}</div> <div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions"> <div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || showBusy} onClick={() => guardGen(shots.filter((s) => s.sort_order === seg.sort_order), () => submitVideoOptimistic(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`))}>{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "重跑"}</button> <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || showBusy} onClick={() => guardVideoGen(shots.filter((s) => s.sort_order === seg.sort_order), seg.id, () => submitVideoOptimistic(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`))}>{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "重跑"}</button>
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */} {/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */}
{verCount > 1 && ( {verCount > 1 && (
<button className="video-ver-badge" type="button" data-vstop title="查看历史版本" onClick={() => openVideoDetail(seg.id)}> {verCount} </button> <button className="video-ver-badge" type="button" data-vstop title="查看历史版本" onClick={() => openVideoDetail(seg.id)}> {verCount} </button>
@@ -3641,7 +3718,7 @@ export function PipelinePage(props: {
<div className="vd-modal-actions"> <div className="vd-modal-actions">
{vdVer?.asset_url && <a className="btn btn-ghost" href={vdVer.asset_url} target="_blank" rel="noreferrer"></a>} {vdVer?.asset_url && <a className="btn btn-ghost" href={vdVer.asset_url} target="_blank" rel="noreferrer"></a>}
<button className="btn btn-ghost" type="button" onClick={() => setVdSegId(null)}></button> <button className="btn btn-ghost" type="button" onClick={() => setVdSegId(null)}></button>
<button className="btn btn-primary" type="button" disabled={loading || showBusy} onClick={() => guardGen(shots.filter((s) => s.sort_order === vdSeg.sort_order), () => submitVideoOptimistic(vdSeg.id, rerunPrompt))}> <button className="btn btn-primary" type="button" disabled={loading || showBusy} onClick={() => guardVideoGen(shots.filter((s) => s.sort_order === vdSeg.sort_order), vdSeg.id, () => submitVideoOptimistic(vdSeg.id, rerunPrompt))}>
{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "↻ 重跑本场"} {showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "↻ 重跑本场"}
</button> </button>
</div> </div>
@@ -3673,6 +3750,46 @@ export function PipelinePage(props: {
</div> </div>
</div> </div>
)} )}
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
{reviewGate && (
<div className="ref-gate-mask" onClick={() => setReviewGate(null)}>
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="rg-title">,</div>
<div className="rg-body">
<strong></strong><strong></strong>(<span style={{ color: "var(--ok, #16a34a)" }}>绿·</span>),
:
</div>
<div className="rg-list">
{reviewGate.blockers.map((b, i) => {
const st = b.review_status === "processing" ? "审核中" : b.review_status === "failed" ? "未过审(被驳回)" : "未送审";
return (
<span className="rg-chip" key={`${b.video_segment_id}:${b.kind}:${b.asset_id}:${i}`}>
<span className={`rg-kind rg-kind-${b.kind === "person" ? "person" : "scene"}`}>{b.scene_no} · {b.kind === "person" ? "人物" : "分镜"}</span>
{b.name} <span className="rg-warn">{st}</span>
</span>
);
})}
</div>
<div className="rg-actions">
<button
type="button"
className="btn btn-primary"
disabled={reviewGate.submitting}
onClick={async () => {
// 一键送审:对「未送审/被驳回」的资产提交审核(审核中的已在素材库、只需等待)。送审后留在视频阶段,
// 由本阶段轮询把盾刷绿(见下方 poll effect 已含 stage4),变绿后再点生成即可。
setReviewGate((g) => (g ? { ...g, submitting: true } : g));
const ids = [...new Set(reviewGate.blockers.filter((b) => b.review_status !== "processing" && b.asset_id).map((b) => b.asset_id))];
await Promise.all(ids.map((id) => api.submitAssetReview(id).then((r) => setReviews((m) => ({ ...m, [id]: r.review_status || "processing" }))).catch(() => undefined)));
setReviewGate(null);
onNotify?.("success", ids.length ? "已提交审核,请稍候待变绿(已过审)后再生成视频" : "素材正在审核中,请稍候待变绿后再生成");
}}
>{reviewGate.submitting ? "提交中…" : "一键送审"}</button>
<button type="button" className="btn btn-ghost" onClick={() => setReviewGate(null)}></button>
</div>
</div>
</div>
)}
{/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */} {/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */}
{adDetail && (() => { {adDetail && (() => {
const entities = buildEntities(adDetail.kind); const entities = buildEntities(adDetail.kind);
+28
View File
@@ -336,6 +336,33 @@ export type StoryboardVersion = {
updated_at: string; updated_at: string;
}; };
// 故事板分镜制(对标 VideoSegment/Version):每镜一个 shot,各自历史版本 + 采用版
export type StoryboardShotVersion = {
id: string;
asset: string | null;
asset_url: string;
prompt: string;
is_adopted: boolean;
review_status?: string;
review_error?: string;
created_at: string;
};
export type StoryboardShot = {
id: string;
script_segment: string | null;
sort_order: number;
status: string;
error_message: string;
prompt: string;
adopted_version: string | null;
adopted_asset?: string | null;
adopted_asset_url?: string;
review_status?: string;
review_error?: string;
versions?: StoryboardShotVersion[];
};
export type ExportPoll = { export type ExportPoll = {
status: string; status: string;
progress: number; progress: number;
@@ -414,6 +441,7 @@ export type Project = {
created_at?: string; created_at?: string;
}>; }>;
storyboard_versions: StoryboardVersion[]; storyboard_versions: StoryboardVersion[];
storyboard_shots: StoryboardShot[];
timeline: Timeline | null; timeline: Timeline | null;
metadata?: { metadata?: {
wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] }; wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] };