feat(storyboard): 故事板改分镜制(单场独立重跑/历史)+ 视频过审闸 + 审核前端如实化
三块改动在 services/views/pipeline/App/api 等文件按行交织、且过审闸后端依赖故事板新 模型,无法拆成各自独立可编译的提交,故合并为一次: · 故事板分镜制:新增 StoryboardShot/StoryboardShotVersion(对标 VideoSegment/Version) + 数据迁移(采用版每帧转一 shot+已采用版本)。每镜一个 shot,各自历史版本,可单场 重跑、逐场切换/采用,新增「开始生成故事板/单场重跑/采用某版」端点。重跑非阻塞(后台 静默轮询出图,不锁其它场按钮);历史「查看」与「采用」分离(切版本不丢在制重跑)。 视频取分镜图/删分镜清理/过审闸全部改读 storyboard_shots。 · 视频生成前过审闸:点生成校验含真人脸的人物立绘/故事板分镜是否过审,未过审弹窗列出 哪一镜/哪个,可一键送审,挡住不生成(collect_video_review_blockers + precheck 端点)。 · 审核前端如实化:不再 `|| "processing"` 伪造审核中;送审失败经 onNotify 如实提示; 审核轮询扩到视频阶段,送审后能看着盾变绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+184
-143
@@ -29,6 +29,8 @@ from apps.projects.models import (
|
|||||||
ScriptSegment,
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
# Generated by Django 5.1.15 on 2026-06-24 08:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("ai", "0016_person_triview_16_9"),
|
||||||
|
("assets", "0006_alter_asset_category_model"),
|
||||||
|
("projects", "0004_project_projects_pr_team_id_e667f8_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="StoryboardShot",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("sort_order", models.PositiveIntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("not_started", "Not Started"),
|
||||||
|
("queued", "Queued"),
|
||||||
|
("running", "Running"),
|
||||||
|
("succeeded", "Succeeded"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
],
|
||||||
|
default="not_started",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("prompt", models.TextField(blank=True)),
|
||||||
|
("error_message", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"project",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="storyboard_shots",
|
||||||
|
to="projects.project",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"script_segment",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="storyboard_shots",
|
||||||
|
to="projects.scriptsegment",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["sort_order", "created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="StoryboardShotVersion",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("prompt", models.TextField(blank=True)),
|
||||||
|
("is_adopted", models.BooleanField(default=False)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
(
|
||||||
|
"asset",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="storyboard_shot_versions",
|
||||||
|
to="assets.asset",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"shot",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="versions",
|
||||||
|
to="projects.storyboardshot",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"task",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="storyboard_shot_versions",
|
||||||
|
to="ai.aitask",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="storyboardshot",
|
||||||
|
name="adopted_version",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="adopted_by_shots",
|
||||||
|
to="projects.storyboardshotversion",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="storyboardshot",
|
||||||
|
unique_together={("project", "sort_order")},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
"""把旧「整版制」故事板迁到新「分镜制」:每个项目只取采用版(无采用版则取最新版)的每一帧,
|
||||||
|
建一个 StoryboardShot + 一条已采用 StoryboardShotVersion(沿用同一张图)。旧的其它整版历史丢弃。"""
|
||||||
|
Project = apps.get_model("projects", "Project")
|
||||||
|
StoryboardVersion = apps.get_model("projects", "StoryboardVersion")
|
||||||
|
StoryboardShot = apps.get_model("projects", "StoryboardShot")
|
||||||
|
StoryboardShotVersion = apps.get_model("projects", "StoryboardShotVersion")
|
||||||
|
|
||||||
|
for project in Project.objects.all().iterator():
|
||||||
|
adopted = (
|
||||||
|
StoryboardVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first()
|
||||||
|
or StoryboardVersion.objects.filter(project=project).order_by("-created_at").first()
|
||||||
|
)
|
||||||
|
if adopted is None:
|
||||||
|
continue
|
||||||
|
seen_orders = set()
|
||||||
|
for frame in adopted.frames.order_by("sort_order", "created_at"):
|
||||||
|
if frame.sort_order in seen_orders: # 同序帧只保第一张,守 (project, sort_order) 唯一约束
|
||||||
|
continue
|
||||||
|
seen_orders.add(frame.sort_order)
|
||||||
|
shot = StoryboardShot.objects.create(
|
||||||
|
project=project,
|
||||||
|
script_segment=frame.script_segment,
|
||||||
|
sort_order=frame.sort_order,
|
||||||
|
status="succeeded",
|
||||||
|
prompt=frame.prompt or "",
|
||||||
|
)
|
||||||
|
version = StoryboardShotVersion.objects.create(
|
||||||
|
shot=shot, asset=frame.asset, prompt=frame.prompt or "", is_adopted=True,
|
||||||
|
)
|
||||||
|
shot.adopted_version = version
|
||||||
|
shot.save(update_fields=["adopted_version"])
|
||||||
|
|
||||||
|
|
||||||
|
def backwards(apps, schema_editor):
|
||||||
|
StoryboardShot = apps.get_model("projects", "StoryboardShot")
|
||||||
|
StoryboardShot.objects.all().delete() # version 随 shot 级联删
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("projects", "0005_storyboardshot_storyboardshotversion_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards, backwards),
|
||||||
|
]
|
||||||
@@ -151,6 +151,54 @@ class StoryboardFrame(TimeStampedModel):
|
|||||||
ordering = ["sort_order", "created_at"]
|
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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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), "已删除")}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[] };
|
||||||
|
|||||||
Reference in New Issue
Block a user