From 76e15188e4d8724a528daaaf66eddd124e20edbb Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Mon, 7 Sep 2026 01:06:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(#1749):=20variant-plans=20=E8=BD=BB?= =?UTF-8?q?=E9=87=8F=E9=80=89=E7=89=87=E6=8E=A5=E5=8F=A3=20+=20=E9=85=8D?= =?UTF-8?q?=E9=9F=B3=E4=B8=A5=E6=A0=BC=E5=AE=88=E5=8D=AB=20+=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=8F=98=E4=BD=93=20plan=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=EF=BC=88=E5=8F=98=E4=BD=930=20clone/=E9=85=8D=E9=9F=B3?= =?UTF-8?q?=E6=97=B6=E9=95=BF=E5=88=86=E9=85=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/router.py | 6 + apps/api/app/api/routes/generation_preview.py | 48 +++- apps/api/app/api/routes/generation_tasks.py | 232 +++++++++++++----- .../api/routes/generation_variant_plans.py | 177 +++++++++++++ apps/api/app/schemas/generation_task.py | 56 ++++- apps/worker/worker_app/tasks/generation.py | 5 +- packages/application/generation_tasks.py | 3 +- 7 files changed, 452 insertions(+), 75 deletions(-) create mode 100644 apps/api/app/api/routes/generation_variant_plans.py diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 53c675d97..968c6b274 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -11,6 +11,7 @@ from app.api.routes.feature_flags import router as feature_flags_router from app.api.routes.generation_cover import router as generation_cover_router from app.api.routes.generation_preview import router as generation_preview_router from app.api.routes.generation_tasks import router as generation_tasks_router +from app.api.routes.generation_variant_plans import router as generation_variant_plans_router from app.api.routes.health import router as health_check_router from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.internal_render import router as internal_render_router @@ -99,6 +100,11 @@ api_router.include_router( prefix="/generation", tags=["Generation"], ) +api_router.include_router( + generation_variant_plans_router, + prefix="/generation", + tags=["Generation"], +) api_router.include_router( generation_cover_router, prefix="/generation", diff --git a/apps/api/app/api/routes/generation_preview.py b/apps/api/app/api/routes/generation_preview.py index 50ca16034..eb2302ce7 100755 --- a/apps/api/app/api/routes/generation_preview.py +++ b/apps/api/app/api/routes/generation_preview.py @@ -390,7 +390,6 @@ def create_preview_generation_task( template_id=request.template_id, asset_ids=list(request.asset_ids), title_ids=list(request.title_ids), - voice_ids=list(request.voice_ids), created_by_user_id=user_id, source_edit_plan_id=request.source_edit_plan_id, asset_select_mode="", @@ -426,8 +425,31 @@ def create_preview_generation_task( # 重跑单视频选片(素材洗牌+镜头洗牌+起点随机+跨变体避让+批次 20% 重叠重选), # 所见即所得——预览变体差异即正式成片差异。 source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else "" + + # #1749:各变体配音解析(严格守卫已在 schema;此处取每变体 voice 查时长)+ 时长分配 + def _preview_voice_durations() -> list[float]: + try: + from packages.domain.variant_voice_resolver import resolve_variant_voice_ids + + voices = resolve_variant_voice_ids( + count=count, + voice_library_id=request.voice_library_id, + voice_library_ids=request.voice_library_ids or None, + ) + except Exception: + logger.warning("[预览生成] 配音解析失败(按无配音处理)", exc_info=True) + return [0.0] * count + try: + from app.api.routes.generation_tasks import _query_voice_durations + + return _query_voice_durations(db, voices) + except Exception: + return [0.0] * count + + voice_durations = _preview_voice_durations() + if source_plan_id and count == 1: - # 单预览:克隆一份(原逻辑) + # 单预览:克隆一份(原逻辑)+ 配音时长分配 try: from app.services.edit_plan_service import EditPlanService @@ -437,6 +459,11 @@ def create_preview_generation_task( created_by_user_id=user_id, name_suffix="预览变体", ) + if voice_durations and voice_durations[0] > 0: + try: + _plan_svc.apply_voice_duration_to_plan(variant_plan.id, voice_durations[0]) + except Exception: + logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", variant_plan.id) variant_plan_ids.append(variant_plan.id) except Exception as e: logger.error("[预览生成] 克隆预览 plan 异常: %s", e, exc_info=True) @@ -451,8 +478,18 @@ def create_preview_generation_task( from app.services.edit_plan_service import EditPlanService _plan_svc = EditPlanService(db) - # 变体 0 直接用源 plan;变体 1..N-1 独立选片 - variant_plan_ids.append(source_plan_id) + # #1749:变体 0 也 clone(不污染源 plan)+ 配音分配;变体 1..N-1 独立选片 + _plan0 = _plan_svc.clone_plan_for_variant( + source_plan_id, + created_by_user_id=user_id, + name_suffix="预览变体1", + ) + if voice_durations and voice_durations[0] > 0: + try: + _plan_svc.apply_voice_duration_to_plan(_plan0.id, voice_durations[0]) + except Exception: + logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", _plan0.id) + variant_plan_ids.append(_plan0.id) batch_asset_pool = list(dict.fromkeys(request.asset_ids or [])) for variant_index in range(1, count): last_err: Exception | None = None @@ -464,6 +501,9 @@ def create_preview_generation_task( batch_asset_pool, created_by_user_id=user_id, name_suffix=f"预览变体{variant_index + 1}", + voice_duration=( + voice_durations[variant_index] if variant_index < len(voice_durations) else 0.0 + ), ) break except ValueError as ve: diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 1358d7f28..f9eb774c4 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -57,6 +57,30 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str: return values[index] if index < len(values) else fallback +def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]: + """批量查询配音素材时长(秒),#1749 配音时长分配用。 + + 逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。 + """ + ids = [v for v in dict.fromkeys(voice_ids or []) if v] + if not ids: + return [] + try: + from packages.adapters.sqlalchemy_impl.models import AssetModel + + rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(ids)).all() + dur_map: dict[str, float] = {} + for row in rows: + try: + dur_map[row[0]] = float(row[1] or 0.0) + except (TypeError, ValueError): + dur_map[row[0]] = 0.0 + return [dur_map.get(v, 0.0) for v in ids] + except Exception: + logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True) + return [0.0 for _ in ids] + + def _to_generation_task_response(task) -> GenerationTaskResponse: return GenerationTaskResponse( id=task.id, @@ -436,19 +460,34 @@ def create_generation_task( logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id) effective_strategy_id = "one_take" - # 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743)。 - # - 变体 0 保留源 plan(保留用户编辑结果); - # - 变体 1..N-1 用 reselect_plan_for_variant 完整重跑选片(素材洗牌 + 镜头洗牌 - # + 起点随机 + 跨变体区间避让 + 批次 20% 重叠重选),而非"克隆只改起点"; - # - count>1 但没有源 plan(前端未传 source_edit_plan_id 且无模板 plan)时, - # 不允许 N 个任务兜底共用同一 plan,直接 4xx 中断(宁可不生成,也不出同源成片)。 - # 在创建任何任务【之前】预生成全部变体 plan:失败直接中断(此时无脏数据)。 + # 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。 + # - 变体 0:clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant + # 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用); + # - #1749:前端可回传 variant-plans 接口预生成的 plan_id(variant_plan_ids),直接复用; + # 回传 plan 仍按各变体配音幂等重分配段长(防 variant-plans 阶段未带配音/占位时长); + # - 配音时长:独立配音各自时长、统一配音同值,逐变体 apply_voice_duration_to_plan, + # 成片总时长=配音时长(素材短→末帧冻结,禁慢放/禁截配音); + # - count>1 但没有源 plan 时,不允许 N 个任务兜底共用同一 plan,直接 4xx 中断。 + # 在创建任何任务【之前】预生成/校验全部变体 plan:失败直接中断(此时无脏数据)。 variant_plan_ids: list[str] = [] if count > 1: from app.services.edit_plan_service import EditPlanService + from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids _plan_svc = EditPlanService(db) + # 解析每变体配音(严格守卫:独立配音长度/缺值 → 400,禁静默 fallback) + try: + variant_voices = resolve_variant_voice_ids( + count=count, + voice_library_id=request.voice_library_id, + voice_library_ids=request.voice_library_ids or None, + ) + except VariantVoiceError as ve: + raise HTTPException(status_code=400, detail=str(ve)) from ve + # 各变体配音时长(查询硬化:异常 → 0.0 不阻断) + voice_durations = _query_voice_durations(db, variant_voices) + # 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源) batch_source_plan_id = request.source_edit_plan_id if not batch_source_plan_id and request.template_id: @@ -469,7 +508,7 @@ def create_generation_task( except Exception: logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True) - if not batch_source_plan_id: + if not batch_source_plan_id and not request.variant_plan_ids: # 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源 logger.error("[生成任务] 批量 count=%d 但无可编辑计划(无 source_edit_plan_id/template plan)", count) raise HTTPException( @@ -480,56 +519,139 @@ def create_generation_task( # 批次素材池:请求显式素材 + 库自动匹配素材(resolved_asset_ids) batch_asset_pool = list(dict.fromkeys(resolved_asset_ids or [])) - for task_index in range(1, count): - variant = None - last_err: Exception | None = None - for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动 - try: - variant = _plan_svc.reselect_plan_for_variant( - batch_source_plan_id, - batch_asset_pool, - created_by_user_id=user_id, - name_suffix=f"批量{task_index + 1}", - ) - break - except ValueError as ve: - # 素材不足等可预期错误:不重试,直接中断并给出明确提示 - logger.warning("[生成任务] 变体独立选片失败(素材不足): %s", ve) - raise HTTPException( - status_code=400, - detail=f"批量生成第 {task_index + 1} 个视频无法独立选片:{ve}。" - "请增加素材库中的视频素材后重试。", - ) from ve - except Exception as reselection_err: # noqa: PERF203 - last_err = reselection_err - logger.warning( - "[生成任务] 变体独立选片失败(尝试%d/2): source=%s error=%s", - _attempt + 1, - batch_source_plan_id, - reselection_err, - exc_info=True, - ) - if variant is None: - logger.error( - "[生成任务] 变体独立选片重试仍失败,中断批量创建: source=%s", - batch_source_plan_id, - exc_info=last_err, - ) + if request.variant_plan_ids: + # ① 前端回传 variant-plans 预生成结果:直接复用(轻量选片接口已建好 plan) + if len(request.variant_plan_ids) != count: raise HTTPException( - status_code=500, - detail="创建批量任务失败:无法生成独立剪辑计划,请重试", - ) from last_err - variant_plan_ids.append(variant.id) + status_code=400, + detail=f"variant_plan_ids 数量({len(request.variant_plan_ids)})与视频数量({count})不一致", + ) + # 校验归属权 + from packages.adapters.sqlalchemy_impl.models import EditPlanModel + + for _pid in request.variant_plan_ids: + _pm = db.query(EditPlanModel).filter(EditPlanModel.id == _pid).first() + if _pm is None: + raise HTTPException(status_code=400, detail=f"剪辑计划不存在: {_pid}") + if _pm.created_by_user_id and _pm.created_by_user_id != user_id: + raise HTTPException(status_code=403, detail=f"无权使用剪辑计划: {_pid}") + variant_plan_ids = list(request.variant_plan_ids) + else: + # ② 服务端选片:变体 0 clone 源 plan(不污染源 plan) + try: + _plan0 = _plan_svc.clone_plan_for_variant( + batch_source_plan_id, + created_by_user_id=user_id, + name_suffix="批量1", + ) + except Exception as clone_err: + logger.error("[生成任务] 变体0 clone 失败: %s", clone_err, exc_info=True) + raise HTTPException( + status_code=500, detail="创建批量任务失败:无法生成独立剪辑计划,请重试" + ) from clone_err + variant_plan_ids.append(_plan0.id) + + # 变体 1..N-1 独立选片 + for task_index in range(1, count): + variant = None + last_err: Exception | None = None + for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动 + try: + variant = _plan_svc.reselect_plan_for_variant( + batch_source_plan_id, + batch_asset_pool, + created_by_user_id=user_id, + name_suffix=f"批量{task_index + 1}", + voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0, + ) + break + except ValueError as ve: + # 素材不足等可预期错误:不重试,直接中断并给出明确提示 + logger.warning("[生成任务] 变体独立选片失败(素材不足): %s", ve) + raise HTTPException( + status_code=400, + detail=f"批量生成第 {task_index + 1} 个视频无法独立选片:{ve}。" + "请增加素材库中的视频素材后重试。", + ) from ve + except Exception as reselection_err: # noqa: PERF203 + last_err = reselection_err + logger.warning( + "[生成任务] 变体独立选片失败(尝试%d/2): source=%s error=%s", + _attempt + 1, + batch_source_plan_id, + reselection_err, + exc_info=True, + ) + if variant is None: + logger.error( + "[生成任务] 变体独立选片重试仍失败,中断批量创建: source=%s", + batch_source_plan_id, + exc_info=last_err, + ) + raise HTTPException( + status_code=500, + detail="创建批量任务失败:无法生成独立剪辑计划,请重试", + ) from last_err + variant_plan_ids.append(variant.id) + + # ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配) + for _vi, _pid in enumerate(variant_plan_ids): + _vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0 + if _vd > 0: + try: + _plan_svc.apply_voice_duration_to_plan(_pid, _vd) + except Exception: + logger.exception("[生成任务] 变体%d 配音时长分配失败(不阻断): plan=%s", _vi, _pid) + + # N=1 正式生成:渲染侧全局慢放兜底已删除(#1749),enqueue 前也必须按配音分配段长 + if count == 1 and not request.is_preview: + from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids + + try: + _voices = resolve_variant_voice_ids( + count=1, + voice_library_id=request.voice_library_id, + voice_library_ids=request.voice_library_ids or None, + ) + _vd = _query_voice_durations(db, _voices) + _single_dur = _vd[0] if _vd else 0.0 + _single_plan = request.source_edit_plan_id + if not _single_plan and request.template_id: + try: + from packages.adapters.sqlalchemy_impl.models import EditPlanModel + + _latest = ( + db.query(EditPlanModel) + .filter( + EditPlanModel.template_id == request.template_id, + EditPlanModel.created_by_user_id == user_id, + ) + .order_by(EditPlanModel.created_at.desc()) + .first() + ) + if _latest: + _single_plan = _latest.id + except Exception: + logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True) + if _single_dur > 0 and _single_plan: + from app.services.edit_plan_service import EditPlanService + + try: + EditPlanService(db).apply_voice_duration_to_plan(_single_plan, _single_dur) + except Exception: + logger.exception("[生成任务] N=1 配音时长分配失败(不阻断): plan=%s", _single_plan) + except VariantVoiceError as ve: + raise HTTPException(status_code=400, detail=str(ve)) from ve + except Exception: + logger.exception("[生成任务] N=1 配音分配兜底异常(不阻断)") try: for task_index in range(count): - # 变体 0 复用源 plan(保留用户编辑结果);变体 1..N-1 用预生成的独立选片 plan。 - # count>1 时上方已保证存在源 plan 且变体 plan 数量 == count-1。 - effective_plan_id = ( - request.source_edit_plan_id or batch_source_plan_id if count > 1 else request.source_edit_plan_id - ) - if task_index > 0 and variant_plan_ids: - effective_plan_id = variant_plan_ids[task_index - 1] + # #1749:count>1 时每个变体(含变体0)都关联各自独立 plan(clone/reselect/variant-plans)。 + if count > 1 and variant_plan_ids: + effective_plan_id = variant_plan_ids[task_index] + else: + effective_plan_id = request.source_edit_plan_id # 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[] # 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段 @@ -549,7 +671,6 @@ def create_generation_task( template_id=request.template_id, asset_ids=resolved_asset_ids, title_ids=request.title_ids, - voice_ids=request.voice_ids, created_by_user_id=user_id, source_edit_plan_id=effective_plan_id, asset_select_mode=request.asset_select_mode, @@ -751,7 +872,6 @@ def confirm_generation( template_id=source_task.template_id, asset_ids=source_task.asset_ids, title_ids=source_task.title_ids, - voice_ids=source_task.voice_ids, created_by_user_id=authenticated_user.user.id, source_edit_plan_id=source_task.source_edit_plan_id or "", asset_select_mode=source_task.asset_select_mode, diff --git a/apps/api/app/api/routes/generation_variant_plans.py b/apps/api/app/api/routes/generation_variant_plans.py new file mode 100644 index 000000000..bf1feea34 --- /dev/null +++ b/apps/api/app/api/routes/generation_variant_plans.py @@ -0,0 +1,177 @@ +"""轻量选片接口 POST /generation/variant-plans(#1749)。 + +与正式生成共用同一套选片函数(EditPlanService.ensure_variant_plans → +clone_plan_for_variant / reselect_plan_for_variant → variant_plan_selector), +但**不建任务、不入队、不渲染**: + +- 仅为 N 个变体创建/选好 EditPlan + clips,返回 plan_id 与片段列表; +- 前端确认后调正式生成接口回传 variant_plan_ids,直接复用这些 plan, + 不再重复选片(回传后仍按各变体配音幂等重分配段长); +- 配音守卫:voice_library_ids 长度/缺值 → 400(variant_voice_resolver), + 禁静默 fallback; +- 素材不足等选片失败 → 400(与正式生成同口径);除此之外不报错打断。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field, model_validator +from sqlalchemy.orm import Session + +from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class VariantPlanRequest(BaseModel): + """轻量选片请求体(与前端 variantPlans.ts 契约一致)。""" + + template_id: str = Field(default="", description="模板 ID(无 source_edit_plan_id 时用于查找骨架 plan)") + asset_ids: list[str] = Field(default_factory=list, description="批次素材池") + count: int = Field(default=1, ge=1, le=50, description="变体数量") + source_edit_plan_id: str = Field(default="", description="源剪辑计划 ID(优先)") + # 配音(可选;传独立配音时严格守卫) + voice_library_id: str = Field(default="", description="统一配音 ID") + voice_library_ids: list[str] = Field(default_factory=list, description="独立配音 ID 列表(长度须=count)") + + @model_validator(mode="after") + def _validate(self) -> "VariantPlanRequest": + if not self.template_id.strip() and not self.source_edit_plan_id.strip(): + raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个") + try: + resolve_variant_voice_ids( + count=self.count, + voice_library_id=self.voice_library_id, + voice_library_ids=self.voice_library_ids or None, + ) + except VariantVoiceError as exc: + raise ValueError(str(exc)) from exc + return self + + +class VariantPlanItem(BaseModel): + variant_index: int + plan_id: str + clips: list[dict[str, Any]] = Field(default_factory=list) + + +class VariantPlanResponse(BaseModel): + items: list[VariantPlanItem] + total: int + + +@router.post("/variant-plans", response_model=VariantPlanResponse) +def create_variant_plans( + request: VariantPlanRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +) -> VariantPlanResponse: + """轻量选片:为 N 个变体创建独立 EditPlan + clips,不建任务/不渲染。 + + Returns: + 200 + {items: [{variant_index, plan_id, clips}], total} + """ + user_id = authenticated_user.user.id + + # 配音严格守卫(schema 已校验,此处复用解析取每变体配音) + try: + voices = resolve_variant_voice_ids( + count=request.count, + voice_library_id=request.voice_library_id, + voice_library_ids=request.voice_library_ids or None, + ) + except VariantVoiceError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # 解析源 plan:显式传入优先;否则按 template_id + user 查最新 + source_plan_id = request.source_edit_plan_id.strip() + if not source_plan_id and request.template_id.strip(): + try: + from packages.adapters.sqlalchemy_impl.models import EditPlanModel + + _latest = ( + db.query(EditPlanModel) + .filter( + EditPlanModel.template_id == request.template_id.strip(), + EditPlanModel.created_by_user_id == user_id, + ) + .order_by(EditPlanModel.created_at.desc()) + .first() + ) + if _latest: + source_plan_id = _latest.id + except Exception: + logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True) + + if not source_plan_id: + raise HTTPException( + status_code=400, + detail="缺少剪辑计划:请先完成一次预览生成(或传入 source_edit_plan_id)后再试。", + ) + + # 配音时长(硬化:异常 → 0.0 不阻断选片) + try: + from app.api.routes.generation_tasks import _query_voice_durations + + voice_durations = _query_voice_durations(db, voices) + except Exception: + logger.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True) + voice_durations = [0.0] * request.count + + from app.services.edit_plan_service import EditPlanService + + svc = EditPlanService(db) + try: + plan_ids = svc.ensure_variant_plans( + source_plan_id, + request.count, + list(dict.fromkeys(request.asset_ids or [])), + created_by_user_id=user_id, + voice_durations=voice_durations, + ) + except ValueError as ve: + # 素材池为空/时长全未知等可预期错误 → 400(与正式生成同口径) + logger.warning("[variant-plans] 选片失败: %s", ve) + raise HTTPException(status_code=400, detail=f"变体选片失败:{ve}。请增加素材后重试。") from ve + except HTTPException: + raise + except Exception as e: + logger.error("[variant-plans] 选片异常: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e + + # 组装 clips 响应 + items: list[VariantPlanItem] = [] + for idx, pid in enumerate(plan_ids): + clips = svc.list_clips(pid) + clip_dicts = [ + { + "id": c.id, + "order": c.order, + "asset_id": c.asset_id, + "start_time": float(c.start_time or 0.0), + "duration": float(c.duration or 0.0), + "clip_type": c.clip_type, + "transition_effect": c.transition_effect, + "transition_duration": float(c.transition_duration or 0.0), + "playback_speed": float(c.playback_speed or 1.0), + "text_content": c.text_content or "", + } + for c in clips + ] + items.append(VariantPlanItem(variant_index=idx, plan_id=pid, clips=clip_dicts)) + + logger.info( + "[variant-plans] 轻量选片完成: user=%s source=%s count=%d plans=%d", + user_id, + source_plan_id, + request.count, + len(plan_ids), + ) + return VariantPlanResponse(items=items, total=len(items)) diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 15f55d895..1c4254373 100755 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -36,9 +36,13 @@ class CreateGenerationTaskRequest(BaseModel): template_id: str = "" asset_ids: list[str] = Field(default_factory=list) title_ids: list[str] = Field(default_factory=list) - voice_ids: list[str] = Field(default_factory=list) # ── 来源剪辑计划 ── source_edit_plan_id: str = "" + # ── variant-plans 轻量选片回传(#1749):正式生成直接复用,不再重选 ── + variant_plan_ids: list[str] = Field( + default_factory=list, + description="POST /generation/variant-plans 返回的各变体 plan_id(长度须=count);为空则走服务端选片", + ) # ── 标题配置(结构化)── title_config: dict | None = Field( default=None, @@ -96,11 +100,33 @@ class CreateGenerationTaskRequest(BaseModel): @model_validator(mode="after") def _check_variant_arrays(self) -> "CreateGenerationTaskRequest": - """变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = count(独立)。""" - for name in ("voice_library_ids", "cover_urls", "titles"): + """变体数组字段长度校验 + #1749 配音严格守卫。 + + - cover_urls/titles:空(回退单值)、长度 1(共用)或长度 = count(独立); + - voice_library_ids:独立配音长度必须恰好 = count 且逐项非空,禁止静默 fallback + (长度 1 的"共用"场景请用 voice_library_id 单值字段); + - variant_plan_ids:非空时长度必须 = count。 + """ + for name in ("cover_urls", "titles"): arr = getattr(self, name) if arr and len(arr) != 1 and len(arr) != self.count: raise ValueError(f"{name} 长度必须为 1(共用)或 {self.count}(与 count 一致),当前为 {len(arr)}") + + from packages.domain.variant_voice_resolver import resolve_variant_voice_ids, VariantVoiceError + + try: + resolve_variant_voice_ids( + count=self.count, + voice_library_id=self.voice_library_id, + voice_library_ids=self.voice_library_ids or None, + ) + except VariantVoiceError as exc: + raise ValueError(str(exc)) from exc + + if self.variant_plan_ids and len(self.variant_plan_ids) != self.count: + raise ValueError( + f"variant_plan_ids 长度({len(self.variant_plan_ids)})必须与 count({self.count})一致" + ) return self @model_validator(mode="after") @@ -110,9 +136,9 @@ class CreateGenerationTaskRequest(BaseModel): if not has_project and not has_template: raise ValueError("project_id 或 template_id 至少需要提供一个") has_library = bool(self.asset_library_id.strip()) - has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids) + has_assets = bool(self.asset_ids or self.title_ids) if not has_library and not has_assets: - raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个") + raise ValueError("asset_library_id 或 asset_ids/title_ids 至少需要提供一个") return self @@ -189,7 +215,6 @@ class CreatePreviewGenerationTaskRequest(BaseModel): template_id: str asset_ids: list[str] = Field(default_factory=list) title_ids: list[str] = Field(default_factory=list) - voice_ids: list[str] = Field(default_factory=list) voice_library_id: str = Field( default="", description="配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材" ) @@ -231,13 +256,24 @@ class CreatePreviewGenerationTaskRequest(BaseModel): @model_validator(mode="after") def _check_variant_arrays(self) -> "CreatePreviewGenerationTaskRequest": - """变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = preview_count(独立)。""" - for name in ("titles", "voice_library_ids", "cover_urls"): + """变体数组字段长度校验 + #1749 配音严格守卫。""" + for name in ("titles", "cover_urls"): arr = getattr(self, name) if arr and len(arr) != 1 and len(arr) != self.preview_count: raise ValueError( f"{name} 长度必须为 1(共用)或 {self.preview_count}(与 preview_count 一致),当前为 {len(arr)}" ) + + from packages.domain.variant_voice_resolver import resolve_variant_voice_ids, VariantVoiceError + + try: + resolve_variant_voice_ids( + count=self.preview_count, + voice_library_id=self.voice_library_id, + voice_library_ids=self.voice_library_ids or None, + ) + except VariantVoiceError as exc: + raise ValueError(str(exc)) from exc return self @model_validator(mode="after") @@ -248,8 +284,8 @@ class CreatePreviewGenerationTaskRequest(BaseModel): @model_validator(mode="after") def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest": - if not self.asset_ids and not self.title_ids and not self.voice_ids: - raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个") + if not self.asset_ids and not self.title_ids: + raise ValueError("asset_ids/title_ids 至少需要提供一个") return self diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 3e2d77f3d..357d5ab13 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -376,7 +376,6 @@ def _load_task_info(task_id: str) -> dict | None: "output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT, "cover_url": getattr(gen_task, "cover_url", "") or "", "title_config": dict(getattr(gen_task, "title_config", {}) or {}), - "voice_ids": list(getattr(gen_task, "voice_ids", []) or []), "source_edit_plan_id": getattr(gen_task, "source_edit_plan_id", "") or "", } finally: @@ -648,8 +647,8 @@ def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> # 配音下载 voiceover_path: str | None = None voice_library_id = task_info.get("voice_library_id", "") - voice_ids = task_info.get("voice_ids", []) or [] - effective_voice_id = voice_library_id or (voice_ids[0] if voice_ids else "") + # #1749:voice_ids 冗余字段已移除;配音一律以 voice_library_id 为准(独立配音每变体各自绑定) + effective_voice_id = voice_library_id or "" if effective_voice_id: import tempfile diff --git a/packages/application/generation_tasks.py b/packages/application/generation_tasks.py index 025ef6f82..f70578894 100755 --- a/packages/application/generation_tasks.py +++ b/packages/application/generation_tasks.py @@ -16,7 +16,6 @@ class CreateGenerationTaskCommand: template_id: str = "" asset_ids: list[str] = field(default_factory=list) title_ids: list[str] = field(default_factory=list) - voice_ids: list[str] = field(default_factory=list) created_by_user_id: str = "" source_edit_plan_id: str = "" asset_select_mode: str = "" @@ -48,7 +47,7 @@ class CreateGenerationTaskUseCase: template_id=command.template_id, asset_ids=command.asset_ids, title_ids=command.title_ids, - voice_ids=command.voice_ids, + voice_ids=[], # #1749:voice_ids 已废弃(冗余 voice_library_id),DB 列保留只读 status="pending", # type: ignore[arg-type] progress=0.0, result_count=0,