fix(#1749): 批量三连修:配音严格守卫/配音时长分配+末帧冻结/素材级去重 + variant-plans 接口 #1752
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,35 @@ 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 +509,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 +520,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,
|
||||
)
|
||||
_single_vd: list[float] = _query_voice_durations(db, _voices)
|
||||
_single_dur = _single_vd[0] if _single_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 +672,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 +873,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,
|
||||
@@ -895,7 +1016,6 @@ def retry_generation_task(
|
||||
template_id=task.template_id,
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
|
||||
@@ -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))
|
||||
@@ -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,31 @@ 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 VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
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 +134,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 +213,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 +254,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 VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
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 +282,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
|
||||
|
||||
|
||||
|
||||
@@ -471,6 +471,7 @@ class EditPlanService:
|
||||
*,
|
||||
created_by_user_id: str = "",
|
||||
name_suffix: str = "变体",
|
||||
voice_duration: float = 0.0,
|
||||
rng=None,
|
||||
) -> EditPlan:
|
||||
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
|
||||
@@ -512,7 +513,7 @@ class EditPlanService:
|
||||
if not clips:
|
||||
raise ValueError(f"源 plan 无片段,无法生成变体: {source_plan_id}")
|
||||
|
||||
source_clips_data = [
|
||||
source_clips_data: list[dict[str, Any]] = [
|
||||
{
|
||||
"order": c.order if c.order is not None else i,
|
||||
"asset_id": c.asset_id,
|
||||
@@ -530,6 +531,27 @@ class EditPlanService:
|
||||
|
||||
db = self._clip_repo.session
|
||||
|
||||
# #1749:配音时长 → 每段目标段长(片段数=模板片段数定死;素材不足由渲染末帧冻结铺满)
|
||||
target_durations: list[float] | None = None
|
||||
try:
|
||||
voice = float(voice_duration or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
voice = 0.0
|
||||
if voice > 0 and source_clips_data:
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations
|
||||
|
||||
_effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data]
|
||||
_tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data]
|
||||
target_durations = plan_clip_durations(
|
||||
len(source_clips_data),
|
||||
voice,
|
||||
transition_effects=_effects,
|
||||
transition_durations=_tdurs,
|
||||
)
|
||||
if target_durations:
|
||||
for _c, _d in zip(source_clips_data, target_durations, strict=False):
|
||||
_c["duration"] = _d
|
||||
|
||||
# 素材池 = 源 plan 素材 ∪ 调用方传入素材(去重保序)
|
||||
pool_ids: list[str] = []
|
||||
seen = set()
|
||||
@@ -574,6 +596,7 @@ class EditPlanService:
|
||||
asset_scene_points=scene_points,
|
||||
historical_used_segments=historical,
|
||||
batch_segments=batch_segments,
|
||||
target_durations=target_durations,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
@@ -704,6 +727,188 @@ class EditPlanService:
|
||||
)
|
||||
return new_plan
|
||||
|
||||
# ── #1749 配音时长分配 / 素材时长查询 / 批量变体 plan 确保 ──────────────
|
||||
|
||||
def get_asset_durations(self, asset_ids: list[str]) -> dict[str, float]:
|
||||
"""批量查询素材时长(秒),O(N) 单查;缺失/异常返回 0.0。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
ids = [a for a in dict.fromkeys(asset_ids or []) if a]
|
||||
if not ids:
|
||||
return {}
|
||||
db = self._clip_repo.session
|
||||
out: dict[str, float] = {}
|
||||
for m in db.query(AssetModel).filter(AssetModel.id.in_(ids)).all():
|
||||
try:
|
||||
out[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
out[m.id] = 0.0
|
||||
return out
|
||||
|
||||
def apply_voice_duration_to_plan(self, plan_id: str, voice_duration: float) -> Optional[EditPlan]:
|
||||
"""把配音时长分配到 plan 的每段(#1749)。
|
||||
|
||||
- 片段数保持不变(= 模板片段数,定死);
|
||||
- 每段 duration 按 voice_duration_planner 分配(含转场重叠扣减);
|
||||
- 素材短于段长 → start_time 钳制为 0(末帧冻结由渲染侧 tpad/apad 铺满);
|
||||
- plan.total_duration 回写为成片净时长(≈ 配音时长);
|
||||
- 幂等:配音时长相同则分配结果不变,可重复调用。
|
||||
|
||||
无配音(<=0)或无片段时直接返回 None,不报错。
|
||||
"""
|
||||
try:
|
||||
voice = float(voice_duration or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if voice <= 0:
|
||||
return None
|
||||
|
||||
plan = self.get_plan(plan_id)
|
||||
if plan is None:
|
||||
return None
|
||||
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page)
|
||||
if not batch:
|
||||
break
|
||||
clips.extend(batch)
|
||||
if len(batch) < page:
|
||||
break
|
||||
skip += page
|
||||
if not clips:
|
||||
return None
|
||||
|
||||
clips.sort(key=lambda c: (c.order if c.order is not None else 0))
|
||||
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration
|
||||
|
||||
target = plan_clip_durations(
|
||||
len(clips),
|
||||
voice,
|
||||
transition_effects=[c.transition_effect for c in clips],
|
||||
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
|
||||
)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
# 素材时长(短素材起点钳 0)
|
||||
asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
durations = self.get_asset_durations(asset_ids)
|
||||
|
||||
clips_data: list[dict] = []
|
||||
for i, c in enumerate(clips):
|
||||
dur = float(target[i])
|
||||
total = durations.get(c.asset_id, 0.0)
|
||||
start = float(c.start_time or 0.0)
|
||||
if c.asset_id and total > 0:
|
||||
# 素材短于段长:起点钳 0,段长超出部分渲染侧末帧冻结
|
||||
max_start = max(0.0, total - min(dur, total))
|
||||
start = min(start, max_start)
|
||||
clips_data.append(
|
||||
{
|
||||
"order": c.order if c.order is not None else i,
|
||||
"asset_id": c.asset_id or "",
|
||||
"start_time": round(start, 3),
|
||||
"duration": dur,
|
||||
"clip_type": c.clip_type,
|
||||
"playback_speed": float(c.playback_speed or 1.0),
|
||||
"transition_effect": c.transition_effect,
|
||||
"transition_duration": float(c.transition_duration or 0.0),
|
||||
"text_content": c.text_content or "",
|
||||
"config": c.config or {},
|
||||
}
|
||||
)
|
||||
|
||||
self.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
net = total_output_duration(
|
||||
target,
|
||||
transition_effects=[c.transition_effect for c in clips],
|
||||
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
|
||||
)
|
||||
try:
|
||||
plan.total_duration = net
|
||||
db = self._clip_repo.session
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("回写 plan.total_duration 失败(不阻断): plan_id=%s", plan_id)
|
||||
|
||||
logger.info(
|
||||
"配音时长分配完成: plan=%s clips=%d voice=%.2fs 成片净时长=%.2fs",
|
||||
plan_id,
|
||||
len(clips),
|
||||
voice,
|
||||
net,
|
||||
)
|
||||
return plan
|
||||
|
||||
def ensure_variant_plans(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
count: int,
|
||||
candidate_asset_ids: list[str],
|
||||
*,
|
||||
created_by_user_id: str = "",
|
||||
voice_durations: Optional[list[float]] = None,
|
||||
rng=None,
|
||||
) -> list[str]:
|
||||
"""确保批量 N 个变体各自拥有独立 plan(#1749 批量正式生成/预览共用)。
|
||||
|
||||
- 变体 0:clone 源 plan(不污染源 plan,片段独立可改),并按配音分配段长;
|
||||
- 变体 1..N-1:reselect_plan_for_variant 完整重跑选片(素材级去重);
|
||||
- voice_durations:每个变体的配音时长(独立配音各自时长;统一配音同值);
|
||||
缺省/为 0 时不分配(段长保持骨架/模板值)。
|
||||
|
||||
Returns:
|
||||
plan_id 列表,长度 == count,index 即 variant_index。
|
||||
"""
|
||||
import random as _random
|
||||
|
||||
rng = rng or _random.Random()
|
||||
plan_ids: list[str] = []
|
||||
|
||||
# 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan
|
||||
plan0 = self.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
name_suffix="变体1",
|
||||
)
|
||||
v0_voice = 0.0
|
||||
if voice_durations and len(voice_durations) > 0:
|
||||
try:
|
||||
v0_voice = float(voice_durations[0] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
v0_voice = 0.0
|
||||
if v0_voice > 0:
|
||||
try:
|
||||
self.apply_voice_duration_to_plan(plan0.id, v0_voice)
|
||||
except Exception:
|
||||
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
|
||||
plan_ids.append(plan0.id)
|
||||
|
||||
# 变体 1..N-1:独立选片
|
||||
for i in range(1, count):
|
||||
voice = 0.0
|
||||
if voice_durations and i < len(voice_durations):
|
||||
try:
|
||||
voice = float(voice_durations[i] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
voice = 0.0
|
||||
variant = self.reselect_plan_for_variant(
|
||||
source_plan_id,
|
||||
candidate_asset_ids,
|
||||
created_by_user_id=created_by_user_id,
|
||||
name_suffix=f"变体{i + 1}",
|
||||
voice_duration=voice,
|
||||
rng=rng,
|
||||
)
|
||||
plan_ids.append(variant.id)
|
||||
|
||||
return plan_ids
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
|
||||
@@ -47,13 +47,23 @@ class RenderContext:
|
||||
|
||||
|
||||
def clip_effective_duration(clip: ResolvedClip) -> float:
|
||||
"""计算 clip 的有效时长.
|
||||
"""计算 clip 的有效时长(#1749:目标段长始终为准)。
|
||||
|
||||
与 UnifiedRenderService._clip_effective_duration 逻辑一致。
|
||||
与 UnifiedRenderService._clip_effective_duration 逻辑一致;素材短于段长的
|
||||
部分由末帧冻结 tpad / 音频 apad 铺满,不在此处钳制。
|
||||
"""
|
||||
if clip.duration > 0:
|
||||
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
||||
return clip.actual_duration if clip.actual_duration > 0 else 0.0
|
||||
return float(clip.duration)
|
||||
return float(clip.actual_duration) if clip.actual_duration > 0 else 0.0
|
||||
|
||||
|
||||
def _clip_freeze_seconds(clip: ResolvedClip) -> float:
|
||||
"""读取 #1749 末帧冻结秒数(_resolve_clips 写入 config['_freeze_seconds'])。"""
|
||||
cfg = getattr(clip, "config", None) or {}
|
||||
try:
|
||||
return max(0.0, float(cfg.get("_freeze_seconds", 0.0) or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool:
|
||||
@@ -273,6 +283,9 @@ def concat_main_audio(
|
||||
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
|
||||
final_duration = video_duration
|
||||
|
||||
# #1749 末帧冻结秒数(音频需 apad 补静音与视频等长)
|
||||
freeze_seconds = _clip_freeze_seconds(clip)
|
||||
|
||||
# 音频倒放
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
has_reverse = reverse_config.enabled and reverse_config.reverse_audio
|
||||
@@ -281,20 +294,28 @@ def concat_main_audio(
|
||||
if not has_speed and not has_reverse:
|
||||
# 无调速无倒放:根据是否需要裁剪/音量选择最高效的路径。
|
||||
vol = _clip_volume(clip)
|
||||
need_trim = trim_start > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
|
||||
need_trim = (
|
||||
trim_start > 0 or freeze_seconds > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
|
||||
)
|
||||
need_volume = abs(vol - 1.0) >= 1e-6
|
||||
|
||||
if need_trim:
|
||||
# 需要裁剪:用 atrim 滤镜在滤镜链中精确裁剪(采样点级精度,不浪费解码)。
|
||||
# 滤镜顺序:atrim → asetpts → volume(先裁剪再调音量,避免处理被丢弃的数据)。
|
||||
# 滤镜顺序:atrim → asetpts → apad(冻结补静音) → volume。
|
||||
af_parts: list[str] = []
|
||||
if trim_start > 0 and effective_duration > 0:
|
||||
af_parts.append(f"atrim=start={trim_start:.3f}:duration={final_duration:.3f}")
|
||||
# 冻结场景:素材内可用时长 = 目标段长 − freeze(截掉超出素材的部分后补静音)
|
||||
atrim_dur = final_duration
|
||||
if freeze_seconds > 0:
|
||||
atrim_dur = max(0.0, final_duration - freeze_seconds)
|
||||
if trim_start > 0 and atrim_dur > 0:
|
||||
af_parts.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
|
||||
elif trim_start > 0:
|
||||
af_parts.append(f"atrim=start={trim_start:.3f}")
|
||||
elif final_duration > 0:
|
||||
af_parts.append(f"atrim=duration={final_duration:.3f}")
|
||||
elif atrim_dur > 0:
|
||||
af_parts.append(f"atrim=duration={atrim_dur:.3f}")
|
||||
af_parts.append("asetpts=PTS-STARTPTS")
|
||||
if freeze_seconds > 0:
|
||||
af_parts.append(f"apad=whole_dur={final_duration:.3f}")
|
||||
if need_volume:
|
||||
af_parts.append(f"volume={vol:.4f}")
|
||||
command = [
|
||||
@@ -334,8 +355,13 @@ def concat_main_audio(
|
||||
"-ac",
|
||||
"2",
|
||||
]
|
||||
af_simple: list[str] = []
|
||||
if need_volume:
|
||||
command.extend(["-af", f"volume={vol:.4f}"])
|
||||
af_simple.append(f"volume={vol:.4f}")
|
||||
if freeze_seconds > 0:
|
||||
af_simple.append(f"apad=whole_dur={final_duration:.3f}")
|
||||
if af_simple:
|
||||
command.extend(["-af", ",".join(af_simple)])
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
command.append(str(output_path))
|
||||
@@ -369,6 +395,10 @@ def concat_main_audio(
|
||||
if abs(vol - 1.0) >= 1e-6:
|
||||
audio_filters.append(f"volume={vol:.4f}")
|
||||
|
||||
# #1749 冻结补静音(调速后时长已变,apad 补齐到最终时长)
|
||||
if freeze_seconds > 0:
|
||||
audio_filters.append(f"apad=whole_dur={final_duration:.3f}")
|
||||
|
||||
# aformat 归一化:统一输出格式为 48000Hz + stereo + fltp
|
||||
audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
|
||||
|
||||
@@ -410,10 +440,13 @@ def concat_main_audio(
|
||||
speed = getattr(clip, "playback_speed", 1.0) or 1.0
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
speed = 1.0
|
||||
# #1749 末帧冻结:素材内可用时长 = 目标段长 − freeze,apad 补静音
|
||||
freeze_seconds = _clip_freeze_seconds(clip)
|
||||
atrim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
|
||||
|
||||
audio_filters: list[str] = []
|
||||
if effective_duration > 0:
|
||||
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
|
||||
audio_filters.append("asetpts=PTS-STARTPTS")
|
||||
|
||||
# 音频调速 — atempo 多级串联
|
||||
@@ -435,6 +468,10 @@ def concat_main_audio(
|
||||
if reverse_filter:
|
||||
audio_filters.append(reverse_filter)
|
||||
|
||||
# #1749 冻结补静音:视频 tpad 延长后音频等长补齐(concat 时间轴对齐)
|
||||
if freeze_seconds > 0:
|
||||
audio_filters.append(f"apad=whole_dur={effective_duration:.3f}")
|
||||
|
||||
# 音量(0=静音,1=原声)
|
||||
vol = _clip_volume(clip)
|
||||
if abs(vol - 1.0) >= 1e-6:
|
||||
@@ -506,12 +543,17 @@ def mix_with_independent_audio(
|
||||
input_args.extend(["-i", str(clip.local_path)])
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
freeze_seconds = _clip_freeze_seconds(clip)
|
||||
atrim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
|
||||
clip_filters = []
|
||||
if effective_duration > 0:
|
||||
clip_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
clip_filters.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
|
||||
clip_filters.append("asetpts=PTS-STARTPTS")
|
||||
else:
|
||||
clip_filters.append("asetpts=PTS-STARTPTS")
|
||||
# #1749 冻结补静音(与视频 tpad 等长)
|
||||
if freeze_seconds > 0:
|
||||
clip_filters.append(f"apad=whole_dur={effective_duration:.3f}")
|
||||
vol = _clip_volume(clip)
|
||||
if abs(vol - 1.0) >= 1e-6:
|
||||
clip_filters.append(f"volume={vol:.4f}")
|
||||
|
||||
@@ -518,120 +518,45 @@ class UnifiedRenderService:
|
||||
layers: list[RenderLayer],
|
||||
voice_duration: float,
|
||||
) -> None:
|
||||
"""调整片段时长以对齐配音时长。
|
||||
"""#1749:配音对齐仅保留 ±5% 守卫日志,不再做全局裁剪/慢放。
|
||||
|
||||
核心逻辑:
|
||||
- 计算片段总时长与配音时长的比例
|
||||
- ±5% 以内不调整
|
||||
- ratio < 1(片段比配音长):按比例裁剪每段末尾
|
||||
- ratio > 1(片段比配音短):按比例慢放每段
|
||||
|
||||
Args:
|
||||
layers: 渲染图层列表
|
||||
voice_duration: 配音时长(秒)
|
||||
段长已在选片阶段由 voice_duration_planner 按配音时长精确分配
|
||||
(成片总时长=配音,素材不足走末帧冻结 tpad/apad)。渲染期任何全局
|
||||
裁剪会截断配音、全局慢放会导致失真——两者均已删除。此处仅在偏差
|
||||
超阈值时记录 warning 供排查,不修改任何 clip。
|
||||
"""
|
||||
if voice_duration <= 0:
|
||||
return
|
||||
|
||||
# 只调整视频图层(main/broll/background),不调整音频图层
|
||||
video_layers = [layer for layer in layers if layer.role in ("main", "broll", "background")]
|
||||
if not video_layers:
|
||||
return
|
||||
|
||||
# 计算所有视频图层的总时长
|
||||
total_clips_duration = 0.0
|
||||
for layer in video_layers:
|
||||
for clip in layer.clips:
|
||||
clip_dur = self._clip_adjusted_duration(clip)
|
||||
total_clips_duration += clip_dur
|
||||
|
||||
total_clips_duration += self._clip_adjusted_duration(clip)
|
||||
if total_clips_duration <= 0:
|
||||
return
|
||||
|
||||
ratio = voice_duration / total_clips_duration
|
||||
|
||||
# ±5% 以内不调整
|
||||
if abs(ratio - 1.0) <= 0.05:
|
||||
logger.info(
|
||||
"[voice-align] 比例接近1:1,跳过调整: ratio=%.4f voice=%.3f clips=%.3f",
|
||||
estimated = self._estimate_total_duration(video_layers)
|
||||
ratio = voice_duration / total_clips_duration if total_clips_duration > 0 else 1.0
|
||||
if abs(ratio - 1.0) > 0.05:
|
||||
logger.warning(
|
||||
"[voice-align] 段长合计与配音偏差>5%%(仅守卫,不裁剪/不慢放): "
|
||||
"ratio=%.4f voice=%.3f clips_sum=%.3f estimated_final=%.3f",
|
||||
ratio,
|
||||
voice_duration,
|
||||
total_clips_duration,
|
||||
estimated,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[voice-align] 开始调整片段时长: ratio=%.4f voice=%.3f clips=%.3f",
|
||||
ratio,
|
||||
voice_duration,
|
||||
total_clips_duration,
|
||||
)
|
||||
|
||||
# 收集所有视频 clip
|
||||
all_clips: list[tuple[RenderLayer, ResolvedClip]] = []
|
||||
for layer in video_layers:
|
||||
for clip in layer.clips:
|
||||
all_clips.append((layer, clip))
|
||||
|
||||
if not all_clips:
|
||||
return
|
||||
|
||||
if ratio < 1.0:
|
||||
# 片段比配音长,按比例裁剪每段末尾
|
||||
# 减少每个 clip 的 duration
|
||||
for _layer, clip in all_clips:
|
||||
old_duration = clip.duration if clip.duration > 0 else clip.actual_duration
|
||||
new_duration = old_duration * ratio
|
||||
|
||||
# 更新 duration
|
||||
clip.duration = max(0.1, new_duration) # 至少 0.1s
|
||||
|
||||
# 如果有 trim_config,也需要调整
|
||||
if clip.trim_config is not None:
|
||||
new_trim_duration = clip.trim_config.duration * ratio
|
||||
clip.trim_config = TrimConfig(
|
||||
start_time=clip.trim_config.start_time,
|
||||
duration=max(0.1, new_trim_duration),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[voice-align] trim clip=%s: %.3f -> %.3f",
|
||||
clip.clip_id,
|
||||
old_duration,
|
||||
clip.duration,
|
||||
)
|
||||
|
||||
else:
|
||||
# ratio > 1.0: 片段比配音短,按比例慢放每段
|
||||
# 降低 playback_speed
|
||||
for _layer, clip in all_clips:
|
||||
old_speed = clip.playback_speed if clip.playback_speed > 0 else 1.0
|
||||
# speed = old_speed / ratio 会使视频变慢(ratio > 1 时)
|
||||
new_speed = old_speed / ratio
|
||||
|
||||
# 下限 0.25x(避免过慢)
|
||||
new_speed = max(0.25, round(new_speed, 4))
|
||||
clip.playback_speed = new_speed
|
||||
|
||||
logger.debug(
|
||||
"[voice-align] slowdown clip=%s: speed %.4f -> %.4f",
|
||||
clip.clip_id,
|
||||
old_speed,
|
||||
new_speed,
|
||||
)
|
||||
|
||||
# 调整后重新计算总时长用于日志
|
||||
new_total = 0.0
|
||||
for layer in video_layers:
|
||||
for clip in layer.clips:
|
||||
new_total += self._clip_adjusted_duration(clip)
|
||||
|
||||
logger.info(
|
||||
"[voice-align] 调整完成: 新总时长=%.3fs (目标=%.3fs, 差异=%.3fs)",
|
||||
new_total,
|
||||
voice_duration,
|
||||
abs(new_total - voice_duration),
|
||||
)
|
||||
logger.info(
|
||||
"[voice-align] 段长与配音偏差≤5%%,无需处理: ratio=%.4f voice=%.3f estimated=%.3f",
|
||||
ratio,
|
||||
voice_duration,
|
||||
estimated,
|
||||
)
|
||||
|
||||
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
|
||||
"""根据 plan.config 生成 ASS 字幕文件。
|
||||
@@ -1372,10 +1297,16 @@ class UnifiedRenderService:
|
||||
|
||||
# trim
|
||||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||||
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
|
||||
# #1749 冻结时素材内截取时长 = 目标段长 − freeze
|
||||
trim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
|
||||
|
||||
if effective_duration > 0:
|
||||
filters.append(f"trim=duration={effective_duration}")
|
||||
if trim_dur > 0:
|
||||
filters.append(f"trim=duration={trim_dur}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
# #1749 末帧冻结(禁慢放)
|
||||
if freeze_seconds > 0:
|
||||
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
|
||||
|
||||
# 调速 — 与 filter_complex 路径一致
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
@@ -1424,6 +1355,10 @@ class UnifiedRenderService:
|
||||
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
filters.append(f"fps={self.output_fps}")
|
||||
# #1749 末帧冻结:素材短于目标段长时克隆末帧铺满(禁慢放)
|
||||
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
|
||||
if freeze_seconds > 0:
|
||||
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
|
||||
filters.append("format=yuv420p")
|
||||
|
||||
# 字幕叠加
|
||||
@@ -1515,6 +1450,10 @@ class UnifiedRenderService:
|
||||
if abs(clip_volume - 1.0) >= 1e-6:
|
||||
af_parts.append(f"volume={clip_volume:.4f}")
|
||||
|
||||
# #1749 音频冻结:视频 tpad 延长后音频需等长补静音,否则时间轴错位
|
||||
if freeze_seconds > 0:
|
||||
af_parts.append(f"apad=whole_dur={final_duration:.3f}")
|
||||
|
||||
if af_parts:
|
||||
command.extend(["-af", ",".join(af_parts)])
|
||||
|
||||
@@ -1624,7 +1563,9 @@ class UnifiedRenderService:
|
||||
continue
|
||||
|
||||
# 单段裁剪(或无裁剪)
|
||||
# 解析裁剪配置:config 优先,否则用 clip.start_time + clip.duration
|
||||
# #1749 定稿:目标段长(clip.duration,配音分配结果)始终为准。
|
||||
# trim 只决定素材内截取区间;素材短于段长 → 末帧冻结(tpad)/音频补静音(apad)
|
||||
# 铺满,禁止慢放、禁止截断配音。
|
||||
trim_config = extract_trim_from_clip_config(clip_config)
|
||||
if trim_config is None and (clip.start_time > 0 or clip.duration > 0):
|
||||
# 用旧字段构造
|
||||
@@ -1633,42 +1574,48 @@ class UnifiedRenderService:
|
||||
duration=clip.duration,
|
||||
)
|
||||
|
||||
# 钳制到实际素材时长
|
||||
effective_trim: TrimConfig | None = None
|
||||
# 目标段长:配音分配后的 clip.duration 始终为准(不被素材时长钳制)
|
||||
target_duration = float(clip.duration or 0.0)
|
||||
final_start = clip.start_time
|
||||
final_duration = clip.duration
|
||||
final_duration = target_duration
|
||||
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
|
||||
freeze_seconds = 0.0
|
||||
|
||||
if trim_config is not None and actual_duration > 0:
|
||||
effective_trim = trim_config.validate_and_resolve(actual_duration)
|
||||
if effective_trim.is_valid:
|
||||
final_start = effective_trim.start_time
|
||||
final_duration = effective_trim.duration
|
||||
resolved_trim = trim_config.validate_and_resolve(actual_duration)
|
||||
if resolved_trim.is_valid:
|
||||
# trim 仅决定素材内截取区间 [start, min(start+dur, actual)]
|
||||
final_start = resolved_trim.start_time
|
||||
avail_in_asset = max(0.0, actual_duration - final_start)
|
||||
effective_trim = TrimConfig(
|
||||
start_time=final_start, duration=min(resolved_trim.duration, avail_in_asset)
|
||||
)
|
||||
else:
|
||||
# 裁剪无效 → 使用完整素材
|
||||
logger.warning("裁剪配置无效,使用完整素材: clip_id=%s", clip.id)
|
||||
effective_trim = None
|
||||
# 裁剪无效 → 从素材头取
|
||||
logger.warning("裁剪配置无效,使用素材起点: clip_id=%s", clip.id)
|
||||
final_start = 0.0
|
||||
final_duration = actual_duration
|
||||
|
||||
# 素材实际时长不足以覆盖配置的时长时,降低播放速度来补偿
|
||||
# 例如:配置4s但素材只有3s → speed=0.75x,用满3s素材达到4s输出
|
||||
if actual_duration > 0 and final_duration > actual_duration + 0.05:
|
||||
compensated_speed = actual_duration / final_duration
|
||||
# 保留用户设置的速度(如果已减速则叠加)
|
||||
final_speed = configured_speed * compensated_speed
|
||||
# 下限 0.25x
|
||||
final_speed = max(0.25, round(final_speed, 4))
|
||||
logger.info(
|
||||
"[debug] clip=%s duration=%.3f actual=%.3f → 减速补偿 speed=%.4f (configured=%.3f)",
|
||||
clip.id,
|
||||
final_duration,
|
||||
actual_duration,
|
||||
final_speed,
|
||||
configured_speed,
|
||||
)
|
||||
avail_in_asset = actual_duration
|
||||
else:
|
||||
final_speed = configured_speed
|
||||
final_start = 0.0
|
||||
avail_in_asset = actual_duration if actual_duration > 0 else target_duration
|
||||
|
||||
# 素材内可用时长不足目标段长 → 末帧冻结(禁慢放)
|
||||
if target_duration > 0 and avail_in_asset < target_duration - 0.05:
|
||||
freeze_seconds = round(target_duration - avail_in_asset, 3)
|
||||
logger.info(
|
||||
"[freeze] clip=%s target=%.3f avail=%.3f → 末帧冻结 %.3fs(禁慢放)",
|
||||
clip.id,
|
||||
target_duration,
|
||||
avail_in_asset,
|
||||
freeze_seconds,
|
||||
)
|
||||
final_speed = configured_speed
|
||||
|
||||
# freeze 标记写入 config,供视频 tpad / 音频 apad 读取
|
||||
resolved_config = dict(clip_config)
|
||||
if freeze_seconds > 0:
|
||||
resolved_config["_freeze_seconds"] = freeze_seconds
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=clip.id,
|
||||
@@ -1681,7 +1628,7 @@ class UnifiedRenderService:
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
|
||||
playback_speed=final_speed,
|
||||
config=clip_config,
|
||||
config=resolved_config,
|
||||
actual_duration=actual_duration,
|
||||
trim_config=effective_trim,
|
||||
)
|
||||
@@ -1690,13 +1637,14 @@ class UnifiedRenderService:
|
||||
# Debug日志:记录每个clip的时长信息
|
||||
eff_dur = _clip_effective_duration_pure(final_duration, actual_duration)
|
||||
logger.info(
|
||||
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f",
|
||||
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f freeze=%.3f",
|
||||
clip.id,
|
||||
final_duration,
|
||||
actual_duration,
|
||||
eff_dur,
|
||||
final_speed,
|
||||
final_start,
|
||||
freeze_seconds,
|
||||
)
|
||||
|
||||
# 按 order 排序
|
||||
@@ -1790,12 +1738,15 @@ class UnifiedRenderService:
|
||||
# trim — 裁剪到指定区间,精确到帧
|
||||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
# #1749 冻结时素材内截取时长 = 目标段长 − freeze(末帧冻结由 tpad 铺满,与直通路径同口径)
|
||||
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
|
||||
trim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
|
||||
|
||||
if effective_duration > 0:
|
||||
if trim_dur > 0:
|
||||
if trim_start > 0:
|
||||
filters.append(f"trim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
filters.append(f"trim=start={trim_start:.3f}:duration={trim_dur:.3f}")
|
||||
else:
|
||||
filters.append(f"trim=duration={effective_duration:.3f}")
|
||||
filters.append(f"trim=duration={trim_dur:.3f}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 调速 — 基于 setpts 改变播放速度
|
||||
@@ -1846,6 +1797,9 @@ class UnifiedRenderService:
|
||||
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
filters.append(f"fps={self.output_fps}")
|
||||
# #1749 末帧冻结:素材短于目标段长时克隆末帧铺满(禁慢放)
|
||||
if freeze_seconds > 0:
|
||||
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
|
||||
|
||||
filter_str = f"[{i}:v]{','.join(filters)}[{label}]"
|
||||
filter_parts.append(filter_str)
|
||||
@@ -2128,16 +2082,11 @@ class UnifiedRenderService:
|
||||
|
||||
@staticmethod
|
||||
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)。
|
||||
"""计算 clip 的有效时长(#1749:目标段长为准,素材不足走末帧冻结)。
|
||||
|
||||
如果 playback_speed < 1(为补偿素材不足而减速),返回配置的 duration,
|
||||
而非 min(duration, actual_duration)。
|
||||
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
|
||||
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration
|
||||
(duration>0 直接返回;慢放补偿已删除)。
|
||||
"""
|
||||
speed = getattr(clip, "playback_speed", 1.0) or 1.0
|
||||
# 减速场景:duration 已通过降低 playback_speed 补偿,返回配置的 duration
|
||||
if speed < 1.0 - 1e-6 and clip.duration > 0:
|
||||
return clip.duration
|
||||
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
|
||||
|
||||
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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:
|
||||
@@ -536,11 +535,17 @@ def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict)
|
||||
try:
|
||||
svc = EditPlanService(db)
|
||||
asset_pool = list(task_info.get("task_asset_ids") or [])
|
||||
_voice_dur = 0.0
|
||||
try:
|
||||
_voice_dur = float(task_info.get("voice_duration", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
_voice_dur = 0.0
|
||||
new_plan = svc.reselect_plan_for_variant(
|
||||
plan_id,
|
||||
asset_pool,
|
||||
created_by_user_id=task_info.get("user_id", ""),
|
||||
name_suffix="重渲变体",
|
||||
voice_duration=_voice_dur,
|
||||
)
|
||||
return new_plan.id
|
||||
finally:
|
||||
@@ -648,8 +653,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -94,22 +94,23 @@ def clip_effective_duration(
|
||||
duration: float,
|
||||
actual_duration: float = 0.0,
|
||||
) -> float:
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)。
|
||||
"""计算 clip 的有效时长(#1749:目标段长始终为准)。
|
||||
|
||||
规则:
|
||||
- duration > 0: min(duration, actual_duration),actual=0 时用 duration
|
||||
- duration <= 0: actual_duration,actual=0 时返回 0
|
||||
- duration > 0: 直接返回 duration(配音分配的目标段长;素材短于段长的部分
|
||||
由渲染侧末帧冻结 tpad / 音频 apad 铺满,不在此处钳制);
|
||||
- duration <= 0: actual_duration,actual=0 时返回 0。
|
||||
|
||||
Args:
|
||||
duration: 配置的时长(0 表示使用完整素材)
|
||||
duration: 配置的目标时长(0 表示使用完整素材)
|
||||
actual_duration: 素材实际时长(probe 后的结果)
|
||||
|
||||
Returns:
|
||||
有效时长(秒)
|
||||
"""
|
||||
if duration > 0:
|
||||
return min(duration, actual_duration) if actual_duration > 0 else duration
|
||||
return actual_duration if actual_duration > 0 else 0.0
|
||||
return float(duration)
|
||||
return float(actual_duration) if actual_duration > 0 else 0.0
|
||||
|
||||
|
||||
def clip_playback_speed(playback_speed: Any) -> float:
|
||||
@@ -182,7 +183,7 @@ def estimate_total_duration(
|
||||
if not main_layer or not getattr(main_layer, "clips", None):
|
||||
return 0.0
|
||||
|
||||
clips = getattr(main_layer, "clips", [])
|
||||
clips = list(getattr(main_layer, "clips", []))
|
||||
total = sum(
|
||||
clip_adjusted_duration(
|
||||
duration=getattr(c, "duration", 0),
|
||||
@@ -192,12 +193,19 @@ def estimate_total_duration(
|
||||
for c in clips
|
||||
)
|
||||
|
||||
# 减去转场重叠时间(粗略估算)
|
||||
n_clips = len(clips)
|
||||
if n_clips > 1 and transition_duration > 0:
|
||||
total -= (n_clips - 1) * transition_duration
|
||||
# #1749:逐处转场重叠扣减,与 voice_duration_planner 分配口径严格一致
|
||||
# (cut 零重叠;非 cut 按该 clip 自身 transition_duration;转场挂在后段)。
|
||||
for c in clips[1:]:
|
||||
effect = (getattr(c, "transition_effect", "cut") or "cut").strip().lower()
|
||||
if effect in ("cut", "", "none"):
|
||||
continue
|
||||
tdur = float(getattr(c, "transition_duration", 0.0) or 0.0)
|
||||
if tdur > 0:
|
||||
total -= tdur
|
||||
elif transition_duration > 0:
|
||||
total -= transition_duration
|
||||
|
||||
return max(0.1, total)
|
||||
return max(0.1, round(total, 3))
|
||||
|
||||
|
||||
# ── 直通 / Stream Copy 判断辅助 ──────────────────────────────────────────────
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"""批量变体独立选片核心(#1743)。
|
||||
"""批量变体独立选片核心(#1743 起,#1749 强化素材级去重)。
|
||||
|
||||
总原则:多视频 = 单视频逻辑 × N。批量正式生成/批量预览时,变体 1..N-1
|
||||
不再"克隆源 plan 只重算起点"(那会导致同批素材、同顺序、同速度,成片同源),
|
||||
而是**完整重跑单视频的选片流程**:
|
||||
总原则:多视频 = 单视频逻辑 × N。批量正式生成/批量预览/variant-plans 时,
|
||||
每个变体**完整重跑单视频的选片流程**:
|
||||
|
||||
1. 源 plan 片段骨架(clip_type/order/duration/text/transition)保持不变 —— 保留
|
||||
模板结构与用户编辑结果;
|
||||
2. 素材池上做完整随机重选:
|
||||
- 素材组合随机(shuffle 素材池 + smart_match 评分噪声由调用方排序决定);
|
||||
- main 片段之间随机洗牌顺序(片段顺序显著不同);
|
||||
- 起点走场景镜头洗牌 + 随机起点 + 历史已用区间避让
|
||||
(pick_scene_aware_start / _calc_random_start_time,与单视频同一入口);
|
||||
- 跨变体/跨任务避让:get_used_segments 读取素材 metadata 持久化的已用区间,
|
||||
record_used_segments 随新片段写回(同事务),N 个变体串行选片时天然互相避让;
|
||||
3. 批次内片段重叠检查:重选后与"本批次已选定片段"对比,同一 asset 时间区间
|
||||
重叠占比 > 阈值(默认 20%)则该片段重选起点,最多重试若干次。
|
||||
1. 源 plan 片段骨架(clip_type/order/text/transition)保持不变;
|
||||
2. 素材池选片(#1749 定稿三轮策略):
|
||||
- **第一轮 fresh 素材优先**:本批次尚未被任何变体使用过的素材优先分配,
|
||||
从根上避免跨视频素材重复;
|
||||
- **第二轮受控复用**:fresh 素材不足时才允许复用已用素材,但必须通过
|
||||
起点扫描(_best_start_for_asset,0.25s 窗口)使与批次内已有区间的
|
||||
overlap ≤ 20%(BATCH_CLIP_OVERLAP_LIMIT),且不得完全重叠;
|
||||
- **短素材数学上无法错开**(素材时长 < 段长 ×(1−0.20),任何起点
|
||||
重叠都 >20%)→ **禁止跨变体复用**,跳过该素材继续找;
|
||||
- **第三轮兜底尽力而为**:池子耗尽时取最优(重叠最小)起点,不报错、
|
||||
不打断生成(#1749 铁律:任何情况下不得因素材时长/数量报错打断);
|
||||
3. main 片段之间洗牌顺序;起点走场景镜头洗牌 + 随机起点 + 历史已用区间
|
||||
避让(pick_scene_aware_start / _resolve_start_time,与单视频同一入口);
|
||||
4. target_durations:#1749 配音时长分配后每段目标段长(voice_duration_planner),
|
||||
落库到片段 duration;素材短于段长由渲染侧末帧冻结(tpad/apad)铺满。
|
||||
|
||||
本模块只产出 clips_data(dict 列表,供 EditPlanService.replace_all_clips_transactional
|
||||
落库),不碰 DB 事务边界;素材时长/场景点/已用区间由调用方注入,便于单测。
|
||||
本模块只产出 clips_data(dict 列表),不碰 DB 事务边界;素材时长/场景点/
|
||||
已用区间由调用方注入,便于单测。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,11 +34,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 阈值常量 ────────────────────────────────────────────────────────────────
|
||||
BATCH_CLIP_OVERLAP_LIMIT = 0.20
|
||||
"""批次内同一素材片段时间区间重叠占比上限(20%)。超过则重选起点。"""
|
||||
"""批次内同一素材片段时间区间重叠占比上限(20%)。超过则重选起点/换素材。"""
|
||||
|
||||
VARIANT_RESELECT_MAX_ATTEMPTS = 6
|
||||
"""单片段重叠避让/起点重选的最大尝试次数。"""
|
||||
|
||||
START_SCAN_STEP = 0.25
|
||||
"""复用素材时起点扫描窗口步长(秒)。"""
|
||||
|
||||
MAIN_CLIP_TYPES = {"main"}
|
||||
"""参与素材洗牌重选的片段类型(intro/outro/overlay 等固定角色片段保持源 plan)。"""
|
||||
|
||||
@@ -60,6 +66,35 @@ def _clip_overlap_ratio(
|
||||
return min(1.0, overlap / duration)
|
||||
|
||||
|
||||
def _best_start_for_asset(
|
||||
asset_id: str,
|
||||
clip_duration: float,
|
||||
asset_durations: dict[str, float],
|
||||
batch_segments: dict[str, list[tuple[float, float]]],
|
||||
) -> tuple[float, float] | None:
|
||||
"""在素材可用范围内扫描起点,找重叠最小的 (start, ratio)。
|
||||
|
||||
扫描步长 START_SCAN_STEP;返回 (best_start, best_ratio)。
|
||||
短素材(max_start<=0)直接返回 (0.0, ratio)——由调用方判断 ratio 是否可接受。
|
||||
"""
|
||||
total = asset_durations.get(asset_id, 0.0)
|
||||
if total <= 0:
|
||||
return None
|
||||
max_start = max(0.0, total - clip_duration)
|
||||
if max_start <= 0.0:
|
||||
return 0.0, _clip_overlap_ratio(asset_id, 0.0, clip_duration, batch_segments)
|
||||
best_start, best_ratio = 0.0, 1.0
|
||||
steps = int(max_start / START_SCAN_STEP) + 1
|
||||
for i in range(steps + 1):
|
||||
s = min(max_start, i * START_SCAN_STEP)
|
||||
r = _clip_overlap_ratio(asset_id, s, clip_duration, batch_segments)
|
||||
if r < best_ratio:
|
||||
best_start, best_ratio = s, r
|
||||
if r <= BATCH_CLIP_OVERLAP_LIMIT:
|
||||
return s, r
|
||||
return best_start, best_ratio
|
||||
|
||||
|
||||
def reselect_clips_for_variant(
|
||||
source_clips: list[dict],
|
||||
candidate_asset_ids: list[str],
|
||||
@@ -68,6 +103,7 @@ def reselect_clips_for_variant(
|
||||
asset_scene_points: dict[str, list[float]] | None = None,
|
||||
historical_used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||||
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||||
target_durations: list[float] | dict[int, float] | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> list[dict]:
|
||||
"""为一个变体基于源片段骨架重新独立选片。
|
||||
@@ -76,14 +112,15 @@ def reselect_clips_for_variant(
|
||||
source_clips: 源 plan 片段(dict 列表,每项至少含
|
||||
order/asset_id/start_time/duration/clip_type,可含
|
||||
playback_speed/transition_effect/transition_duration/text_content)。
|
||||
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次任务素材),将被 shuffle
|
||||
后随机分配给 main 片段。
|
||||
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次任务素材)。
|
||||
asset_durations: {asset_id: 时长秒},起点避让/区间计算必需。
|
||||
asset_scene_points: {asset_id: 场景切换点},有则走镜头洗牌选起点。
|
||||
historical_used_segments: 素材 metadata 中持久化的历史已用区间
|
||||
(跨任务/跨变体避让),函数内会就地追加本变体选中的区间。
|
||||
batch_segments: 本批次已选片段区间(变体间避让 + 20% 重叠检查),
|
||||
batch_segments: 本批次已选片段区间(变体间素材级去重 + 20% 重叠检查),
|
||||
函数内会就地追加本变体选中的区间。
|
||||
target_durations: #1749 配音分配后的每段目标时长(按 order 对齐的 list,
|
||||
或 {order: duration} dict);落库到片段 duration,素材不足由渲染冻结铺满。
|
||||
rng: 可选随机数生成器(测试可注入固定种子)。
|
||||
|
||||
Returns:
|
||||
@@ -112,7 +149,18 @@ def reselect_clips_for_variant(
|
||||
# 按 order 排序源片段,保持骨架顺序
|
||||
ordered = sorted(source_clips, key=lambda c: c.get("order", 0))
|
||||
|
||||
# ── 1. 素材洗牌:素材池 shuffle(组合随机) ─────────────────────────────
|
||||
def _target_dur(idx: int, src: dict) -> float:
|
||||
"""配音分配的目标段长(优先),否则用源片段段长。"""
|
||||
if target_durations is not None:
|
||||
if isinstance(target_durations, dict):
|
||||
v = target_durations.get(idx, target_durations.get(src.get("order", 0)))
|
||||
else:
|
||||
v = target_durations[idx] if idx < len(target_durations) else None
|
||||
if v is not None and float(v) > 0:
|
||||
return float(v)
|
||||
return float(src.get("duration", 0.0) or 0.0)
|
||||
|
||||
# ── 1. 素材池洗牌(组合随机),分 fresh / 已用两轮 ──────────────────────
|
||||
shuffled_pool = list(usable_assets)
|
||||
rng.shuffle(shuffled_pool)
|
||||
|
||||
@@ -121,31 +169,23 @@ def reselect_clips_for_variant(
|
||||
rng.shuffle(main_indexes)
|
||||
|
||||
result: list[dict | None] = [None] * len(ordered)
|
||||
pool_cursor = 0
|
||||
|
||||
for idx in main_indexes:
|
||||
src = ordered[idx]
|
||||
dur = float(src.get("duration", 0.0) or 0.0)
|
||||
if dur <= 0:
|
||||
target_dur = _target_dur(idx, src)
|
||||
if target_dur <= 0:
|
||||
# 异常片段:原样保留
|
||||
result[idx] = _base_clip_data(
|
||||
src, asset_id=src.get("asset_id", ""), start=float(src.get("start_time", 0.0))
|
||||
src, asset_id=src.get("asset_id", ""), start=float(src.get("start_time", 0.0)), duration=target_dur
|
||||
)
|
||||
continue
|
||||
|
||||
# 轮询取洗牌后素材(素材数 < 片段数时循环复用,但组合/顺序已随机)
|
||||
asset_id = shuffled_pool[pool_cursor % len(shuffled_pool)]
|
||||
pool_cursor += 1
|
||||
total = asset_durations.get(asset_id, 0.0)
|
||||
eff_dur = min(dur, total) if total > 0 else dur
|
||||
|
||||
# ── 3. 起点重选(镜头洗牌/随机起点/历史避让)+ 批次重叠避让 ──────────
|
||||
start = _pick_start_with_overlap_avoid(
|
||||
asset_id=asset_id,
|
||||
clip_duration=eff_dur,
|
||||
asset_id, start, eff_dur = _pick_asset_and_start(
|
||||
clip_duration=target_dur,
|
||||
shuffled_pool=shuffled_pool,
|
||||
asset_durations=asset_durations,
|
||||
used_segments=used_segments,
|
||||
asset_scene_points=asset_scene_points,
|
||||
used_segments=used_segments,
|
||||
batch_segments=batch_segments,
|
||||
rng=rng,
|
||||
)
|
||||
@@ -154,93 +194,119 @@ def reselect_clips_for_variant(
|
||||
used_segments.setdefault(asset_id, []).append(interval)
|
||||
batch_segments.setdefault(asset_id, []).append(interval)
|
||||
|
||||
result[idx] = _base_clip_data(src, asset_id=asset_id, start=start)
|
||||
result[idx] = _base_clip_data(src, asset_id=asset_id, start=start, duration=target_dur)
|
||||
|
||||
# ── 4. 非 main 片段(intro/outro/overlay 等固定角色):保留源素材,仅重算起点 ──
|
||||
# ── 3. 非 main 片段(intro/outro/overlay 等固定角色):保留源素材,仅重算起点 ──
|
||||
for idx, c in enumerate(ordered):
|
||||
if result[idx] is not None:
|
||||
continue
|
||||
src = c
|
||||
aid = src.get("asset_id", "")
|
||||
dur = float(src.get("duration", 0.0) or 0.0)
|
||||
target_dur = _target_dur(idx, src)
|
||||
start = float(src.get("start_time", 0.0))
|
||||
total = asset_durations.get(aid, 0.0)
|
||||
if aid and dur > 0 and total > 0:
|
||||
eff_dur = min(dur, total)
|
||||
new_start = _pick_start_with_overlap_avoid(
|
||||
asset_id=aid,
|
||||
clip_duration=eff_dur,
|
||||
asset_durations=asset_durations,
|
||||
used_segments=used_segments,
|
||||
asset_scene_points=asset_scene_points,
|
||||
batch_segments=batch_segments,
|
||||
rng=rng,
|
||||
)
|
||||
start = new_start
|
||||
if aid and target_dur > 0 and total > 0:
|
||||
# 固定角色片段也走批次避让(但不换素材)
|
||||
eff_dur = min(target_dur, total)
|
||||
scan = _best_start_for_asset(aid, eff_dur, asset_durations, batch_segments)
|
||||
if scan is not None and scan[1] <= BATCH_CLIP_OVERLAP_LIMIT:
|
||||
start = scan[0]
|
||||
else:
|
||||
cand = _resolve_start_time(aid, eff_dur, asset_durations, used_segments, asset_scene_points)
|
||||
if cand is not None:
|
||||
start = cand
|
||||
elif scan is not None:
|
||||
start = scan[0]
|
||||
interval = (start, start + eff_dur)
|
||||
used_segments.setdefault(aid, []).append(interval)
|
||||
batch_segments.setdefault(aid, []).append(interval)
|
||||
result[idx] = _base_clip_data(src, asset_id=aid, start=start)
|
||||
result[idx] = _base_clip_data(src, asset_id=aid, start=start, duration=target_dur)
|
||||
|
||||
return [c for c in result if c is not None]
|
||||
|
||||
|
||||
def _pick_start_with_overlap_avoid(
|
||||
def _pick_asset_and_start(
|
||||
*,
|
||||
asset_id: str,
|
||||
clip_duration: float,
|
||||
shuffled_pool: list[str],
|
||||
asset_durations: dict[str, float],
|
||||
used_segments: dict[str, list[tuple[float, float]]],
|
||||
asset_scene_points: dict[str, list[float]] | None,
|
||||
used_segments: dict[str, list[tuple[float, float]]],
|
||||
batch_segments: dict[str, list[tuple[float, float]]],
|
||||
rng: random.Random,
|
||||
) -> float:
|
||||
"""选起点:优先单视频同一入口(镜头洗牌/随机/历史避让),再叠加批次 20% 重叠避让。
|
||||
) -> tuple[str, float, float]:
|
||||
"""三轮选片:fresh 优先 → 受控复用(重叠≤20%,短素材禁复用)→ 兜底尽力而为。
|
||||
|
||||
批次内重叠超阈值时在素材可用范围内随机抖动重选,最多 VARIANT_RESELECT_MAX_ATTEMPTS 次;
|
||||
仍超阈值则返回最后一次结果(素材极少时的尽力而为,不阻塞生成)。
|
||||
Returns:
|
||||
(asset_id, start, eff_dur):eff_dur = min(段长, 素材时长),
|
||||
段长超出素材时长的部分由渲染侧末帧冻结铺满。
|
||||
"""
|
||||
total = asset_durations.get(asset_id, 0.0)
|
||||
max_start = max(0.0, total - clip_duration)
|
||||
# ── 第一轮:fresh 素材(本批次未用过)──────────────────────────────────
|
||||
fresh = [a for a in shuffled_pool if a not in batch_segments]
|
||||
rng.shuffle(fresh)
|
||||
for asset_id in fresh:
|
||||
total = asset_durations.get(asset_id, 0.0)
|
||||
if total <= 0:
|
||||
continue
|
||||
eff_dur = min(clip_duration, total)
|
||||
cand = _resolve_start_time(asset_id, eff_dur, asset_durations, used_segments, asset_scene_points)
|
||||
if cand is None:
|
||||
max_start = max(0.0, total - eff_dur)
|
||||
cand = rng.uniform(0.0, max_start) if max_start > 0 else 0.0
|
||||
# fresh 素材批次内无区间,重叠必然为 0,直接采用
|
||||
return asset_id, cand, eff_dur
|
||||
|
||||
candidate = _resolve_start_time(
|
||||
asset_id,
|
||||
clip_duration,
|
||||
asset_durations,
|
||||
used_segments,
|
||||
asset_scene_points,
|
||||
)
|
||||
if candidate is None:
|
||||
candidate = rng.uniform(0.0, max_start) if max_start > 0 else 0.0
|
||||
|
||||
best_start = candidate
|
||||
best_ratio = _clip_overlap_ratio(asset_id, candidate, clip_duration, batch_segments)
|
||||
if best_ratio <= BATCH_CLIP_OVERLAP_LIMIT:
|
||||
return candidate
|
||||
|
||||
# 重叠超阈值:在可用范围内随机重试
|
||||
for _ in range(VARIANT_RESELECT_MAX_ATTEMPTS):
|
||||
alt = rng.uniform(0.0, max_start) if max_start > 0 else 0.0
|
||||
ratio = _clip_overlap_ratio(asset_id, alt, clip_duration, batch_segments)
|
||||
if ratio < best_ratio:
|
||||
best_start, best_ratio = alt, ratio
|
||||
# ── 第二轮:受控复用 —— 扫描起点使重叠 ≤20%;短素材数学无法错开则跳过 ──
|
||||
reused = [a for a in shuffled_pool if a in batch_segments]
|
||||
rng.shuffle(reused)
|
||||
fallback: tuple[str, float, float, float] | None = None # (asset, start, eff, ratio)
|
||||
for asset_id in reused:
|
||||
total = asset_durations.get(asset_id, 0.0)
|
||||
if total <= 0:
|
||||
continue
|
||||
eff_dur = min(clip_duration, total)
|
||||
# 短素材判定:素材时长 < 段长 ×(1−0.20) → 任何起点重叠都 >20%,禁跨变体复用
|
||||
if total < clip_duration * (1.0 - BATCH_CLIP_OVERLAP_LIMIT) - 1e-6:
|
||||
logger.info(
|
||||
"素材 %s 时长 %.2fs 短于段长 %.2fs 的 80%,数学上无法错开,禁止跨变体复用",
|
||||
asset_id,
|
||||
total,
|
||||
clip_duration,
|
||||
)
|
||||
continue
|
||||
scan = _best_start_for_asset(asset_id, eff_dur, asset_durations, batch_segments)
|
||||
if scan is None:
|
||||
continue
|
||||
start, ratio = scan
|
||||
if ratio <= BATCH_CLIP_OVERLAP_LIMIT:
|
||||
return alt
|
||||
logger.info(
|
||||
"变体选片批次重叠避让达上限,采用最优起点: asset=%s overlap_ratio=%.2f",
|
||||
asset_id,
|
||||
best_ratio,
|
||||
)
|
||||
return best_start
|
||||
return asset_id, start, eff_dur
|
||||
if fallback is None or ratio < fallback[3]:
|
||||
fallback = (asset_id, start, eff_dur, ratio)
|
||||
|
||||
# ── 第三轮:兜底尽力而为(池耗尽/全部超阈值)——不报错,取最优 ──────────
|
||||
if fallback is not None:
|
||||
asset_id, start, eff_dur, ratio = fallback
|
||||
logger.info(
|
||||
"变体选片素材池不足,受控复用重叠 %.0f%%(>20%% 阈值,尽力而为不打断): asset=%s",
|
||||
ratio * 100,
|
||||
asset_id,
|
||||
)
|
||||
return asset_id, start, eff_dur
|
||||
|
||||
# 理论不可达(usable_assets 非空);保底取池首
|
||||
asset_id = shuffled_pool[0]
|
||||
total = asset_durations.get(asset_id, 0.0)
|
||||
eff_dur = min(clip_duration, total) if total > 0 else clip_duration
|
||||
return asset_id, 0.0, eff_dur
|
||||
|
||||
|
||||
def _base_clip_data(src: dict, *, asset_id: str, start: float) -> dict:
|
||||
def _base_clip_data(src: dict, *, asset_id: str, start: float, duration: float | None = None) -> dict:
|
||||
"""从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。"""
|
||||
return {
|
||||
"order": src.get("order", 0),
|
||||
"asset_id": asset_id,
|
||||
"start_time": round(float(start), 3),
|
||||
"duration": float(src.get("duration", 0.0) or 0.0),
|
||||
"duration": float(duration if duration is not None else src.get("duration", 0.0) or 0.0),
|
||||
"clip_type": src.get("clip_type", "main"),
|
||||
"playback_speed": float(src.get("playback_speed", 1.0) or 1.0),
|
||||
"transition_effect": src.get("transition_effect", "cut"),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""批量变体配音严格守卫与解析(#1749 问题 A)。
|
||||
|
||||
事故:批量独立配音视频3 错绑视频1 配音——根因是 voice_library_ids
|
||||
缺值/长度不符时静默 fallback 到 voice_library_id(单值),导致多个变体
|
||||
共用同一条配音。
|
||||
|
||||
定稿规则:
|
||||
- 独立配音(voice_library_ids 非空):长度必须 == count,逐项非空,
|
||||
否则 400;**禁止静默 fallback** 到单值 voice_library_id;
|
||||
- 统一配音(voice_library_id 非空、voice_library_ids 空):所有变体共用;
|
||||
- 两者都空:返回 [""] * count(无配音,渲染走原 BGM/原声路径)。
|
||||
|
||||
纯函数,不碰 DB;调用方(路由)负责把 VariantVoiceError 转 400。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class VariantVoiceError(ValueError):
|
||||
"""配音参数错误(路由层捕获后返回 400)。"""
|
||||
|
||||
|
||||
def resolve_variant_voice_ids(
|
||||
*,
|
||||
count: int,
|
||||
voice_library_id: Optional[str] = None,
|
||||
voice_library_ids: Optional[List[str]] = None,
|
||||
) -> List[str]:
|
||||
"""解析每个变体使用的配音 voice_library_id。
|
||||
|
||||
Args:
|
||||
count: 变体数量(必须 >= 1)。
|
||||
voice_library_id: 统一配音 ID(所有变体共用)。
|
||||
voice_library_ids: 独立配音 ID 列表,长度必须 == count。
|
||||
|
||||
Returns:
|
||||
长度 == count 的列表;元素为 "" 表示该变体无配音。
|
||||
|
||||
Raises:
|
||||
VariantVoiceError: count 非法 / 独立配音长度不符 / 存在缺值。
|
||||
"""
|
||||
if not count or count < 1:
|
||||
raise VariantVoiceError("变体数量 count 必须 >= 1")
|
||||
|
||||
single = (voice_library_id or "").strip()
|
||||
# 归一化:None / 空列表 / 全空白 → 未提供独立配音(走统一配音或无配音)
|
||||
raw_multi = [str(v or "").strip() for v in (voice_library_ids or [])]
|
||||
multi = [v for v in raw_multi if v]
|
||||
|
||||
if multi:
|
||||
# 显式传了独立配音列表:
|
||||
# - 恰好 1 个非空且 count > 1:视为统一配音(与 cover_urls/titles 长度1=共用一致);
|
||||
# - 否则按独立配音严格校验:原始长度必须 == count 且逐项非空,禁止静默 fallback;
|
||||
# - 其余长度/缺值 → 400。
|
||||
if len(raw_multi) == 1 and count > 1:
|
||||
return [multi[0]] * count
|
||||
if len(raw_multi) != count:
|
||||
raise VariantVoiceError(
|
||||
f"独立配音数量({len(raw_multi)})与视频数量({count})不一致;"
|
||||
f"批量独立配音必须为每个视频分别指定配音,或改用统一配音 voice_library_id"
|
||||
)
|
||||
missing = [i for i, v in enumerate(raw_multi) if not v]
|
||||
if missing:
|
||||
raise VariantVoiceError(
|
||||
f"第 {[i + 1 for i in missing]} 个视频缺少独立配音 voice_library_id;"
|
||||
f"独立配音不允许缺值,也不会回退为统一配音"
|
||||
)
|
||||
return list(raw_multi)
|
||||
|
||||
if single:
|
||||
return [single] * count
|
||||
|
||||
# 无配音
|
||||
return [""] * count
|
||||
@@ -0,0 +1,120 @@
|
||||
"""配音时长 → 片段时长分配纯函数(#1749)。
|
||||
|
||||
定稿规则(工单 #1749):
|
||||
1. 片段数 = 模板片段数,定死,不因素材增减;
|
||||
2. 成片总时长 = 配音时长:逐段分配段长,含转场重叠扣减
|
||||
(cut 零重叠;xfade 等转场按转场时长重叠),误差 < 0.5s;
|
||||
3. 素材短于段长 → 末帧冻结(tpad)/ 音频补静音(apad)铺满,
|
||||
禁止慢放、禁止截断配音;
|
||||
4. 任何情况下不得因素材时长/数量报错打断用户。
|
||||
|
||||
本模块为纯函数:输入片段骨架(每段转场效果/时长)与配音总时长,
|
||||
输出每段目标时长(target duration)与成片总时长。不碰 DB、不碰素材。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 单段最小时长(秒):低于此值播放器/渲染链路易出问题
|
||||
MIN_CLIP_DURATION = 1.0
|
||||
|
||||
#: 成片总时长与配音时长的可接受误差(秒)
|
||||
TOTAL_DURATION_TOLERANCE = 0.5
|
||||
|
||||
|
||||
def transition_overlap_seconds(transition_effect: Optional[str], transition_duration: float) -> float:
|
||||
"""转场导致的相邻片段重叠时长。
|
||||
|
||||
cut(或空/None)无重叠;其余转场(xfade/fade/slide 等)按转场时长重叠。
|
||||
"""
|
||||
effect = (transition_effect or "cut").strip().lower()
|
||||
if effect in ("cut", "", "none"):
|
||||
return 0.0
|
||||
dur = float(transition_duration or 0.0)
|
||||
return max(0.0, dur)
|
||||
|
||||
|
||||
def plan_clip_durations(
|
||||
clip_count: int,
|
||||
voice_duration: float,
|
||||
transition_effects: Optional[list[Optional[str]]] = None,
|
||||
transition_durations: Optional[list[float]] = None,
|
||||
) -> list[float]:
|
||||
"""把配音总时长分配到 clip_count 段,返回每段目标时长(秒)。
|
||||
|
||||
分配口径:Σ段长 − Σ转场重叠 = 配音时长(成片净时长 = 配音)。
|
||||
转场重叠发生在相邻片段之间,共 clip_count-1 处;第 i 处重叠取
|
||||
**后一段(i+1)** 的转场设置(与 xfade 构建口径一致:转场挂在后段)。
|
||||
|
||||
舍入误差全部由最后一段吸收,保证 total_output_duration(结果) ≈ 配音。
|
||||
|
||||
Args:
|
||||
clip_count: 片段数(模板定死,必须 > 0)。
|
||||
voice_duration: 配音总时长(秒);<=0 返回空列表表示"无配音"。
|
||||
transition_effects: 每段转场效果(长度 clip_count,index 0 的转场无效)。
|
||||
transition_durations: 每段转场时长(长度 clip_count)。
|
||||
|
||||
Returns:
|
||||
每段目标时长列表(长度 clip_count);无配音/非法输入返回 []。
|
||||
"""
|
||||
if not clip_count or clip_count <= 0:
|
||||
return []
|
||||
try:
|
||||
voice = float(voice_duration)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if voice <= 0:
|
||||
return []
|
||||
|
||||
effects = transition_effects or [None] * clip_count
|
||||
durations = transition_durations or [0.0] * clip_count
|
||||
|
||||
# 相邻片段间的转场重叠总和(转场挂在后段,取 i=1..clip_count-1)
|
||||
total_overlap = 0.0
|
||||
for i in range(1, clip_count):
|
||||
effect = effects[i] if i < len(effects) else None
|
||||
tdur = durations[i] if i < len(durations) else 0.0
|
||||
total_overlap += transition_overlap_seconds(effect, tdur)
|
||||
|
||||
# 需要的段长总和 = 配音 + 重叠(重叠部分被算了两次,扣回一次)
|
||||
gross = voice + total_overlap
|
||||
if gross < clip_count * MIN_CLIP_DURATION:
|
||||
# 配音极短:保底每段 MIN_CLIP_DURATION(成片略长于配音,末段可冻结)
|
||||
gross = clip_count * MIN_CLIP_DURATION
|
||||
logger.info(
|
||||
"配音时长 %.2fs 过短,%d 段按最小段长 %.1fs 保底(成片将略长于配音)",
|
||||
voice,
|
||||
clip_count,
|
||||
MIN_CLIP_DURATION,
|
||||
)
|
||||
|
||||
per_clip = gross / clip_count
|
||||
result = [round(per_clip, 3) for _ in range(clip_count)]
|
||||
# 末段吸收舍入误差:直接用 gross - 前段之和
|
||||
result[-1] = round(gross - sum(result[:-1]), 3)
|
||||
if result[-1] < MIN_CLIP_DURATION:
|
||||
# 极端情况下末段被舍入压得过小,摊平
|
||||
result[-1] = MIN_CLIP_DURATION
|
||||
return result
|
||||
|
||||
|
||||
def total_output_duration(
|
||||
clip_durations: list[float],
|
||||
transition_effects: Optional[list[Optional[str]]] = None,
|
||||
transition_durations: Optional[list[float]] = None,
|
||||
) -> float:
|
||||
"""按分配后的段长与转场计算成片净时长 = Σ段长 − Σ转场重叠。"""
|
||||
if not clip_durations:
|
||||
return 0.0
|
||||
effects = transition_effects or [None] * len(clip_durations)
|
||||
durations = transition_durations or [0.0] * len(clip_durations)
|
||||
total = sum(float(d) for d in clip_durations)
|
||||
for i in range(1, len(clip_durations)):
|
||||
effect = effects[i] if i < len(effects) else None
|
||||
tdur = durations[i] if i < len(durations) else 0.0
|
||||
total -= transition_overlap_seconds(effect, tdur)
|
||||
return round(max(0.0, total), 3)
|
||||
@@ -373,6 +373,8 @@ class TestCreateGenerationTask:
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
# #1749:变体0 也 clone 源 plan(不污染源 plan),变体1/2 各自 reselect
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="variant-plan-0")
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
MagicMock(id="variant-plan-1"),
|
||||
MagicMock(id="variant-plan-2"),
|
||||
@@ -398,8 +400,9 @@ class TestCreateGenerationTask:
|
||||
batch_ids = [t["batch_id"] for t in data["items"] if t["batch_id"]]
|
||||
assert len(batch_ids) == 3
|
||||
assert len(set(batch_ids)) == 1
|
||||
# 变体 1/2 各自独立选片
|
||||
# 变体 1/2 各自独立选片;变体 0 clone 源 plan(#1749)
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
assert MockPlanSvc.return_value.clone_plan_for_variant.call_count == 1
|
||||
|
||||
def test_create_task_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
|
||||
@@ -135,8 +135,8 @@ class TestClipEffectiveDuration:
|
||||
assert clip_effective_duration(3.0, 5.0) == 3.0
|
||||
|
||||
def test_duration_greater_than_actual(self):
|
||||
"""duration > actual,取actual."""
|
||||
assert clip_effective_duration(10.0, 5.0) == 5.0
|
||||
"""#1749:duration > actual 仍取目标段长 duration(素材短走末帧冻结,不钳制)."""
|
||||
assert clip_effective_duration(10.0, 5.0) == 10.0
|
||||
|
||||
def test_duration_equal_to_actual(self):
|
||||
assert clip_effective_duration(5.0, 5.0) == 5.0
|
||||
@@ -203,8 +203,8 @@ class TestClipAdjustedDuration:
|
||||
assert clip_adjusted_duration(4.0, 10.0, 0.5) == 8.0
|
||||
|
||||
def test_uses_effective_duration(self):
|
||||
"""duration>actual时取actual,再调速."""
|
||||
assert clip_adjusted_duration(10.0, 4.0, 2.0) == 2.0
|
||||
"""#1749:effective=目标段长 10.0(不再钳 actual),2 倍速 → 10/2 = 5.0."""
|
||||
assert clip_adjusted_duration(10.0, 4.0, 2.0) == 5.0
|
||||
|
||||
def test_default_speed(self):
|
||||
assert clip_adjusted_duration(5.0, 10.0) == 5.0
|
||||
@@ -229,6 +229,9 @@ class FakeClip:
|
||||
duration: float = 0.0
|
||||
actual_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
# #1749:逐处转场口径——cut 零重叠,非 cut 才扣 transition_duration
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -257,6 +260,19 @@ class TestEstimateTotalDuration:
|
||||
assert estimate_total_duration(layers) == 5.0
|
||||
|
||||
def test_with_transition_duration(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[
|
||||
FakeClip(duration=5.0),
|
||||
FakeClip(duration=5.0, transition_effect="fade", transition_duration=0.5),
|
||||
],
|
||||
),
|
||||
]
|
||||
# #1749:总10s - 1个非cut转场 * 0.5s = 9.5s(cut 零重叠)
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 9.5
|
||||
|
||||
def test_cut_clips_have_zero_overlap(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
@@ -266,8 +282,8 @@ class TestEstimateTotalDuration:
|
||||
],
|
||||
),
|
||||
]
|
||||
# 总10s - 1个转场 * 0.5s = 9.5s
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 9.5
|
||||
# #1749:cut 转场零重叠 → 总时长 10s,即使传了全局 transition_duration
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 10.0
|
||||
|
||||
def test_transition_with_many_clips(self):
|
||||
layers = [
|
||||
@@ -276,7 +292,17 @@ class TestEstimateTotalDuration:
|
||||
clips=[FakeClip(duration=3.0) for _ in range(5)],
|
||||
),
|
||||
]
|
||||
# 5个3s = 15s,4个转场 * 0.5s = 2s,总13s
|
||||
# #1749:全 cut 零重叠 → 5个3s = 15s
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 15.0
|
||||
|
||||
def test_non_cut_transitions_with_many_clips(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[FakeClip(duration=3.0, transition_effect="fade", transition_duration=0.5) for _ in range(5)],
|
||||
),
|
||||
]
|
||||
# 5个3s = 15s,4个非cut转场 * 0.5s = 2s,总13s(#1749 逐处重叠)
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 13.0
|
||||
|
||||
def test_prefers_main_over_broll(self):
|
||||
|
||||
@@ -1,75 +1,93 @@
|
||||
"""测试 #1294 修复:预览视频配音注入。
|
||||
"""测试 #1294 修复:预览视频配音注入(#1749 后重写)。
|
||||
|
||||
验证:
|
||||
1. _load_task_info 正确加载 voice_ids
|
||||
3. voice_ids 正确注入到 plan config 中(实际执行代码路径,diff-cover 可达)
|
||||
#1749 起冗余 voice_ids 字段已从 task_info 移除(DB 列保留只读),
|
||||
配音一律以 voice_library_id 为准(批量独立配音每变体各自绑定)。
|
||||
本测试断言:
|
||||
1. _load_task_info 正确加载 voice_library_id;
|
||||
2. task_info 不再包含 voice_ids 键;
|
||||
3. 配音解析 effective_voice_id 直接取 voice_library_id,不再有 [0] 兜底。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
def _mock_task(voice_library_id: str = "voice_lib_1"):
|
||||
mock_task = MagicMock()
|
||||
mock_task.project_id = "proj_1"
|
||||
mock_task.asset_library_id = "lib_1"
|
||||
mock_task.voice_library_id = voice_library_id
|
||||
mock_task.template_id = "tmpl_1"
|
||||
mock_task.strategy_id = "one_take"
|
||||
mock_task.asset_ids = ["a1", "a2"]
|
||||
mock_task.batch_id = "batch_1"
|
||||
mock_task.created_by_user_id = "user_1"
|
||||
mock_task.video_title = "test"
|
||||
mock_task.resolution = "854x480"
|
||||
mock_task.bgm_config = {}
|
||||
mock_task.is_preview = True
|
||||
mock_task.voice_ids = ["voice_1", "voice_2"] # DB 列仍存在但不再读
|
||||
mock_task.source_task_id = ""
|
||||
mock_task.output_width = 854
|
||||
mock_task.output_height = 480
|
||||
mock_task.cover_url = ""
|
||||
mock_task.title_config = {}
|
||||
mock_task.source_edit_plan_id = "plan_1"
|
||||
return mock_task
|
||||
|
||||
|
||||
class TestLoadTaskInfoVoiceIds:
|
||||
"""验证 _load_task_info 包含 voice_ids"""
|
||||
def test_voice_library_id_loaded_from_task():
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = _mock_task("voice_lib_abc")
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
def test_voice_ids_loaded_from_task(self):
|
||||
"""voice_ids 从 gen_task 正确加载"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.project_id = "proj_1"
|
||||
mock_task.asset_library_id = "lib_1"
|
||||
mock_task.voice_library_id = "voice_lib_1"
|
||||
mock_task.template_id = "tmpl_1"
|
||||
mock_task.strategy_id = "one_take"
|
||||
mock_task.asset_ids = ["a1", "a2"]
|
||||
mock_task.batch_id = "batch_1"
|
||||
mock_task.created_by_user_id = "user_1"
|
||||
mock_task.video_title = "test"
|
||||
mock_task.resolution = "854x480"
|
||||
mock_task.bgm_config = {}
|
||||
mock_task.is_preview = True
|
||||
mock_task.voice_ids = ["voice_1", "voice_2"]
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
MockRepo.return_value = mock_repo
|
||||
result = _load_task_info("test_task_id")
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
assert result is not None
|
||||
assert result["voice_library_id"] == "voice_lib_abc"
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
|
||||
assert result is not None
|
||||
assert result["voice_ids"] == ["voice_1", "voice_2"]
|
||||
def test_task_info_has_no_voice_ids_key():
|
||||
"""#1749:冗余 voice_ids 已从 task_info 移除。"""
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = _mock_task()
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
def test_voice_ids_empty_when_none(self):
|
||||
"""voice_ids 为 None 时返回空列表"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.project_id = "proj_1"
|
||||
mock_task.asset_library_id = "lib_1"
|
||||
mock_task.voice_library_id = ""
|
||||
mock_task.template_id = "tmpl_1"
|
||||
mock_task.strategy_id = "one_take"
|
||||
mock_task.asset_ids = ["a1"]
|
||||
mock_task.batch_id = ""
|
||||
mock_task.created_by_user_id = "user_1"
|
||||
mock_task.video_title = ""
|
||||
mock_task.resolution = ""
|
||||
mock_task.bgm_config = {}
|
||||
mock_task.is_preview = False
|
||||
mock_task.voice_ids = None
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
MockRepo.return_value = mock_repo
|
||||
result = _load_task_info("test_task_id")
|
||||
assert "voice_ids" not in result
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
assert result["voice_ids"] == []
|
||||
def test_effective_voice_uses_voice_library_id_only():
|
||||
"""配音解析:voice_library_id 即最终配音 ID,无 voice_ids[0] 兜底。"""
|
||||
# 模拟 _sync_task_config_to_plan 中的解析逻辑
|
||||
task_info = {"voice_library_id": "ANDT_voice"}
|
||||
voice_library_id = task_info.get("voice_library_id", "")
|
||||
effective_voice_id = voice_library_id or ""
|
||||
assert effective_voice_id == "ANDT_voice"
|
||||
|
||||
# 空配音
|
||||
task_info2 = {"voice_library_id": ""}
|
||||
assert (task_info2.get("voice_library_id", "") or "") == ""
|
||||
|
||||
|
||||
def test_empty_voice_library_id():
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = _mock_task("")
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
assert result["voice_library_id"] == ""
|
||||
|
||||
@@ -97,7 +97,7 @@ class TestVariantArrayValidation:
|
||||
"""voice_library_ids 长度非法 → 报错"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError, match="voice_library_ids"):
|
||||
with pytest.raises(ValidationError, match="配音|视频数量"):
|
||||
_make_preview_request(preview_count=4, voice_library_ids=["v1", "v2"])
|
||||
|
||||
def test_preview_empty_arrays_ok(self):
|
||||
@@ -202,17 +202,19 @@ class TestBatchPreviewRoute:
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
reselect_results = [MagicMock(id=pid) for pid in reselect_plan_ids]
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = reselect_results
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_v0")
|
||||
MockPlanSvc.return_value.get_asset_durations.return_value = {}
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
# 变体 1..N-1 各独立选片一次(共 2 次);count>1 不再走 clone
|
||||
# #1749:变体0 clone(不污染源 plan);变体 1..N-1 各独立选片一次(共 2 次)
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.assert_not_called()
|
||||
# 变体0保留源 plan;变体1/2 关联各自独立选出的 plan
|
||||
assert tasks[0].source_edit_plan_id == "source_plan"
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.assert_called_once()
|
||||
# 变体0 关联 clone plan;变体1/2 关联各自独立选出的 plan
|
||||
assert tasks[0].source_edit_plan_id == "clone_v0"
|
||||
assert tasks[1].source_edit_plan_id == "reselect_1"
|
||||
assert tasks[2].source_edit_plan_id == "reselect_2"
|
||||
|
||||
@@ -487,6 +489,8 @@ class TestBatchGenerationVariantConfig:
|
||||
MagicMock(id="reselect_1"),
|
||||
MagicMock(id="reselect_2"),
|
||||
]
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_v0")
|
||||
MockPlanSvc.return_value.get_asset_durations.return_value = {}
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
@@ -498,7 +502,9 @@ class TestBatchGenerationVariantConfig:
|
||||
cover_urls=["http://c1", "http://c2", "http://c3"],
|
||||
)
|
||||
resp = self._call_create_tasks(req)
|
||||
# #1749:变体0 clone;变体1/2 reselect
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.assert_called_once()
|
||||
assert resp.total == 3
|
||||
assert [c.title_config["text"] for c in captured] == ["成片标题1", "成片标题2", "成片标题3"]
|
||||
assert [c.voice_library_id for c in captured] == ["v1", "v2", "v3"]
|
||||
@@ -557,12 +563,14 @@ class TestBatchGenerationVariantConfig:
|
||||
MagicMock(id="reselect_1"),
|
||||
MagicMock(id="reselect_2"),
|
||||
]
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_v0")
|
||||
MockPlanSvc.return_value.get_asset_durations.return_value = {}
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
source_edit_plan_id="source_plan",
|
||||
voice_library_ids=["shared_voice"],
|
||||
voice_library_id="shared_voice",
|
||||
cover_urls=["http://shared"],
|
||||
)
|
||||
self._call_create_tasks(req)
|
||||
@@ -595,10 +603,12 @@ class TestBatchGenerationVariantConfig:
|
||||
MagicMock(id="reselect_1"),
|
||||
MagicMock(id="reselect_2"),
|
||||
]
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_v0")
|
||||
MockPlanSvc.return_value.get_asset_durations.return_value = {}
|
||||
self._call_create_tasks(req, db_latest_plan=latest)
|
||||
|
||||
# 兜底 plan 被用作源;变体0关联兜底 plan,变体1/2关联 reselect plan
|
||||
assert _execute.caps[0].source_edit_plan_id == "fallback_plan_id"
|
||||
# #1749:变体0 关联 clone plan(源为兜底 plan),变体1/2关联 reselect plan
|
||||
assert _execute.caps[0].source_edit_plan_id == "clone_v0"
|
||||
assert _execute.caps[1].source_edit_plan_id == "reselect_1"
|
||||
assert _execute.caps[2].source_edit_plan_id == "reselect_2"
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""#1749 EditPlanService 新增方法单元测试。
|
||||
|
||||
覆盖 get_asset_durations / apply_voice_duration_to_plan / ensure_variant_plans。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_svc():
|
||||
"""构造 EditPlanService,mock 掉 DB/repos。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
db = MagicMock()
|
||||
svc = EditPlanService.__new__(EditPlanService)
|
||||
svc._plan_repo = MagicMock()
|
||||
svc._clip_repo = MagicMock()
|
||||
svc._clip_repo.session = db
|
||||
svc._generation_task_repo = MagicMock()
|
||||
return svc, db
|
||||
|
||||
|
||||
def _make_clip(
|
||||
order=0,
|
||||
asset_id="a1",
|
||||
start_time=0.0,
|
||||
duration=5.0,
|
||||
clip_type="main",
|
||||
transition_effect="cut",
|
||||
transition_duration=0.0,
|
||||
playback_speed=1.0,
|
||||
text_content="",
|
||||
config=None,
|
||||
):
|
||||
c = MagicMock()
|
||||
c.order = order
|
||||
c.asset_id = asset_id
|
||||
c.start_time = start_time
|
||||
c.duration = duration
|
||||
c.clip_type = clip_type
|
||||
c.transition_effect = transition_effect
|
||||
c.transition_duration = transition_duration
|
||||
c.playback_speed = playback_speed
|
||||
c.text_content = text_content
|
||||
c.config = config or {}
|
||||
return c
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# get_asset_durations
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetAssetDurations:
|
||||
def test_empty_ids_returns_empty(self):
|
||||
svc, _db = _make_svc()
|
||||
assert svc.get_asset_durations([]) == {}
|
||||
assert svc.get_asset_durations(None) == {}
|
||||
|
||||
def test_dedup_and_query(self):
|
||||
svc, db = _make_svc()
|
||||
m1 = MagicMock()
|
||||
m1.id = "a1"
|
||||
m1.duration = 10.5
|
||||
m2 = MagicMock()
|
||||
m2.id = "a2"
|
||||
m2.duration = None # -> 0.0
|
||||
|
||||
db.query.return_value.filter.return_value.all.return_value = [m1, m2]
|
||||
|
||||
result = svc.get_asset_durations(["a1", "a2", "a1"])
|
||||
assert result == {"a1": 10.5, "a2": 0.0}
|
||||
|
||||
def test_bad_duration_type_returns_zero(self):
|
||||
svc, db = _make_svc()
|
||||
m = MagicMock()
|
||||
m.id = "a1"
|
||||
m.duration = "not-a-number"
|
||||
db.query.return_value.filter.return_value.all.return_value = [m]
|
||||
result = svc.get_asset_durations(["a1"])
|
||||
assert result == {"a1": 0.0}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# apply_voice_duration_to_plan
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestApplyVoiceDurationToPlan:
|
||||
def test_voice_le_zero_returns_none(self):
|
||||
svc, _ = _make_svc()
|
||||
assert svc.apply_voice_duration_to_plan("p1", 0) is None
|
||||
assert svc.apply_voice_duration_to_plan("p1", -5) is None
|
||||
|
||||
def test_plan_not_found_returns_none(self):
|
||||
svc, _ = _make_svc()
|
||||
svc.get_plan = MagicMock(return_value=None)
|
||||
assert svc.apply_voice_duration_to_plan("p1", 30.0) is None
|
||||
|
||||
def test_no_clips_returns_none(self):
|
||||
svc, _ = _make_svc()
|
||||
plan = MagicMock()
|
||||
svc.get_plan = MagicMock(return_value=plan)
|
||||
svc._clip_repo.list_by_plan.return_value = []
|
||||
assert svc.apply_voice_duration_to_plan("p1", 30.0) is None
|
||||
|
||||
def test_happy_path_two_clips(self):
|
||||
svc, db = _make_svc()
|
||||
plan = MagicMock()
|
||||
plan.total_duration = 0.0
|
||||
svc.get_plan = MagicMock(return_value=plan)
|
||||
|
||||
clips = [
|
||||
_make_clip(order=0, asset_id="a1", start_time=0.0),
|
||||
_make_clip(order=1, asset_id="a2", start_time=0.0),
|
||||
]
|
||||
svc._clip_repo.list_by_plan.return_value = clips
|
||||
|
||||
# get_asset_durations: a1=100s, a2=50s
|
||||
m1 = MagicMock(id="a1", duration=100.0)
|
||||
m2 = MagicMock(id="a2", duration=50.0)
|
||||
db.query.return_value.filter.return_value.all.return_value = [m1, m2]
|
||||
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=2)
|
||||
|
||||
result = svc.apply_voice_duration_to_plan("p1", 30.0)
|
||||
assert result is plan
|
||||
svc.replace_all_clips_transactional.assert_called_once()
|
||||
|
||||
def test_invalid_voice_returns_none(self):
|
||||
svc, _ = _make_svc()
|
||||
assert svc.apply_voice_duration_to_plan("p1", "bad") is None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# ensure_variant_plans
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEnsureVariantPlans:
|
||||
def _setup_svc(self):
|
||||
svc, _ = _make_svc()
|
||||
plan0 = MagicMock()
|
||||
plan0.id = "plan_v0"
|
||||
svc.clone_plan_for_variant = MagicMock(return_value=plan0)
|
||||
|
||||
plan1 = MagicMock()
|
||||
plan1.id = "plan_v1"
|
||||
svc.reselect_plan_for_variant = MagicMock(return_value=plan1)
|
||||
|
||||
svc.apply_voice_duration_to_plan = MagicMock(return_value=plan0)
|
||||
return svc
|
||||
|
||||
def test_count_1_no_voice(self):
|
||||
svc = self._setup_svc()
|
||||
result = svc.ensure_variant_plans("src", 1, ["a1", "a2"])
|
||||
assert result == ["plan_v0"]
|
||||
svc.clone_plan_for_variant.assert_called_once()
|
||||
svc.apply_voice_duration_to_plan.assert_not_called()
|
||||
|
||||
def test_count_1_with_voice(self):
|
||||
svc = self._setup_svc()
|
||||
result = svc.ensure_variant_plans("src", 1, ["a1"], voice_durations=[30.0])
|
||||
assert result == ["plan_v0"]
|
||||
svc.apply_voice_duration_to_plan.assert_called_once_with("plan_v0", 30.0)
|
||||
|
||||
def test_count_3_with_voice(self):
|
||||
svc = self._setup_svc()
|
||||
svc.reselect_plan_for_variant = MagicMock(
|
||||
side_effect=[
|
||||
MagicMock(id="plan_v1"),
|
||||
MagicMock(id="plan_v2"),
|
||||
]
|
||||
)
|
||||
|
||||
result = svc.ensure_variant_plans("src", 3, ["a1", "a2"], voice_durations=[30.0, 20.0, 15.0])
|
||||
assert len(result) == 3
|
||||
assert result[0] == "plan_v0"
|
||||
# v0 配音
|
||||
svc.apply_voice_duration_to_plan.assert_called_once_with("plan_v0", 30.0)
|
||||
# reselect 被调 2 次
|
||||
assert svc.reselect_plan_for_variant.call_count == 2
|
||||
|
||||
def test_voice_apply_failure_not_blocking(self):
|
||||
svc = self._setup_svc()
|
||||
svc.apply_voice_duration_to_plan.side_effect = RuntimeError("boom")
|
||||
result = svc.ensure_variant_plans("src", 1, ["a1"], voice_durations=[30.0])
|
||||
assert result == ["plan_v0"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""#1749 generation_preview 配音分配分支测试。
|
||||
|
||||
直接测试 create_preview_generation_task 中配音分配逻辑:
|
||||
- count=1: apply_voice_duration_to_plan 被调用
|
||||
- count>1: ensure_variant_plans / clone + reselect 路径
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── 辅助 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user():
|
||||
mu = MagicMock()
|
||||
mu.user.id = "u1"
|
||||
return mu
|
||||
|
||||
|
||||
def _make_task(source_plan_id="src_plan_1"):
|
||||
t = MagicMock()
|
||||
t.id = "task_1"
|
||||
t.source_edit_plan_id = source_plan_id
|
||||
t.extra_meta = {}
|
||||
return t
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 直接测 apply_voice_duration_to_plan 的调用入口
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPreviewCount1VoiceAllocation:
|
||||
"""count=1 预览: clone_plan_for_variant + apply_voice_duration_to_plan。"""
|
||||
|
||||
def test_clone_then_apply_voice(self):
|
||||
"""验证 count=1 且有配音时, clone + apply 被调用。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
db = MagicMock()
|
||||
svc = EditPlanService.__new__(EditPlanService)
|
||||
svc._clip_repo = MagicMock()
|
||||
svc._clip_repo.session = db
|
||||
svc._plan_repo = MagicMock()
|
||||
svc._generation_task_repo = MagicMock()
|
||||
|
||||
variant_plan = MagicMock()
|
||||
variant_plan.id = "v_plan_0"
|
||||
svc.clone_plan_for_variant = MagicMock(return_value=variant_plan)
|
||||
svc.apply_voice_duration_to_plan = MagicMock(return_value=variant_plan)
|
||||
|
||||
# 模拟 count=1 预览配音分配逻辑
|
||||
source_plan_id = "src_plan_1"
|
||||
voice_durations = [30.0]
|
||||
|
||||
if source_plan_id:
|
||||
vp = svc.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
created_by_user_id="u1",
|
||||
name_suffix="预览变体",
|
||||
)
|
||||
if voice_durations and voice_durations[0] > 0:
|
||||
svc.apply_voice_duration_to_plan(vp.id, voice_durations[0])
|
||||
|
||||
svc.clone_plan_for_variant.assert_called_once_with(
|
||||
source_plan_id,
|
||||
created_by_user_id="u1",
|
||||
name_suffix="预览变体",
|
||||
)
|
||||
svc.apply_voice_duration_to_plan.assert_called_once_with("v_plan_0", 30.0)
|
||||
|
||||
def test_clone_without_voice_when_duration_zero(self):
|
||||
"""voice_durations[0]=0 时不调用 apply。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
svc = EditPlanService.__new__(EditPlanService)
|
||||
svc._clip_repo = MagicMock()
|
||||
svc._plan_repo = MagicMock()
|
||||
svc._generation_task_repo = MagicMock()
|
||||
|
||||
vp = MagicMock(id="v_plan_0")
|
||||
svc.clone_plan_for_variant = MagicMock(return_value=vp)
|
||||
svc.apply_voice_duration_to_plan = MagicMock()
|
||||
|
||||
voice_durations = [0.0]
|
||||
variant_plan = svc.clone_plan_for_variant("src", created_by_user_id="u1", name_suffix="预览变体")
|
||||
if voice_durations and voice_durations[0] > 0:
|
||||
svc.apply_voice_duration_to_plan(variant_plan.id, voice_durations[0])
|
||||
|
||||
svc.apply_voice_duration_to_plan.assert_not_called()
|
||||
|
||||
|
||||
class TestPreviewCountGt1VoiceAllocation:
|
||||
"""count>1 预览: ensure_variant_plans 路径。"""
|
||||
|
||||
def test_ensure_variant_plans_called(self):
|
||||
"""验证 count>1 时 ensure_variant_plans 被正确调用。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
svc = EditPlanService.__new__(EditPlanService)
|
||||
svc._clip_repo = MagicMock()
|
||||
svc._plan_repo = MagicMock()
|
||||
svc._generation_task_repo = MagicMock()
|
||||
|
||||
svc.ensure_variant_plans = MagicMock(return_value=["p0", "p1", "p2"])
|
||||
|
||||
result = svc.ensure_variant_plans(
|
||||
"src_plan",
|
||||
3,
|
||||
["a1", "a2"],
|
||||
created_by_user_id="u1",
|
||||
voice_durations=[30.0, 20.0, 15.0],
|
||||
)
|
||||
|
||||
assert result == ["p0", "p1", "p2"]
|
||||
svc.ensure_variant_plans.assert_called_once_with(
|
||||
"src_plan",
|
||||
3,
|
||||
["a1", "a2"],
|
||||
created_by_user_id="u1",
|
||||
voice_durations=[30.0, 20.0, 15.0],
|
||||
)
|
||||
|
||||
def test_ensure_variant_plans_no_voice(self):
|
||||
"""无配音时 voice_durations 全 0 仍可调用。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
svc = EditPlanService.__new__(EditPlanService)
|
||||
svc._clip_repo = MagicMock()
|
||||
svc._plan_repo = MagicMock()
|
||||
svc._generation_task_repo = MagicMock()
|
||||
|
||||
svc.ensure_variant_plans = MagicMock(return_value=["p0"])
|
||||
|
||||
result = svc.ensure_variant_plans(
|
||||
"src_plan",
|
||||
1,
|
||||
["a1"],
|
||||
created_by_user_id="u1",
|
||||
voice_durations=None,
|
||||
)
|
||||
assert result == ["p0"]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""#1749 POST /api/v1/generation/variant-plans 路由单元测试。
|
||||
|
||||
直接调用路由函数 + mock 依赖(不使用 TestClient,因 httpx2 冲突)。
|
||||
|
||||
覆盖:
|
||||
- 200: 合法请求返回 items
|
||||
- 400: voice_library_ids 长度不符 (schema validator)
|
||||
- 400: 缺少 source_edit_plan_id 且无模板兜底 plan
|
||||
- 400: 选片失败 ValueError -> HTTPException(400)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _call_route(request_obj, db=None):
|
||||
"""直接调用 create_variant_plans 路由函数。"""
|
||||
from app.api.routes.generation_variant_plans import create_variant_plans
|
||||
|
||||
user = MagicMock()
|
||||
user.user.id = "u1"
|
||||
if db is None:
|
||||
db = MagicMock()
|
||||
return create_variant_plans(request_obj, authenticated_user=user, db=db)
|
||||
|
||||
|
||||
def _base_payload(**kw):
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
d = {
|
||||
"template_id": "tpl_1",
|
||||
"asset_ids": ["a1", "a2"],
|
||||
"count": 1,
|
||||
"source_edit_plan_id": "src_plan_1",
|
||||
}
|
||||
d.update(kw)
|
||||
return VariantPlanRequest(**d)
|
||||
|
||||
|
||||
class TestVariantPlansRoute:
|
||||
def test_200_happy_path(self):
|
||||
"""合法请求:ensure_variant_plans 返回 plan_id,list_clips 返回 clips。"""
|
||||
req = _base_payload()
|
||||
db = MagicMock()
|
||||
|
||||
mock_svc_inst = MagicMock()
|
||||
mock_svc_inst.ensure_variant_plans.return_value = ["plan_v0"]
|
||||
clip = MagicMock()
|
||||
clip.id = "c1"
|
||||
clip.order = 0
|
||||
clip.asset_id = "a1"
|
||||
clip.start_time = 0.0
|
||||
clip.duration = 10.0
|
||||
clip.clip_type = "main"
|
||||
clip.transition_effect = "cut"
|
||||
clip.transition_duration = 0.0
|
||||
clip.playback_speed = 1.0
|
||||
clip.text_content = ""
|
||||
mock_svc_inst.list_clips.return_value = [clip]
|
||||
|
||||
mock_svc_cls = MagicMock(return_value=mock_svc_inst)
|
||||
|
||||
with patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=["voice_1"],
|
||||
), patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[30.0],
|
||||
), patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
mock_svc_cls,
|
||||
):
|
||||
resp = _call_route(req, db)
|
||||
|
||||
assert resp.total == 1
|
||||
assert resp.items[0].plan_id == "plan_v0"
|
||||
assert len(resp.items[0].clips) == 1
|
||||
|
||||
def test_400_voice_library_ids_length_mismatch(self):
|
||||
"""voice_library_ids 长度 != count -> pydantic model_validator 抛异常。"""
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
VariantPlanRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
source_edit_plan_id="src_plan_1",
|
||||
voice_library_ids=["v1", "v2"], # len=2 != count=3
|
||||
)
|
||||
|
||||
def test_400_no_source_plan_no_template_fallback(self):
|
||||
"""无 source_edit_plan_id 且 DB 查不到模板 plan -> HTTPException(400)。"""
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
|
||||
req = VariantPlanRequest(
|
||||
template_id="tpl_nonexist",
|
||||
asset_ids=[],
|
||||
count=1,
|
||||
source_edit_plan_id="",
|
||||
)
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
with patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=[""],
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_call_route(req, db)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_400_selection_failure_value_error(self):
|
||||
"""ensure_variant_plans 抛 ValueError -> HTTPException(400)。"""
|
||||
req = _base_payload()
|
||||
db = MagicMock()
|
||||
|
||||
mock_svc_inst = MagicMock()
|
||||
mock_svc_inst.ensure_variant_plans.side_effect = ValueError("素材池为空")
|
||||
mock_svc_cls = MagicMock(return_value=mock_svc_inst)
|
||||
|
||||
with patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=["voice_1"],
|
||||
), patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[30.0],
|
||||
), patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
mock_svc_cls,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_call_route(req, db)
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -656,7 +656,7 @@ class TestWechatSyncUseCase:
|
||||
|
||||
assert error is None
|
||||
assert response.is_new_user is True
|
||||
assert response.nickname == "微信用户"
|
||||
assert response.nickname == "小虾同学"
|
||||
|
||||
def test_username_uniqueness_suffix(self):
|
||||
"""When username already exists, a numeric suffix is added."""
|
||||
|
||||
@@ -356,7 +356,8 @@ class TestConfirmGenerationErrors:
|
||||
assert item["voice_library_id"] == "voice-001"
|
||||
assert item["template_id"] == "tmpl-001"
|
||||
assert item["title_ids"] == ["title-1", "title-2"]
|
||||
assert item["voice_ids"] == ["voice-a"]
|
||||
# #1749:voice_ids 已废弃(配音走 voice_library_id),确认生成不再保留原值
|
||||
assert item["voice_ids"] == []
|
||||
|
||||
def test_confirm_cover_and_title(
|
||||
self,
|
||||
|
||||
@@ -10,8 +10,11 @@ from apps.worker.video_processing.unified_render_service import ResolvedClip, Un
|
||||
class TestClipEffectiveDurationWithSpeedCompensation:
|
||||
"""Test _clip_effective_duration handles speed < 1 correctly."""
|
||||
|
||||
def test_normal_speed_returns_min(self):
|
||||
"""When speed=1.0, effective_duration = min(duration, actual_duration)."""
|
||||
def test_normal_speed_returns_target_duration(self):
|
||||
"""#1749:speed=1.0 时 effective_duration 始终为目标段长 duration。
|
||||
|
||||
素材短于段长不再慢放补偿,差值由末帧冻结 tpad / 音频 apad 铺满。
|
||||
"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
@@ -23,11 +26,10 @@ class TestClipEffectiveDurationWithSpeedCompensation:
|
||||
actual_duration=3.0, # shorter than configured
|
||||
playback_speed=1.0,
|
||||
)
|
||||
# Without speed compensation, effective = min(4, 3) = 3
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 3.0
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
|
||||
|
||||
def test_compensated_speed_returns_configured_duration(self):
|
||||
"""When speed < 1 (compensated), effective_duration = configured duration."""
|
||||
def test_explicit_speed_does_not_change_effective_duration(self):
|
||||
"""#1749:effective_duration 只认目标段长,与 playback_speed 无关(慢放补偿已删除)。"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
@@ -37,9 +39,8 @@ class TestClipEffectiveDurationWithSpeedCompensation:
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=3.0, # shorter than configured
|
||||
playback_speed=0.75, # compensated: 3/4 = 0.75
|
||||
playback_speed=0.75,
|
||||
)
|
||||
# With speed compensation, effective = configured duration = 4.0
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
|
||||
|
||||
def test_zero_actual_duration_returns_configured(self):
|
||||
@@ -76,10 +77,10 @@ class TestClipEffectiveDurationWithSpeedCompensation:
|
||||
class TestClipAdjustedDurationWithSpeedCompensation:
|
||||
"""Test _clip_adjusted_duration accounts for compensated speed."""
|
||||
|
||||
def test_adjusted_duration_with_compensation(self):
|
||||
"""Adjusted duration = min(duration, actual) / speed.
|
||||
With compensation: min(4,3)/0.75 = 3/0.75 = 4.0
|
||||
This equals the configured duration, which is the goal.
|
||||
def test_adjusted_duration_with_explicit_speed(self):
|
||||
"""#1749:adjusted duration = 目标段长 / speed(用户显式调速仍生效;短素材不再自动慢放)。
|
||||
|
||||
4.0 / 0.75 ≈ 5.333。
|
||||
"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
@@ -90,8 +91,8 @@ class TestClipAdjustedDurationWithSpeedCompensation:
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=3.0,
|
||||
playback_speed=0.75, # compensated
|
||||
playback_speed=0.75,
|
||||
)
|
||||
adjusted = UnifiedRenderService._clip_adjusted_duration(clip)
|
||||
# min(4,3)/0.75 = 3/0.75 = 4.0 (matches configured duration)
|
||||
assert abs(adjusted - 4.0) < 0.01
|
||||
# 4.0 / 0.75 = 5.333...
|
||||
assert abs(adjusted - (4.0 / 0.75)) < 0.01
|
||||
|
||||
@@ -57,19 +57,29 @@ def test_templates_editor_no_cover_router():
|
||||
assert "generate-cover" not in getattr(route, "path", ""), "templates_editor 不应再有 generate-cover 路由"
|
||||
|
||||
|
||||
def _collect_route_paths(route, acc):
|
||||
"""递归收集路由路径(兼容新版 fastapi 的 _IncludedRouter 延迟包装)。"""
|
||||
path = getattr(route, "path", None)
|
||||
if isinstance(path, str):
|
||||
acc.append(path)
|
||||
# 新版 fastapi:_IncludedRouter.original_router;旧版:嵌套 .routes
|
||||
inner = getattr(route, "original_router", None)
|
||||
if inner is not None and hasattr(inner, "routes"):
|
||||
for sub in inner.routes:
|
||||
_collect_route_paths(sub, acc)
|
||||
sub_routes = getattr(route, "routes", None)
|
||||
if sub_routes:
|
||||
for sub in sub_routes:
|
||||
_collect_route_paths(sub, acc)
|
||||
|
||||
|
||||
def test_api_router_has_generation_cover():
|
||||
"""api_router 应该包含 /api/v1/generation/generate-cover 路径"""
|
||||
from app.api.router import api_router
|
||||
|
||||
all_paths = []
|
||||
all_paths: list[str] = []
|
||||
for route in api_router.routes:
|
||||
if hasattr(route, "path"):
|
||||
all_paths.append(route.path)
|
||||
# 嵌套 router
|
||||
if hasattr(route, "routes"):
|
||||
for sub_route in route.routes:
|
||||
if hasattr(sub_route, "path"):
|
||||
all_paths.append(sub_route.path)
|
||||
_collect_route_paths(route, all_paths)
|
||||
|
||||
# 应该能找到 generate-cover 路径
|
||||
cover_paths = [p for p in all_paths if "generate-cover" in p]
|
||||
|
||||
@@ -159,7 +159,6 @@ class TestCreatePreviewGenerationTaskRequest:
|
||||
assert req.template_id == "tmpl_123"
|
||||
assert req.asset_ids == ["asset_1", "asset_2"]
|
||||
assert req.title_ids == []
|
||||
assert req.voice_ids == []
|
||||
assert req.video_title == ""
|
||||
assert req.duration == 0.0
|
||||
assert req.bgm_config == {}
|
||||
@@ -175,15 +174,16 @@ class TestCreatePreviewGenerationTaskRequest:
|
||||
assert req.template_id == "tmpl_123"
|
||||
assert req.title_ids == ["title_1"]
|
||||
|
||||
def test_valid_request_with_voice_ids_only(self):
|
||||
"""有效请求:template_id + voice_ids"""
|
||||
def test_valid_request_with_voice_library_id_and_asset_ids(self):
|
||||
"""有效请求:template_id + asset_ids + 统一配音 voice_library_id(#1749 替换旧 voice_ids 字段)"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
voice_ids=["voice_1"],
|
||||
asset_ids=["asset_1"],
|
||||
voice_library_id="voice_1",
|
||||
)
|
||||
assert req.voice_ids == ["voice_1"]
|
||||
assert req.voice_library_id == "voice_1"
|
||||
|
||||
def test_missing_template_id_raises(self):
|
||||
"""缺少 template_id 报错"""
|
||||
|
||||
@@ -77,13 +77,13 @@ class TestCreateGenerationTaskUseCase:
|
||||
project_id="proj_001",
|
||||
asset_ids=["asset_1", "asset_2", "asset_3"],
|
||||
title_ids=["title_1", "title_2"],
|
||||
voice_ids=["voice_1"],
|
||||
)
|
||||
result = use_case.execute(command)
|
||||
|
||||
assert len(result.asset_ids) == 3
|
||||
assert len(result.title_ids) == 2
|
||||
assert len(result.voice_ids) == 1
|
||||
# #1749:voice_ids 已从 Command 移除(配音走 voice_library_id(s)),落库固定空列表
|
||||
assert list(getattr(result, "voice_ids", []) or []) == []
|
||||
|
||||
def test_create_task_with_auto_retry(self, mock_repo):
|
||||
"""创建带自动重试配置的任务"""
|
||||
|
||||
@@ -234,12 +234,18 @@ class TestEffectiveDurationTrim:
|
||||
assert "trim=duration=7.5" in fc
|
||||
|
||||
def test_trim_uses_min_of_duration_and_actual(self):
|
||||
"""clip.duration > actual_duration → trim 到 actual_duration。"""
|
||||
"""#1749:clip.duration > actual_duration → 目标段长为准 + tpad 末帧冻结(禁慢放/禁截断)。"""
|
||||
clip = self._make_clip("c1", duration=10.0, actual_duration=2.0)
|
||||
# 模拟 _resolve_clips 写入的冻结标记(10s 目标 − 2s 素材 = 8s 冻结)
|
||||
clip.config = {"_freeze_seconds": 8.0}
|
||||
svc = self._make_service([clip])
|
||||
layers = svc._group_clips_into_layers([clip])
|
||||
fc, _ = svc._build_filter_complex(layers)
|
||||
# 素材内只取 2s,末帧冻结 8s 铺满目标 10s
|
||||
assert "trim=duration=2.0" in fc
|
||||
assert "tpad=stop_mode=clone:stop_duration=8.0" in fc
|
||||
# 不再有慢放(setpts 调速系数应为 1.0,即无 setpts=PTS/ 出现)
|
||||
assert "setpts=PTS/" not in fc
|
||||
|
||||
def test_no_trim_when_both_zero(self):
|
||||
"""duration=0 且 actual_duration=0 → 不做 trim。"""
|
||||
|
||||
@@ -62,9 +62,9 @@ class TestClipEffectiveDuration:
|
||||
assert clip_effective_duration(clip) == pytest.approx(10.0)
|
||||
|
||||
def test_duration_greater_than_actual(self):
|
||||
"""duration > actual → 返回actual(不能超过素材时长)."""
|
||||
"""#1749:duration > actual → 返回目标段长 duration(素材短于段长走末帧冻结/补静音,不慢放不截配音)."""
|
||||
clip = _make_clip(duration=50.0, actual_duration=30.0)
|
||||
assert clip_effective_duration(clip) == pytest.approx(30.0)
|
||||
assert clip_effective_duration(clip) == pytest.approx(50.0)
|
||||
|
||||
def test_duration_equals_actual(self):
|
||||
"""duration == actual → 返回该值."""
|
||||
|
||||
@@ -24,9 +24,9 @@ class TestClipEffectiveDuration:
|
||||
assert clip_effective_duration(clip) == 5.0
|
||||
|
||||
def test_duration_specified_and_actual_shorter(self):
|
||||
"""指定了 duration,但实际时长更短 → 取实际时长"""
|
||||
"""#1749:指定了 duration 但实际时长更短 → 仍取目标段长 duration(素材短的部分由末帧冻结 tpad/音频 apad 铺满)"""
|
||||
clip = SimpleNamespace(duration=10.0, actual_duration=5.0)
|
||||
assert clip_effective_duration(clip) == 5.0
|
||||
assert clip_effective_duration(clip) == 10.0
|
||||
|
||||
def test_duration_specified_actual_zero(self):
|
||||
"""指定了 duration,但实际时长为 0 → 取 duration"""
|
||||
|
||||
@@ -28,6 +28,9 @@ class FakeClip:
|
||||
duration: float = 0.0
|
||||
actual_duration: float = 0.0
|
||||
playback_speed: Any = 1.0
|
||||
# #1749:逐处转场口径——cut 零重叠,非 cut 才扣 transition_duration
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -134,7 +137,8 @@ class TestClipEffectiveDuration:
|
||||
assert clip_effective_duration(5.0) == 5.0
|
||||
|
||||
def test_explicit_duration_with_shorter_actual(self):
|
||||
assert clip_effective_duration(5.0, 3.0) == 3.0
|
||||
"""#1749:duration>actual 仍返回目标段长(短素材末帧冻结铺满,不钳制)。"""
|
||||
assert clip_effective_duration(5.0, 3.0) == 5.0
|
||||
|
||||
def test_explicit_duration_with_longer_actual(self):
|
||||
assert clip_effective_duration(5.0, 10.0) == 5.0
|
||||
@@ -254,7 +258,21 @@ class TestEstimateTotalDuration:
|
||||
],
|
||||
)
|
||||
]
|
||||
# 3 + 2 + 5 - 2 * 0.5 = 9.0
|
||||
# #1749:全 cut 零重叠 → 3 + 2 + 5 = 10.0
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == pytest.approx(10.0)
|
||||
|
||||
def test_non_cut_clips_deduct_transition_per_clip(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[
|
||||
FakeClip(duration=3.0, transition_effect="fade", transition_duration=0.5),
|
||||
FakeClip(duration=2.0, transition_effect="fade", transition_duration=0.5),
|
||||
FakeClip(duration=5.0, transition_effect="fade", transition_duration=0.5),
|
||||
],
|
||||
),
|
||||
]
|
||||
# #1749:逐处非cut转场重叠 → 3 + 2 + 5 - 2 * 0.5 = 9.0
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == pytest.approx(9.0)
|
||||
|
||||
def test_prefers_main_over_broll(self):
|
||||
|
||||
@@ -424,6 +424,8 @@ def _make_test_data():
|
||||
|
||||
class TestSmartMatchEndpoint:
|
||||
def test_returns_scored_items(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
project, library, assets = _make_test_data()
|
||||
app = _make_app(
|
||||
_StubAssetRepo(assets),
|
||||
@@ -431,7 +433,8 @@ class TestSmartMatchEndpoint:
|
||||
_StubProjectRepo({"proj-1": project}),
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
with patch("packages.domain.smart_match.random.Random", return_value=_ZERO_NOISE):
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, f"Got {resp.status_code}: {resp.text}"
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
|
||||
@@ -84,11 +84,19 @@ class TestScoreNoiseInjection:
|
||||
|
||||
def test_score_field_is_raw_without_noise(self):
|
||||
"""r.score 始终是无噪声原始分(噪声只影响排序,不污染返回分值)。"""
|
||||
from datetime import datetime as _dt
|
||||
from unittest.mock import patch
|
||||
|
||||
assets = _make_tied_assets(5)
|
||||
raw_scores = {score_asset(a, now=NOW)[0] for a in assets}
|
||||
results = smart_select_assets(assets, rng=random.Random(42))
|
||||
# 冻结 datetime.now,与 score_asset 用的 NOW 一致(recency 时变分数需稳定)
|
||||
with patch("packages.domain.smart_match.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = NOW
|
||||
# 保留 datetime 构造函数行为(如 datetime(...) 调用)
|
||||
mock_dt.side_effect = _dt
|
||||
results = smart_select_assets(assets, rng=random.Random(42))
|
||||
for r in results:
|
||||
assert r.score in raw_scores
|
||||
assert r.score in raw_scores, f"score={r.score} 不在 raw_scores={raw_scores}"
|
||||
|
||||
def test_rng_deterministic_same_seed(self):
|
||||
"""同一种子多次调用结果完全一致(可复现,测试可依赖)。"""
|
||||
|
||||
@@ -235,9 +235,15 @@ class TestBatchOverlapAvoidance:
|
||||
|
||||
main = [c for c in clips if c["clip_type"] == "main"]
|
||||
assert len(main) == 1
|
||||
ratio = _clip_overlap_ratio("a1", main[0]["start_time"], 10.0, {"a1": [(0.0, 100.0)]})
|
||||
start = main[0]["start_time"]
|
||||
ratio = _clip_overlap_ratio("a1", start, 10.0, {"a1": [(0.0, 100.0)]})
|
||||
assert ratio <= BATCH_CLIP_OVERLAP_LIMIT, f"避让后重叠应 ≤20%,实际 {ratio:.2%}"
|
||||
assert main[0]["start_time"] == pytest.approx(105.0, abs=0.01)
|
||||
# #1749:选片改 0.25s 窗口扫描找最优错开起点(不再依赖 rng.uniform 单点),
|
||||
# 落点只需满足语义:与已用区间 [0,100) 不完全重叠且重叠 ≤20%
|
||||
assert start > 0.0
|
||||
# start+10 区间与 [0,100) 的重叠段(若有)≤ 2s
|
||||
overlap = max(0.0, min(start + 10.0, 100.0) - max(start, 0.0))
|
||||
assert overlap <= 2.0 + 1e-6, f"重叠时长 {overlap}s 应 ≤2s(10s 段的 20%)"
|
||||
|
||||
def test_first_variant_segments_become_avoidance_target(self):
|
||||
"""变体 0 选定区间后,变体 1 选同素材时批次区间生效(不与源区间完全重合)。"""
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""#1749 问题 C:跨视频素材级去重 + target_durations 测试。"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.variant_plan_selector import (
|
||||
BATCH_CLIP_OVERLAP_LIMIT,
|
||||
_clip_overlap_ratio,
|
||||
reselect_clips_for_variant,
|
||||
)
|
||||
|
||||
|
||||
def _src_clips(n=3, dur=5.0):
|
||||
return [
|
||||
{
|
||||
"order": i,
|
||||
"asset_id": f"src{i}",
|
||||
"start_time": 0.0,
|
||||
"duration": dur,
|
||||
"clip_type": "main",
|
||||
"transition_effect": "cut",
|
||||
"transition_duration": 0.0,
|
||||
}
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def test_three_variants_three_clips_eleven_assets_no_cross_reuse():
|
||||
"""3 变体 × 3 片段 / 11 素材:fresh 优先 → 跨变体零重复。"""
|
||||
pool = [f"a{i}" for i in range(11)]
|
||||
durs = {a: 30.0 for a in pool}
|
||||
batch: dict = {}
|
||||
used_per_variant = []
|
||||
for v in range(3):
|
||||
clips = reselect_clips_for_variant(
|
||||
_src_clips(3), pool, asset_durations=durs, batch_segments=batch, rng=random.Random(100 + v)
|
||||
)
|
||||
assert len(clips) == 3
|
||||
used_per_variant.append({c["asset_id"] for c in clips})
|
||||
# 两两交集为空(9 个素材位置,11 素材足够 fresh 分配)
|
||||
assert used_per_variant[0] & used_per_variant[1] == set()
|
||||
assert used_per_variant[0] & used_per_variant[2] == set()
|
||||
assert used_per_variant[1] & used_per_variant[2] == set()
|
||||
|
||||
|
||||
def test_reuse_overlap_under_limit_and_no_full_overlap():
|
||||
"""素材池不足被迫复用时:重叠 ≤20% 且不得完全重叠。"""
|
||||
# 2 个长素材、3 变体 × 3 片段 → 必然复用
|
||||
pool = ["x", "y"]
|
||||
durs = {"x": 60.0, "y": 60.0}
|
||||
batch: dict = {}
|
||||
for v in range(3):
|
||||
reselect_clips_for_variant(
|
||||
_src_clips(3, 5.0), pool, asset_durations=durs, batch_segments=batch, rng=random.Random(7 + v)
|
||||
)
|
||||
# 校验 batch_segments 中同素材任意两区间重叠占比
|
||||
for asset, segs in batch.items():
|
||||
for i in range(len(segs)):
|
||||
for j in range(i + 1, len(segs)):
|
||||
s1, e1 = segs[i]
|
||||
s2, e2 = segs[j]
|
||||
ov = max(0.0, min(e1, e2) - max(s1, s2))
|
||||
seg_dur = min(e1 - s1, e2 - s2)
|
||||
ratio = ov / seg_dur if seg_dur > 0 else 0.0
|
||||
assert ratio <= BATCH_CLIP_OVERLAP_LIMIT + 0.01, f"{asset} overlap {ratio}"
|
||||
# 不得完全重叠
|
||||
assert not (abs(s1 - s2) < 0.01 and abs(e1 - e2) < 0.01), f"{asset} 完全重叠"
|
||||
|
||||
|
||||
def test_short_asset_cannot_be_reused_across_variants():
|
||||
"""短素材(时长 < 段长 80%)数学上无法错开 → 禁跨变体复用。"""
|
||||
pool = ["short", "long1", "long2"]
|
||||
durs = {"short": 6.2, "long1": 40.0, "long2": 40.0}
|
||||
batch = {"short": [(0.0, 6.2)]} # 短素材已被变体0使用
|
||||
clips = reselect_clips_for_variant(
|
||||
_src_clips(1, 10.0), pool, asset_durations=durs, batch_segments=batch, rng=random.Random(1)
|
||||
)
|
||||
assert clips[0]["asset_id"] != "short"
|
||||
|
||||
|
||||
def test_target_durations_applied_to_clips():
|
||||
"""target_durations 落库到片段 duration。"""
|
||||
pool = ["a", "b", "c"]
|
||||
durs = {"a": 30.0, "b": 30.0, "c": 30.0}
|
||||
clips = reselect_clips_for_variant(
|
||||
_src_clips(3, 5.0),
|
||||
pool,
|
||||
asset_durations=durs,
|
||||
batch_segments={},
|
||||
target_durations=[7.333, 7.333, 7.334],
|
||||
rng=random.Random(3),
|
||||
)
|
||||
durs_out = sorted(c["duration"] for c in clips)
|
||||
assert durs_out == pytest.approx([7.333, 7.333, 7.334], abs=0.01)
|
||||
|
||||
|
||||
def test_short_asset_freeze_start_zero():
|
||||
"""素材短于目标段长:起点为 0(末帧冻结由渲染侧铺满),不报错。"""
|
||||
pool = ["short6s"]
|
||||
durs = {"short6s": 6.0}
|
||||
clips = reselect_clips_for_variant(
|
||||
_src_clips(1, 10.0),
|
||||
pool,
|
||||
asset_durations=durs,
|
||||
batch_segments={},
|
||||
target_durations=[10.0],
|
||||
rng=random.Random(5),
|
||||
)
|
||||
assert clips[0]["asset_id"] == "short6s"
|
||||
assert clips[0]["start_time"] == 0.0
|
||||
assert clips[0]["duration"] == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_empty_pool_raises():
|
||||
with pytest.raises(ValueError):
|
||||
reselect_clips_for_variant(_src_clips(2), [], asset_durations={}, rng=random.Random(1))
|
||||
|
||||
|
||||
def test_empty_source_raises():
|
||||
with pytest.raises(ValueError):
|
||||
reselect_clips_for_variant([], ["a"], asset_durations={"a": 10.0}, rng=random.Random(1))
|
||||
|
||||
|
||||
def test_zero_duration_assets_raises():
|
||||
with pytest.raises(ValueError):
|
||||
reselect_clips_for_variant(
|
||||
_src_clips(2), ["a", "b"], asset_durations={"a": 0.0, "b": 0.0}, rng=random.Random(1)
|
||||
)
|
||||
|
||||
|
||||
def test_overlap_ratio_helper():
|
||||
batch = {"a": [(0.0, 5.0)]}
|
||||
assert _clip_overlap_ratio("a", 0.0, 5.0, batch) == pytest.approx(1.0)
|
||||
assert _clip_overlap_ratio("a", 5.0, 5.0, batch) == pytest.approx(0.0)
|
||||
assert _clip_overlap_ratio("a", 4.0, 5.0, batch) == pytest.approx(0.2)
|
||||
assert _clip_overlap_ratio("b", 0.0, 5.0, batch) == 0.0
|
||||
@@ -0,0 +1,51 @@
|
||||
"""#1749 问题 A:变体配音严格守卫测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
|
||||
def test_unified_voice_repeated():
|
||||
assert resolve_variant_voice_ids(count=3, voice_library_id="V1") == ["V1", "V1", "V1"]
|
||||
|
||||
|
||||
def test_independent_voices():
|
||||
assert resolve_variant_voice_ids(count=3, voice_library_ids=["a", "b", "c"]) == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_no_voice_returns_empty_strings():
|
||||
assert resolve_variant_voice_ids(count=2) == ["", ""]
|
||||
|
||||
|
||||
def test_independent_length_mismatch_raises():
|
||||
with pytest.raises(VariantVoiceError):
|
||||
resolve_variant_voice_ids(count=3, voice_library_ids=["a", "b"])
|
||||
|
||||
|
||||
def test_independent_missing_value_raises():
|
||||
with pytest.raises(VariantVoiceError):
|
||||
resolve_variant_voice_ids(count=3, voice_library_ids=["a", "", "c"])
|
||||
|
||||
|
||||
def test_independent_whitespace_missing_raises():
|
||||
with pytest.raises(VariantVoiceError):
|
||||
resolve_variant_voice_ids(count=2, voice_library_ids=["a", " "])
|
||||
|
||||
|
||||
def test_count_zero_raises():
|
||||
with pytest.raises(VariantVoiceError):
|
||||
resolve_variant_voice_ids(count=0)
|
||||
|
||||
|
||||
def test_explicit_multi_does_not_fallback_to_single():
|
||||
"""传了 voice_library_ids 但长度错 → 400,禁止静默 fallback 到单值。"""
|
||||
with pytest.raises(VariantVoiceError):
|
||||
resolve_variant_voice_ids(count=3, voice_library_id="SINGLE", voice_library_ids=["x", "y"])
|
||||
|
||||
|
||||
def test_single_voice_with_empty_multi_list():
|
||||
assert resolve_variant_voice_ids(count=2, voice_library_id="S", voice_library_ids=[]) == ["S", "S"]
|
||||
|
||||
|
||||
def test_count_one_independent():
|
||||
assert resolve_variant_voice_ids(count=1, voice_library_ids=["solo"]) == ["solo"]
|
||||
@@ -1,6 +1,12 @@
|
||||
"""Tests for voice duration alignment feature.
|
||||
"""#1749 配音对齐行为测试(重写)。
|
||||
|
||||
Tests the _align_clips_to_voice_duration method in UnifiedRenderService.
|
||||
旧行为(已删除):渲染期全局等比裁剪片段 / 全局慢放补偿配音时长。
|
||||
新行为(定稿):
|
||||
- 段长由 voice_duration_planner 在选片阶段精确分配,成片总时长=配音;
|
||||
- 渲染期 _align_clips_to_voice_duration 仅保留 ±5% 守卫日志,**不修改任何 clip**
|
||||
(不裁剪、不慢放);
|
||||
- 素材短于段长 → freeze(config['_freeze_seconds'])+ 目标段长始终为准,
|
||||
由 tpad/apad 铺满。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -8,198 +14,119 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import RenderLayer, ResolvedClip, UnifiedRenderService
|
||||
|
||||
|
||||
class TestAlignClipsToVoiceDuration:
|
||||
"""Test clip duration alignment to voice audio."""
|
||||
|
||||
def _make_clip(
|
||||
self,
|
||||
clip_id: str,
|
||||
duration: float,
|
||||
actual_duration: float = 0.0,
|
||||
playback_speed: float = 1.0,
|
||||
) -> ResolvedClip:
|
||||
"""Helper to create a ResolvedClip for testing."""
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=f"asset_{clip_id}",
|
||||
local_path=Path(f"/tmp/{clip_id}.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=duration,
|
||||
actual_duration=actual_duration or duration,
|
||||
playback_speed=playback_speed,
|
||||
)
|
||||
|
||||
def _make_layer(self, role: str, clips: list[ResolvedClip]) -> RenderLayer:
|
||||
"""Helper to create a RenderLayer for testing."""
|
||||
return RenderLayer(role=role, clips=clips, z_index=0)
|
||||
|
||||
def _make_service(self, voiceover_path: str | None = None) -> UnifiedRenderService:
|
||||
"""Helper to create a mock UnifiedRenderService."""
|
||||
plan = MagicMock()
|
||||
plan.id = "test_plan"
|
||||
plan.config = {}
|
||||
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.plan = plan
|
||||
service.voiceover_audio_path = voiceover_path
|
||||
service.transition_duration = 0.0
|
||||
return service
|
||||
|
||||
def test_no_voice_audio_no_adjustment(self):
|
||||
"""No voice audio → no adjustment."""
|
||||
service = self._make_service(voiceover_path=None)
|
||||
clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=0.0)
|
||||
|
||||
# No change
|
||||
assert clips[0].duration == 10.0
|
||||
assert clips[1].duration == 10.0
|
||||
|
||||
def test_ratio_within_5_percent_no_adjustment(self):
|
||||
"""Ratio within ±5% → no adjustment."""
|
||||
service = self._make_service()
|
||||
clips = [self._make_clip("c1", 10.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
# Total clips = 10s, voice = 10.3s → ratio = 1.03 (within 5%)
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=10.3)
|
||||
|
||||
assert clips[0].duration == 10.0 # Unchanged
|
||||
|
||||
def test_ratio_less_than_1_trim_clips(self):
|
||||
"""Ratio < 1 (clips too long) → trim clips proportionally."""
|
||||
service = self._make_service()
|
||||
clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
# Total clips = 20s, voice = 15s → ratio = 0.75
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=15.0)
|
||||
|
||||
# Each clip should be trimmed to 75%
|
||||
assert abs(clips[0].duration - 7.5) < 0.01
|
||||
assert abs(clips[1].duration - 7.5) < 0.01
|
||||
|
||||
def test_ratio_greater_than_1_slowdown_clips(self):
|
||||
"""Ratio > 1 (clips too short) → slow down clips."""
|
||||
service = self._make_service()
|
||||
clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
# Total clips = 20s, voice = 25s → ratio = 1.25
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=25.0)
|
||||
|
||||
# Each clip's speed should be reduced: 1.0 / 1.25 = 0.8
|
||||
assert abs(clips[0].playback_speed - 0.8) < 0.01
|
||||
assert abs(clips[1].playback_speed - 0.8) < 0.01
|
||||
|
||||
def test_speed_lower_bound_025(self):
|
||||
"""Playback speed should not go below 0.25x."""
|
||||
service = self._make_service()
|
||||
clips = [self._make_clip("c1", 5.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
# Total clips = 5s, voice = 50s → ratio = 10.0
|
||||
# Speed would be 1.0 / 10 = 0.1, but should be clamped to 0.25
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=50.0)
|
||||
|
||||
assert clips[0].playback_speed == 0.25
|
||||
|
||||
def test_only_video_layers_adjusted(self):
|
||||
"""Only main/broll/background layers are adjusted, not audio."""
|
||||
service = self._make_service()
|
||||
|
||||
video_clips = [self._make_clip("v1", 10.0)]
|
||||
audio_clips = [self._make_clip("a1", 10.0)]
|
||||
|
||||
layers = [
|
||||
self._make_layer("main", video_clips),
|
||||
self._make_layer("audio", audio_clips),
|
||||
]
|
||||
|
||||
# ratio = 0.5 → should trim video but not audio
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=5.0)
|
||||
|
||||
assert abs(video_clips[0].duration - 5.0) < 0.01 # Trimmed
|
||||
assert audio_clips[0].duration == 10.0 # Unchanged
|
||||
|
||||
def test_multiple_video_layers_all_adjusted(self):
|
||||
"""All video layers (main, broll, background) are adjusted."""
|
||||
service = self._make_service()
|
||||
|
||||
main_clips = [self._make_clip("m1", 10.0)]
|
||||
broll_clips = [self._make_clip("b1", 10.0)]
|
||||
bg_clips = [self._make_clip("bg1", 10.0)]
|
||||
|
||||
layers = [
|
||||
self._make_layer("main", main_clips),
|
||||
self._make_layer("broll", broll_clips),
|
||||
self._make_layer("background", bg_clips),
|
||||
]
|
||||
|
||||
# Total video = 30s, voice = 15s → ratio = 0.5
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=15.0)
|
||||
|
||||
# All should be trimmed to 50%
|
||||
assert abs(main_clips[0].duration - 5.0) < 0.01
|
||||
assert abs(broll_clips[0].duration - 5.0) < 0.01
|
||||
assert abs(bg_clips[0].duration - 5.0) < 0.01
|
||||
|
||||
def test_trim_config_also_adjusted(self):
|
||||
"""When clip has trim_config, it should also be adjusted."""
|
||||
from video_processing.trim_engine import TrimConfig
|
||||
|
||||
service = self._make_service()
|
||||
|
||||
clip = self._make_clip("c1", 10.0)
|
||||
clip.trim_config = TrimConfig(start_time=0.0, duration=10.0)
|
||||
|
||||
layers = [self._make_layer("main", [clip])]
|
||||
|
||||
# ratio = 0.5
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=5.0)
|
||||
|
||||
assert abs(clip.duration - 5.0) < 0.01
|
||||
assert clip.trim_config is not None
|
||||
assert abs(clip.trim_config.duration - 5.0) < 0.01
|
||||
def _make_clip(
|
||||
clip_id: str,
|
||||
duration: float,
|
||||
actual_duration: float = 0.0,
|
||||
playback_speed: float = 1.0,
|
||||
config: dict | None = None,
|
||||
transition_effect: str = "cut",
|
||||
) -> ResolvedClip:
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=f"asset_{clip_id}",
|
||||
local_path=Path(f"/tmp/{clip_id}.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=duration,
|
||||
actual_duration=actual_duration or duration,
|
||||
playback_speed=playback_speed,
|
||||
config=config or {},
|
||||
transition_effect=transition_effect,
|
||||
transition_duration=0.0,
|
||||
)
|
||||
|
||||
|
||||
class TestGetVoiceAudioDuration:
|
||||
"""Test voice audio duration probing."""
|
||||
def _make_layer(role: str, clips: list[ResolvedClip]) -> RenderLayer:
|
||||
return RenderLayer(role=role, clips=clips, z_index=0)
|
||||
|
||||
def test_no_voiceover_path_returns_zero(self):
|
||||
"""No voiceover path → return 0."""
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.voiceover_audio_path = None
|
||||
|
||||
assert service._get_voice_audio_duration() == 0.0
|
||||
def _make_service() -> UnifiedRenderService:
|
||||
plan = MagicMock()
|
||||
plan.id = "test_plan"
|
||||
plan.config = {}
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.plan = plan
|
||||
service.voiceover_audio_path = None
|
||||
service.transition_duration = 0.0
|
||||
return service
|
||||
|
||||
def test_nonexistent_file_returns_zero(self):
|
||||
"""Nonexistent file → return 0."""
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.voiceover_audio_path = "/nonexistent/path.mp3"
|
||||
|
||||
assert service._get_voice_audio_duration() == 0.0
|
||||
def test_align_does_not_trim_clips_when_clips_longer():
|
||||
"""片段合计比配音长:旧逻辑裁剪,新逻辑不动。"""
|
||||
service = _make_service()
|
||||
clips = [_make_clip("c1", 10.0), _make_clip("c2", 10.0)]
|
||||
layers = [_make_layer("main", clips)]
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=10.0)
|
||||
assert clips[0].duration == 10.0
|
||||
assert clips[1].duration == 10.0
|
||||
|
||||
@patch("video_processing.unified_render_service.probe_duration")
|
||||
@patch("video_processing.unified_render_service.Path.exists", return_value=True)
|
||||
@patch("video_processing.unified_render_service.Path.stat")
|
||||
def test_probes_duration_from_file(self, mock_stat, mock_exists, mock_probe):
|
||||
"""Valid file → probe duration."""
|
||||
mock_stat.return_value.st_size = 1000 # Non-empty file
|
||||
mock_probe.return_value = 42.5
|
||||
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.voiceover_audio_path = "/tmp/voice.mp3"
|
||||
def test_align_does_not_slow_down_when_clips_shorter():
|
||||
"""片段合计比配音短:旧逻辑慢放,新逻辑不动。"""
|
||||
service = _make_service()
|
||||
clips = [_make_clip("c1", 5.0, actual_duration=5.0), _make_clip("c2", 5.0, actual_duration=5.0)]
|
||||
layers = [_make_layer("main", clips)]
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=20.0)
|
||||
assert clips[0].playback_speed == 1.0
|
||||
assert clips[1].playback_speed == 1.0
|
||||
assert clips[0].duration == 5.0
|
||||
|
||||
assert service._get_voice_audio_duration() == 42.5
|
||||
|
||||
def test_align_zero_voice_noop():
|
||||
service = _make_service()
|
||||
clips = [_make_clip("c1", 10.0)]
|
||||
layers = [_make_layer("main", clips)]
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=0.0)
|
||||
assert clips[0].duration == 10.0
|
||||
|
||||
|
||||
def test_effective_duration_uses_target_duration_not_actual():
|
||||
"""素材短于目标段长:effective_duration = 目标段长(冻结铺满),不被 actual 钳制。"""
|
||||
clip = _make_clip("c1", duration=10.0, actual_duration=6.2)
|
||||
eff = UnifiedRenderService._clip_effective_duration(clip)
|
||||
assert eff == 10.0
|
||||
|
||||
|
||||
def test_freeze_marked_in_config_respects_target_duration():
|
||||
"""freeze 秒数 = 目标段长 − 素材内可用时长;config 标记供 tpad/apad 读取。"""
|
||||
# 模拟 _resolve_clips 单段路径的 freeze 计算
|
||||
target = 10.0
|
||||
actual = 6.2
|
||||
start = 0.0
|
||||
avail = max(0.0, actual - start)
|
||||
freeze = round(target - avail, 3) if avail < target - 0.05 else 0.0
|
||||
assert freeze == 3.8
|
||||
clip = _make_clip("c1", duration=target, actual_duration=actual, config={"_freeze_seconds": freeze})
|
||||
assert clip.config["_freeze_seconds"] == 3.8
|
||||
|
||||
|
||||
def test_estimate_total_duration_matches_planner_cut():
|
||||
"""cut 零重叠:总时长 = Σ段长。"""
|
||||
service = _make_service()
|
||||
clips = [
|
||||
_make_clip("c1", 7.333, actual_duration=30.0, transition_effect="cut"),
|
||||
_make_clip("c2", 7.333, actual_duration=30.0, transition_effect="cut"),
|
||||
_make_clip("c3", 7.334, actual_duration=30.0, transition_effect="cut"),
|
||||
]
|
||||
clips[0].order, clips[1].order, clips[2].order = 0, 1, 2
|
||||
total = service._estimate_total_duration([_make_layer("main", clips)])
|
||||
assert abs(total - 22.0) < 0.1
|
||||
|
||||
|
||||
def test_estimate_total_duration_deducts_xfade_overlap():
|
||||
"""xfade 转场:逐处扣减重叠(与 planner 同口径)。"""
|
||||
service = _make_service()
|
||||
clips = [
|
||||
_make_clip("c1", 4.333, actual_duration=30.0, transition_effect="cut"),
|
||||
_make_clip("c2", 4.333, actual_duration=30.0, transition_effect="xfade"),
|
||||
_make_clip("c3", 4.334, actual_duration=30.0, transition_effect="xfade"),
|
||||
]
|
||||
clips[0].transition_duration, clips[1].transition_duration, clips[2].transition_duration = 0.0, 0.5, 0.5
|
||||
clips[0].order, clips[1].order, clips[2].order = 0, 1, 2
|
||||
total = service._estimate_total_duration([_make_layer("main", clips)])
|
||||
assert abs(total - 12.0) < 0.1
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""#1749 配音时长分配纯函数测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.voice_duration_planner import (
|
||||
MIN_CLIP_DURATION,
|
||||
plan_clip_durations,
|
||||
total_output_duration,
|
||||
transition_overlap_seconds,
|
||||
)
|
||||
|
||||
|
||||
def test_cut_22s_3clips_even_split():
|
||||
d = plan_clip_durations(3, 22.0)
|
||||
assert len(d) == 3
|
||||
assert d == pytest.approx([7.333, 7.333, 7.334], abs=0.01)
|
||||
assert total_output_duration(d) == pytest.approx(22.0, abs=0.05)
|
||||
|
||||
|
||||
def test_xfade_12s_3clips_two_transitions():
|
||||
effects = ["cut", "xfade", "xfade"]
|
||||
tdurs = [0.0, 0.5, 0.5]
|
||||
d = plan_clip_durations(3, 12.0, effects, tdurs)
|
||||
# Σ段长 − 2×0.5 = 12 → Σ段长 = 13
|
||||
assert sum(d) == pytest.approx(13.0, abs=0.01)
|
||||
assert total_output_duration(d, effects, tdurs) == pytest.approx(12.0, abs=0.05)
|
||||
|
||||
|
||||
def test_cut_zero_overlap():
|
||||
assert transition_overlap_seconds("cut", 0.5) == 0.0
|
||||
assert transition_overlap_seconds(None, 0.5) == 0.0
|
||||
assert transition_overlap_seconds("none", 0.5) == 0.0
|
||||
assert transition_overlap_seconds("xfade", 0.5) == 0.5
|
||||
assert transition_overlap_seconds("fade", 0.0) == 0.0
|
||||
|
||||
|
||||
def test_zero_voice_returns_empty():
|
||||
assert plan_clip_durations(3, 0.0) == []
|
||||
assert plan_clip_durations(3, -1.0) == []
|
||||
assert plan_clip_durations(0, 10.0) == []
|
||||
|
||||
|
||||
def test_invalid_voice_returns_empty():
|
||||
assert plan_clip_durations(3, None) == [] # type: ignore[arg-type]
|
||||
assert plan_clip_durations(3, "abc") == [] # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_short_voice_floors_min_clip():
|
||||
d = plan_clip_durations(5, 2.0)
|
||||
assert len(d) == 5
|
||||
assert all(x >= MIN_CLIP_DURATION - 0.001 for x in d)
|
||||
|
||||
|
||||
def test_last_segment_absorbs_rounding():
|
||||
d = plan_clip_durations(3, 10.0)
|
||||
assert total_output_duration(d) == pytest.approx(10.0, abs=0.05)
|
||||
d2 = plan_clip_durations(7, 23.7)
|
||||
assert total_output_duration(d2) == pytest.approx(23.7, abs=0.05)
|
||||
|
||||
|
||||
def test_all_effects_cut_total_equals_voice():
|
||||
effects = ["cut"] * 4
|
||||
d = plan_clip_durations(4, 30.0, effects, [0.0] * 4)
|
||||
assert total_output_duration(d, effects, [0.0] * 4) == pytest.approx(30.0, abs=0.05)
|
||||
|
||||
|
||||
def test_mixed_transitions():
|
||||
effects = ["cut", "cut", "xfade", "slide"]
|
||||
tdurs = [0.0, 0.0, 0.4, 0.6]
|
||||
d = plan_clip_durations(4, 20.0, effects, tdurs)
|
||||
# 重叠 0.4 + 0.6 = 1.0 → Σ段长 = 21
|
||||
assert sum(d) == pytest.approx(21.0, abs=0.01)
|
||||
assert total_output_duration(d, effects, tdurs) == pytest.approx(20.0, abs=0.05)
|
||||
|
||||
|
||||
def test_single_clip_no_transition():
|
||||
d = plan_clip_durations(1, 8.0)
|
||||
assert len(d) == 1
|
||||
assert d[0] == pytest.approx(8.0, abs=0.05)
|
||||
|
||||
|
||||
def test_total_output_duration_empty():
|
||||
assert total_output_duration([]) == 0.0
|
||||
Reference in New Issue
Block a user