diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 813f277..fd8d945 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -29,6 +29,8 @@ from apps.projects.models import ( ScriptSegment, ScriptVersion, StoryboardFrame, + StoryboardShot, + StoryboardShotVersion, StoryboardVersion, Timeline, VideoSegment, @@ -1126,8 +1128,9 @@ def _segment_script_text(segment, entities=None, *, with_dialogue: bool = False, return "\n".join(parts) -def build_storyboard_frame_prompt(project, version, segment) -> str: - """单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)。""" +def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> str: + """单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)。 + extra_prompt = 整张风格提示词(原 StoryboardVersion.prompt,现存项目级)。""" dur = segment.duration_seconds or 15 default = ( "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" @@ -1141,7 +1144,7 @@ def build_storyboard_frame_prompt(project, version, segment) -> str: 场景上下文=_scene_context(project), 时长=dur, 脚本=(_segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜"), - 补充=(("\n" + version.prompt.strip()) if version.prompt else ""), + 补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""), ) return "\n".join(line for line in rendered.split("\n") if line.strip()) @@ -1178,21 +1181,59 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: return "\n".join(line for line in rendered.split("\n") if line.strip()) -def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion: - """异步故事板·提交:快速创建(或复用)一个未采用的版本,不在此处生图。逐帧生成交给 generate_storyboard_frame(轮询)。""" +def ensure_storyboard_shots(project) -> list: + """确保「采用版分镜数」= StoryboardShot 数:每镜一个 shot(对标视频段)。 + 缺则按 sort_order 建、补绑 script_segment;多出且从没出过图的尾 shot 裁掉(已出图的不动)。""" adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() + if adopted_script is None: + return [] + segs = list(adopted_script.segments.order_by("sort_order")) + shots = {s.sort_order: s for s in project.storyboard_shots.all()} + for seg in segs: + shot = shots.get(seg.sort_order) + if shot is None: + shots[seg.sort_order] = StoryboardShot.objects.create( + project=project, script_segment=seg, sort_order=seg.sort_order, prompt=seg.visual_prompt or "") + elif shot.script_segment_id != seg.id: + shot.script_segment = seg + shot.save(update_fields=["script_segment", "updated_at"]) + target = len(segs) + for order, shot in list(shots.items()): + if order >= target and not shot.versions.exists(): + shot.delete() + del shots[order] + return [shots[k] for k in sorted(shots)] + + +def submit_storyboard(*, project, user, prompt: str = "", shot_ids: list | None = None) -> list: + """故事板·提交(分镜制,对标视频「开始生成/单段重跑」):确保每镜一个 shot,把目标场标 QUEUED, + 真正出图由 poll_storyboard 起后台线程。返回受影响的 shots。 + - shot_ids 给定 → 只(重)生成这些场(单场重跑); + - 不给 → 还没出片的场;若全部已出片 → 视为整批重跑,全部重出。 + prompt = 整张风格提示词,存项目级 metadata,供每场出图带上(原 StoryboardVersion.prompt 的去处)。""" + adopted_script = project.script_versions.filter(is_adopted=True).first() if adopted_script is None: raise ValueError("script must be adopted before generating storyboard") if get_default_model(ModelConfig.Capability.IMAGE) is None: raise ValueError("no active image model configured") - # 复用尚未完成(未采用)的版本,避免重复提交产生多版本;否则新建 - version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first() - if version is None: - version = StoryboardVersion.objects.create(project=project, prompt=prompt) - elif prompt and version.prompt != prompt: - version.prompt = prompt - version.save(update_fields=["prompt", "updated_at"]) - return version + if prompt: + meta = dict(project.metadata or {}) + if meta.get("storyboard_prompt") != prompt: + meta["storyboard_prompt"] = prompt + project.metadata = meta + project.save(update_fields=["metadata", "updated_at"]) + shots = ensure_storyboard_shots(project) + if shot_ids is not None: + want = {str(x) for x in shot_ids} + targets = [s for s in shots if str(s.id) in want] + else: + pending = [s for s in shots if s.status != StoryboardShot.Status.SUCCEEDED or s.adopted_version_id is None] + targets = pending if pending else shots # 全部已出片 = 整批重跑 + for s in targets: + s.status = StoryboardShot.Status.QUEUED + s.error_message = "" + s.save(update_fields=["status", "error_message", "updated_at"]) + return targets _ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"} @@ -1269,8 +1310,9 @@ def _storyboard_reference_images(project, segment) -> list[dict]: used.add(match.id) url = _asset_preview_url(match.adopted_asset) if url: - # 带上审核态/素材库 ID:视频路按需换成 asset:// 引用;图像生成路只读 url 忽略它们 + # 带上审核态/素材库 ID/资产 ID:视频路按需换成 asset:// 引用、过审闸据此定位送审;图像生成路只读 url 忽略它们 out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type"), + "asset_id": match.adopted_asset.id, "review_status": match.adopted_asset.review_status, "review_remote_id": match.adopted_asset.review_remote_id}) if len(out) >= 3: # 给商品留一个位置(总计最多 4 张) break @@ -1284,11 +1326,11 @@ def _storyboard_reference_images(project, segment) -> list[dict]: return out[:4] -def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str: +def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra_prompt: str = "") -> str: """参考图合成版(用户钦定格式):顶部 @图N 点名每张参考图(角色/商品/场景),再给导演故事板指令 + 分镜脚本。 @图N 顺序与传给 gpt-image-2 的参考图顺序严格一致(refs 即 image_edit 的 images 顺序)。""" if not refs: - return build_storyboard_frame_prompt(project, version, segment) + return build_storyboard_frame_prompt(project, segment, extra_prompt) setup = ",".join( f"@图{i + 1}是{r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs) ) @@ -1306,7 +1348,7 @@ def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dic 场景上下文=_scene_context(project), 时长=dur, 脚本=(_segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜"), - 补充=(("\n" + version.prompt.strip()) if version.prompt else ""), + 补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""), ) return "\n".join(line for line in rendered.split("\n") if line.strip()) @@ -1341,62 +1383,46 @@ def _call_image_with_retry(fn, *, attempts: int = 2, base_delay: float = 2.0): raise last # 理论不可达(循环内已 return/raise) -def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None: - """后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。""" - import threading # noqa: F401 — 仅标注此函数运行在独立线程 +def _storyboard_shot_worker(task_id, shot_id, user_id) -> None: + """后台线程:为一个 StoryboardShot 出一张图 → 落成一条 StoryboardShotVersion 并采用。HTTP 永远秒回。""" from django.db import connections from apps.accounts.models import User try: task = AITask.objects.select_related("model_config__provider").get(id=task_id) - version = StoryboardVersion.objects.select_related("project__team").get(id=version_id) - segment = ScriptSegment.objects.get(id=segment_id) + shot = StoryboardShot.objects.select_related("project__team", "script_segment").get(id=shot_id) user = User.objects.get(id=user_id) - project = version.project + project = shot.project + segment = shot.script_segment model_config = task.model_config reservation = task.credit_reservation + extra_prompt = (project.metadata or {}).get("storyboard_prompt", "") or "" task.status = AITask.Status.SUBMITTED task.save(update_fields=["status", "updated_at"]) try: provider = get_image_provider(model_config) - refs = _storyboard_reference_images(project, segment) + refs = _storyboard_reference_images(project, segment) if segment is not None else [] ref_urls = [r["url"] for r in refs] if ref_urls and hasattr(provider, "image_edit"): - # gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品), - # 不能复用 request_payload['prompt'](那是建任务时写死的基础提示词,恒为真值会架空一致性约束)。 - frame_prompt = build_storyboard_frame_prompt_refs(project, version, segment, refs) - # 中转站偶发 Read timeout 会掐掉单帧(4 镜出 3 张的根因)→ 瞬时错误有界重试 + # gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品) + frame_prompt = build_storyboard_frame_prompt_refs(project, segment, refs, extra_prompt) response = _call_image_with_retry( - lambda: provider.image_edit( - model=model_config.name, - prompt=frame_prompt, - images=ref_urls, - size="1024x1536", - ) + lambda: provider.image_edit(model=model_config.name, prompt=frame_prompt, images=ref_urls, size="1024x1536") ) else: - frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment) + frame_prompt = ( + build_storyboard_frame_prompt(project, segment, extra_prompt) if segment is not None + else (task.request_payload.get("prompt") or "") + ) response = _call_image_with_retry( - lambda: provider.image_generation( - model=model_config.name, - endpoint=model_config.endpoint, - prompt=frame_prompt, - ) + lambda: provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=frame_prompt) ) media = provider.extract_first_media_url(response) - # 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。 - # 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」 - # 为同一镜重复起线程 → 重复帧 + 重复扣费(实测 4 帧出 6 帧)。 asset = _store_generated_media( - team=project.team, - user=user, - project=project, - task=task, - media=media, - name=f"{project.name}-storyboard-{segment.sort_order + 1}", - category=Asset.Category.STORYBOARD, - asset_type=Asset.Type.IMAGE, + team=project.team, user=user, project=project, task=task, media=media, + name=f"{project.name}-storyboard-{shot.sort_order + 1}", + category=Asset.Category.STORYBOARD, asset_type=Asset.Type.IMAGE, ) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED @@ -1405,120 +1431,94 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None: task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) - # 幂等守卫:该镜已有帧(任何残余竞态/双 poll)就不再建,保持一镜一帧 - if not StoryboardFrame.objects.filter(storyboard=version, script_segment=segment).exists(): - StoryboardFrame.objects.create( - storyboard=version, - script_segment=segment, - asset=asset, - sort_order=segment.sort_order, - prompt=segment.visual_prompt, - ) - # 合规(期3):分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort) + # 落一条新版本并采用(反采用同 shot 其余版本)= 该场历史 +1,采用最新 + version = StoryboardShotVersion.objects.create( + shot=shot, task=task, asset=asset, + prompt=(segment.visual_prompt if segment is not None else ""), is_adopted=True, + ) + shot.versions.exclude(id=version.id).update(is_adopted=False) + StoryboardShot.objects.filter(id=shot.id).update( + adopted_version=version, status=StoryboardShot.Status.SUCCEEDED, error_message="", updated_at=timezone.now()) + # 合规:分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort) from apps.assets.review import submit_asset_for_review transaction.on_commit(lambda a=asset: submit_asset_for_review(a)) - except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报 + except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务+shot 失败供 poll 上报 task.status = AITask.Status.FAILED task.error_message = str(exc) task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)) + # 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 错误供前端显示 + StoryboardShot.objects.filter(id=shot.id).update( + status=StoryboardShot.Status.FAILED, error_message=str(exc), updated_at=timezone.now()) finally: connections.close_all() # 释放该线程的 DB 连接 -def generate_storyboard_frame(*, project, user) -> dict: - """异步故事板·轮询(秒回):读取进度;若无帧在生成则后台起线程生成下一帧。永不阻塞在 ARK 调用上。 - 返回 {status: generating|succeeded|failed, done, total, version_id}。全部完成→采用版本。""" +def poll_storyboard(*, project, user) -> dict: + """异步故事板·轮询(秒回):为 QUEUED/在制 shot 起线程出图;报告进度。永不阻塞在 ARK 调用上。 + 返回 {status: generating|succeeded|failed, done, total}。done = 已出片(有采用版且 SUCCEEDED)的场数。""" import threading - version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first() - adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() - if version is None or adopted_script is None: - latest = project.storyboard_versions.order_by("-created_at").first() - n = latest.frames.count() if latest else 0 - return {"status": "succeeded", "done": n, "total": n, "version_id": str(latest.id) if latest else ""} - - segments = list(adopted_script.segments.all().order_by("sort_order")) - total = len(segments) - done_segment_ids = set(version.frames.values_list("script_segment_id", flat=True)) - done = len(done_segment_ids) - - if done >= total: - _finalize_storyboard(project, version) - return {"status": "succeeded", "done": total, "total": total, "version_id": str(version.id)} - - # 每镜独立「占位锁」(该镜有 CREATED/RESERVED/SUBMITTED 的任务=在生成中)。 - # 旧实现是版本级单锁、一次只生成一帧;改为缺哪几镜就同时起哪几镜的线程。 - # ★ 实测注记(2026-06-10):4 线程并发时当前 Seedream 端点在服务侧排队,单帧 25s→83-105s, - # 整版总时长 ≈ 串行(117s)。瓶颈是 ARK 端点并发配额而非本机;并行无额外成本, - # 配额提升后自动受益。可用 settings.STORYBOARD_MAX_PARALLEL 调并发(1=回到串行)。 - # 仅算「近 N 分钟内」的任务:线程意外中断留下的僵尸任务超时后不再占锁,允许重新发起。 - # 窗口须 > worker 最坏运行时长(单帧 HTTP 超时 300s × 重试 2 次 ≈ 10min),否则 worker 正在 - # 重试时其任务被误判为僵尸 → 同一镜重复起线程 + 重复扣费。故默认 12min(覆盖一次瞬时重试)。 from django.conf import settings as dj_settings + + shots = list(project.storyboard_shots.select_related("script_segment").order_by("sort_order")) + if not shots: + return {"status": "succeeded", "done": 0, "total": 0} + total = len(shots) + done = sum(1 for s in shots if s.adopted_version_id is not None and s.status == StoryboardShot.Status.SUCCEEDED) + active = [s for s in shots if s.status in (StoryboardShot.Status.QUEUED, StoryboardShot.Status.RUNNING)] + if not active: + failed = [s for s in shots if s.status == StoryboardShot.Status.FAILED] + if failed: + return {"status": "failed", "done": done, "total": total, "error": failed[0].error_message or "storyboard shot failed"} + return {"status": "succeeded", "done": done, "total": total} + + # 每镜独立「占位锁」:近 N 分钟内有 CREATED/RESERVED/SUBMITTED 任务的 shot = 在生成中(僵尸超时后释放)。 STORYBOARD_MAX_PARALLEL = int(getattr(dj_settings, "STORYBOARD_MAX_PARALLEL", 4)) stale_minutes = int(getattr(dj_settings, "STORYBOARD_INFLIGHT_STALE_MINUTES", 12)) stale_cutoff = timezone.now() - timedelta(minutes=stale_minutes) - inflight_segment_ids = { + inflight_shot_ids = { str(v) for v in AITask.objects.filter( - project=project, - task_type=AITask.Type.STORYBOARD, + project=project, task_type=AITask.Type.STORYBOARD, status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED], - request_payload__storyboard_version=str(version.id), created_at__gte=stale_cutoff, - ).values_list("request_payload__storyboard_segment", flat=True) + ).values_list("request_payload__storyboard_shot", flat=True) if v } - - pending = [s for s in segments if s.id not in done_segment_ids] - # 单帧失败次数上限,避免持续失败时无限重试;任一镜到上限即整版上报失败 - for segment in pending: - failed_for_segment = AITask.objects.filter( - project=project, - task_type=AITask.Type.STORYBOARD, - status=AITask.Status.FAILED, - request_payload__storyboard_segment=str(segment.id), - ).count() - if failed_for_segment >= 2: - last = AITask.objects.filter(project=project, task_type=AITask.Type.STORYBOARD, status=AITask.Status.FAILED, - request_payload__storyboard_segment=str(segment.id)).order_by("-created_at").first() - return {"status": "failed", "done": done, "total": total, "version_id": str(version.id), - "error": last.error_message if last else "storyboard frame failed"} - - spawnable = [s for s in pending if str(s.id) not in inflight_segment_ids] - slots = max(0, STORYBOARD_MAX_PARALLEL - len(inflight_segment_ids)) model_config = get_default_model(ModelConfig.Capability.IMAGE) - for segment in spawnable[:slots]: + extra_prompt = (project.metadata or {}).get("storyboard_prompt", "") or "" + spawnable = [s for s in active if str(s.id) not in inflight_shot_ids] + slots = max(0, STORYBOARD_MAX_PARALLEL - len(inflight_shot_ids)) + for shot in spawnable[:slots]: + segment = shot.script_segment task = create_ai_task( - project=project, - user=user, - task_type=AITask.Type.STORYBOARD, - model_config=model_config, + project=project, user=user, task_type=AITask.Type.STORYBOARD, model_config=model_config, request_payload={ - "model": model_config.name, - "endpoint": model_config.endpoint, - "prompt": build_storyboard_frame_prompt(project, version, segment), - "storyboard_version": str(version.id), - "storyboard_segment": str(segment.id), + "model": model_config.name, "endpoint": model_config.endpoint, + "prompt": build_storyboard_frame_prompt(project, segment, extra_prompt) if segment is not None else "", + "storyboard_shot": str(shot.id), }, ) + StoryboardShot.objects.filter(id=shot.id).update(status=StoryboardShot.Status.RUNNING, updated_at=timezone.now()) threading.Thread( - target=_storyboard_frame_worker, - args=(str(task.id), str(version.id), str(segment.id), str(user.id)), - daemon=True, + target=_storyboard_shot_worker, args=(str(task.id), str(shot.id), str(user.id)), daemon=True ).start() - return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)} + return {"status": "generating", "done": done, "total": total} -def _finalize_storyboard(project, version) -> None: - """全部帧就绪:采用该版本(反采用其余版本)。项目阶段推进由视图负责(与原同步实现一致)。""" - project.storyboard_versions.exclude(id=version.id).update(is_adopted=False) +def adopt_storyboard_shot_version(*, shot: StoryboardShot, version: StoryboardShotVersion) -> None: + """采用某场的某个历史版本(对标 adopt-video-version):反采用同场其余版本,置该场为采用版+SUCCEEDED。""" + shot.versions.exclude(id=version.id).update(is_adopted=False) if not version.is_adopted: version.is_adopted = True version.save(update_fields=["is_adopted", "updated_at"]) + shot.adopted_version = version + shot.status = StoryboardShot.Status.SUCCEEDED + shot.error_message = "" + shot.save(update_fields=["adopted_version", "status", "error_message", "updated_at"]) def _asset_preview_url(asset) -> str: @@ -1561,21 +1561,17 @@ def _video_reference_images(project, video_segment) -> list[dict]: _vord = {"character": 0, "scene": 1, "product": 2} refs.sort(key=lambda r: _vord.get(r.get("type"), 9)) out = refs - # 末位追加本镜故事板帧(@图N 末位 = 分镜图) - version = ( - project.storyboard_versions.filter(is_adopted=True).order_by("-created_at").first() - or project.storyboard_versions.order_by("-created_at").first() + # 末位追加本镜故事板分镜图(@图N 末位 = 分镜图):取该 sort_order 的 shot 的采用版资产 + shot = ( + project.storyboard_shots.filter(sort_order=video_segment.sort_order, adopted_version__isnull=False).select_related("adopted_version__asset").first() + or project.storyboard_shots.filter(adopted_version__isnull=False).select_related("adopted_version__asset").order_by("sort_order").first() ) - if version is not None: - frame = ( - version.frames.filter(sort_order=video_segment.sort_order).first() - or version.frames.order_by("sort_order").first() - ) - if frame is not None: - url = _asset_preview_url(frame.asset) - if url: - out.append({"url": url, "label": "分镜图", "type": "storyboard", - "review_status": frame.asset.review_status, "review_remote_id": frame.asset.review_remote_id}) + if shot is not None and shot.adopted_version_id: + frame_asset = shot.adopted_version.asset + url = _asset_preview_url(frame_asset) + if url: + out.append({"url": url, "label": "分镜图", "type": "storyboard", + "review_status": frame_asset.review_status, "review_remote_id": frame_asset.review_remote_id}) if not out: product_group = ( project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False) @@ -1591,6 +1587,51 @@ def _video_reference_images(project, video_segment) -> list[dict]: return out +def collect_video_review_blockers(project, only_segment: "VideoSegment | None" = None) -> list[dict]: + """点「生成视频」前的过审闸:列出本次将生成的段里,含真人脸的参考图(人物立绘 + 该镜故事板分镜) + 中**尚未过审(review_status != 'active')** 的项。火山视频对含真人脸的图,只接受素材库已过审的引用, + 否则报 InputImageSensitiveContentDetected。这里在调火山前先拦,弹窗指明是哪一镜的哪个人物/分镜未过审。 + + only_segment 给定 → 只校验该段(单段重跑);为 None → 校验全部「未出片」段(整批生成)。 + 返回 [{video_segment_id, sort_order, scene_no, kind:'person'|'storyboard', name, asset_id, review_status}]; + 空列表 = 全部已过审,可放行。""" + segs = [only_segment] if only_segment is not None else list(project.video_segments.order_by("sort_order")) + adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() + shots_by_order = { + s.sort_order: s + for s in project.storyboard_shots.filter(adopted_version__isnull=False).select_related("adopted_version__asset") + } + blockers: list[dict] = [] + for seg in segs: + # 整批生成只校验还没出片的段;单段重跑则无论状态都校验(用户主动要重出这一段) + if only_segment is None and seg.status == VideoSegment.Status.SUCCEEDED: + continue + scene_no = seg.sort_order + 1 + scene = adopted_script.segments.filter(sort_order=seg.sort_order).first() if adopted_script else None + # 人物立绘(与视频实际取图同一套逻辑,保证「拦的」就是「会传给火山的」) + if scene is not None: + for ref in _storyboard_reference_images(project, scene): + if ref.get("type") != "character": + continue + if (ref.get("review_status") or "") != "active": + blockers.append({ + "video_segment_id": str(seg.id), "sort_order": seg.sort_order, "scene_no": scene_no, + "kind": "person", "name": ref.get("label") or "人物", + "asset_id": str(ref.get("asset_id") or ""), "review_status": ref.get("review_status") or "", + }) + # 该镜故事板分镜图(分镜图里也有同一张脸,是另一个需过审的资产)= 对应 shot 的采用版资产 + shot = shots_by_order.get(seg.sort_order) + if shot is not None and shot.adopted_version_id: + frame_asset = shot.adopted_version.asset + if (frame_asset.review_status or "") != "active": + blockers.append({ + "video_segment_id": str(seg.id), "sort_order": seg.sort_order, "scene_no": scene_no, + "kind": "storyboard", "name": f"场{scene_no} 分镜图", + "asset_id": str(frame_asset.id), "review_status": frame_asset.review_status or "", + }) + return blockers + + def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None: model_config = get_default_model(ModelConfig.Capability.VIDEO) if model_config is None: diff --git a/core/backend/apps/projects/admin.py b/core/backend/apps/projects/admin.py index 69e0457..e1ae614 100644 --- a/core/backend/apps/projects/admin.py +++ b/core/backend/apps/projects/admin.py @@ -9,6 +9,8 @@ from .models import ( ScriptSegment, ScriptVersion, StoryboardFrame, + StoryboardShot, + StoryboardShotVersion, StoryboardVersion, SubtitleTrack, Timeline, @@ -36,6 +38,8 @@ admin.site.register(ScriptSegment) admin.site.register(BaseAssetGroup) admin.site.register(StoryboardVersion) admin.site.register(StoryboardFrame) +admin.site.register(StoryboardShot) +admin.site.register(StoryboardShotVersion) admin.site.register(VideoSegment) admin.site.register(VideoSegmentVersion) admin.site.register(Timeline) diff --git a/core/backend/apps/projects/migrations/0005_storyboardshot_storyboardshotversion_and_more.py b/core/backend/apps/projects/migrations/0005_storyboardshot_storyboardshotversion_and_more.py new file mode 100644 index 0000000..c701f5f --- /dev/null +++ b/core/backend/apps/projects/migrations/0005_storyboardshot_storyboardshotversion_and_more.py @@ -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")}, + ), + ] diff --git a/core/backend/apps/projects/migrations/0006_migrate_storyboard_to_shots.py b/core/backend/apps/projects/migrations/0006_migrate_storyboard_to_shots.py new file mode 100644 index 0000000..dee11c7 --- /dev/null +++ b/core/backend/apps/projects/migrations/0006_migrate_storyboard_to_shots.py @@ -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), + ] diff --git a/core/backend/apps/projects/models.py b/core/backend/apps/projects/models.py index 877c7bf..b0a3709 100644 --- a/core/backend/apps/projects/models.py +++ b/core/backend/apps/projects/models.py @@ -151,6 +151,54 @@ class StoryboardFrame(TimeStampedModel): ordering = ["sort_order", "created_at"] +# ── 故事板「分镜制」(对标视频 VideoSegment/VideoSegmentVersion)── +# 一镜 = 一个 StoryboardShot(有自己的采用版 + 状态);每次出图 = 一条 StoryboardShotVersion(历史版本)。 +# 取代旧的「整版制」StoryboardVersion/StoryboardFrame:单场可独立重跑、单场各有历史、可逐场切换采用。 +class StoryboardShot(TimeStampedModel): + class Status(models.TextChoices): + NOT_STARTED = "not_started", "Not Started" + QUEUED = "queued", "Queued" + RUNNING = "running", "Running" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="storyboard_shots") + script_segment = models.ForeignKey( + ScriptSegment, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="storyboard_shots", + ) + sort_order = models.PositiveIntegerField(default=0) + status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED) + prompt = models.TextField(blank=True) + adopted_version = models.ForeignKey( + "projects.StoryboardShotVersion", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="adopted_by_shots", + ) + error_message = models.TextField(blank=True) + + class Meta: + unique_together = [("project", "sort_order")] + ordering = ["sort_order", "created_at"] + + +class StoryboardShotVersion(TimeStampedModel): + shot = models.ForeignKey(StoryboardShot, on_delete=models.CASCADE, related_name="versions") + task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="storyboard_shot_versions") + asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="storyboard_shot_versions") + prompt = models.TextField(blank=True) + is_adopted = models.BooleanField(default=False) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ["-created_at"] + + class VideoSegment(TimeStampedModel): class Status(models.TextChoices): NOT_STARTED = "not_started", "Not Started" diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py index 69879ba..5b18990 100644 --- a/core/backend/apps/projects/serializers.py +++ b/core/backend/apps/projects/serializers.py @@ -13,6 +13,8 @@ from .models import ( ScriptSegment, ScriptVersion, StoryboardFrame, + StoryboardShot, + StoryboardShotVersion, StoryboardVersion, SubtitleTrack, Timeline, @@ -130,6 +132,54 @@ class StoryboardVersionSerializer(serializers.ModelSerializer): read_only_fields = fields +# ── 故事板分镜制(对标 VideoSegment/Version):每镜一个 shot,采用版缩略图 + 各自历史版本 ── +class StoryboardShotSerializer(serializers.ModelSerializer): + adopted_asset = serializers.SerializerMethodField() + adopted_asset_url = serializers.SerializerMethodField() + # 分镜图含人脸,后端自动送火山人像审核;暴露采用版审核态让前端挂盾牌 + review_status = serializers.SerializerMethodField() + review_error = serializers.SerializerMethodField() + versions = serializers.SerializerMethodField() + + class Meta: + model = StoryboardShot + fields = ["id", "script_segment", "sort_order", "status", "error_message", "prompt", + "adopted_version", "adopted_asset", "adopted_asset_url", "review_status", "review_error", "versions"] + read_only_fields = fields + + def get_adopted_asset(self, obj): + v = obj.adopted_version + return str(v.asset_id) if v and v.asset_id else None + + def get_adopted_asset_url(self, obj) -> str: + v = obj.adopted_version + return _asset_preview_url(v.asset) if v is not None else "" + + def get_review_status(self, obj) -> str: + v = obj.adopted_version + return (getattr(v.asset, "review_status", "") or "") if (v and v.asset_id) else "" + + def get_review_error(self, obj) -> str: + v = obj.adopted_version + return (getattr(v.asset, "review_error", "") or "") if (v and v.asset_id) else "" + + def get_versions(self, obj): + versions = sorted(obj.versions.all(), key=lambda v: (v.created_at is None, v.created_at), reverse=True) + return [ + { + "id": str(v.id), + "asset": str(v.asset_id) if v.asset_id else None, + "asset_url": _asset_preview_url(v.asset) if v.asset_id else "", + "prompt": v.prompt, + "is_adopted": v.is_adopted, + "review_status": (getattr(v.asset, "review_status", "") or "") if v.asset_id else "", + "review_error": (getattr(v.asset, "review_error", "") or "") if v.asset_id else "", + "created_at": v.created_at.isoformat() if v.created_at else "", + } + for v in versions + ] + + class VideoSegmentVersionSerializer(serializers.ModelSerializer): class Meta: model = VideoSegmentVersion @@ -289,7 +339,8 @@ class ProjectSerializer(serializers.ModelSerializer): video_segments = VideoSegmentSerializer(many=True, read_only=True) script_versions = ScriptVersionSerializer(many=True, read_only=True) base_asset_groups = BaseAssetGroupSerializer(many=True, read_only=True) - storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) + storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) # 过渡期保留(旧整版,新生成不再写) + storyboard_shots = StoryboardShotSerializer(many=True, read_only=True) timeline = TimelineSerializer(read_only=True) class Meta: @@ -307,6 +358,7 @@ class ProjectSerializer(serializers.ModelSerializer): "script_versions", "base_asset_groups", "storyboard_versions", + "storyboard_shots", "video_segments", "timeline", "created_at", diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index 8a4f74e..eb8491c 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -13,6 +13,10 @@ from apps.projects.models import ( ProjectStage, ScriptSegment, ScriptVersion, + StoryboardFrame, + StoryboardShot, + StoryboardShotVersion, + StoryboardVersion, SubtitleTrack, Timeline, TimelineClip, @@ -470,6 +474,124 @@ class ProjectApiTests(TestCase): # 已出片的尾段保留 → 不裁到 1,停在 2 self.assertEqual(project.video_segments.count(), 2) + def _mk_image_model(self): + return ModelConfig.objects.create( + provider=self.provider, name="gpt-image-2", display_name="img", + capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000", + ) + + def _mk_shot(self, project, script, order, *, review_status="active"): + """建一个已出片的 StoryboardShot(+ 已采用版本,资产审核态可控),供过审/采用测试。""" + seg = script.segments.filter(sort_order=order).first() or ScriptSegment.objects.create( + script_version=script, sort_order=order, narration=f"n{order}", visual_prompt=f"v{order}") + a = Asset.objects.create(team=self.team, created_by=self.user, name=f"sb{order}", asset_type="image", + source="ai_generated", category=Asset.Category.STORYBOARD, review_status=review_status) + shot = StoryboardShot.objects.create(project=project, script_segment=seg, sort_order=order, + status=StoryboardShot.Status.SUCCEEDED) + ver = StoryboardShotVersion.objects.create(shot=shot, asset=a, is_adopted=True) + shot.adopted_version = ver + shot.save(update_fields=["adopted_version"]) + return shot, ver, a + + def test_submit_storyboard_creates_one_shot_per_segment_all_queued(self): + """开始生成故事板:确保每镜一个 shot、全部置 QUEUED(真正出图交给 poll)。""" + from apps.ai.services import submit_storyboard + self._mk_image_model() + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + script = ScriptVersion.objects.create(project=project, is_adopted=True) + for i in range(3): + ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}") + + targets = submit_storyboard(project=project, user=self.user, prompt="风格") + + self.assertEqual(project.storyboard_shots.count(), 3) + self.assertEqual(len(targets), 3) + self.assertTrue(all(s.status == StoryboardShot.Status.QUEUED for s in project.storyboard_shots.all())) + project.refresh_from_db() + self.assertEqual((project.metadata or {}).get("storyboard_prompt"), "风格") # 整张风格存项目级 + + def test_submit_storyboard_single_shot_only_targets_that_shot(self): + """单场重跑:只把指定 shot 置 QUEUED,其余已出片的场不动。""" + from apps.ai.services import submit_storyboard + self._mk_image_model() + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + script = ScriptVersion.objects.create(project=project, is_adopted=True) + for i in range(2): + ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}") + shot0, _, _ = self._mk_shot(project, script, 0) + shot1, _, _ = self._mk_shot(project, script, 1) + + submit_storyboard(project=project, user=self.user, shot_ids=[str(shot1.id)]) + + shot0.refresh_from_db(); shot1.refresh_from_db() + self.assertEqual(shot0.status, StoryboardShot.Status.SUCCEEDED) # 没动 + self.assertEqual(shot1.status, StoryboardShot.Status.QUEUED) # 只重跑这场 + + def test_adopt_storyboard_shot_version_switches_adopted(self): + """采用某场的历史版本:切换该场采用的分镜图。""" + self._mk_image_model() + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + script = ScriptVersion.objects.create(project=project, is_adopted=True) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v") + shot, v1, _ = self._mk_shot(project, script, 0) + a2 = Asset.objects.create(team=self.team, created_by=self.user, name="sb-v2", asset_type="image", + source="ai_generated", category=Asset.Category.STORYBOARD) + v2 = StoryboardShotVersion.objects.create(shot=shot, asset=a2, is_adopted=False) + + res = self.client.post( + f"/api/projects/{project.id}/adopt-storyboard-shot-version/", + {"shot_id": str(shot.id), "version_id": str(v2.id)}, format="json", + ) + self.assertEqual(res.status_code, 200) + shot.refresh_from_db(); v1.refresh_from_db(); v2.refresh_from_db() + self.assertEqual(shot.adopted_version_id, v2.id) + self.assertTrue(v2.is_adopted) + self.assertFalse(v1.is_adopted) + + def test_video_review_precheck_flags_unreviewed_storyboard_then_clears(self): + """生成视频前的过审闸:某镜故事板分镜未过审(非 active)→ precheck 列为 blocker; + 过审(active)后 → blockers 清空,可放行生成。""" + project = Project.objects.create( + team=self.team, created_by=self.user, product=self.product, name="P", + current_stage=ProjectStage.Stage.VIDEO, + ) + VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15) + script = ScriptVersion.objects.create(project=project, is_adopted=True) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v") + _, _, frame_asset = self._mk_shot(project, script, 0, review_status="") + + res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json") + self.assertEqual(res.status_code, 200) + blockers = res.json()["blockers"] + self.assertTrue(any(b["kind"] == "storyboard" and b["scene_no"] == 1 for b in blockers)) + + # 过审后再查 → 不再拦 + frame_asset.review_status = "active" + frame_asset.save(update_fields=["review_status"]) + res2 = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json") + self.assertEqual(res2.json()["blockers"], []) + + def test_video_review_precheck_skips_succeeded_segments_in_batch(self): + """整批校验(不传 segment_id)只看未出片的段:已 succeeded 的段即便分镜未过审也不拦。""" + project = Project.objects.create( + team=self.team, created_by=self.user, product=self.product, name="P", + current_stage=ProjectStage.Stage.VIDEO, + ) + seg = VideoSegment.objects.create(project=project, sort_order=0, status=VideoSegment.Status.SUCCEEDED) + script = ScriptVersion.objects.create(project=project, is_adopted=True) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v") + self._mk_shot(project, script, 0, review_status="") + + # 整批:succeeded 段跳过 → 不拦 + res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json") + self.assertEqual(res.json()["blockers"], []) + # 指定该段(单段重跑):无论 succeeded 都校验 → 拦 + res2 = self.client.post( + f"/api/projects/{project.id}/video-review-precheck/", + {"video_segment_id": str(seg.id)}, format="json", + ) + self.assertTrue(len(res2.json()["blockers"]) >= 1) + def test_adopt_video_version_remaps_timeline_draft(self): """切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。""" project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index a057543..c816a19 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -18,12 +18,13 @@ from apps.ai.script_agent import stream_script_agent from apps.ai.services import ( DEFAULT_VOICEOVER_VOICE, VOICEOVER_VOICES, + adopt_storyboard_shot_version, create_export_job, generate_base_asset, generate_person_triview, - generate_storyboard_frame, get_default_model, get_inflight_extraction, + poll_storyboard, poll_video_segment, regenerate_script_segment, submit_extract_entities, @@ -46,6 +47,8 @@ from .models import ( ScriptSegment, ScriptVersion, StoryboardFrame, + StoryboardShot, + StoryboardShotVersion, StoryboardVersion, SubtitleTrack, Timeline, @@ -130,6 +133,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): "base_asset_groups__candidate_assets__files", "storyboard_versions", "storyboard_versions__frames__asset__files", + "storyboard_shots__adopted_version__asset__files", + "storyboard_shots__versions__asset__files", "timeline__clips__asset__files", ).all() serializer_class = ProjectSerializer @@ -511,6 +516,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15)) next_order += 1 + def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None: + """采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐): + 多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。""" + if not script.is_adopted: + return + target = script.segments.count() + shots = list(project.storyboard_shots.order_by("sort_order")) + while len(shots) > target: + tail = shots[-1] + if tail.status == StoryboardShot.Status.NOT_STARTED and not tail.versions.exists(): + tail.delete() + shots.pop() + else: + break + @action(detail=True, methods=["post"], url_path="update-script-segment") def update_script_segment(self, request, pk=None): project = self.get_object() @@ -575,6 +595,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): seg.sort_order = index seg.save(update_fields=["sort_order", "updated_at"]) self._sync_video_segments_to_script(project, script) + self._sync_storyboard_shots_to_script(project, script) return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED) @action(detail=True, methods=["post"], url_path="delete-script-segment") @@ -605,6 +626,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): if resort: ScriptSegment.objects.bulk_update(resort, ["sort_order"]) self._sync_video_segments_to_script(project, script) + self._sync_storyboard_shots_to_script(project, script) return Response(ScriptVersionSerializer(script).data) # ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ── @@ -633,20 +655,48 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): @action(detail=True, methods=["post"], url_path="generate-storyboard") def generate_storyboard_action(self, request, pk=None): - """异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。""" + """开始生成故事板(全部场):确保每镜一个 shot 并标 QUEUED;逐场出图交给 poll-storyboard 起线程。 + 全部已出片时再点 = 整批重跑(全部场重出)。不推进阶段。""" require_worker() project = self.get_object() - storyboard = submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", "")) + submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", "")) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) stage.status = ProjectStage.Status.RUNNING stage.save(update_fields=["status", "updated_at"]) - return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_202_ACCEPTED) + return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED) + + @action(detail=True, methods=["post"], url_path="rerun-storyboard-shot") + def rerun_storyboard_shot_action(self, request, pk=None): + """单场重跑:只重出该 shot 的图 —— 新增一条历史版本并采用,其余场不动。出图交给 poll-storyboard。""" + require_worker() + project = self.get_object() + shot_id = request.data.get("shot_id") + if not project.storyboard_shots.filter(id=shot_id).exists(): + return Response({"detail": "shot not found"}, status=status.HTTP_404_NOT_FOUND) + submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""), shot_ids=[shot_id]) + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) + stage.status = ProjectStage.Status.RUNNING + stage.save(update_fields=["status", "updated_at"]) + return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED) + + @action(detail=True, methods=["post"], url_path="adopt-storyboard-shot-version") + def adopt_storyboard_shot_version_action(self, request, pk=None): + """采用某场的某个历史版本(对标 adopt-video-version):切换该场采用的分镜图。""" + project = self.get_object() + shot = StoryboardShot.objects.filter(project=project, id=request.data.get("shot_id")).first() + if shot is None: + return Response({"detail": "shot not found"}, status=status.HTTP_404_NOT_FOUND) + version = StoryboardShotVersion.objects.filter(shot=shot, id=request.data.get("version_id")).first() + if version is None: + return Response({"detail": "version not found"}, status=status.HTTP_404_NOT_FOUND) + adopt_storyboard_shot_version(shot=shot, version=version) + return Response(ProjectSerializer(self.get_object()).data) @action(detail=True, methods=["post"], url_path="poll-storyboard") def poll_storyboard_action(self, request, pk=None): - """异步故事板·轮询:每次生成下一帧(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。""" + """异步故事板·轮询:为在制场起线程出图(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。""" project = self.get_object() - result = generate_storyboard_frame(project=project, user=request.user) + result = poll_storyboard(project=project, user=request.user) if result.get("status") == "succeeded": stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) stage.status = ProjectStage.Status.SUCCEEDED @@ -677,6 +727,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): project.save(update_fields=["current_stage", "status", "updated_at"]) return Response(ProjectSerializer(project).data) + @action(detail=True, methods=["post"], url_path="video-review-precheck") + def video_review_precheck(self, request, pk=None): + """点「生成视频」前的过审校验:返回未过审(非 active)的人物立绘 / 故事板分镜清单。 + body 可选 video_segment_id:只校验该段(单段重跑);不传 = 校验全部未出片段。 + blockers 空 = 全部已过审,前端放行生成。""" + from apps.ai.services import collect_video_review_blockers + + project = self.get_object() + seg = None + sid = request.data.get("video_segment_id") + if sid: + seg = project.video_segments.filter(id=sid).first() + blockers = collect_video_review_blockers(project, only_segment=seg) + return Response({"blockers": blockers}) + @action(detail=True, methods=["post"], url_path="submit-video-segment") def submit_video_segment_action(self, request, pk=None): # 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index c1ac3e4..1a93141 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -357,6 +357,25 @@ export function App() { } }, [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。 const refreshExport = useCallback(async () => { if (!activeProjectId) return; @@ -866,7 +885,9 @@ export function App() { notice={notice} unreadCount={unreadCount} 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 })} 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 })} @@ -883,19 +904,15 @@ export function App() { return assetId ? { adopted_asset: assetId } : null; }} onGenerateStoryboard={(prompt) => - action(async () => { - // 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待 - 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("故事板生成超时,请稍后刷新查看或重试"); - }, "故事板已生成") + // 只「提交」(秒回);出图由后台 pollStoryboardQuiet 驱动 —— 不占全局 loading,不锁其它场按钮(对标视频「开始生成」) + action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已开始生成") } + 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 }), "已采用该候选")} onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")} onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")} diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 10b194c..894a2ce 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -405,9 +405,24 @@ export const api = { skipStoryboard(projectId: string) { return request(`/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(`/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 }) { return request(`/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) { return request(`/api/projects/${projectId}/poll-video-segment/`, { method: "POST", diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index df194d8..e7ee2e5 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -2,7 +2,7 @@ import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useR import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react"; import { Play } from "lucide-react"; 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 { money, stageOrder, statusPill } from "./stage-config"; import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell"; @@ -457,6 +457,7 @@ export function PipelinePage(props: { unreadCount: number; avatarChar: string; logout: () => void; + onNotify?: (type: "success" | "error", text: string) => void; scriptModelName: string; textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise; onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise; @@ -482,6 +483,10 @@ export function PipelinePage(props: { // 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产 onRenameActor?: (assetId: string, name: string) => Promise; onGenerateStoryboard: (prompt: string) => void | Promise; + // 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本 + onRerunStoryboardShot?: (shotId: string, prompt?: string) => Promise; + onAdoptStoryboardShotVersion?: (shotId: string, versionId: string) => void | Promise; + onPollStoryboardQuiet?: () => void | Promise; onSkipStoryboard: () => Promise; onSubmitVideo: (segmentId: string, prompt: string) => void | Promise; onSubmitAllVideos: (prompt: string) => void | Promise; @@ -495,9 +500,9 @@ export function PipelinePage(props: { onSubmitExport: (payload?: TimelineSavePayload) => void; }) { 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, - 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, onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport } = props; @@ -772,6 +777,24 @@ export function PipelinePage(props: { if (missing.length) setRefGate({ missing, proceed: 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 跨域 + 强制下载而非新开标签) async function downloadAssetImage(assetId: string, name: string) { try { @@ -834,20 +857,47 @@ export function PipelinePage(props: { }, [adDetail]); // 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。 - // ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ── - const storyboards = project.storyboard_versions ?? []; - const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null; - const [sbViewId, setSbViewId] = useState(null); - const displayedStoryboard = storyboards.find((s) => s.id === sbViewId) || adoptedStoryboard; - const sbFrames = [...(displayedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order); + // ── Stage 3:分镜制(对标视频段)—— 每镜一个 StoryboardShot,各自采用版图 + 历史版本,可单场重跑/切版 ── + const sbShots = [...(project.storyboard_shots ?? [])].sort((a, b) => a.sort_order - b.sort_order); 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(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), // 让用户一眼看出「这里本该有几张」,出图后逐个填真图。无采用版脚本则为 0(显示「暂无」)。 const sbAdoptedScript = scripts.find((s) => s.is_adopted) || null; const sbExpectedShots = sbAdoptedScript ? (sbAdoptedScript.segments?.length ?? 0) : 0; - // 整张故事板生成中:不全屏遮挡,只在每张分镜占位上转个 spinner(样式同基础资产趴) + // 整张/全部生成中:不全屏遮挡,只在每张分镜占位上转个 spinner(样式同基础资产趴) const [sbGenerating, setSbGenerating] = useState(false); + // 单场重跑的乐观态:点下立刻给该场转圈(撑到状态回传 queued/running 接管) + const [sbBusyShots, setSbBusyShots] = useState>(() => 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 自动收起) const [sbConfirmHint, setSbConfirmHint] = useState(false); useEffect(() => { @@ -942,14 +992,19 @@ export function PipelinePage(props: { setReviewBusyId(id); try { const r = await api.submitAssetReview(id); - setReviews((m) => ({ ...m, [id]: r.review_status || "processing" })); - } catch { /* 失败保持灰盾可再点 */ } finally { + // 用后端真态,别再 `|| "processing"` 伪造审核中 —— 没真送出去就该回灰盾,而不是骗一个刷新即消失的「审核中」 + setReviews((m) => ({ ...m, [id]: r.review_status || "" })); + } catch (e) { + // 送审没成(503=审核服务不可用等):如实提示,灰盾保持可重试,不再静默吞掉 + onNotify?.("error", e instanceof Error && e.message ? e.message : "审核服务暂不可用,请稍后重试"); + } finally { setReviewBusyId(null); } } - // 在基础资产趴(stage 2)/ 故事板趴(stage 3)定时轮询审核状态,刷新徽章(processing → active绿 / failed红) + // 在基础资产趴(stage 2)/ 故事板趴(stage 3)/ 视频趴(stage 4)定时轮询审核状态,刷新徽章 + // (processing → active绿 / failed红)。stage4 也轮询:过审闸里「一键送审」后,要在视频阶段就能看到盾变绿再生成。 useEffect(() => { - if (viewStage !== 2 && viewStage !== 3) return; + if (viewStage !== 2 && viewStage !== 3 && viewStage !== 4) return; let alive = true; let sawProcessing = false; let timer = 0; @@ -969,6 +1024,14 @@ export function PipelinePage(props: { timer = window.setInterval(tick, 8000); return () => { alive = false; window.clearInterval(timer); }; }, [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 监听,这里补齐 useEffect(() => { function onHashChange() { @@ -1359,12 +1422,9 @@ export function PipelinePage(props: { }; }, [gutterDragging]); const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成可直接指导视频的分镜图"; - const [storyboardPrompt, setStoryboardPrompt] = useState(SB_PROMPT_DEFAULT); - // 切换查看的故事板版本时,提示词编辑框重新种子为该版本的提示词 - useEffect(() => { - setStoryboardPrompt(displayedStoryboard?.prompt || SB_PROMPT_DEFAULT); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [displayedStoryboard?.id]); + // 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效 + const sbSavedPrompt = (project.metadata as Record | undefined)?.storyboard_prompt as string | undefined; + const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT); const videoPrompt = "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感"; 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,真图 + 镜头提示词)============= */} - {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 (
- {sbFrames.length ? (() => { - // 每场显示时间区间(累加脚本镜时长 → 「0~5s」),对齐 HTML;无脚本时回退序号 - let cum = 0; - const times = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; }); - return sbFrames.map((frame, idx) => { - const url = frameUrl(frame); - return ( -
setSbSelected(idx)}> -
- 场 {idx + 1} - {sbGenerating &&
-
场 {idx + 1}
-
{times[idx] || `#${frame.sort_order + 1}`}
+ {sbShots.length ? sbShots.map((shot, idx) => { + const url = shotImg(shot); + const aid = shot.adopted_asset || ""; + return ( +
setSbSelected(idx)}> +
+ 场 {idx + 1} + {shotBusy(shot) &&
- ); - }); - })() : sbExpectedShots ? ( +
场 {idx + 1}
+
{sceneTimes[idx] || `#${shot.sort_order + 1}`}
+
+ ); + }) : sbExpectedShots ? ( /* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */ Array.from({ length: sbExpectedShots }, (_, idx) => (
setSbSelected(idx)}> @@ -3005,12 +3076,13 @@ export function PipelinePage(props: { ) :
// 暂无
}
{(() => { - const url = frameUrl(sbActiveFrame); + const url = mainUrl; + const aid = mainAid; return (
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}> - {sbActiveFrame ? `场 ${sbSelected + 1}` : sbExpectedShots ? `场 ${sbSelected + 1} · 待生成` : "// 故事板未生成"} - {sbGenerating &&
); })()} @@ -3019,65 +3091,70 @@ export function PipelinePage(props: {
- 故事板 · {sbActiveFrame ? `场 ${sbSelected + 1}` : "—"} + 故事板 · {sbShots.length ? `场 ${sbSelected + 1}` : "—"} - {displayedStoryboard - ? (displayedStoryboard.is_adopted - ? 已采用 - : 历史版本) + {sbActiveShot + ? (activeBusy + ? 生成中 + : sbActiveShot.status === "failed" + ? 生成失败 + : sbActiveShot.adopted_version + ? 已出片 + : 待生成) : 未生成}
-
每个场一张分镜表 · image-2 把该场拆成多个分镜(不同景别/运镜)画进一张竖屏图。
-
- -
仅支持整张重跑 · 不能局部改某一镜。如需调单镜,先在 { event.preventDefault(); goStage(1); }}>Stage 1 脚本 改镜头描述,再回此处整张重跑。
-
-
// 整张提示词(重跑时生效,可编辑)
- {/* key 跟版本走:切版本重新种子;onChange 实时回写 state → 整张重跑用的就是你编辑后的文本 */} +
每个场一张分镜图 · 可单独「重跑本场」只重出这一张,每场各自留历史版本,互不影响。
+
// 整张风格提示词(重跑时生效,可编辑)
setStoryboardPrompt(v.trim())} />
- + {sbActiveShot && ( + + )} ~¥0.45/场
-
// 历史版本({storyboards.length})
+
// 本场历史版本({activeVers.length})· 点击预览
- {storyboards.length ? storyboards.map((ver) => { - const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]); + {activeVers.length ? activeVers.map((ver) => { + 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 ( -
{ setSbViewId(ver.id); setSbSelected(0); }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewId(ver.id); setSbSelected(0); } }}> -
{ver.is_adopted ? "采用" : "历史"}
+
setSbViewVerId(ver.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewVerId(ver.id); } }}> +
{isAdopted ? "采用" : "历史"}
{(ver.created_at || "").slice(11, 16) || "--:--"}
); - }) : // 暂无历史} + }) : // 本场暂无历史}
+ {/* 预览的是非采用版 → 显式「采用此版本」(对标视频详情弹窗的采用按钮);采用不影响在制重跑 */} + {sbViewedVer && !sbViewedIsAdopted && sbActiveShot && ( + + )}
// 绑定的资产
{(() => { - // 与 Stage 2 同源:用 metadata.label 取名(不依赖全局 assets 列表),排除三视图组并按名字去重, - // 否则同一角色的立绘组+三视图组+多版本会各算一个,且名字会退化成「人物(人物)」。 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) })), ...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) })), ]; - // 圆点占位 → 已引用资产缩略图(一眼看出绑了哪张),无图时回退圆点 return bound.length ? bound.map((b) => ( {b.url ? : }{b.name}({KIND_LABEL[b.kind]}) )) : // 暂无绑定资产; @@ -3088,26 +3165,26 @@ export function PipelinePage(props: {
-
[ image-2 整张输出 · {sbFrames.length ? `${sbFrames.length} 场` : sbExpectedShots ? `待生成 ${sbExpectedShots} 场` : "0 场"} · 整张重跑,失败不扣 ]
+
[ image-2 逐场输出 · {cardCount ? `${cardCount} 场` : "0 场"} · 单场可重跑,失败不扣 ]
- {/* 不再允许跳过故事板:统一「确认故事板」。没生成故事板时点它 → 弹气泡提示先去生成,不放行 */}
{sbConfirmHint && (
- 还没有故事板 + 故事板还没出齐 - 请先点上方 生成故事板,出图后再确认进入视频生成。 + 请先点上方 开始生成故事板,每场都出片后再确认进入视频生成。
)} - +
- )} + ); + })()} {/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */} {viewStage === 4 && (() => { const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0; @@ -3128,7 +3205,7 @@ export function PipelinePage(props: {
{pct}% - + + {/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */} {verCount > 1 && ( @@ -3641,7 +3718,7 @@ export function PipelinePage(props: {
{vdVer?.asset_url && 下载} -
@@ -3673,6 +3750,46 @@ export function PipelinePage(props: { )} + {/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */} + {reviewGate && ( +
setReviewGate(null)}> +
e.stopPropagation()}> +
这些素材还没过审,先过审再生成视频
+
+ 含真人脸的人物形象故事板分镜必须先通过火山合规审核(变绿盾·已过审), + 否则视频生成会被火山以「疑似真人」拒绝。下面这些还没过审: +
+
+ {reviewGate.blockers.map((b, i) => { + const st = b.review_status === "processing" ? "审核中" : b.review_status === "failed" ? "未过审(被驳回)" : "未送审"; + return ( + + 场{b.scene_no} · {b.kind === "person" ? "人物" : "分镜"} + {b.name} {st} + + ); + })} +
+
+ + +
+
+
+ )} {/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */} {adDetail && (() => { const entities = buildEntities(adDetail.kind); diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 2cf1c53..aaf2c0f 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -336,6 +336,33 @@ export type StoryboardVersion = { 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 = { status: string; progress: number; @@ -414,6 +441,7 @@ export type Project = { created_at?: string; }>; storyboard_versions: StoryboardVersion[]; + storyboard_shots: StoryboardShot[]; timeline: Timeline | null; metadata?: { wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] };