fix(#1749): 批量三连修:配音严格守卫/配音时长分配+末帧冻结/素材级去重 + variant-plans 接口 #1752

Merged
auto-approve-bot merged 20 commits from fix/batch-voice-duration-dedup-1749 into develop 2026-09-07 09:22:34 +08:00
38 changed files with 2266 additions and 636 deletions
+6
View File
@@ -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",
+44 -4
View File
@@ -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:
+177 -57
View File
@@ -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)。
# - 变体 0clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
# - #1749:前端可回传 variant-plans 接口预生成的 plan_idvariant_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]
# #1749count>1 时每个变体(含变体0)都关联各自独立 planclone/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 长度/缺值 → 400variant_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))
+44 -10
View File
@@ -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
+206 -1
View File
@@ -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 批量正式生成/预览共用)。
- 变体 0clone 源 plan(不污染源 plan,片段独立可改),并按配音分配段长;
- 变体 1..N-1reselect_plan_for_variant 完整重跑选片(素材级去重);
- voice_durations:每个变体的配音时长(独立配音各自时长;统一配音同值);
缺省/为 0 时不分配(段长保持骨架/模板值)。
Returns:
plan_id 列表,长度 == countindex 即 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]:
+55 -13
View File
@@ -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)相关方法 ──────────────────────────────────────────────────
+8 -3
View File
@@ -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 "")
# #1749voice_ids 冗余字段已移除;配音一律以 voice_library_id 为准(独立配音每变体各自绑定)
effective_voice_id = voice_library_id or ""
if effective_voice_id:
import tempfile
+1 -2
View File
@@ -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=[], # #1749voice_ids 已废弃(冗余 voice_library_id),DB 列保留只读
status="pending", # type: ignore[arg-type]
progress=0.0,
result_count=0,
+20 -12
View File
@@ -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_durationactual=0 时返回 0
- duration > 0: 直接返回 duration(配音分配的目标段长;素材短于段长的部分
由渲染侧末帧冻结 tpad / 音频 apad 铺满,不在此处钳制);
- duration <= 0: actual_durationactual=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 判断辅助 ──────────────────────────────────────────────
+158 -92
View File
@@ -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_asset0.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_datadict 列表,供 EditPlanService.replace_all_clips_transactional
落库),不碰 DB 事务边界;素材时长/场景点/已用区间由调用方注入,便于单测。
本模块只产出 clips_datadict 列表),不碰 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"),
+76
View File
@@ -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
+120
View File
@@ -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)
+4 -1
View File
@@ -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。"""
+33 -7
View File
@@ -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
"""#1749duration > 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
"""#1749effective=目标段长 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.5scut 零重叠)
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
# #1749cut 转场零重叠 → 总时长 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 = 15s4个转场 * 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 = 15s4个非cut转场 * 0.5s = 2s,总13s#1749 逐处重叠)
assert estimate_total_duration(layers, transition_duration=0.5) == 13.0
def test_prefers_main_over_broll(self):
+77 -59
View File
@@ -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"] == ""
+18 -8
View File
@@ -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():
"""构造 EditPlanServicemock 掉 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"]
+137
View File
@@ -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_idlist_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
+1 -1
View File
@@ -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."""
+2 -1
View File
@@ -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"]
# #1749voice_ids 已废弃(配音走 voice_library_id),确认生成不再保留原值
assert item["voice_ids"] == []
def test_confirm_cover_and_title(
self,
+16 -15
View File
@@ -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):
"""#1749speed=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):
"""#1749effective_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):
"""#1749adjusted 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
+18 -8
View File
@@ -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]
+5 -5
View File
@@ -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 报错"""
+2 -2
View File
@@ -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
# #1749voice_ids 已从 Command 移除(配音走 voice_library_id(s)),落库固定空列表
assert list(getattr(result, "voice_ids", []) or []) == []
def test_create_task_with_auto_retry(self, mock_repo):
"""创建带自动重试配置的任务"""
+7 -1
View File
@@ -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"""
"""#1749clip.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。"""
+2 -2
View File
@@ -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(不能超过素材时长."""
"""#1749duration > 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 → 返回该值."""
+2 -2
View File
@@ -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"""
+20 -2
View File
@@ -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
"""#1749duration>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):
+4 -1
View File
@@ -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
+10 -2
View File
@@ -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 应 ≤2s10s 段的 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"]
+112 -185
View File
@@ -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**
不裁剪不慢放
- 素材短于段长 freezeconfig['_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