Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aea012fb74 | |||
| 320aa89751 | |||
| c894163446 |
@@ -11,7 +11,6 @@ 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
|
||||
@@ -100,11 +99,6 @@ api_router.include_router(
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_variant_plans_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_cover_router,
|
||||
prefix="/generation",
|
||||
|
||||
@@ -390,6 +390,7 @@ 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="",
|
||||
@@ -419,118 +420,42 @@ def create_preview_generation_task(
|
||||
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
|
||||
|
||||
# ── 独立变体 plan(#1743)──
|
||||
# count=1:克隆源 plan(预览不污染源 plan,仅起点重算),行为与旧版一致;
|
||||
# count>1:变体 0 保留源 plan,变体 1..N-1 用 reselect_plan_for_variant 完整
|
||||
# 重跑单视频选片(素材洗牌+镜头洗牌+起点随机+跨变体避让+批次 20% 重叠重选),
|
||||
# 所见即所得——预览变体差异即正式成片差异。
|
||||
# ── 克隆独立变体 plan:N 个预览全部克隆(预览不污染源 plan)──
|
||||
# 源 plan 不存在(无编辑历史)时各任务走自身随机选片流程,不克隆。
|
||||
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:
|
||||
# 单预览:克隆一份(原逻辑)+ 配音时长分配
|
||||
if source_plan_id:
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
variant_plan = _plan_svc.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
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)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览计划创建失败")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from e
|
||||
elif source_plan_id and count > 1:
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
# #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):
|
||||
for variant_index in range(count):
|
||||
last_err: Exception | None = None
|
||||
variant_plan = None
|
||||
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
|
||||
try:
|
||||
variant_plan = _plan_svc.reselect_plan_for_variant(
|
||||
variant_plan = _plan_svc.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
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
|
||||
),
|
||||
name_suffix=f"预览变体{variant_index + 1}" if count > 1 else "预览变体",
|
||||
)
|
||||
break
|
||||
except ValueError as ve:
|
||||
logger.warning("[预览生成] 变体独立选片失败(素材不足): %s", ve)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体选片失败")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"批量预览第 {variant_index + 1} 个视频无法独立选片:{ve}。"
|
||||
"请增加素材库中的视频素材后重试。",
|
||||
) from ve
|
||||
except Exception as reselection_err: # noqa: PERF203
|
||||
last_err = reselection_err
|
||||
except Exception as clone_err: # noqa: PERF203
|
||||
last_err = clone_err
|
||||
logger.warning(
|
||||
"[预览生成] 变体独立选片失败(尝试%d/2): variant=%d error=%s",
|
||||
"[预览生成] 克隆变体 plan 失败(尝试%d/2): variant=%d error=%s",
|
||||
_attempt + 1,
|
||||
variant_index,
|
||||
reselection_err,
|
||||
clone_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if variant_plan is None:
|
||||
logger.error(
|
||||
"[预览生成] 变体独立选片重试仍失败: variant=%d source=%s",
|
||||
"[预览生成] 克隆预览变体 plan 重试仍失败: variant=%d source=%s",
|
||||
variant_index,
|
||||
source_plan_id,
|
||||
exc_info=last_err,
|
||||
)
|
||||
# 标记已创建任务失败
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
|
||||
raise HTTPException(
|
||||
@@ -541,7 +466,7 @@ def create_preview_generation_task(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[预览生成] 变体 plan 生成异常: %s", e, exc_info=True)
|
||||
logger.error("[预览生成] 克隆变体 plan 异常: %s", e, exc_info=True)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -57,30 +57,6 @@ 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,
|
||||
@@ -147,7 +123,6 @@ def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
rng=None,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
@@ -155,8 +130,6 @@ def _select_assets_from_library(
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||||
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
|
||||
测试可注入固定种子或零噪声随机源获得确定性结果。
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
@@ -169,9 +142,8 @@ def _select_assets_from_library(
|
||||
if mode == "smart":
|
||||
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
|
||||
limit = count if count > 0 else None
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video")
|
||||
return [r.asset.id for r in results]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
@@ -460,199 +432,55 @@ def create_generation_task(
|
||||
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
|
||||
effective_strategy_id = "one_take"
|
||||
|
||||
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。
|
||||
# - 变体 0:clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
|
||||
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
|
||||
# - #1749:前端可回传 variant-plans 接口预生成的 plan_id(variant_plan_ids),直接复用;
|
||||
# 回传 plan 仍按各变体配音幂等重分配段长(防 variant-plans 阶段未带配音/占位时长);
|
||||
# - 配音时长:独立配音各自时长、统一配音同值,逐变体 apply_voice_duration_to_plan,
|
||||
# 成片总时长=配音时长(素材短→末帧冻结,禁慢放/禁截配音);
|
||||
# - count>1 但没有源 plan 时,不允许 N 个任务兜底共用同一 plan,直接 4xx 中断。
|
||||
# 在创建任何任务【之前】预生成/校验全部变体 plan:失败直接中断(此时无脏数据)。
|
||||
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
|
||||
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
|
||||
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
|
||||
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
|
||||
variant_plan_ids: list[str] = []
|
||||
if count > 1:
|
||||
if count > 1 and request.source_edit_plan_id:
|
||||
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:
|
||||
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,
|
||||
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.clone_plan_for_variant(
|
||||
request.source_edit_plan_id,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix=f"批量{task_index + 1}",
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
break
|
||||
except Exception as clone_err: # noqa: PERF203
|
||||
last_err = clone_err
|
||||
logger.warning(
|
||||
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
|
||||
_attempt + 1,
|
||||
request.source_edit_plan_id,
|
||||
clone_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if variant is None:
|
||||
logger.error(
|
||||
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
|
||||
request.source_edit_plan_id,
|
||||
exc_info=last_err,
|
||||
)
|
||||
if _latest:
|
||||
batch_source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
|
||||
|
||||
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(
|
||||
status_code=400,
|
||||
detail="批量生成需要先完成预览生成(缺少剪辑计划)。请先生成预览后再批量创建。",
|
||||
)
|
||||
|
||||
# 批次素材池:请求显式素材 + 库自动匹配素材(resolved_asset_ids)
|
||||
batch_asset_pool = list(dict.fromkeys(resolved_asset_ids or []))
|
||||
|
||||
if request.variant_plan_ids:
|
||||
# ① 前端回传 variant-plans 预生成结果:直接复用(轻量选片接口已建好 plan)
|
||||
if len(request.variant_plan_ids) != count:
|
||||
raise HTTPException(
|
||||
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 配音分配兜底异常(不阻断)")
|
||||
status_code=500,
|
||||
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from last_err
|
||||
variant_plan_ids.append(variant.id)
|
||||
|
||||
try:
|
||||
for task_index in range(count):
|
||||
# #1749:count>1 时每个变体(含变体0)都关联各自独立 plan(clone/reselect/variant-plans)。
|
||||
if count > 1 and variant_plan_ids:
|
||||
effective_plan_id = variant_plan_ids[task_index]
|
||||
else:
|
||||
effective_plan_id = request.source_edit_plan_id
|
||||
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
|
||||
# 无源 plan(source_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
|
||||
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
|
||||
effective_plan_id = request.source_edit_plan_id
|
||||
if task_index > 0 and variant_plan_ids:
|
||||
effective_plan_id = variant_plan_ids[task_index - 1]
|
||||
|
||||
# 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[]
|
||||
# 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段
|
||||
@@ -672,6 +500,7 @@ 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,
|
||||
@@ -694,19 +523,7 @@ def create_generation_task(
|
||||
try:
|
||||
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
|
||||
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
|
||||
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)。
|
||||
# #1743:批量(count>1)场景严禁兜底共用——变体 plan 已在上方预生成,
|
||||
# 走到这里还缺 plan 说明预生成漏配,直接报错中断,不允许 N 任务关联同一 plan。
|
||||
if not task.source_edit_plan_id and count > 1:
|
||||
logger.error(
|
||||
"[生成任务] 批量任务缺少独立 plan(禁止共用兜底): task_index=%d task_id=%s",
|
||||
task_index,
|
||||
task.id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建批量任务失败:变体剪辑计划缺失,请重新预览后再批量生成。",
|
||||
)
|
||||
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
|
||||
if not task.source_edit_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
@@ -873,6 +690,7 @@ 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,
|
||||
@@ -1016,6 +834,7 @@ 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", ""),
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"""轻量选片接口 POST /generation/variant-plans(#1749)。
|
||||
|
||||
与正式生成共用同一套选片函数(EditPlanService.ensure_variant_plans →
|
||||
clone_plan_for_variant / reselect_plan_for_variant → variant_plan_selector),
|
||||
但**不建任务、不入队、不渲染**:
|
||||
|
||||
- 仅为 N 个变体创建/选好 EditPlan + clips,返回 plan_id 与片段列表;
|
||||
- 前端确认后调正式生成接口回传 variant_plan_ids,直接复用这些 plan,
|
||||
不再重复选片(回传后仍按各变体配音幂等重分配段长);
|
||||
- 配音守卫:voice_library_ids 长度/缺值 → 400(variant_voice_resolver),
|
||||
禁静默 fallback;
|
||||
- 素材不足等选片失败 → 400(与正式生成同口径);除此之外不报错打断。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VariantPlanRequest(BaseModel):
|
||||
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
|
||||
|
||||
template_id: str = Field(default="", description="模板 ID(无 source_edit_plan_id 时用于查找骨架 plan)")
|
||||
asset_ids: list[str] = Field(default_factory=list, description="批次素材池")
|
||||
count: int = Field(default=1, ge=1, le=50, description="变体数量")
|
||||
source_edit_plan_id: str = Field(default="", description="源剪辑计划 ID(优先)")
|
||||
# 配音(可选;传独立配音时严格守卫)
|
||||
voice_library_id: str = Field(default="", description="统一配音 ID")
|
||||
voice_library_ids: list[str] = Field(default_factory=list, description="独立配音 ID 列表(长度须=count)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate(self) -> "VariantPlanRequest":
|
||||
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
|
||||
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
|
||||
try:
|
||||
resolve_variant_voice_ids(
|
||||
count=self.count,
|
||||
voice_library_id=self.voice_library_id,
|
||||
voice_library_ids=self.voice_library_ids or None,
|
||||
)
|
||||
except VariantVoiceError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
return self
|
||||
|
||||
|
||||
class VariantPlanItem(BaseModel):
|
||||
variant_index: int
|
||||
plan_id: str
|
||||
clips: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VariantPlanResponse(BaseModel):
|
||||
items: list[VariantPlanItem]
|
||||
total: int
|
||||
|
||||
|
||||
@router.post("/variant-plans", response_model=VariantPlanResponse)
|
||||
def create_variant_plans(
|
||||
request: VariantPlanRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> VariantPlanResponse:
|
||||
"""轻量选片:为 N 个变体创建独立 EditPlan + clips,不建任务/不渲染。
|
||||
|
||||
Returns:
|
||||
200 + {items: [{variant_index, plan_id, clips}], total}
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 配音严格守卫(schema 已校验,此处复用解析取每变体配音)
|
||||
try:
|
||||
voices = resolve_variant_voice_ids(
|
||||
count=request.count,
|
||||
voice_library_id=request.voice_library_id,
|
||||
voice_library_ids=request.voice_library_ids or None,
|
||||
)
|
||||
except VariantVoiceError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新
|
||||
source_plan_id = request.source_edit_plan_id.strip()
|
||||
if not source_plan_id and request.template_id.strip():
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id.strip(),
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
|
||||
|
||||
if not source_plan_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="缺少剪辑计划:请先完成一次预览生成(或传入 source_edit_plan_id)后再试。",
|
||||
)
|
||||
|
||||
# 配音时长(硬化:异常 → 0.0 不阻断选片)
|
||||
try:
|
||||
from app.api.routes.generation_tasks import _query_voice_durations
|
||||
|
||||
voice_durations = _query_voice_durations(db, voices)
|
||||
except Exception:
|
||||
logger.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
|
||||
voice_durations = [0.0] * request.count
|
||||
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
svc = EditPlanService(db)
|
||||
try:
|
||||
plan_ids = svc.ensure_variant_plans(
|
||||
source_plan_id,
|
||||
request.count,
|
||||
list(dict.fromkeys(request.asset_ids or [])),
|
||||
created_by_user_id=user_id,
|
||||
voice_durations=voice_durations,
|
||||
)
|
||||
except ValueError as ve:
|
||||
# 素材池为空/时长全未知等可预期错误 → 400(与正式生成同口径)
|
||||
logger.warning("[variant-plans] 选片失败: %s", ve)
|
||||
raise HTTPException(status_code=400, detail=f"变体选片失败:{ve}。请增加素材后重试。") from ve
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[variant-plans] 选片异常: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e
|
||||
|
||||
# 组装 clips 响应
|
||||
items: list[VariantPlanItem] = []
|
||||
for idx, pid in enumerate(plan_ids):
|
||||
clips = svc.list_clips(pid)
|
||||
clip_dicts = [
|
||||
{
|
||||
"id": c.id,
|
||||
"order": c.order,
|
||||
"asset_id": c.asset_id,
|
||||
"start_time": float(c.start_time or 0.0),
|
||||
"duration": float(c.duration or 0.0),
|
||||
"clip_type": c.clip_type,
|
||||
"transition_effect": c.transition_effect,
|
||||
"transition_duration": float(c.transition_duration or 0.0),
|
||||
"playback_speed": float(c.playback_speed or 1.0),
|
||||
"text_content": c.text_content or "",
|
||||
"status": c.status or "ready",
|
||||
}
|
||||
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))
|
||||
@@ -108,8 +108,9 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str:
|
||||
return "video/mp4" # default
|
||||
|
||||
|
||||
# 兜底去重:无 file_hash / client_upload_id 且大小已知时,同库同名同大小近期活动记录视为重复
|
||||
# 兜底去重:无 file_hash / client_upload_id 时,同库同名近期活动记录视为重复
|
||||
FALLBACK_DEDUP_WINDOW_MINUTES = 30
|
||||
ACTIVE_ASSET_STATUSES = (AssetStatus.UPLOADING, AssetStatus.PROCESSING)
|
||||
|
||||
|
||||
def _find_duplicate_asset(
|
||||
@@ -125,12 +126,8 @@ def _find_duplicate_asset(
|
||||
|
||||
1. client_upload_id(客户端幂等 token,同一次上传的重试保持一致)
|
||||
2. file_hash(内容哈希,不同上传只要内容相同即去重)
|
||||
3. 兜底(严格模式,宁可漏判不可误杀):file_hash 与 client_upload_id
|
||||
均缺失、且 file_size > 0 时,同库 + 同文件名 + **同大小** 且 30 分钟内
|
||||
仍处 uploading/processing 的记录才判重。
|
||||
- file_hash 非空时跳过兜底(hash 已代表内容;同名但内容全新的视频
|
||||
如 iPhone 的 IMG_xxxx.MOV 绝不能被同名占位误杀)
|
||||
- file_size=0(未知)时不允许仅凭同名 + processing 判重,直接放行
|
||||
3. 兜底:同库 + 同文件名(+同大小)且 30 分钟内仍处 uploading/processing
|
||||
的记录——旧客户端不传 hash/token 时,防止 complete 超时重试反复建占位。
|
||||
|
||||
全部为鸭子类型调用:旧仓储无对应方法时静默跳过,不破坏既有实现。
|
||||
"""
|
||||
@@ -159,35 +156,53 @@ def _find_duplicate_asset(
|
||||
existing.id,
|
||||
)
|
||||
return existing
|
||||
# 同名兜底去重(最后防线,严格模式):
|
||||
# - 仅当 file_hash / client_upload_id 均缺失时启用(hash 能代表内容时不靠同名猜)
|
||||
# - file_size 必须 > 0 且与记录大小严格一致;大小未知(0)直接放行
|
||||
# - 只命中近期 UPLOADING/PROCESSING 活动记录(READY 历史素材不拦)
|
||||
if filename and not file_hash and not client_upload_id and file_size and file_size > 0:
|
||||
if filename:
|
||||
find_recent = getattr(asset_repository, "find_recent_active_by_library_and_name", None)
|
||||
if callable(find_recent):
|
||||
existing = find_recent(
|
||||
library_id=library_id,
|
||||
name=filename,
|
||||
within_minutes=FALLBACK_DEDUP_WINDOW_MINUTES,
|
||||
file_size=file_size,
|
||||
file_size=file_size or 0,
|
||||
)
|
||||
# 兜底去重:按状态区分处理
|
||||
# - READY/ERROR:稳定素材,总命中(避免重复创建)
|
||||
# - PROCESSING/UPLOADING:预建或 complete 占位,仅当 hash 一致才命中
|
||||
# - 占位无 hash(旧客户端 complete 建的)→ 命中
|
||||
# - 占位有 hash 且与当前请求 hash 一致 → 命中
|
||||
# - 占位有 hash 且与当前请求 hash 不同 → 跳过(内容不同)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材幂等兜底命中(近期同名同大小活动记录): library=%s name=%s asset=%s status=%s size=%s",
|
||||
library_id,
|
||||
filename,
|
||||
getattr(existing, "id", "?"),
|
||||
getattr(existing, "status", None),
|
||||
file_size,
|
||||
)
|
||||
return existing
|
||||
elif filename and not file_hash and not client_upload_id and not file_size:
|
||||
logger.debug(
|
||||
"同名兜底去重跳过(file_size 未知,宁可放行不可误杀): library=%s name=%s",
|
||||
library_id,
|
||||
filename,
|
||||
)
|
||||
status = getattr(existing, "status", None)
|
||||
existing_hash = getattr(existing, "file_hash", "") or ""
|
||||
if status in (AssetStatus.READY, AssetStatus.ERROR):
|
||||
logger.info(
|
||||
"素材幂等兜底命中(近期同名稳定记录): library=%s name=%s asset=%s status=%s",
|
||||
library_id,
|
||||
filename,
|
||||
getattr(existing, "id", "?"),
|
||||
status,
|
||||
)
|
||||
return existing
|
||||
elif status in ACTIVE_ASSET_STATUSES:
|
||||
if existing_hash and file_hash and existing_hash != file_hash:
|
||||
logger.debug(
|
||||
"素材兜底去重跳过(占位 hash 不同): library=%s name=%s asset=%s hash=%s req_hash=%s",
|
||||
library_id,
|
||||
filename,
|
||||
getattr(existing, "id", "?"),
|
||||
existing_hash,
|
||||
file_hash,
|
||||
)
|
||||
existing = None
|
||||
else:
|
||||
logger.info(
|
||||
"素材幂等兜底命中(近期同名活动记录): library=%s name=%s asset=%s status=%s",
|
||||
library_id,
|
||||
filename,
|
||||
getattr(existing, "id", "?"),
|
||||
status,
|
||||
)
|
||||
return existing
|
||||
return None
|
||||
|
||||
|
||||
@@ -472,7 +487,6 @@ async def complete_direct_upload(
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
|
||||
@@ -36,13 +36,9 @@ 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,
|
||||
@@ -100,31 +96,11 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_variant_arrays(self) -> "CreateGenerationTaskRequest":
|
||||
"""变体数组字段长度校验 + #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"):
|
||||
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = count(独立)。"""
|
||||
for name in ("voice_library_ids", "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")
|
||||
@@ -134,9 +110,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)
|
||||
has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids)
|
||||
if not has_library and not has_assets:
|
||||
raise ValueError("asset_library_id 或 asset_ids/title_ids 至少需要提供一个")
|
||||
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
|
||||
return self
|
||||
|
||||
|
||||
@@ -213,6 +189,7 @@ 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配音),对应配音选择页面选择的配音素材"
|
||||
)
|
||||
@@ -254,24 +231,13 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_variant_arrays(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
"""变体数组字段长度校验 + #1749 配音严格守卫。"""
|
||||
for name in ("titles", "cover_urls"):
|
||||
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = preview_count(独立)。"""
|
||||
for name in ("titles", "voice_library_ids", "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")
|
||||
@@ -282,8 +248,8 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
if not self.asset_ids and not self.title_ids:
|
||||
raise ValueError("asset_ids/title_ids 至少需要提供一个")
|
||||
if not self.asset_ids and not self.title_ids and not self.voice_ids:
|
||||
raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -409,13 +409,8 @@ class EditPlanService:
|
||||
clip_type=clip_item.get("clip_type", "main"),
|
||||
order=order,
|
||||
asset_id=clip_item.get("asset_id", ""),
|
||||
text_content=clip_item.get("text_content", ""),
|
||||
start_time=clip_item.get("start_time", 0.0),
|
||||
duration=clip_item.get("duration", 0.0),
|
||||
transition_effect=clip_item.get("transition_effect", "cut"),
|
||||
transition_duration=clip_item.get("transition_duration", 0.0),
|
||||
playback_speed=clip_item.get("playback_speed", 1.0),
|
||||
config=clip_item.get("config") or None,
|
||||
)
|
||||
model = EditPlanClipModel(
|
||||
id=clip.id,
|
||||
@@ -464,159 +459,6 @@ class EditPlanService:
|
||||
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
|
||||
raise
|
||||
|
||||
def reselect_plan_for_variant(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
candidate_asset_ids: list[str],
|
||||
*,
|
||||
created_by_user_id: str = "",
|
||||
name_suffix: str = "变体",
|
||||
voice_duration: float = 0.0,
|
||||
rng=None,
|
||||
) -> EditPlan:
|
||||
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
|
||||
|
||||
与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,本方法:
|
||||
- 源 plan 片段骨架(clip_type/order/duration/文案/转场)保留;
|
||||
- 素材池 shuffle 随机分配 + main 片段顺序洗牌;
|
||||
- 起点走场景镜头洗牌/随机起点/历史区间避让(与单视频同一入口);
|
||||
- 批次内同素材区间重叠 >20% 自动重选起点;
|
||||
- 新片段区间 record_used_segments 写回素材 metadata(跨变体/跨任务避让)。
|
||||
|
||||
Args:
|
||||
source_plan_id: 源 plan(任务 0 / 预览源)。
|
||||
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次素材)。
|
||||
created_by_user_id: 新 plan 归属用户。
|
||||
name_suffix: plan 名后缀。
|
||||
rng: 可选随机数(测试注入种子)。
|
||||
|
||||
Raises:
|
||||
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
|
||||
from packages.domain.variant_plan_selector import reselect_clips_for_variant
|
||||
|
||||
source = self.get_plan_or_raise(source_plan_id)
|
||||
|
||||
# 分页读取源 plan 全部片段
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
|
||||
if not batch:
|
||||
break
|
||||
clips.extend(batch)
|
||||
if len(batch) < page:
|
||||
break
|
||||
skip += page
|
||||
if not clips:
|
||||
raise ValueError(f"源 plan 无片段,无法生成变体: {source_plan_id}")
|
||||
|
||||
source_clips_data: list[dict[str, Any]] = [
|
||||
{
|
||||
"order": c.order if c.order is not None else i,
|
||||
"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,
|
||||
"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 {},
|
||||
}
|
||||
for i, c in enumerate(clips)
|
||||
]
|
||||
|
||||
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()
|
||||
for aid in [c.asset_id for c in clips if c.asset_id] + list(candidate_asset_ids or []):
|
||||
if aid and aid not in seen:
|
||||
seen.add(aid)
|
||||
pool_ids.append(aid)
|
||||
|
||||
# 时长 + 场景点
|
||||
durations: dict[str, float] = {}
|
||||
scene_points: dict[str, list[float]] = {}
|
||||
if pool_ids:
|
||||
for m in db.query(AssetModel).filter(AssetModel.id.in_(pool_ids)).all():
|
||||
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
|
||||
pts = extract_scene_points_from_metadata(getattr(m, "metadata", None))
|
||||
if pts:
|
||||
scene_points[m.id] = pts
|
||||
|
||||
historical = get_used_segments(db, pool_ids)
|
||||
|
||||
# 创建新 plan(复制模板归属与 config)
|
||||
new_plan = self.create_plan(
|
||||
template_id=source.template_id,
|
||||
name=f"{source.name or '剪辑计划'} · {name_suffix}",
|
||||
config=dict(source.config or {}),
|
||||
total_duration=source.total_duration,
|
||||
project_id=source.project_id or "",
|
||||
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
|
||||
)
|
||||
|
||||
# 批次内区间:以源 plan(变体 0)片段为初始避让对象
|
||||
batch_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
for c in clips:
|
||||
if c.asset_id and float(c.duration or 0) > 0:
|
||||
st = float(c.start_time or 0.0)
|
||||
batch_segments.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
|
||||
|
||||
clips_data = reselect_clips_for_variant(
|
||||
source_clips_data,
|
||||
pool_ids,
|
||||
asset_durations=durations,
|
||||
asset_scene_points=scene_points,
|
||||
historical_used_segments=historical,
|
||||
batch_segments=batch_segments,
|
||||
target_durations=target_durations,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit)
|
||||
for item in clips_data:
|
||||
aid = item.get("asset_id", "")
|
||||
if aid:
|
||||
st = float(item.get("start_time", 0.0))
|
||||
record_used_segments(db, aid, st, st + float(item.get("duration", 0.0)), new_plan.id)
|
||||
|
||||
self.replace_all_clips_transactional(new_plan.id, clips_data)
|
||||
logger.info(
|
||||
"变体独立选片完成: source=%s new=%s clips=%d assets=%d",
|
||||
source_plan_id,
|
||||
new_plan.id,
|
||||
len(clips_data),
|
||||
len(pool_ids),
|
||||
)
|
||||
return new_plan
|
||||
|
||||
def clone_plan_for_variant(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
@@ -727,195 +569,6 @@ class EditPlanService:
|
||||
)
|
||||
return new_plan
|
||||
|
||||
# ── #1749 配音时长分配 / 素材时长查询 / 批量变体 plan 确保 ──────────────
|
||||
|
||||
def get_asset_durations(self, asset_ids: list[str]) -> dict[str, float]:
|
||||
"""批量查询素材时长(秒),O(N) 单查;缺失/异常返回 0.0。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
ids = [a for a in dict.fromkeys(asset_ids or []) if a]
|
||||
if not ids:
|
||||
return {}
|
||||
db = self._clip_repo.session
|
||||
out: dict[str, float] = {}
|
||||
for m in db.query(AssetModel).filter(AssetModel.id.in_(ids)).all():
|
||||
try:
|
||||
out[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
out[m.id] = 0.0
|
||||
return out
|
||||
|
||||
def apply_voice_duration_to_plan(self, plan_id: str, voice_duration: float) -> Optional[EditPlan]:
|
||||
"""把配音时长分配到 plan 的每段(#1749)。
|
||||
|
||||
- 片段数保持不变(= 模板片段数,定死);
|
||||
- 每段 duration 按 voice_duration_planner 分配(含转场重叠扣减);
|
||||
- 素材短于段长 → start_time 钳制为 0(末帧冻结由渲染侧 tpad/apad 铺满);
|
||||
- plan.total_duration 回写为成片净时长(≈ 配音时长);
|
||||
- 幂等:配音时长相同则分配结果不变,可重复调用。
|
||||
|
||||
无配音(<=0)或无片段时直接返回 None,不报错。
|
||||
"""
|
||||
try:
|
||||
voice = float(voice_duration or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if voice <= 0:
|
||||
return None
|
||||
|
||||
plan = self.get_plan(plan_id)
|
||||
if plan is None:
|
||||
return None
|
||||
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page)
|
||||
if not batch:
|
||||
break
|
||||
clips.extend(batch)
|
||||
if len(batch) < page:
|
||||
break
|
||||
skip += page
|
||||
if not clips:
|
||||
return None
|
||||
|
||||
clips.sort(key=lambda c: (c.order if c.order is not None else 0))
|
||||
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration
|
||||
|
||||
target = plan_clip_durations(
|
||||
len(clips),
|
||||
voice,
|
||||
transition_effects=[c.transition_effect for c in clips],
|
||||
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
|
||||
)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
# 素材时长(短素材起点钳 0)
|
||||
asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
durations = self.get_asset_durations(asset_ids)
|
||||
|
||||
clips_data: list[dict] = []
|
||||
for i, c in enumerate(clips):
|
||||
dur = float(target[i])
|
||||
total = durations.get(c.asset_id, 0.0)
|
||||
start = float(c.start_time or 0.0)
|
||||
if c.asset_id and total > 0:
|
||||
# 素材短于段长:起点钳 0,段长超出部分渲染侧末帧冻结
|
||||
max_start = max(0.0, total - min(dur, total))
|
||||
start = min(start, max_start)
|
||||
clips_data.append(
|
||||
{
|
||||
"order": c.order if c.order is not None else i,
|
||||
"asset_id": c.asset_id or "",
|
||||
"start_time": round(start, 3),
|
||||
"duration": dur,
|
||||
"clip_type": c.clip_type,
|
||||
"playback_speed": float(c.playback_speed or 1.0),
|
||||
"transition_effect": c.transition_effect,
|
||||
"transition_duration": float(c.transition_duration or 0.0),
|
||||
"text_content": c.text_content or "",
|
||||
"config": c.config or {},
|
||||
}
|
||||
)
|
||||
|
||||
self.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
net = total_output_duration(
|
||||
target,
|
||||
transition_effects=[c.transition_effect for c in clips],
|
||||
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
|
||||
)
|
||||
try:
|
||||
plan.total_duration = net
|
||||
db = self._clip_repo.session
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("回写 plan.total_duration 失败(不阻断): plan_id=%s", plan_id)
|
||||
|
||||
logger.info(
|
||||
"配音时长分配完成: plan=%s clips=%d voice=%.2fs 成片净时长=%.2fs",
|
||||
plan_id,
|
||||
len(clips),
|
||||
voice,
|
||||
net,
|
||||
)
|
||||
return plan
|
||||
|
||||
def ensure_variant_plans(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
count: int,
|
||||
candidate_asset_ids: list[str],
|
||||
*,
|
||||
created_by_user_id: str = "",
|
||||
voice_durations: Optional[list[float]] = None,
|
||||
rng=None,
|
||||
) -> list[str]:
|
||||
"""确保批量 N 个变体各自拥有独立 plan(#1749 批量正式生成/预览共用)。
|
||||
|
||||
- 变体 0:clone 源 plan(不污染源 plan,片段独立可改),并按配音分配段长;
|
||||
- 变体 1..N-1:reselect_plan_for_variant 完整重跑选片(素材级去重);
|
||||
- voice_durations:每个变体的配音时长(独立配音各自时长;统一配音同值);
|
||||
缺省/为 0 时不分配(段长保持骨架/模板值)。
|
||||
|
||||
Returns:
|
||||
plan_id 列表,长度 == count,index 即 variant_index。
|
||||
"""
|
||||
import random as _random
|
||||
|
||||
rng = rng or _random.Random()
|
||||
plan_ids: list[str] = []
|
||||
|
||||
# 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan
|
||||
plan0 = self.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
name_suffix="变体1",
|
||||
)
|
||||
v0_voice = 0.0
|
||||
if voice_durations and len(voice_durations) > 0:
|
||||
try:
|
||||
v0_voice = float(voice_durations[0] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
v0_voice = 0.0
|
||||
if v0_voice > 0:
|
||||
try:
|
||||
self.apply_voice_duration_to_plan(plan0.id, v0_voice)
|
||||
except Exception:
|
||||
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
|
||||
plan_ids.append(plan0.id)
|
||||
|
||||
# 变体 1..N-1:独立选片
|
||||
for i in range(1, count):
|
||||
voice = 0.0
|
||||
if voice_durations and i < len(voice_durations):
|
||||
try:
|
||||
voice = float(voice_durations[i] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
voice = 0.0
|
||||
variant = self.reselect_plan_for_variant(
|
||||
source_plan_id,
|
||||
candidate_asset_ids,
|
||||
created_by_user_id=created_by_user_id,
|
||||
name_suffix=f"变体{i + 1}",
|
||||
voice_duration=voice,
|
||||
rng=rng,
|
||||
)
|
||||
plan_ids.append(variant.id)
|
||||
|
||||
# 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready)
|
||||
for pid in plan_ids:
|
||||
try:
|
||||
self.mark_clips_ready(pid)
|
||||
except Exception:
|
||||
logger.exception("标记 clips ready 失败(不阻断): plan=%s", pid)
|
||||
|
||||
return plan_ids
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
|
||||
@@ -44,10 +44,8 @@ export interface BgmConfig {
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
/** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取,新模板可不传 */
|
||||
duration_min?: number
|
||||
/** @deprecated 同上 */
|
||||
duration_max?: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
@@ -61,8 +59,7 @@ export interface EditingTemplate {
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
/** @deprecated 模板无时长概念(#1750 基线):成片时长由配音时长决定;字段保留兼容旧数据 */
|
||||
estimated_duration?: number
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
@@ -92,8 +89,7 @@ export interface SaveTemplatePayload {
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
/** @deprecated 模板无时长概念(#1750 基线):保留兼容旧数据 */
|
||||
estimated_duration?: number
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 批量变体剪辑计划 API(#1744)
|
||||
*
|
||||
* 批量预览时向后端申请 N 个变体的「独立剪辑计划片段」:
|
||||
* - 变体 0 保留源 plan(用户在编辑器/智能选片产出的片段,含标题样式编辑结果);
|
||||
* - 变体 1..N-1 由后端 reselect_plan_for_variant 完整重跑单视频选片流程
|
||||
* (素材洗牌 + main 片段顺序洗牌 + 镜头/起点随机 + 跨变体 20% 区间避让 +
|
||||
* 素材使用区间写回 metadata),与正式批量生成 POST /generation/tasks?count=N
|
||||
* 使用同一套选片逻辑;
|
||||
* - 正式生成时把 variant_plan_ids 原样回传,后端直接关联这些 plan 渲染,
|
||||
* 不再重新选片 —— 预览所见即成片。
|
||||
*
|
||||
* 该接口只做选片/建 plan(秒级),不触发视频渲染,无渲染成本。
|
||||
* 后端端点未上线(404)或选片失败(素材不足等)时前端降级为本地 variantSeed
|
||||
* 模拟预览,不阻塞用户流程。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { EditPlanClip } from "../template-editor"
|
||||
|
||||
/** 批量变体计划请求体 */
|
||||
export interface BatchVariantPlansRequest {
|
||||
template_id: string
|
||||
/** 本批次素材池(手动选择或智能匹配结果) */
|
||||
asset_ids: string[]
|
||||
/** 变体数量(≥1);=1 时只返回源 plan 片段 */
|
||||
count: number
|
||||
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 单个变体的计划片段 */
|
||||
export interface VariantPlan {
|
||||
/** 变体序号,从 0 开始 */
|
||||
variant_index: number
|
||||
/** 该变体关联的剪辑计划 ID(正式生成时回传,实现预览即成片) */
|
||||
plan_id: string
|
||||
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
|
||||
clips: EditPlanClip[]
|
||||
}
|
||||
|
||||
/** 批量变体计划响应 */
|
||||
export interface BatchVariantPlansResponse {
|
||||
items: VariantPlan[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量变体剪辑计划并返回各变体片段。
|
||||
*
|
||||
* 注意:端点 404(后端未上线)/ 400(素材不足)等失败由调用方 catch 后降级,
|
||||
* 不要抛 unhandled rejection。
|
||||
*/
|
||||
export async function createBatchVariantPlans(
|
||||
params: BatchVariantPlansRequest,
|
||||
): Promise<BatchVariantPlansResponse> {
|
||||
const response = await apiClient.post<BatchVariantPlansResponse>(
|
||||
"/generation/variant-plans",
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -100,12 +100,6 @@ export interface CreateGenerationTaskRequest {
|
||||
voice_library_ids?: string[]
|
||||
/** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
|
||||
cover_urls?: string[]
|
||||
/**
|
||||
* 批量变体剪辑计划 ID(#1744):预览阶段后端独立选片产出的 plan id 列表
|
||||
* (按变体全量索引,长度=previewCount)。正式生成回传后后端直接关联这些
|
||||
* plan 渲染、不再重新选片,保证预览所见即成片。后端未支持时忽略该字段。
|
||||
*/
|
||||
variant_plan_ids?: string[]
|
||||
}
|
||||
|
||||
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
|
||||
|
||||
@@ -42,10 +42,8 @@ export interface TemplateItem {
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
/** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取 */
|
||||
duration_min?: number
|
||||
/** @deprecated 同上 */
|
||||
duration_max?: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
description?: string
|
||||
}
|
||||
@@ -57,6 +55,8 @@ export interface TemplateListParams {
|
||||
category?: string
|
||||
tags?: string
|
||||
keyword?: string
|
||||
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
|
||||
duration_range?: "short" | "medium" | "long"
|
||||
}
|
||||
|
||||
/** 模板列表分页响应 */
|
||||
|
||||
@@ -108,10 +108,7 @@ export function usePlanLoading({
|
||||
id: `seg-${idx}`,
|
||||
template_segment_id: `seg-${idx}`,
|
||||
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
|
||||
duration:
|
||||
seg.duration_min != null && seg.duration_max != null
|
||||
? (seg.duration_min + seg.duration_max) / 2
|
||||
: 5,
|
||||
duration: (seg.duration_min + seg.duration_max) / 2,
|
||||
startOffset: 0,
|
||||
script_text: "",
|
||||
order: seg.segment_order,
|
||||
|
||||
@@ -43,10 +43,7 @@ export function useTemplateDetail({
|
||||
id: seg.id || `seg-${idx}`,
|
||||
template_segment_id: seg.id || `seg-${idx}`,
|
||||
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
|
||||
duration:
|
||||
seg.duration_min != null && seg.duration_max != null
|
||||
? (seg.duration_min + seg.duration_max) / 2
|
||||
: 5,
|
||||
duration: (seg.duration_min + seg.duration_max) / 2,
|
||||
startOffset: 0,
|
||||
script_text: "",
|
||||
order: seg.segment_order,
|
||||
|
||||
@@ -27,8 +27,6 @@ import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets"
|
||||
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
|
||||
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
@@ -138,17 +136,14 @@ const GeneratePage: React.FC = () => {
|
||||
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
|
||||
|
||||
useEffect(() => {
|
||||
// #1750:批量模式每变体独立/共用配音由 useVariantVoicePreview 接管(含独立模式每卡片挂各自 URL),
|
||||
// 本 effect 只服务单视频,批量模式直接跳过(避免共用 TTS 结果错误下发给所有变体)
|
||||
if (isBatch) return
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
if (voiceAsset?.file_url) {
|
||||
setPreviewVoiceAudioUrl(voiceAsset.file_url)
|
||||
return
|
||||
}
|
||||
|
||||
// 单视频 TTS 文案跟随主标题
|
||||
const ttsTitle = titleSettings.title
|
||||
// 批量模式下 TTS 文案跟随变体0标题;单视频跟随主标题
|
||||
const ttsTitle = isBatch ? variant0Title || "" : titleSettings.title
|
||||
const voiceId = selectedClonedVoice || selectedVoice
|
||||
if (!voiceId || !ttsTitle) {
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
@@ -218,56 +213,14 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 加载素材详情(供前端预览播放器使用) ── */
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0
|
||||
const {
|
||||
assets: previewAssets,
|
||||
ready: previewAssetsReady,
|
||||
ensureAssets,
|
||||
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
|
||||
previewAssetIds,
|
||||
previewAssetsEnabled,
|
||||
)
|
||||
|
||||
/* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */
|
||||
const previewReady = previewAssetsReady && !!currentTemplate
|
||||
|
||||
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
|
||||
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
|
||||
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
|
||||
const {
|
||||
clipsByVariant: variantClips,
|
||||
planIdsByVariant: variantPlanIds,
|
||||
loading: variantClipsLoading,
|
||||
error: variantClipsError,
|
||||
retry: retryVariantClips,
|
||||
} = useBatchVariantPlans({
|
||||
enabled: isBatch && currentStep === 4 && previewAssetsReady,
|
||||
count: previewCount,
|
||||
templateId: selectedTemplate || "",
|
||||
assetIds: previewAssetIds,
|
||||
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
|
||||
})
|
||||
|
||||
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
|
||||
hook 内部自取配音素材库并做 TTS 缓存/串行/防竞态 */
|
||||
const variantVoiceAudioUrls = useVariantVoicePreview({
|
||||
enabled: isBatch,
|
||||
count: previewCount,
|
||||
perVideo: voiceModePerVideo,
|
||||
sharedVoiceId: selectedVoice || "",
|
||||
clonedVoiceId: selectedClonedVoice || "",
|
||||
variantVoiceIds: voiceLibraryIds || [],
|
||||
titles: previewTitles || [],
|
||||
})
|
||||
|
||||
/* ── 变体计划 clips 引用的素材可能不在已选列表中(#1750):差集补拉素材详情,
|
||||
保证播放器拿得到 file_url,而不是静默丢片段/用假数据冒充 */
|
||||
const clipAssetIds = useMemo(() => {
|
||||
if (!isBatch || !variantClips?.length) return []
|
||||
const ids = new Set<string>()
|
||||
variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id)))
|
||||
return Array.from(ids)
|
||||
}, [isBatch, variantClips])
|
||||
useEffect(() => {
|
||||
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds)
|
||||
}, [clipAssetIds, ensureAssets])
|
||||
|
||||
/* ── 勾选变体 ── */
|
||||
const toggleVariantSelect = useCallback(
|
||||
(index: number) => {
|
||||
@@ -309,8 +262,6 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
|
||||
// #1744:批量预览阶段后端为每个变体生成的独立 plan id,正式生成回传 → 预览即成片
|
||||
variantPlanIds,
|
||||
previewTaskId,
|
||||
bgmConfig,
|
||||
previewCount,
|
||||
@@ -442,6 +393,7 @@ const GeneratePage: React.FC = () => {
|
||||
/* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
@@ -473,14 +425,11 @@ const GeneratePage: React.FC = () => {
|
||||
<CanvasPreviewGrid
|
||||
count={previewCount}
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
variantClips={variantClips}
|
||||
clipsLoading={variantClipsLoading}
|
||||
clipsError={variantClipsError}
|
||||
onRetryClips={retryVariantClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
selectedIds={selectedVariantIds}
|
||||
onToggleSelect={toggleVariantSelect}
|
||||
selectable={!generating}
|
||||
@@ -558,31 +507,12 @@ const GeneratePage: React.FC = () => {
|
||||
{/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */}
|
||||
{currentStep >= 5 && !isBatch && generated && finalVideo && (
|
||||
<div className="xx-generate-right-col">
|
||||
<div
|
||||
className="xx-inline-video-player"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<div className="xx-inline-video-player">
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay={currentStep === 5}
|
||||
// 竖屏自适应(#1750):成片固定 1080×1920(9:16),元数据到达前按 9:16 占位,
|
||||
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
|
||||
@@ -66,32 +66,12 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
</>
|
||||
)}
|
||||
{task.status === "completed" && video && (
|
||||
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
|
||||
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={video.download_url || video.file_url}
|
||||
controls
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
borderRadius: 4,
|
||||
background: "#000",
|
||||
}}
|
||||
poster={video.thumbnail_url}
|
||||
/>
|
||||
</div>
|
||||
<video
|
||||
src={video.download_url || video.file_url}
|
||||
controls
|
||||
style={{ width: "100%", borderRadius: 8, background: "#000", maxHeight: 280 }}
|
||||
poster={video.thumbnail_url}
|
||||
/>
|
||||
)}
|
||||
{task.status === "completed" && !video && (
|
||||
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
|
||||
|
||||
@@ -1,47 +1,27 @@
|
||||
/**
|
||||
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划)
|
||||
* 批量前端 Canvas 实时预览网格(Issue #1677 修正方案)
|
||||
*
|
||||
* N 个 FrontendPreviewPlayer 网格排列:
|
||||
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口;
|
||||
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
|
||||
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
|
||||
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
|
||||
* 加载中 → 9:16 占位防塌陷;
|
||||
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
|
||||
* - 勾选框决定提交时生成哪些变体;
|
||||
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
|
||||
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
|
||||
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口
|
||||
* - variantSeed 让每个变体素材排布/起始点不同,画面有可见差异
|
||||
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用
|
||||
* - 勾选框决定提交时生成哪些变体
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import React from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "antd"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { TitleSettings } from "../types"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
|
||||
interface CanvasPreviewGridProps {
|
||||
count: number
|
||||
assets: AssetItem[]
|
||||
template: EditingTemplate | null
|
||||
videoRatio: string
|
||||
titles: string[]
|
||||
titleSettings: TitleSettings
|
||||
/**
|
||||
* 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条;
|
||||
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
|
||||
*/
|
||||
voiceAudioUrls?: (string | null)[]
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744/#1750):长度=count。
|
||||
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
|
||||
*/
|
||||
variantClips?: EditPlanClip[][]
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
clipsLoading?: boolean
|
||||
/** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */
|
||||
clipsError?: boolean
|
||||
/** 用户点击错误态「重试」 */
|
||||
onRetryClips?: () => void
|
||||
/** 共用配音预览音频(仅第 1 个变体播放,避免多路音频重叠) */
|
||||
voiceAudioUrl?: string
|
||||
/** 勾选的变体序号 */
|
||||
selectedIds: number[]
|
||||
onToggleSelect: (index: number) => void
|
||||
@@ -52,21 +32,15 @@ interface CanvasPreviewGridProps {
|
||||
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
count,
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrls,
|
||||
variantClips,
|
||||
clipsLoading = false,
|
||||
clipsError = false,
|
||||
onRetryClips,
|
||||
voiceAudioUrl,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750) ──
|
||||
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
|
||||
|
||||
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
|
||||
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
|
||||
return (
|
||||
@@ -90,54 +64,29 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
<span>视频 {i + 1}</span>
|
||||
</label>
|
||||
</div>
|
||||
{clipsError ? (
|
||||
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
|
||||
<div className="xx-variant-clips-status" role="alert">
|
||||
<span className="xx-variant-clips-error-text">预览加载失败,请重试</span>
|
||||
{i === 0 && onRetryClips ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetryClips}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : clipsLoading || !variantClips?.[i]?.length ? (
|
||||
// ── 加载态:9:16 占位防塌陷 ──
|
||||
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
|
||||
<LoadingOutlined />
|
||||
<span style={{ marginTop: 8 }}>独立选片中…</span>
|
||||
</div>
|
||||
) : (
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
videoRatio={videoRatio}
|
||||
ready={assets.length > 0}
|
||||
playToken={i}
|
||||
serverClips={variantClips[i]}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
titleSettings={{
|
||||
title: titles[i] || "",
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
template={template}
|
||||
videoRatio={videoRatio}
|
||||
ready={assets.length > 0}
|
||||
variantSeed={i + 1}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={i === 0 ? voiceAudioUrl : undefined}
|
||||
compact
|
||||
titleSettings={{
|
||||
title: titles[i] || "",
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -13,19 +13,18 @@ import {
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
LoadingOutlined,
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
assets: AssetItem[]
|
||||
template: EditingTemplate | null
|
||||
videoRatio: string
|
||||
ready: boolean
|
||||
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
|
||||
serverClips?: EditPlanClip[]
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
@@ -43,21 +42,15 @@ interface FrontendPreviewPlayerProps {
|
||||
}
|
||||
onTitlePositionChange?: (posX: number, posY: number) => void
|
||||
/**
|
||||
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
|
||||
* 持有播放权的实例播放,其余自动暂停。
|
||||
* 变体种子(批量生成 #1677):同一批素材在不同变体中采用不同的素材顺序与
|
||||
* 片段起始点,让 N 个 Canvas 预览画面有差异(纯前端随机剪辑模拟,不调后端)。
|
||||
* 0 / 不传 = 单视频,排布与旧版完全一致(零回归)。
|
||||
*/
|
||||
playToken?: number
|
||||
variantSeed?: number
|
||||
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
|
||||
variantTitle?: string
|
||||
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
|
||||
compact?: boolean
|
||||
/**
|
||||
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
|
||||
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
|
||||
*/
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -67,51 +60,108 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback)
|
||||
*
|
||||
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
|
||||
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
|
||||
* 将素材映射为播放片段(复用原逻辑)
|
||||
*/
|
||||
/** 简单可复现随机数(mulberry32),同一种子产出稳定排布,避免每次渲染抖动 */
|
||||
function seededRandom(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
template: EditingTemplate | null,
|
||||
serverClips?: EditPlanClip[],
|
||||
variantSeed = 0,
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length || !serverClips || serverClips.length === 0) return []
|
||||
if (!assets.length) return []
|
||||
|
||||
// Build asset lookup map
|
||||
const assetMap = new Map(assets.map((a) => [a.id, a]))
|
||||
const segments: PlaybackSegment[] = []
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 0
|
||||
const startTime = clip.start_time || 0
|
||||
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
|
||||
const clipDuration = clip.duration || 0
|
||||
const endTime =
|
||||
assetDuration > 0
|
||||
? Math.min(startTime + clipDuration, assetDuration)
|
||||
: startTime + clipDuration
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
|
||||
|
||||
// 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致
|
||||
if (serverClips && serverClips.length > 0) {
|
||||
const segments: PlaybackSegment[] = []
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const startTime = clip.start_time || 0
|
||||
const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration)
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
}
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
|
||||
// Fallback: 本地构建片段
|
||||
// variantSeed=0(单视频):与旧行为完全一致(素材原序、起始点 0),零回归
|
||||
// variantSeed>0(批量变体):素材顺序按种子轮换 + 片段起始点在素材内偏移,
|
||||
// 模拟后端"AI 随机剪辑出不同版本",让 N 个预览画面有可见差异
|
||||
const templateSegments = template?.segments || []
|
||||
const segments: PlaybackSegment[] = []
|
||||
const orderedAssets = variantSeed > 0 ? [...assets] : assets
|
||||
if (variantSeed > 0 && orderedAssets.length > 1) {
|
||||
const rand = seededRandom(variantSeed * 7919 + 13)
|
||||
// 素材轮换:把数组旋转 (seed % n) 位,再对后半段做一次稳定交换
|
||||
const n = orderedAssets.length
|
||||
const rotate = variantSeed % n
|
||||
orderedAssets.push(...orderedAssets.splice(0, rotate))
|
||||
const swapA = Math.floor(rand() * n)
|
||||
const swapB = Math.floor(rand() * n)
|
||||
if (swapA !== swapB) {
|
||||
;[orderedAssets[swapA], orderedAssets[swapB]] = [orderedAssets[swapB], orderedAssets[swapA]]
|
||||
}
|
||||
}
|
||||
|
||||
orderedAssets.forEach((asset, i) => {
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
let startTime = 0
|
||||
if (variantSeed > 0 && assetDuration - segDuration > 1) {
|
||||
const rand = seededRandom(variantSeed * 104729 + i * 31 + 7)
|
||||
// 起始点在素材可用区间内随机偏移(至少留 0.5s 余量)
|
||||
const maxStart = Math.max(0, assetDuration - segDuration - 0.5)
|
||||
startTime = Math.round(rand() * maxStart * 10) / 10
|
||||
}
|
||||
const endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
|
||||
})
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
playToken,
|
||||
variantSeed = 0,
|
||||
variantTitle,
|
||||
compact = false,
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, serverClips), [assets, serverClips])
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips, variantSeed),
|
||||
[assets, template, serverClips, variantSeed],
|
||||
)
|
||||
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
|
||||
const effectiveTitle = variantTitle ?? titleSettings?.title
|
||||
|
||||
@@ -289,7 +339,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
canPlay: videoCanPlay,
|
||||
togglePlayPause: videoTogglePlayPause,
|
||||
seekTo: videoSeekTo,
|
||||
pause: videoPause,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
@@ -304,10 +353,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
|
||||
const [muted, setMuted] = useState(false)
|
||||
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
|
||||
const hasVoice = !!voiceAudioUrl
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
@@ -325,8 +370,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
audioRef.current.muted = muted
|
||||
}, [voiceAudioUrl, muted])
|
||||
}, [voiceAudioUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
@@ -365,42 +409,17 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
|
||||
)
|
||||
|
||||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
|
||||
useEffect(() => {
|
||||
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) canvasControls.pause()
|
||||
} else if (isPlaying) {
|
||||
videoPause()
|
||||
}
|
||||
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
|
||||
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePlayToken, playToken, effectiveUseWebCodecs])
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) {
|
||||
canvasControls.pause()
|
||||
onPlayTokenChange?.(null)
|
||||
} else {
|
||||
if (playToken != null) onPlayTokenChange?.(playToken)
|
||||
canvasControls.play()
|
||||
}
|
||||
} else {
|
||||
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
|
||||
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
|
||||
videoTogglePlayPause()
|
||||
}
|
||||
}, [
|
||||
effectiveUseWebCodecs,
|
||||
canvasState.isPlaying,
|
||||
canvasControls,
|
||||
videoTogglePlayPause,
|
||||
isPlaying,
|
||||
playToken,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -589,7 +608,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
muted={hasVoice || muted}
|
||||
muted
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
@@ -643,8 +662,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
? { top: "50%", transform: "translate(-50%, -50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}),
|
||||
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
|
||||
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
|
||||
pointerEvents: onTitlePositionChange && variantSeed === 0 ? "auto" : "none",
|
||||
cursor: onTitlePositionChange && variantSeed === 0 ? "grab" : "default",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
@@ -724,45 +743,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
|
||||
{segments.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
title={muted ? "取消静音" : "静音"}
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: compact ? 26 : 30,
|
||||
height: compact ? 26 : 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "50%",
|
||||
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
|
||||
fontSize: compact ? 13 : 15,
|
||||
cursor: "pointer",
|
||||
zIndex: 10,
|
||||
padding: 0,
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 — 右上角胶囊 */}
|
||||
<div
|
||||
style={{
|
||||
@@ -773,9 +753,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: compact ? 9 : 10,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
padding: compact ? "1px 6px" : "2px 8px",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
zIndex: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
@@ -794,8 +774,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: compact ? 6 : 10,
|
||||
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
|
||||
gap: 10,
|
||||
padding: "12px 16px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
@@ -808,10 +788,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: compact ? 14 : 16,
|
||||
fontSize: 16,
|
||||
cursor: "pointer",
|
||||
width: compact ? 26 : 32,
|
||||
height: compact ? 26 : 32,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -831,9 +811,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: compact ? 10 : 11,
|
||||
fontSize: 11,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
minWidth: compact ? 58 : 72,
|
||||
minWidth: 72,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { ProgressIndicator } from "./result-panel/ProgressIndicator"
|
||||
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface GenerateResultPanelProps {
|
||||
generated: boolean
|
||||
generating: boolean
|
||||
progress: number
|
||||
generateError: string | null
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onVideoPreview: (video: GeneratedVideo) => void
|
||||
onDownload: () => void
|
||||
onShare: () => void
|
||||
onGoToLibrary: () => void
|
||||
}
|
||||
|
||||
const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
generated,
|
||||
generating,
|
||||
progress,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
onVideoPreview,
|
||||
onDownload,
|
||||
onShare,
|
||||
onGoToLibrary,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-generate-result">
|
||||
<div className="xx-result-header">
|
||||
<h3>生成结果</h3>
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<span className="xx-result-count">{generatedVideos.length} 个视频</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generating && <ProgressIndicator progress={progress} />}
|
||||
|
||||
{generateError && !generating && (
|
||||
<div className="xx-result-empty">
|
||||
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
|
||||
<Text strong style={{ display: "block", marginBottom: 4 }}>
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{typeof generateError === "string" ? generateError : "请重试"}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!generated && !generating && !generateError && (
|
||||
<div className="xx-result-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
完成配置后点击「确认生成」
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
|
||||
生成的视频将在这里展示
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div className="xx-video-grid">
|
||||
{generatedVideos.map((video, idx) => (
|
||||
<ResultVideoCard
|
||||
key={video.id || idx}
|
||||
video={video}
|
||||
index={idx}
|
||||
onPreview={onVideoPreview}
|
||||
onDownload={onDownload}
|
||||
onShare={onShare}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated && (
|
||||
<div className="xx-result-footer">
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onGoToLibrary}>
|
||||
前往成片库 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerateResultPanel
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 右侧预览视频面板 — 服务器渲染预览架构
|
||||
*
|
||||
* Step4+: 显示预览面板
|
||||
* Step5: 播放服务器渲染的真实视频(POST /generation/preview)
|
||||
*
|
||||
* 架构:
|
||||
* - 进入 Step4/5 时自动创建服务器预览渲染任务
|
||||
* - 轮询完成后用 <video> 标签播放返回的 video_url
|
||||
* - 标题样式编辑时 CSS TitleOverlay 实时叠加预览
|
||||
* - 素材/配音/BGM 变更自动重新渲染
|
||||
* - 标题文字/样式变更标记 stale,保留旧视频 + 显示"重新预览"按钮
|
||||
*
|
||||
* 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致。
|
||||
*/
|
||||
import React, { useMemo, useRef, useState, useEffect } from "react"
|
||||
import { LoadingOutlined, ReloadOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { getFontFamily } from "../constants"
|
||||
import type { ServerPreviewStatus } from "../hooks/useServerPreview"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
/** 服务器预览状态 */
|
||||
previewStatus: ServerPreviewStatus
|
||||
/** 服务器渲染视频 URL */
|
||||
videoUrl: string | null
|
||||
/** 渲染进度 0-100 */
|
||||
progress: number
|
||||
/** 错误信息 */
|
||||
error: string | null
|
||||
/** 重新预览回调 */
|
||||
onRetry: () => void
|
||||
/** 视频比例 */
|
||||
videoRatio: string
|
||||
/** 标题设置 — CSS 实时预览层 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 素材数量 */
|
||||
assetCount?: number
|
||||
}
|
||||
|
||||
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
|
||||
const TITLE_MARGIN_TOP = 120
|
||||
const TITLE_MARGIN_BOTTOM = 60
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
|
||||
/** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY) */
|
||||
function getResolution(ratio: string): { width: number; height: number } {
|
||||
switch (ratio) {
|
||||
case "16:9":
|
||||
return { width: 1920, height: 1080 }
|
||||
case "1:1":
|
||||
return { width: 1080, height: 1080 }
|
||||
case "9:16":
|
||||
default:
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
}
|
||||
|
||||
function getPositionStyle(
|
||||
position: string,
|
||||
playResX: number,
|
||||
playResY: number,
|
||||
): React.CSSProperties {
|
||||
const sidePercent = (TITLE_MARGIN_SIDE / playResX) * 100
|
||||
switch (position) {
|
||||
case "bottom":
|
||||
return {
|
||||
bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
case "center":
|
||||
return {
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
case "top":
|
||||
default:
|
||||
return {
|
||||
top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTitleStyle(
|
||||
settings: TitleSettings,
|
||||
containerHeight: number,
|
||||
playResY: number,
|
||||
): React.CSSProperties {
|
||||
// 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致)
|
||||
const fontSizePx =
|
||||
containerHeight > 0
|
||||
? (settings.size / playResY) * containerHeight
|
||||
: (settings.size / playResY) * 400
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: getFontFamily(settings.font),
|
||||
fontSize: `${fontSizePx}px`,
|
||||
color: settings.color || "#ffffff",
|
||||
fontWeight: settings.bold ? 700 : 400,
|
||||
fontStyle: settings.italic ? "italic" : "normal",
|
||||
lineHeight: 1.05,
|
||||
wordBreak: "break-word",
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
}
|
||||
if (settings.stroke) base.WebkitTextStroke = "1px #000000"
|
||||
if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
return base
|
||||
}
|
||||
|
||||
/** CSS 标题实时预览覆盖层 */
|
||||
const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({
|
||||
titleSettings,
|
||||
videoRatio,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height
|
||||
if (h > 0) setContainerHeight(h)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height > 0) setContainerHeight(rect.height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const { width: playResX, height: playResY } = getResolution(videoRatio)
|
||||
|
||||
const positionStyle = useMemo(
|
||||
() => getPositionStyle(titleSettings.position, playResX, playResY),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[titleSettings.position, playResX, playResY],
|
||||
)
|
||||
const titleStyle = useMemo(
|
||||
() => buildTitleStyle(titleSettings, containerHeight, playResY),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
containerHeight,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
titleSettings.bold,
|
||||
titleSettings.italic,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
playResY,
|
||||
],
|
||||
)
|
||||
|
||||
const displayTitle = titleSettings.title?.trim() || "标题预览"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 20,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ ...positionStyle, ...titleStyle, position: "absolute" }}>
|
||||
{displayTitle.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 主组件 ── */
|
||||
|
||||
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
previewStatus,
|
||||
videoUrl,
|
||||
progress,
|
||||
error,
|
||||
onRetry,
|
||||
videoRatio,
|
||||
titleSettings,
|
||||
assetCount,
|
||||
}) => {
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
|
||||
const isLoading = previewStatus === "loading"
|
||||
const isReady = previewStatus === "ready" || previewStatus === "stale"
|
||||
const isFailed = previewStatus === "failed"
|
||||
const isIdle = previewStatus === "idle"
|
||||
const isStale = previewStatus === "stale"
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{isReady && !isStale && <span className="xx-preview-badge">服务器渲染</span>}
|
||||
{isStale && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#faad14",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleOutlined /> 配置已变更
|
||||
</span>
|
||||
)}
|
||||
{isLoading && <span className="xx-preview-badge">渲染中</span>}
|
||||
</div>
|
||||
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
{/* 加载中 */}
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 5,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
}}
|
||||
>
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.9)", fontSize: 14 }}>
|
||||
正在渲染预览视频{progress > 0 ? `...${progress}%` : "..."}
|
||||
</p>
|
||||
<p style={{ marginTop: 4, color: "rgba(255,255,255,0.5)", fontSize: 12 }}>
|
||||
首次渲染约需 30-60 秒
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空闲状态(尚未触发预览) */}
|
||||
{isIdle && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "rgba(255,255,255,0.7)", fontSize: 14 }}>等待素材选择...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 服务器渲染的真实视频 */}
|
||||
{isReady && videoUrl && (
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题样式实时预览层(仅在有视频时叠加) */}
|
||||
{isReady && titleSettings && (
|
||||
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
|
||||
)}
|
||||
|
||||
{/* stale 遮罩:配置变更提示 */}
|
||||
{isStale && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: "10px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.85))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "rgba(255,255,255,0.9)", fontSize: 12 }}>
|
||||
配置已变更,预览内容可能不是最新
|
||||
</span>
|
||||
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={onRetry}>
|
||||
重新预览
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{isFailed && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.7)",
|
||||
zIndex: 10,
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleOutlined style={{ fontSize: 40, color: "#ff4d4f" }} />
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{error || "预览渲染失败"}
|
||||
</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetry}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
重新预览
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 素材信息 */}
|
||||
{assetCount !== undefined && assetCount > 0 && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>素材数</span>
|
||||
<span>{assetCount} 个</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
<span>{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewVideoPanel
|
||||
@@ -9,13 +9,12 @@
|
||||
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { Input, message } from "antd"
|
||||
import { AutoComplete, Input, message } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
import { AI_TITLE_TEMPLATES } from "../constants"
|
||||
|
||||
@@ -202,14 +201,21 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder="输入或从标题库选择"
|
||||
value={previewTitles?.[0] ?? t.titleSettings.title}
|
||||
<AutoComplete
|
||||
placeholder="输入标题文字…"
|
||||
allowClear
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={(previewTitles?.[0] ?? t.titleSettings.title) || undefined}
|
||||
onChange={(val) => {
|
||||
t.updateTitle(val || "")
|
||||
onPreviewTitlesChange?.([val || ""])
|
||||
}}
|
||||
options={titleOptions}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -263,11 +269,17 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder={`输入或选择视频 ${i + 1} 的标题`}
|
||||
value={previewTitles?.[i] || ""}
|
||||
onChange={(val) => updateVariantTitle(i, val)}
|
||||
<AutoComplete
|
||||
placeholder={`视频 ${i + 1} 的标题…`}
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={previewTitles?.[i] || undefined}
|
||||
onChange={(val) => updateVariantTitle(i, val || "")}
|
||||
options={titleOptions}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -37,12 +37,6 @@ const isAiVoice = (item: AssetItem): boolean => {
|
||||
return (!duration || duration <= 0) && (!size || size <= 0)
|
||||
}
|
||||
|
||||
/** 格式化时长(秒 → "14.7秒";0/无效 → "时长未知",不做任何时长过滤) */
|
||||
const formatDurationSeconds = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "时长未知"
|
||||
return `${seconds.toFixed(1)}秒`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes || bytes <= 0) return "未知"
|
||||
@@ -267,9 +261,7 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
>
|
||||
{isAiVoice(item) ? (
|
||||
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI 音色</span>
|
||||
) : (
|
||||
<span>{formatDurationSeconds(getDuration(item))}</span>
|
||||
)}
|
||||
) : null}
|
||||
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,13 +91,11 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
shadow: props.titleSettings?.shadow ?? false,
|
||||
},
|
||||
covers: batchCoversList,
|
||||
onCoversChange: (updater) => {
|
||||
// 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖)
|
||||
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
|
||||
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
|
||||
onCoversChange: (urls) => {
|
||||
// 按卡片顺序写回对应变体索引
|
||||
const next = [...(props.previewCovers || [])]
|
||||
cardIndexes.forEach((vi, cardPos) => {
|
||||
next[vi] = nextCardView[cardPos] || ""
|
||||
next[vi] = urls[cardPos] || ""
|
||||
})
|
||||
props.onPreviewCoversChange?.(next)
|
||||
},
|
||||
@@ -144,8 +142,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void batchCovers.generateAll()}
|
||||
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
|
||||
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
|
||||
disabled={completedVideos.length === 0 || batchCovers.loadingIndex !== null}
|
||||
>
|
||||
✨ 一键全部自动生成
|
||||
</Button>
|
||||
@@ -154,8 +151,8 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<div className="xx-cover-grid">
|
||||
{cardIndexes.map((variantIndex, cardPos) => {
|
||||
const url = batchCoversList[cardPos]
|
||||
const isLoading = batchCovers.busyIndexes.includes(cardPos)
|
||||
const isUploading = batchCovers.uploadingIndexes.includes(cardPos)
|
||||
const isLoading = batchCovers.loadingIndex === cardPos
|
||||
const isUploading = batchCovers.uploadingIndex === cardPos
|
||||
const title = batchTitles[cardPos]
|
||||
return (
|
||||
<div className="xx-cover-card" key={variantIndex}>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
progress: number
|
||||
}
|
||||
|
||||
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ progress }) => {
|
||||
return (
|
||||
<div className="xx-result-progress">
|
||||
<div className="xx-progress-circle">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="36" fill="none" stroke="var(--border-color)" strokeWidth="6" />
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--primary-color)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="xx-progress-text">
|
||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||
正在生成视频
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
AI 正在处理素材,请稍候…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, DownloadOutlined, ShareAltOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { formatDuration } from "@/api/voice-clone"
|
||||
|
||||
interface ResultVideoCardProps {
|
||||
video: GeneratedVideo
|
||||
index: number
|
||||
onPreview: (video: GeneratedVideo) => void
|
||||
onDownload: () => void
|
||||
onShare: () => void
|
||||
}
|
||||
|
||||
export const ResultVideoCard: React.FC<ResultVideoCardProps> = ({
|
||||
video,
|
||||
index,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onShare,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-video-card" onClick={() => onPreview(video)}>
|
||||
<div className="xx-video-thumb">
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="xx-video-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-video-play-overlay">
|
||||
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
||||
</div>
|
||||
{video.duration && (
|
||||
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-video-info">
|
||||
<div className="xx-video-title">视频 {index + 1}</div>
|
||||
<div className="xx-video-actions">
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDownload()
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShare()
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 标题库 AutoComplete(Issue #1737)
|
||||
*
|
||||
* 原生 antd AutoComplete(combobox 模式)的两个行为不符合产品预期:
|
||||
* 1. combobox 默认 showAction=[],输入框聚焦时下拉不展开——用户必须先打字才能看到标题库,
|
||||
* 且组件无下拉箭头,视觉上是"纯输入框",不知道标题库里已有标题可选。
|
||||
* 2. 空态聚焦不展示任何标题库内容。
|
||||
*
|
||||
* 本组件封装修复:
|
||||
* - 受控 open:聚焦(且标题库非空)即展开,展示全部标题;失焦/选中/Esc 关闭
|
||||
* (rc-select 失焦会主动 onToggleOpen(false),onOpenChange 同步状态即可,不会死循环)
|
||||
* - suffixIcon 加下拉三角,视觉提示"可选择";有值时 allowClear 的清除按钮照常出现
|
||||
* - 输入文字时由 filterOption 过滤(空串展示全部)
|
||||
* - 保留 combobox 自由输入能力:用户可输入标题库之外的自定义标题
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { AutoComplete } from "antd"
|
||||
import { DownOutlined } from "@ant-design/icons"
|
||||
import type { AutoCompleteProps } from "antd"
|
||||
|
||||
export interface TitleOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface TitleLibraryAutoCompleteProps {
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
options: TitleOption[]
|
||||
placeholder?: string
|
||||
allowClear?: boolean
|
||||
maxLength?: number
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const TitleLibraryAutoComplete: React.FC<TitleLibraryAutoCompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "输入或从标题库选择",
|
||||
allowClear = true,
|
||||
maxLength = 50,
|
||||
style,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasTitles = options.length > 0
|
||||
|
||||
const filterOption: AutoCompleteProps["filterOption"] = (inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
value={value || undefined}
|
||||
onChange={(val) => onChange(val || "")}
|
||||
options={options}
|
||||
filterOption={filterOption}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onFocus={() => {
|
||||
// 标题库为空时不展开(避免弹出"暂无数据"空壳)
|
||||
if (hasTitles) setOpen(true)
|
||||
}}
|
||||
onSelect={() => setOpen(false)}
|
||||
suffixIcon={<DownOutlined style={{ color: "var(--text-secondary, #bbb)", fontSize: 12 }} />}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
maxLength={maxLength}
|
||||
style={{ width: "100%", ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleLibraryAutoComplete
|
||||
@@ -3282,24 +3282,13 @@
|
||||
/* ============================================================
|
||||
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
|
||||
============================================================ */
|
||||
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
|
||||
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
|
||||
.xx-canvas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 220px));
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
|
||||
@media (max-width: 960px) {
|
||||
.xx-canvas-grid {
|
||||
grid-template-columns: minmax(0, 320px);
|
||||
}
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
@@ -3341,37 +3330,6 @@
|
||||
accent-color: var(--primary-color, #1677ff);
|
||||
}
|
||||
|
||||
/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */
|
||||
.xx-variant-clips-status {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: #0a0a0a;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||
0 20px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.xx-variant-clips-status .anticon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.xx-variant-clips-error-text {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
批量标题:AI 一键生成行(Issue #1677)
|
||||
============================================================ */
|
||||
@@ -3477,8 +3435,9 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── 响应式:窄屏批量网格回退单列(.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
|
||||
/* ── 响应式:窄屏批量网格回退单列 ── */
|
||||
@media (max-width: 960px) {
|
||||
.xx-canvas-grid,
|
||||
.xx-batch-gen-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -42,12 +42,6 @@ export interface UseGenerateVideoProps {
|
||||
variantCoverUrls?: string[]
|
||||
/** 勾选要生成的变体索引(批量模式) */
|
||||
selectedVariantIndexes?: number[]
|
||||
/**
|
||||
* 批量变体剪辑计划 ID(#1744,按变体全量索引长度=previewCount):
|
||||
* 预览阶段后端独立选片产出,正式生成按勾选顺序回传,实现预览即成片;
|
||||
* 为空(降级本地模拟/后端未上线)时不传,后端自行独立选片。
|
||||
*/
|
||||
variantPlanIds?: string[]
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
@@ -13,22 +13,21 @@ interface UseSmartMatchOptions {
|
||||
}
|
||||
|
||||
/** 默认 limit(拿不到目标时长时的兜底上限) */
|
||||
const DEFAULT_LIMIT = 30
|
||||
/**
|
||||
* 候选池放大倍数(#1744):批量 N 个变体独立选片、跨变体 20% 区间避让需要
|
||||
* 足够大的素材池才能保证 N 条成片素材排布互不相同(历史已用区间写回素材 metadata)。
|
||||
* 旧规则「总时长/15 秒」只够 1 条成片的选片量,池太小会导致变体间大量复用同区间。
|
||||
*/
|
||||
const ASSET_POOL_MULTIPLIER = 3
|
||||
const DEFAULT_LIMIT = 10
|
||||
/** 每个素材切片按 15 秒估算所需素材数 */
|
||||
const SECONDS_PER_ASSET = 15
|
||||
|
||||
/**
|
||||
* 根据模板 segments 计算智能匹配候选素材数量上限(#1750:模板无时长概念)。
|
||||
* 候选池 = 片段数 × 3:每个片段至少 3 个候选素材供变体间洗牌/避让
|
||||
* (独立选片 + 区间不重叠);结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
|
||||
* 根据模板 segments 计算所需素材数量上限。
|
||||
* 取每个 segment 的 duration_min 之和作为目标视频总时长,
|
||||
* 再按 15 秒/素材估算需要多少个素材,且保证不少于片段数(每个片段至少 1 个素材);
|
||||
* 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
|
||||
*/
|
||||
export function computeLimitFromSegments(segments?: TemplateSegment[]): number {
|
||||
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
|
||||
if (!segments || segments.length === 0) return DEFAULT_LIMIT
|
||||
const limit = segments.length * ASSET_POOL_MULTIPLIER
|
||||
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
|
||||
if (totalSeconds <= 0) return DEFAULT_LIMIT
|
||||
const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET))
|
||||
return Math.max(1, Math.min(limit, 200))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
/**
|
||||
* 批量封面 Hook(Issue #1677 / #1750 修复)
|
||||
* 批量封面 Hook(Issue #1677)
|
||||
* N 个视频时:逐个自动生成封面(从对应成片抽帧 + 叠加对应标题)或上传自定义封面
|
||||
*
|
||||
* #1750 修复:
|
||||
* - patchCover 改为函数式 updater 回写(onCoversChange 支持 (prev)=>next),
|
||||
* 修复串行 generateAll 时闭包捕获旧 covers 数组导致回写互相覆盖的 bug;
|
||||
* - loading/uploading 状态改数组(busyIndexes/uploadingIndexes),支持多个卡片在途;
|
||||
* - generateOne 返回 boolean,generateAll 据此统计成功/失败数量分级提示,单个失败不阻塞;
|
||||
* - generateAll 一次点击即处理所有未生成封面(无需点 3 次)。
|
||||
*/
|
||||
import { useCallback, useState } from "react"
|
||||
import { message } from "antd"
|
||||
@@ -15,9 +8,6 @@ import { generateCover } from "@/api/generation"
|
||||
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */
|
||||
type CoversChangeFn = (updater: string[] | ((prev: string[]) => string[])) => void
|
||||
|
||||
interface UseBatchCoversOptions {
|
||||
selectedTemplate: string
|
||||
generatedVideos: GeneratedVideo[]
|
||||
@@ -34,7 +24,7 @@ interface UseBatchCoversOptions {
|
||||
shadow: boolean
|
||||
}
|
||||
covers: string[]
|
||||
onCoversChange: CoversChangeFn
|
||||
onCoversChange: (urls: string[]) => void
|
||||
}
|
||||
|
||||
export function useBatchCovers({
|
||||
@@ -45,46 +35,28 @@ export function useBatchCovers({
|
||||
covers,
|
||||
onCoversChange,
|
||||
}: UseBatchCoversOptions) {
|
||||
/** 正在自动生成封面的变体序号集合(数组支持多个卡片同时在途) */
|
||||
const [busyIndexes, setBusyIndexes] = useState<number[]>([])
|
||||
/** 正在上传自定义封面的变体序号集合 */
|
||||
const [uploadingIndexes, setUploadingIndexes] = useState<number[]>([])
|
||||
const [loadingIndex, setLoadingIndex] = useState<number | null>(null)
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null)
|
||||
|
||||
const addBusy = useCallback((i: number) => {
|
||||
setBusyIndexes((prev) => (prev.includes(i) ? prev : [...prev, i]))
|
||||
}, [])
|
||||
const removeBusy = useCallback((i: number) => {
|
||||
setBusyIndexes((prev) => prev.filter((x) => x !== i))
|
||||
}, [])
|
||||
const addUploading = useCallback((i: number) => {
|
||||
setUploadingIndexes((prev) => (prev.includes(i) ? prev : [...prev, i]))
|
||||
}, [])
|
||||
const removeUploading = useCallback((i: number) => {
|
||||
setUploadingIndexes((prev) => prev.filter((x) => x !== i))
|
||||
}, [])
|
||||
|
||||
/** 函数式回写单个封面位置(基于最新 prev,不受闭包旧值影响) */
|
||||
const patchCover = useCallback(
|
||||
(index: number, url: string) => {
|
||||
onCoversChange((prev) => {
|
||||
const next = [...prev]
|
||||
next[index] = url
|
||||
return next
|
||||
})
|
||||
const next = [...covers]
|
||||
next[index] = url
|
||||
onCoversChange(next)
|
||||
},
|
||||
[onCoversChange],
|
||||
[covers, onCoversChange],
|
||||
)
|
||||
|
||||
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
|
||||
/** 为第 index 个视频自动生成封面 */
|
||||
const generateOne = useCallback(
|
||||
async (index: number): Promise<boolean> => {
|
||||
async (index: number) => {
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const target = finalVideos[index] || generatedVideos[index]
|
||||
if (!target) {
|
||||
message.warning("该视频尚未生成完成")
|
||||
return false
|
||||
return
|
||||
}
|
||||
addBusy(index)
|
||||
setLoadingIndex(index)
|
||||
try {
|
||||
const titleText = titles[index] || ""
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
@@ -110,25 +82,23 @@ export function useBatchCovers({
|
||||
if (url) {
|
||||
patchCover(index, url)
|
||||
message.success(`视频 ${index + 1} 封面生成成功`)
|
||||
return true
|
||||
} else {
|
||||
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
|
||||
}
|
||||
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
|
||||
return false
|
||||
} catch (err) {
|
||||
console.error(`[封面] 视频 ${index + 1} 生成失败:`, err)
|
||||
message.error(`视频 ${index + 1} 封面生成失败,请重试`)
|
||||
return false
|
||||
} finally {
|
||||
removeBusy(index)
|
||||
setLoadingIndex(null)
|
||||
}
|
||||
},
|
||||
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
|
||||
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover],
|
||||
)
|
||||
|
||||
/** 为第 index 个视频上传自定义封面 */
|
||||
const uploadOne = useCallback(
|
||||
async (index: number, file: File) => {
|
||||
addUploading(index)
|
||||
setUploadingIndex(index)
|
||||
try {
|
||||
const libs = await getAssetLibraries()
|
||||
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
|
||||
@@ -151,43 +121,26 @@ export function useBatchCovers({
|
||||
console.error(`[封面] 视频 ${index + 1} 上传失败:`, err)
|
||||
message.error("封面上传失败,请重试")
|
||||
} finally {
|
||||
removeUploading(index)
|
||||
setUploadingIndex(null)
|
||||
}
|
||||
},
|
||||
[patchCover, addUploading, removeUploading],
|
||||
[patchCover],
|
||||
)
|
||||
|
||||
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
|
||||
/** 一键全部自动生成(串行,避免队列限流) */
|
||||
const generateAll = useCallback(async () => {
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const total = finalVideos.length
|
||||
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
|
||||
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
|
||||
const pending = finalVideos.map((_, i) => i).filter((i) => !covers[i])
|
||||
if (pending.length === 0) {
|
||||
message.info("所有视频封面已生成")
|
||||
return
|
||||
}
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
for (const i of pending) {
|
||||
for (let i = 0; i < finalVideos.length; i++) {
|
||||
if (covers[i]) continue // 已有封面跳过
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const ok = await generateOne(i)
|
||||
if (ok) okCount += 1
|
||||
else failCount += 1
|
||||
}
|
||||
if (failCount === 0) {
|
||||
message.success(`全部 ${total} 个封面已生成`)
|
||||
} else if (okCount === 0) {
|
||||
message.error("封面生成全部失败,请重试")
|
||||
} else {
|
||||
message.warning(`已生成 ${okCount} 个封面,${failCount} 个失败,请对失败项重试`)
|
||||
await generateOne(i)
|
||||
}
|
||||
message.success("全部封面已生成")
|
||||
}, [generatedVideos, covers, generateOne])
|
||||
|
||||
return {
|
||||
busyIndexes,
|
||||
uploadingIndexes,
|
||||
loadingIndex,
|
||||
uploadingIndex,
|
||||
generateOne,
|
||||
uploadOne,
|
||||
generateAll,
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* 批量变体真实片段 Hook(#1744 接入 / #1750 修正)
|
||||
*
|
||||
* 批量预览(N>1)进入第 4 步时,向后端轻量接口 POST /generation/variant-plans
|
||||
* 申请 N 个变体的独立剪辑计划片段:
|
||||
* - 接口只做选片/建 plan(秒级),不渲染视频,无渲染成本;
|
||||
* - 选片逻辑与正式批量生成(POST /generation/tasks?count=N)完全同源
|
||||
* (reselect_plan_for_variant:素材洗牌 + main 片段洗牌 + 起点随机 +
|
||||
* 跨变体 20% 区间避让 + 使用区间写回素材 metadata),
|
||||
* 因此前端按这些 clips 播放的预览与最终成片一致;
|
||||
* - 正式生成时把 plan_ids 回传,后端直接关联预览 plan 渲染,不再重新选片。
|
||||
*
|
||||
* 错误处理(#1750 基线:严禁用前端假数据冒充预览):
|
||||
* - 端点 404(后端未上线)/ 400 / 网络错误 / 超时:一律置 error 状态,
|
||||
* UI 显示「预览加载失败,请重试」,不再静默降级本地模拟;
|
||||
* - 单个变体 clips 为空同样视为加载失败(计划数据不完整)。
|
||||
*
|
||||
* N=1 不调用本 hook(单视频零回归)。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { createBatchVariantPlans, type VariantPlan } from "@/api/generation/variantPlans"
|
||||
|
||||
export interface BatchVariantClipsState {
|
||||
/** 各变体的服务端真实片段(按 variant_index 排序);未就绪为空数组 */
|
||||
clipsByVariant: EditPlanClip[][]
|
||||
/** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */
|
||||
planIdsByVariant: string[]
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
loading: boolean
|
||||
/** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */
|
||||
ready: boolean
|
||||
/** 加载失败(端点未上线/网络错误/数据不完整);true 时 UI 显示错误态+重试 */
|
||||
error: boolean
|
||||
/** 用户点击「重试」:强制重新申请变体计划 */
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
interface UseBatchVariantPlansOptions {
|
||||
/** 是否启用:仅批量(count>1)且素材已选时为 true */
|
||||
enabled: boolean
|
||||
count: number
|
||||
templateId: string
|
||||
assetIds: string[]
|
||||
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
|
||||
sourcePlanId?: string
|
||||
}
|
||||
|
||||
export function useBatchVariantPlans({
|
||||
enabled,
|
||||
count,
|
||||
templateId,
|
||||
assetIds,
|
||||
sourcePlanId = "",
|
||||
}: UseBatchVariantPlansOptions): BatchVariantClipsState {
|
||||
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
|
||||
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const requestSeqRef = useRef(0)
|
||||
// 记录上次成功申请的入参指纹,素材/数量未变时不重复请求
|
||||
const lastKeyRef = useRef("")
|
||||
// 重试计数(state):用户点重试时 +1,驱动 effect 重新发起请求(即使指纹相同);
|
||||
// 用 state 而非 ref + 直接调 load,避免 retry 与 effect 双发导致 seq 竞争
|
||||
const [retryNonce, setRetryNonce] = useState(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++requestSeqRef.current
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const resp = await createBatchVariantPlans({
|
||||
template_id: templateId,
|
||||
asset_ids: assetIds,
|
||||
count,
|
||||
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
|
||||
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
|
||||
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
|
||||
const planIds: string[] = Array.from({ length: count }, () => "")
|
||||
for (const item of items) {
|
||||
const idx = item.variant_index
|
||||
if (idx < 0 || idx >= count) continue
|
||||
planIds[idx] = item.plan_id || ""
|
||||
clips[idx] = (item.clips || [])
|
||||
.filter((c) => c && c.asset_id && c.status === "ready")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
// 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充)
|
||||
const incomplete = clips.some((list) => list.length === 0)
|
||||
if (incomplete) {
|
||||
console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败")
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
setError(true)
|
||||
} else {
|
||||
setClipsByVariant(clips)
|
||||
setPlanIdsByVariant(planIds)
|
||||
setError(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (seq !== requestSeqRef.current) return
|
||||
// 404(后端端点未上线)/ 400(参数错误)/ 网络错误 / 超时:统一错误态
|
||||
console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err)
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
setError(true)
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoading(false)
|
||||
}
|
||||
}, [templateId, count, sourcePlanId, assetIds])
|
||||
|
||||
/** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */
|
||||
const retry = useCallback(() => {
|
||||
setRetryNonce((n) => n + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !templateId || assetIds.length === 0 || count <= 1) {
|
||||
requestSeqRef.current += 1
|
||||
// 函数式更新:已是目标值时返回 prev(Object.is 相等 React 跳过渲染),
|
||||
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
|
||||
setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setLoading((prev) => (prev === false ? prev : false))
|
||||
setError((prev) => (prev === false ? prev : false))
|
||||
lastKeyRef.current = ""
|
||||
return
|
||||
}
|
||||
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
|
||||
.sort()
|
||||
.join(",")}`
|
||||
if (key === lastKeyRef.current) return
|
||||
lastKeyRef.current = key
|
||||
load()
|
||||
}, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce])
|
||||
|
||||
const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0)
|
||||
|
||||
return {
|
||||
clipsByVariant,
|
||||
planIdsByVariant,
|
||||
loading,
|
||||
ready,
|
||||
error,
|
||||
retry,
|
||||
}
|
||||
}
|
||||
|
||||
export default useBatchVariantPlans
|
||||
@@ -178,14 +178,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
isBatch && props.variantCoverUrls?.length
|
||||
? indexes.map((i) => props.variantCoverUrls![i] || "")
|
||||
: []
|
||||
// #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传,
|
||||
// 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。
|
||||
// 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。
|
||||
const variantPlansArr =
|
||||
isBatch && props.variantPlanIds?.length
|
||||
? indexes.map((i) => props.variantPlanIds![i] || "")
|
||||
: []
|
||||
const hasVariantPlans = variantPlansArr.some((id) => !!id)
|
||||
|
||||
try {
|
||||
const taskResp = await createGenerationTask({
|
||||
@@ -208,7 +200,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
...(titlesArr.length ? { titles: titlesArr } : {}),
|
||||
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
|
||||
...(coversArr.length ? { cover_urls: coversArr } : {}),
|
||||
...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}),
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
|
||||
@@ -44,12 +44,6 @@ interface UsePreviewAssetsReturn {
|
||||
ready: boolean
|
||||
/** 手动触发重新加载 */
|
||||
reload: () => void
|
||||
/**
|
||||
* 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材
|
||||
* (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并,
|
||||
* 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。
|
||||
*/
|
||||
ensureAssets: (ids: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,41 +91,11 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
}
|
||||
}, [stableAssetIds, enabled])
|
||||
|
||||
// 补拉用独立序号:不干扰主 load 的竞态守卫
|
||||
const ensureSeqRef = useRef(0)
|
||||
const assetsRef = useRef<AssetItem[]>([])
|
||||
useEffect(() => {
|
||||
assetsRef.current = assets
|
||||
}, [assets])
|
||||
|
||||
/**
|
||||
* 差集补拉(#1750):变体计划 clips 引用的 asset_id 不在当前素材列表时,
|
||||
* 补拉素材详情并去重合并(不静默丢片段、不用假数据冒充)。
|
||||
*/
|
||||
const ensureAssets = useCallback(async (ids: string[]) => {
|
||||
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
if (!validIds.length) return
|
||||
const seq = ++ensureSeqRef.current
|
||||
const missing = Array.from(new Set(validIds)).filter(
|
||||
(id) => !assetsRef.current.some((a) => a.id === id),
|
||||
)
|
||||
if (!missing.length) return
|
||||
const fetched = await fetchAssetsByIds(missing)
|
||||
if (seq !== ensureSeqRef.current || !fetched.length) return
|
||||
const existing = new Set(assetsRef.current.map((a) => a.id))
|
||||
const additions = fetched.filter((a) => !existing.has(a.id))
|
||||
if (!additions.length) return
|
||||
const merged = [...assetsRef.current, ...additions]
|
||||
assetsRef.current = merged
|
||||
setAssets(merged)
|
||||
setReady(merged.length > 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return { assets, loading, ready, reload: load, ensureAssets }
|
||||
return { assets, loading, ready, reload: load }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 服务器渲染预览 Hook
|
||||
*
|
||||
* 核心职责:
|
||||
* 1. 调用 POST /generation/preview 创建服务器预览渲染任务
|
||||
* 2. 轮询 GET /generation/preview/{task_id} 直到完成
|
||||
* 3. 返回服务器渲染的真实视频 URL(供 <video> 标签播放)
|
||||
* 4. 检测配置变更,标记预览失效(stale)或自动重新渲染
|
||||
* 5. 网络错误自动重试 2 次
|
||||
*
|
||||
* 状态机:
|
||||
* idle → loading → ready → stale (config changed)
|
||||
* ↘ failed → idle (retry)
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import type { CreatePreviewRequest } from "@/api/generation/types"
|
||||
|
||||
export type ServerPreviewStatus = "idle" | "loading" | "ready" | "stale" | "failed"
|
||||
|
||||
interface UseServerPreviewOptions {
|
||||
/** 是否启用预览(Step4+ 且有素材和模板时) */
|
||||
enabled: boolean
|
||||
/** 构建预览请求参数(每次 render 调用,获取最新配置) */
|
||||
buildRequest: () => CreatePreviewRequest
|
||||
/** 预览任务创建成功回调 */
|
||||
onPreviewTaskCreated?: (taskId: string, sourceEditPlanId?: string) => void
|
||||
}
|
||||
|
||||
interface UseServerPreviewReturn {
|
||||
status: ServerPreviewStatus
|
||||
videoUrl: string | null
|
||||
error: string | null
|
||||
/** 进度 0-100 */
|
||||
progress: number
|
||||
/** 手动触发预览创建("重新预览"按钮或标题变更后手动刷新) */
|
||||
triggerPreview: () => void
|
||||
/** 当前预览任务 ID */
|
||||
taskId: string | null
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 2000
|
||||
const POLL_TIMEOUT = 120_000
|
||||
const MAX_NETWORK_RETRIES = 2
|
||||
|
||||
/**
|
||||
* 对配置参数做指纹,用于检测配置是否变化
|
||||
*/
|
||||
function buildFingerprint(req: CreatePreviewRequest): string {
|
||||
return JSON.stringify({
|
||||
t: req.template_id,
|
||||
a: [...req.asset_ids].sort(),
|
||||
d: req.duration,
|
||||
r: req.video_ratio,
|
||||
v: req.voice_library_id,
|
||||
b: req.bgm_config,
|
||||
title: req.title_config,
|
||||
})
|
||||
}
|
||||
|
||||
export function useServerPreview({
|
||||
enabled,
|
||||
buildRequest,
|
||||
onPreviewTaskCreated,
|
||||
}: UseServerPreviewOptions): UseServerPreviewReturn {
|
||||
const [status, setStatus] = useState<ServerPreviewStatus>("idle")
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [taskId, setTaskId] = useState<string | null>(null)
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const requestSeqRef = useRef(0)
|
||||
const renderedFingerprintRef = useRef<string>("")
|
||||
const mountedRef = useRef(true)
|
||||
const networkRetriesRef = useRef(0)
|
||||
|
||||
// 始终持有最新的 buildRequest 和回调
|
||||
const buildRequestRef = useRef(buildRequest)
|
||||
buildRequestRef.current = buildRequest
|
||||
const onCreatedRef = useRef(onPreviewTaskCreated)
|
||||
onCreatedRef.current = onPreviewTaskCreated
|
||||
|
||||
/* ── 清理 ── */
|
||||
const clearTimers = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
if (timeoutTimerRef.current) {
|
||||
clearTimeout(timeoutTimerRef.current)
|
||||
timeoutTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearTimers()
|
||||
}
|
||||
}, [clearTimers])
|
||||
|
||||
/* ── 创建预览 + 轮询 ── */
|
||||
const createAndPoll = useCallback(
|
||||
async (request: CreatePreviewRequest, seq: number) => {
|
||||
setStatus("loading")
|
||||
setProgress(0)
|
||||
setError(null)
|
||||
networkRetriesRef.current = 0
|
||||
|
||||
try {
|
||||
const resp = await createPreview(request)
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
// 兼容批量响应 {items, total}:取第一个变体
|
||||
const firstTask = resp.items?.[0]
|
||||
const taskId = firstTask?.task_id || ""
|
||||
setTaskId(taskId)
|
||||
onCreatedRef.current?.(taskId, resp.source_edit_plan_id)
|
||||
|
||||
let completed = false
|
||||
|
||||
// 超时保护
|
||||
timeoutTimerRef.current = setTimeout(() => {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError("预览渲染超时(120秒),请重试")
|
||||
}, POLL_TIMEOUT)
|
||||
|
||||
const poll = async () => {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
try {
|
||||
const st = await getPreviewStatus(taskId)
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
if (st.status === "completed" && st.video_url) {
|
||||
completed = true
|
||||
clearTimers()
|
||||
renderedFingerprintRef.current = buildFingerprint(request)
|
||||
setVideoUrl(st.video_url)
|
||||
setProgress(100)
|
||||
setStatus("ready")
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (st.status === "failed" || st.status === "cancelled") {
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError(
|
||||
st.status === "cancelled"
|
||||
? "预览任务已取消"
|
||||
: st.error_message || "预览渲染失败,请重试",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating
|
||||
if (typeof st.progress === "number") setProgress(st.progress)
|
||||
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL)
|
||||
} catch (pollErr) {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
if (networkRetriesRef.current < MAX_NETWORK_RETRIES) {
|
||||
networkRetriesRef.current += 1
|
||||
console.warn(
|
||||
`[ServerPreview] 轮询网络错误,第 ${networkRetriesRef.current} 次重试`,
|
||||
pollErr,
|
||||
)
|
||||
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL * 2)
|
||||
} else {
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError("网络错误,无法获取预览状态,请重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
} catch (createErr) {
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
console.error("[ServerPreview] 创建预览任务失败:", createErr)
|
||||
|
||||
const isNetworkError =
|
||||
!!(createErr as { request?: unknown })?.request ||
|
||||
(createErr as { code?: string })?.code === "ERR_NETWORK"
|
||||
|
||||
if (isNetworkError && networkRetriesRef.current < MAX_NETWORK_RETRIES) {
|
||||
networkRetriesRef.current += 1
|
||||
console.warn(`[ServerPreview] 创建任务网络错误,第 ${networkRetriesRef.current} 次重试`)
|
||||
setTimeout(() => {
|
||||
if (seq === requestSeqRef.current && mountedRef.current) {
|
||||
createAndPoll(request, seq)
|
||||
}
|
||||
}, POLL_INTERVAL * 2)
|
||||
return
|
||||
}
|
||||
|
||||
const errData = (
|
||||
createErr as { response?: { data?: { detail?: string; message?: string } } }
|
||||
)?.response?.data
|
||||
setStatus("failed")
|
||||
setError(errData?.detail || errData?.message || "预览任务创建失败,请重试")
|
||||
}
|
||||
},
|
||||
[clearTimers],
|
||||
)
|
||||
|
||||
/* ── 手动触发预览 ── */
|
||||
const triggerPreview = useCallback(() => {
|
||||
if (!enabled) return
|
||||
const request = buildRequestRef.current()
|
||||
if (!request.template_id || request.asset_ids.length === 0) return
|
||||
|
||||
clearTimers()
|
||||
const seq = ++requestSeqRef.current
|
||||
setVideoUrl(null)
|
||||
setTaskId(null)
|
||||
createAndPoll(request, seq)
|
||||
}, [enabled, clearTimers, createAndPoll])
|
||||
|
||||
/* ── 自动触发 + 配置变更检测 ── */
|
||||
// 每次 render 都检查最新配置 fingerprint,与已渲染的 fingerprint 比较
|
||||
const request = enabled ? buildRequest() : null
|
||||
const currentFingerprint = request
|
||||
? request.template_id && request.asset_ids.length > 0
|
||||
? buildFingerprint(request)
|
||||
: ""
|
||||
: ""
|
||||
|
||||
// 首次进入自动触发
|
||||
const didInitRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentFingerprint) {
|
||||
didInitRef.current = false
|
||||
// 禁用时取消进行中的轮询,避免回到前序步骤后仍在后台轮询
|
||||
requestSeqRef.current += 1
|
||||
clearTimers()
|
||||
return
|
||||
}
|
||||
if (!didInitRef.current) {
|
||||
didInitRef.current = true
|
||||
renderedFingerprintRef.current = currentFingerprint
|
||||
triggerPreview()
|
||||
}
|
||||
}, [enabled, currentFingerprint, triggerPreview, clearTimers])
|
||||
|
||||
// 配置变更检测:素材/配音/BGM 等变化 → 自动重渲染;标题样式变化 → 标记 stale
|
||||
const prevFingerprintRef = useRef(currentFingerprint)
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentFingerprint) return
|
||||
const prev = prevFingerprintRef.current
|
||||
prevFingerprintRef.current = currentFingerprint
|
||||
|
||||
if (!prev || prev === currentFingerprint) return
|
||||
if (currentFingerprint === renderedFingerprintRef.current) return
|
||||
|
||||
// 配置已变更
|
||||
// 判断是标题样式变更还是素材/配音/BGM 变更
|
||||
const prevParsed = JSON.parse(prev) as Record<string, unknown>
|
||||
const currParsed = JSON.parse(currentFingerprint) as Record<string, unknown>
|
||||
const nonTitleChanged =
|
||||
prevParsed.t !== currParsed.t ||
|
||||
prevParsed.a !== currParsed.a ||
|
||||
prevParsed.d !== currParsed.d ||
|
||||
prevParsed.r !== currParsed.r ||
|
||||
prevParsed.v !== currParsed.v ||
|
||||
JSON.stringify(prevParsed.b) !== JSON.stringify(currParsed.b)
|
||||
|
||||
if (nonTitleChanged) {
|
||||
// 素材/配音/BGM/模板等变化 → 自动重新渲染
|
||||
renderedFingerprintRef.current = currentFingerprint
|
||||
triggerPreview()
|
||||
} else {
|
||||
// 仅标题文字/样式变化 → 标记 stale,不自动重渲染(避免频繁请求)
|
||||
// 实时预览由 CSS TitleOverlay 提供
|
||||
setStatus((s) => (s === "ready" ? "stale" : s))
|
||||
}
|
||||
}, [enabled, currentFingerprint, triggerPreview])
|
||||
|
||||
return {
|
||||
status,
|
||||
videoUrl,
|
||||
error,
|
||||
progress,
|
||||
triggerPreview,
|
||||
taskId,
|
||||
}
|
||||
}
|
||||
|
||||
export default useServerPreview
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* 批量变体配音预览音频解析(#1750)
|
||||
*
|
||||
* 独立配音模式下每个变体挂载各自的配音 URL:
|
||||
* - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL;
|
||||
* - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成;
|
||||
* - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。
|
||||
*
|
||||
* N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
|
||||
interface UseVariantVoicePreviewOptions {
|
||||
/** 是否批量模式(count>1) */
|
||||
enabled: boolean
|
||||
/** 变体数量 */
|
||||
count: number
|
||||
/** 是否每个视频独立配音 */
|
||||
perVideo: boolean
|
||||
/** 共用配音 ID(独立模式下为变体 0 的配音) */
|
||||
sharedVoiceId: string
|
||||
/** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice) */
|
||||
clonedVoiceId?: string
|
||||
/** 各变体独立配音 ID(独立模式);长度不足时回退共用 */
|
||||
variantVoiceIds: string[]
|
||||
/** 各变体标题(TTS 合成文案源) */
|
||||
titles: string[]
|
||||
}
|
||||
|
||||
/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */
|
||||
const EMPTY_VOICE_MATERIALS: AssetItem[] = []
|
||||
|
||||
/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */
|
||||
function findMaterialUrl(id: string, materials: AssetItem[]): string | null {
|
||||
if (!id) return null
|
||||
const m = materials.find((x) => x.id === id)
|
||||
return m?.file_url || null
|
||||
}
|
||||
|
||||
export function useVariantVoicePreview({
|
||||
enabled,
|
||||
count,
|
||||
perVideo,
|
||||
sharedVoiceId,
|
||||
clonedVoiceId = "",
|
||||
variantVoiceIds,
|
||||
titles,
|
||||
}: UseVariantVoicePreviewOptions): (string | null)[] {
|
||||
const [urls, setUrls] = useState<(string | null)[]>([])
|
||||
// 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发
|
||||
const { data: voiceMaterialsData } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS
|
||||
// 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成
|
||||
const ttsCacheRef = useRef<Map<string, string>>(new Map())
|
||||
// 在途请求 AbortController
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || count <= 1) {
|
||||
setUrls((prev) => (prev.length === 0 ? prev : []))
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++seqRef.current
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
// 每个变体实际使用的配音 ID
|
||||
const voiceIds = Array.from({ length: count }, (_, i) =>
|
||||
perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId,
|
||||
)
|
||||
|
||||
// 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成
|
||||
const result: (string | null)[] = voiceIds.map((id, i) => {
|
||||
const materialUrl = findMaterialUrl(id, voiceMaterials)
|
||||
if (materialUrl) return materialUrl
|
||||
// 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致)
|
||||
if (!perVideo && i === 0 && clonedVoiceId) {
|
||||
return findMaterialUrl(clonedVoiceId, voiceMaterials)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID
|
||||
const ttsJobs: { index: number; voiceId: string; title: string }[] = []
|
||||
voiceIds.forEach((id, i) => {
|
||||
if (result[i]) return
|
||||
// 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice
|
||||
const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id
|
||||
const title = titles[i] || ""
|
||||
if (!ttsVoiceId || !title) return
|
||||
ttsJobs.push({ index: i, voiceId: ttsVoiceId, title })
|
||||
})
|
||||
|
||||
setUrls((prev) =>
|
||||
prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result,
|
||||
)
|
||||
|
||||
if (ttsJobs.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
// 串行 TTS,避免瞬间 N 个合成请求打爆后端
|
||||
for (const job of ttsJobs) {
|
||||
const cacheKey = `${job.voiceId}|${job.title}`
|
||||
const cached = ttsCacheRef.current.get(cacheKey)
|
||||
if (cached) {
|
||||
if (seq === seqRef.current) {
|
||||
setUrls((prev) => {
|
||||
if (prev[job.index] === cached) return prev
|
||||
const next = [...prev]
|
||||
next[job.index] = cached
|
||||
return next
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
const audioUrl = res.audio_url || ""
|
||||
if (audioUrl) {
|
||||
ttsCacheRef.current.set(cacheKey, audioUrl)
|
||||
setUrls((prev) => {
|
||||
if (prev[job.index] === audioUrl) return prev
|
||||
const next = [...prev]
|
||||
next[job.index] = audioUrl
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
console.warn(`[变体${job.index + 1}预览配音生成失败]`, err)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
count,
|
||||
perVideo,
|
||||
sharedVoiceId,
|
||||
clonedVoiceId,
|
||||
variantVoiceIds,
|
||||
titles,
|
||||
voiceMaterials,
|
||||
])
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
export default useVariantVoicePreview
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 共享:根据素材列表和模板片段计算总视频时长
|
||||
* GeneratePage(配音校验)和 FrontendPreviewPlayer(播放控制)共用
|
||||
*/
|
||||
|
||||
export interface DurationAsset {
|
||||
id?: string
|
||||
duration?: number
|
||||
metadata?: { duration?: number }
|
||||
}
|
||||
|
||||
export interface DurationTemplateSegment {
|
||||
duration_min?: number
|
||||
duration_max?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算总视频时长
|
||||
* @param assets 素材列表
|
||||
* @param template 模板(含 segments)
|
||||
* @returns 总时长(秒),无有效数据时返回 0
|
||||
*/
|
||||
export function calculateTotalVideoDuration(
|
||||
assets: DurationAsset[] | undefined,
|
||||
template: { segments?: DurationTemplateSegment[] } | undefined,
|
||||
): number {
|
||||
if (!assets || assets.length === 0 || !template) return 0
|
||||
|
||||
const templateSegments = template.segments || []
|
||||
|
||||
return assets.reduce((sum, asset, i) => {
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
|
||||
const segDuration = tplSeg
|
||||
? Math.min(
|
||||
tplSeg.duration_max ?? assetDuration,
|
||||
Math.max(tplSeg.duration_min ?? 0, assetDuration),
|
||||
)
|
||||
: Math.min(assetDuration, 10)
|
||||
return sum + segDuration
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算总视频时长(仅依赖模板 segments)
|
||||
* 当素材未加载或加载失败时,用各片段 duration_max 之和作为估算值
|
||||
* 确保配音时长校验不会因素材未就绪而跳过
|
||||
*/
|
||||
export function estimateTotalVideoDuration(
|
||||
template: { segments?: DurationTemplateSegment[] } | undefined,
|
||||
): number {
|
||||
if (!template?.segments || template.segments.length === 0) return 0
|
||||
return template.segments.reduce((sum, seg) => sum + (seg.duration_max || 0), 0)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
|
||||
<div className="mt-card-meta">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{tpl.segments.length} 个片段
|
||||
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
|
||||
</Text>
|
||||
{tpl.category && <Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>}
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,11 @@ export function useMyTemplates() {
|
||||
title_config: tpl.title_config,
|
||||
subtitle_config: tpl.subtitle_config,
|
||||
bgm_config: tpl.bgm_config,
|
||||
estimated_duration:
|
||||
tpl.estimated_duration ??
|
||||
Math.round(
|
||||
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
|
||||
),
|
||||
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 模板库页面(升级版)— V21 设计系统
|
||||
* 对接后端模板管理 API:
|
||||
* - 分页查询(page/page_size/category/keyword)
|
||||
* - 分页查询(page/page_size/category/keyword/duration_range)
|
||||
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
|
||||
* - 复制模板 / 从模板生成
|
||||
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
||||
@@ -24,6 +24,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
error,
|
||||
searchText,
|
||||
activeType,
|
||||
durationRange,
|
||||
page,
|
||||
pageSize,
|
||||
setPage,
|
||||
@@ -33,6 +34,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
handleCreate,
|
||||
handleSearchChange,
|
||||
handleCategoryChange,
|
||||
handleDurationChange,
|
||||
} = useTemplateLibrary()
|
||||
|
||||
const {
|
||||
@@ -60,6 +62,8 @@ const TemplateLibrary: React.FC = () => {
|
||||
onSearchChange={handleSearchChange}
|
||||
activeType={activeType}
|
||||
onTypeChange={handleCategoryChange}
|
||||
durationRange={durationRange}
|
||||
onDurationChange={handleDurationChange}
|
||||
/>
|
||||
|
||||
{/* 模板展示区 */}
|
||||
@@ -73,6 +77,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
errorMessage={error?.message}
|
||||
searchText={searchText}
|
||||
activeType={activeType}
|
||||
durationRange={durationRange}
|
||||
onPageChange={setPage}
|
||||
onPreview={handlePreview}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react"
|
||||
import { Tag } from "antd"
|
||||
import type { TemplateItem } from "@/api/templates"
|
||||
import { gradientForCategory, getTypeColor } from "../../utils/templateLibrary"
|
||||
import { gradientForCategory, getTypeColor, formatDuration } from "../../utils/templateLibrary"
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: TemplateItem
|
||||
@@ -37,7 +37,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-thumb-meta">
|
||||
<span className="xx-template-thumb-duration">
|
||||
{template.clip_count || template.segments?.length || 0} 个片段
|
||||
{formatDuration(template.estimated_duration ?? template.target_duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||
|
||||
@@ -5,8 +5,9 @@ import PreviewArea from "./template-detail-modal/PreviewArea"
|
||||
import SegmentList from "./template-detail-modal/SegmentList"
|
||||
import StyleConfig from "./template-detail-modal/StyleConfig"
|
||||
import DetailFooter from "./template-detail-modal/DetailFooter"
|
||||
import { getTypeColor } from "../../utils/templateLibrary"
|
||||
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
|
||||
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
||||
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
|
||||
|
||||
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
template,
|
||||
@@ -17,6 +18,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
onCopy,
|
||||
}) => {
|
||||
const segments = template.segments ?? []
|
||||
const totalSegmentDuration = calcTotalSegmentDuration(segments)
|
||||
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
|
||||
return (
|
||||
@@ -62,6 +64,11 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
size="small"
|
||||
className="xx-template-modal-desc-table"
|
||||
items={[
|
||||
{
|
||||
key: "duration",
|
||||
label: "目标时长",
|
||||
children: formatDuration(template.estimated_duration ?? template.target_duration),
|
||||
},
|
||||
{
|
||||
key: "clips",
|
||||
label: "片段数量",
|
||||
@@ -80,7 +87,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
]}
|
||||
/>
|
||||
|
||||
<SegmentList segments={segments} />
|
||||
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
|
||||
<StyleConfig template={template} />
|
||||
<DetailFooter
|
||||
template={template}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface TemplateGridProps {
|
||||
errorMessage?: string
|
||||
searchText: string
|
||||
activeType: string
|
||||
durationRange: string
|
||||
onPageChange: (page: number) => void
|
||||
onPreview: (template: TemplateItem) => void
|
||||
onToggleFavorite: (id: string, e: React.MouseEvent) => void
|
||||
@@ -30,6 +31,7 @@ export const TemplateGrid: React.FC<TemplateGridProps> = ({
|
||||
errorMessage,
|
||||
searchText,
|
||||
activeType,
|
||||
durationRange,
|
||||
onPageChange,
|
||||
onPreview,
|
||||
onToggleFavorite,
|
||||
@@ -62,7 +64,7 @@ export const TemplateGrid: React.FC<TemplateGridProps> = ({
|
||||
|
||||
// 空状态
|
||||
if (templates.length === 0) {
|
||||
const hasFilter = !!searchText || activeType !== "全部"
|
||||
const hasFilter = !!searchText || activeType !== "全部" || !!durationRange
|
||||
return (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined } from "@ant-design/icons"
|
||||
import type { EditTemplateType } from "../../types/templateLibrary"
|
||||
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
||||
import type { EditTemplateType, DurationRange } from "../../types/templateLibrary"
|
||||
import { TEMPLATE_TYPES, DURATION_OPTIONS } from "../../constants/templateLibrary"
|
||||
|
||||
interface TemplateToolbarProps {
|
||||
searchText: string
|
||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
activeType: EditTemplateType | "全部"
|
||||
onTypeChange: (type: EditTemplateType | "全部") => void
|
||||
durationRange: DurationRange
|
||||
onDurationChange: (value: DurationRange) => void
|
||||
}
|
||||
|
||||
export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
|
||||
@@ -15,6 +17,8 @@ export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
|
||||
onSearchChange,
|
||||
activeType,
|
||||
onTypeChange,
|
||||
durationRange,
|
||||
onDurationChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-templates-toolbar">
|
||||
@@ -42,8 +46,18 @@ export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 时长筛选 */}
|
||||
<div className="xx-templates-duration-filter">
|
||||
{DURATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
||||
onClick={() => onDurationChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateToolbar
|
||||
|
||||
+13
-3
@@ -1,13 +1,15 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import type { TemplateSegment } from "@/api/templates"
|
||||
import { getMaterialTypeLabel, getTypeColor } from "../../../utils/templateLibrary"
|
||||
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
|
||||
|
||||
interface SegmentListProps {
|
||||
segments: TemplateSegment[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/** 素材规则 / 片段列表(#1750:模板只定片段数/结构,无时长概念) */
|
||||
const SegmentList: React.FC<SegmentListProps> = ({ segments }) => {
|
||||
/** 素材规则 / 片段列表 */
|
||||
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
@@ -33,9 +35,17 @@ const SegmentList: React.FC<SegmentListProps> = ({ segments }) => {
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EditTemplateType } from "../types/templateLibrary"
|
||||
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
|
||||
|
||||
export const TEMPLATE_TYPES: Array<{
|
||||
type: EditTemplateType | "全部"
|
||||
@@ -15,6 +15,16 @@ export const TEMPLATE_TYPES: Array<{
|
||||
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
|
||||
]
|
||||
|
||||
export const DURATION_OPTIONS: Array<{
|
||||
value: DurationRange
|
||||
label: string
|
||||
}> = [
|
||||
{ value: "", label: "全部时长" },
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
]
|
||||
|
||||
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type TemplateItem,
|
||||
type TemplateListParams,
|
||||
} from "@/api/templates"
|
||||
import type { EditTemplateType } from "../types/templateLibrary"
|
||||
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
|
||||
import { DEFAULT_PAGE_SIZE } from "../constants/templateLibrary"
|
||||
|
||||
export const useTemplateLibrary = () => {
|
||||
@@ -19,6 +19,7 @@ export const useTemplateLibrary = () => {
|
||||
/* 筛选状态 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部")
|
||||
const [durationRange, setDurationRange] = useState<DurationRange>("")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
|
||||
@@ -30,8 +31,9 @@ export const useTemplateLibrary = () => {
|
||||
}
|
||||
if (activeType !== "全部") params.category = activeType
|
||||
if (searchText.trim()) params.keyword = searchText.trim()
|
||||
if (durationRange) params.duration_range = durationRange
|
||||
return params
|
||||
}, [page, pageSize, activeType, searchText])
|
||||
}, [page, pageSize, activeType, searchText, durationRange])
|
||||
|
||||
/* 获取模板列表 */
|
||||
const {
|
||||
@@ -110,6 +112,12 @@ export const useTemplateLibrary = () => {
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
/* 切换时长筛选 */
|
||||
const handleDurationChange = useCallback((value: DurationRange) => {
|
||||
setDurationRange(value)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
/* 状态 */
|
||||
templates,
|
||||
@@ -119,6 +127,7 @@ export const useTemplateLibrary = () => {
|
||||
error,
|
||||
searchText,
|
||||
activeType,
|
||||
durationRange,
|
||||
page,
|
||||
pageSize,
|
||||
/* mutations */
|
||||
@@ -133,5 +142,6 @@ export const useTemplateLibrary = () => {
|
||||
handleCreate,
|
||||
handleSearchChange,
|
||||
handleCategoryChange,
|
||||
handleDurationChange,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"
|
||||
|
||||
/** 时长筛选值 */
|
||||
export type DurationRange = "" | "short" | "medium" | "long"
|
||||
|
||||
/** 配置展示字段 */
|
||||
export interface ConfigDisplayFields {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_GRADIENT,
|
||||
MATERIAL_TYPE_LABELS,
|
||||
} from "../constants/templateLibrary"
|
||||
import type { TemplateSegment } from "@/api/templates"
|
||||
|
||||
/** 获取类型对应颜色 */
|
||||
export const getTypeColor = (type: string): string => {
|
||||
@@ -17,6 +18,16 @@ export const gradientForCategory = (category: string): string => {
|
||||
return CATEGORY_GRADIENT_MAP[category] ?? DEFAULT_GRADIENT
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds: number | undefined | null): string => {
|
||||
if (!seconds || seconds <= 0) return "0秒"
|
||||
const totalSec = Math.round(seconds)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
if (m === 0) return `${s}秒`
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`
|
||||
}
|
||||
|
||||
/** 格式化配置对象为可读文本 */
|
||||
export const formatConfig = (config?: object): string => {
|
||||
if (!config || Object.keys(config).length === 0) return "默认"
|
||||
@@ -36,3 +47,8 @@ export const getMaterialTypeLabel = (materialType: string | null | undefined): s
|
||||
if (!materialType) return "不限"
|
||||
return MATERIAL_TYPE_LABELS[materialType] ?? materialType
|
||||
}
|
||||
|
||||
/** 计算片段总时长(取每个片段 min/max 的平均值) */
|
||||
export const calcTotalSegmentDuration = (segments: TemplateSegment[]): number => {
|
||||
return segments.reduce((sum, s) => sum + (s.duration_min + s.duration_max) / 2, 0)
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* CanvasPreviewGrid 单测(Issue #1741 / #1750)
|
||||
*
|
||||
* 验证:
|
||||
* - N=3 时每个变体都拿到各自的 voiceAudioUrls[i](独立配音模式,null=TTS 合成中)
|
||||
* - 每个变体都拿到各自的后端真实片段 variantClips[i](serverClips),playToken 为 0 基变体序号
|
||||
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
|
||||
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
|
||||
* - 实例上报暂停(null)→ 播放权释放
|
||||
* - #1750 变体计划申请失败:渲染 role=alert 错误占位 ×N(严禁假数据/不渲染播放器),
|
||||
* 「重试」按钮仅变体 0 卡片出现且点击触发 onRetryClips
|
||||
* - #1750 加载态:渲染加载占位,不渲染播放器
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { render, fireEvent, screen } from "@testing-library/react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
|
||||
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
|
||||
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
playerCalls.push(props)
|
||||
const token = props.playToken as number
|
||||
const active = props.activePlayToken as number | null
|
||||
const change = props.onPlayTokenChange as (t: number | null) => void
|
||||
return (
|
||||
<div data-testid={`player-${token}`}>
|
||||
<span data-testid={`voice-${token}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
|
||||
<span data-testid={`token-${token}`}>{active == null ? "none" : String(active)}</span>
|
||||
<button type="button" onClick={() => change(token)}>
|
||||
play-{token}
|
||||
</button>
|
||||
<button type="button" onClick={() => change(null)}>
|
||||
pause-{token}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
|
||||
|
||||
function makeAsset(id: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
library_id: "lib-1",
|
||||
name: `${id}.mp4`,
|
||||
storage_key: `media/${id}.mp4`,
|
||||
file_url: `https://cdn.example.com/${id}.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 10 },
|
||||
duration: 10,
|
||||
}
|
||||
}
|
||||
|
||||
function makeClips(variant: number): EditPlanClip[] {
|
||||
return ["a1", "a2"].map((assetId, order) => ({
|
||||
id: `clip-v${variant}-${assetId}`,
|
||||
plan_id: `plan-${variant}`,
|
||||
clip_type: "main",
|
||||
order,
|
||||
asset_id: assetId,
|
||||
text_content: "",
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
transition_effect: "none",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1,
|
||||
status: "ready",
|
||||
config: {},
|
||||
}))
|
||||
}
|
||||
|
||||
const titleSettings = {
|
||||
size: 36,
|
||||
font: "思源黑体",
|
||||
color: "#fff",
|
||||
position: "bottom" as const,
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}
|
||||
|
||||
const variantClips: EditPlanClip[][] = [makeClips(0), makeClips(1), makeClips(2)]
|
||||
|
||||
function renderGrid(overrides: Record<string, unknown> = {}) {
|
||||
playerCalls.length = 0
|
||||
return render(
|
||||
<CanvasPreviewGrid
|
||||
count={3}
|
||||
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
|
||||
videoRatio="9:16"
|
||||
titles={["标题1", "标题2", "标题3"]}
|
||||
titleSettings={titleSettings}
|
||||
variantClips={variantClips}
|
||||
voiceAudioUrls={["https://cdn.example.com/v0.mp3", null, "https://cdn.example.com/v2.mp3"]}
|
||||
selectedIds={[0, 1, 2]}
|
||||
onToggleSelect={() => {}}
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("CanvasPreviewGrid 配音、真实片段与播放互斥 (#1741/#1750)", () => {
|
||||
beforeEach(() => {
|
||||
playerCalls.length = 0
|
||||
})
|
||||
|
||||
it("N=3 时每个变体都拿到各自的配音 URL 与后端真实片段,playToken 为 0 基序号", () => {
|
||||
renderGrid()
|
||||
expect(playerCalls).toHaveLength(3)
|
||||
playerCalls.forEach((p, i) => {
|
||||
expect(p.playToken).toBe(i)
|
||||
expect(p.serverClips).toBe(variantClips[i])
|
||||
expect(p.variantTitle).toBe(`标题${i + 1}`)
|
||||
})
|
||||
// 独立配音:变体 0/2 有各自 URL;变体 1 为 null(TTS 合成中)→ 传 undefined
|
||||
expect(playerCalls[0].voiceAudioUrl).toBe("https://cdn.example.com/v0.mp3")
|
||||
expect(playerCalls[1].voiceAudioUrl).toBeUndefined()
|
||||
expect(playerCalls[2].voiceAudioUrl).toBe("https://cdn.example.com/v2.mp3")
|
||||
// 配音状态徽标
|
||||
expect(screen.getByTestId("voice-0").textContent).toBe("has-voice")
|
||||
expect(screen.getByTestId("voice-1").textContent).toBe("no-voice")
|
||||
expect(screen.getByTestId("voice-2").textContent).toBe("has-voice")
|
||||
})
|
||||
|
||||
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
|
||||
renderGrid()
|
||||
playerCalls.forEach((p) => {
|
||||
expect(p.activePlayToken).toBeNull()
|
||||
expect(typeof p.onPlayTokenChange).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
it("点击变体2播放:所有实例 activePlayToken 变为 1(0 基,其他实例自动暂停)", () => {
|
||||
renderGrid()
|
||||
fireEvent.click(screen.getByTestId("player-1").querySelector("button")!)
|
||||
expect(screen.getByTestId("token-0").textContent).toBe("1")
|
||||
expect(screen.getByTestId("token-1").textContent).toBe("1")
|
||||
expect(screen.getByTestId("token-2").textContent).toBe("1")
|
||||
})
|
||||
|
||||
it("正在播放实例上报暂停后,播放权释放(token 回 null)", () => {
|
||||
renderGrid()
|
||||
fireEvent.click(screen.getByTestId("player-2").querySelector("button")!)
|
||||
expect(screen.getByTestId("token-0").textContent).toBe("2")
|
||||
|
||||
fireEvent.click(screen.getByTestId("player-2").querySelectorAll("button")[1])
|
||||
expect(screen.getByTestId("token-2").textContent).toBe("none")
|
||||
})
|
||||
|
||||
it("clipsError:每张卡片显示错误占位(role=alert)且不渲染任何播放器,重试按钮仅一个并触发回调", () => {
|
||||
const onRetry = vi.fn()
|
||||
renderGrid({ clipsError: true, onRetryClips: onRetry })
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(3)
|
||||
expect(screen.getAllByText("预览加载失败,请重试")).toHaveLength(3)
|
||||
// 严禁假数据:错误态不渲染播放器
|
||||
expect(playerCalls).toHaveLength(0)
|
||||
// 重试按钮仅变体 0 卡片出现
|
||||
const retryButtons = screen.getAllByText("重试")
|
||||
expect(retryButtons).toHaveLength(1)
|
||||
fireEvent.click(retryButtons[0])
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("clipsLoading:渲染加载占位,不渲染播放器", () => {
|
||||
renderGrid({ clipsLoading: true })
|
||||
expect(screen.getAllByText("独立选片中…")).toHaveLength(3)
|
||||
expect(screen.queryAllByRole("alert")).toHaveLength(0)
|
||||
expect(playerCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750)
|
||||
*
|
||||
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
|
||||
* - 有配音时 video 保持 muted(素材原声不与配音混音)
|
||||
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
|
||||
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
|
||||
* - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause
|
||||
* - 点击播放/暂停时上报播放权(onPlayTokenChange)
|
||||
* - #1750:serverClips 是唯一片段来源,缺失时不渲染任何 video(无本地模拟 fallback)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isPlaying: false,
|
||||
pause: vi.fn(),
|
||||
togglePlayPause: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
|
||||
// segments 由组件 buildPlaybackSegments 产出(唯一来源 serverClips);
|
||||
// canPlay 与真实 hook 一致:无片段时不可播放
|
||||
useSegmentScheduler: vi.fn((segments: unknown[]) => ({
|
||||
isPlaying: mocks.isPlaying,
|
||||
currentTime: 0,
|
||||
totalDuration: 20,
|
||||
currentSegmentIndex: 0,
|
||||
canPlay: segments.length > 0,
|
||||
togglePlayPause: mocks.togglePlayPause,
|
||||
seekTo: vi.fn(),
|
||||
pause: mocks.pause,
|
||||
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
|
||||
useCanvasPlayer: () => ({
|
||||
state: {
|
||||
isPlaying: false,
|
||||
isReady: false,
|
||||
isBuffering: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
errorMessage: "",
|
||||
hasDecodeError: false,
|
||||
},
|
||||
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.isPlaying = false
|
||||
mocks.pause.mockClear()
|
||||
mocks.togglePlayPause.mockClear()
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function makeAsset(id: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
library_id: "lib-1",
|
||||
name: `${id}.mp4`,
|
||||
storage_key: `media/${id}.mp4`,
|
||||
file_url: `https://cdn.example.com/${id}.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 10, width: 1080, height: 1920 },
|
||||
duration: 10,
|
||||
}
|
||||
}
|
||||
|
||||
function makeClip(assetId: string, order: number): EditPlanClip {
|
||||
return {
|
||||
id: `clip-${assetId}`,
|
||||
plan_id: "plan-1",
|
||||
clip_type: "main",
|
||||
order,
|
||||
asset_id: assetId,
|
||||
text_content: "",
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
transition_effect: "none",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1,
|
||||
status: "ready",
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
|
||||
const serverClips = [makeClip("a1", 0), makeClip("a2", 1)]
|
||||
|
||||
const baseProps = {
|
||||
assets: [makeAsset("a1"), makeAsset("a2")],
|
||||
videoRatio: "9:16",
|
||||
ready: true,
|
||||
serverClips,
|
||||
}
|
||||
|
||||
function videos(): HTMLVideoElement[] {
|
||||
return Array.from(document.querySelectorAll("video"))
|
||||
}
|
||||
|
||||
describe("FrontendPreviewPlayer 音频行为 (#1741/#1750)", () => {
|
||||
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
|
||||
expect(videos()).toHaveLength(2)
|
||||
videos().forEach((v) => expect(v.muted).toBe(true))
|
||||
})
|
||||
|
||||
it("无配音时 video 不 muted(素材原声兜底)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} />)
|
||||
videos().forEach((v) => expect(v.muted).toBe(false))
|
||||
})
|
||||
|
||||
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} />)
|
||||
const vs = videos()
|
||||
expect(vs[0].muted).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "静音" }))
|
||||
videos().forEach((v) => expect(v.muted).toBe(true))
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
|
||||
videos().forEach((v) => expect(v.muted).toBe(false))
|
||||
})
|
||||
|
||||
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
|
||||
mocks.isPlaying = true
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
playToken={1}
|
||||
activePlayToken={1}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
|
||||
// 播放权切给实例 2(0 基 token)
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
playToken={1}
|
||||
activePlayToken={2}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).toHaveBeenCalledTimes(1)
|
||||
|
||||
// token 切回自己:不重复暂停
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
playToken={1}
|
||||
activePlayToken={1}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
|
||||
// mocks.isPlaying = false(beforeEach 重置)
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={0} compact />,
|
||||
)
|
||||
rerender(<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={1} compact />)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => {
|
||||
const onToken = vi.fn()
|
||||
render(
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={2} onPlayTokenChange={onToken} compact />,
|
||||
)
|
||||
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
|
||||
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
|
||||
expect(playButtons.length).toBeGreaterThanOrEqual(1)
|
||||
fireEvent.click(playButtons[0])
|
||||
expect(onToken).toHaveBeenCalledWith(2)
|
||||
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("每个卡片都渲染独立静音按钮", () => {
|
||||
render(
|
||||
<div>
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={0} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={1} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={2} compact />
|
||||
</div>,
|
||||
)
|
||||
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} serverClips={undefined} />)
|
||||
expect(videos()).toHaveLength(0)
|
||||
expect(screen.getByText("暂无可播放素材")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 智能匹配候选池数量规则单测(#1744/#1750)
|
||||
*
|
||||
* #1750 基线:模板只有片段数/结构、没有时长概念——候选池纯按「片段数 × 3」
|
||||
* (每个片段至少 3 个候选供变体间洗牌避让),不再参考任何 duration 字段;
|
||||
* 结果钳制到 [1, 200](后端 limit 上限 200);无片段信息时兜底 30。
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { computeLimitFromSegments } from "@/pages/generate/hooks/step2-materials/useSmartMatch"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
|
||||
function segs(n: number): TemplateSegment[] {
|
||||
// 新规则只看片段数量,duration 字段已废弃,这里不再构造
|
||||
return Array.from({ length: n }, () => ({}) as TemplateSegment)
|
||||
}
|
||||
|
||||
describe("computeLimitFromSegments (#1750 候选池 = 片段数×3)", () => {
|
||||
it("无 segments 时返回兜底 30", () => {
|
||||
expect(computeLimitFromSegments(undefined)).toBe(30)
|
||||
expect(computeLimitFromSegments([])).toBe(30)
|
||||
})
|
||||
|
||||
it("候选池 = 片段数 × 3(与任何时长字段无关)", () => {
|
||||
expect(computeLimitFromSegments(segs(1))).toBe(3)
|
||||
expect(computeLimitFromSegments(segs(3))).toBe(9)
|
||||
expect(computeLimitFromSegments(segs(5))).toBe(15)
|
||||
expect(computeLimitFromSegments(segs(10))).toBe(30)
|
||||
})
|
||||
|
||||
it("超过上限钳制为 200(后端 limit 上限)", () => {
|
||||
expect(computeLimitFromSegments(segs(67))).toBe(200) // 67×3=201 → 200
|
||||
expect(computeLimitFromSegments(segs(100))).toBe(200) // 100×3=300 → 200
|
||||
})
|
||||
|
||||
it("任何正常片段数下 limit 均 ≥ 1", () => {
|
||||
for (let n = 0; n <= 10; n++) {
|
||||
expect(computeLimitFromSegments(segs(n))).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@
|
||||
*
|
||||
* 重构记录:
|
||||
* - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook
|
||||
* - Phase1 预览生成:新增 generation API 层 + PreviewVideoPanel
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
@@ -22,6 +23,8 @@ import "@/pages/generate/components/Step3VoiceWithMode"
|
||||
import "@/pages/generate/components/CanvasPreviewGrid"
|
||||
import "@/pages/generate/components/BatchGenerationGrid"
|
||||
import "@/pages/generate/components/PreviewCountModal"
|
||||
import "@/pages/generate/components/PreviewVideoPanel"
|
||||
import "@/pages/generate/components/GenerateResultPanel"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/components/voice/VoiceRecommendSection"
|
||||
import "@/pages/generate/components/voice/VoiceChoiceCard"
|
||||
@@ -47,8 +50,6 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useBatchCovers"
|
||||
import "@/pages/generate/hooks/useBatchVariantPlans"
|
||||
import "@/pages/generate/hooks/useVariantVoicePreview"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* TitleLibraryAutoComplete 单测(Issue #1737)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 聚焦空输入框 → 下拉立即展开,展示标题库全部标题(原生 AutoComplete 聚焦不展开,此为本工单核心修复)
|
||||
* - 输入关键词 → 下拉只显示匹配项
|
||||
* - 点击下拉项 → onChange 回填所选标题
|
||||
* - 自由输入自定义标题 → onChange 正常透传,不被下拉干扰
|
||||
* - 标题库为空 → 聚焦不展开(不出"暂无数据"空壳)
|
||||
* - 选中后下拉关闭
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
|
||||
|
||||
const OPTIONS = [
|
||||
{ label: "永康这家面馆绝了", value: "永康这家面馆绝了" },
|
||||
{ label: "永康美食探店vlog", value: "永康美食探店vlog" },
|
||||
{ label: "萌宠日常第一天", value: "萌宠日常第一天" },
|
||||
]
|
||||
|
||||
function renderBox(initialValue = "", opts = OPTIONS) {
|
||||
const onChange = vi.fn()
|
||||
const result = render(
|
||||
<TitleLibraryAutoComplete
|
||||
value={initialValue}
|
||||
onChange={onChange}
|
||||
options={opts}
|
||||
placeholder="输入或从标题库选择"
|
||||
/>,
|
||||
)
|
||||
return { onChange, ...result }
|
||||
}
|
||||
|
||||
/** 聚焦输入框(combobox role) */
|
||||
function focusInput() {
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement
|
||||
fireEvent.focus(input)
|
||||
return input
|
||||
}
|
||||
|
||||
/** 取下拉中实际可见的选项(rc-virtual-list 渲染为 .ant-select-item-option;role=option 的 listbox 是 a11y 哨兵) */
|
||||
function getVisibleOptions(): HTMLElement[] {
|
||||
const dropdown = document.querySelector(".ant-select-dropdown:not(.ant-select-dropdown-hidden)")
|
||||
if (!dropdown) return []
|
||||
return Array.from(dropdown.querySelectorAll(".ant-select-item-option")) as HTMLElement[]
|
||||
}
|
||||
|
||||
describe("TitleLibraryAutoComplete (#1737)", () => {
|
||||
it("聚焦空输入框时下拉展开并展示标题库全部标题", async () => {
|
||||
renderBox()
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
|
||||
focusInput()
|
||||
|
||||
await screen.findByRole("listbox")
|
||||
await waitFor(() => expect(getVisibleOptions()).toHaveLength(3))
|
||||
const options = getVisibleOptions()
|
||||
expect(options[0]).toHaveTextContent("永康这家面馆绝了")
|
||||
expect(options[2]).toHaveTextContent("萌宠日常第一天")
|
||||
})
|
||||
|
||||
it("输入关键词时下拉只显示匹配项", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderBox()
|
||||
const input = screen.getByRole("combobox")
|
||||
await user.click(input)
|
||||
await screen.findByRole("listbox")
|
||||
|
||||
await user.type(input, "永康")
|
||||
await waitFor(() => expect(getVisibleOptions()).toHaveLength(2))
|
||||
const options = getVisibleOptions()
|
||||
expect(options.every((o) => o.textContent?.includes("永康"))).toBe(true)
|
||||
})
|
||||
|
||||
it("点击下拉项后 onChange 回填标题且下拉关闭", async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = renderBox()
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement
|
||||
await user.click(input)
|
||||
await screen.findByRole("listbox")
|
||||
|
||||
await user.click(screen.getByText("萌宠日常第一天"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith("萌宠日常第一天")
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("自由输入自定义标题时 onChange 正常透传(不被下拉干扰)", async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = renderBox()
|
||||
const input = screen.getByRole("combobox")
|
||||
await user.click(input)
|
||||
|
||||
await user.type(input, "我自己编的标题XYZ")
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith("我自己编的标题XYZ")
|
||||
})
|
||||
// 输入无匹配关键词,下拉无 option 时不阻塞输入
|
||||
expect(input).toHaveValue("我自己编的标题XYZ")
|
||||
})
|
||||
|
||||
it("标题库为空时聚焦不展开下拉", async () => {
|
||||
renderBox("", [])
|
||||
focusInput()
|
||||
// 等一帧确认没有 listbox
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("渲染下拉箭头图标作为可选择提示", () => {
|
||||
const { container } = renderBox()
|
||||
// antd 后缀图标在 .ant-select-arrow 内
|
||||
expect(container.querySelector(".ant-select-arrow")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("有初始值时输入框正常展示", () => {
|
||||
renderBox("已有标题")
|
||||
expect(screen.getByRole("combobox")).toHaveValue("已有标题")
|
||||
})
|
||||
})
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* useBatchVariantPlans 单测(#1750 修正:严禁假数据冒充预览)
|
||||
* - 批量(N>1)时向后端申请变体计划,返回 clips/planIds
|
||||
* - 404(端点未上线)/ 400(参数错误)/ 网络错误:一律 error=true,UI 可重试
|
||||
* - 数据不完整(某变体 clips 为空):error=true
|
||||
* - retry() 重新发起请求,成功后 ready=true
|
||||
* - N=1 / 无素材 / disabled 不发请求
|
||||
* - 入参变化重新申请;未变化不重复请求
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor, act } from "@testing-library/react"
|
||||
import { useBatchVariantPlans } from "@/pages/generate/hooks/useBatchVariantPlans"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
|
||||
vi.mock("@/api/generation/variantPlans", () => ({
|
||||
createBatchVariantPlans: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createBatchVariantPlans } from "@/api/generation/variantPlans"
|
||||
|
||||
const mockCreate = vi.mocked(createBatchVariantPlans)
|
||||
|
||||
function makeClip(
|
||||
partial: Partial<EditPlanClip> & { asset_id: string; order: number },
|
||||
): EditPlanClip {
|
||||
return {
|
||||
id: `clip-${partial.asset_id}-${partial.order}`,
|
||||
plan_id: "plan-x",
|
||||
clip_type: "main",
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
text_content: "",
|
||||
transition_effect: "",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1,
|
||||
status: "ready",
|
||||
config: {},
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function buildResp(count: number) {
|
||||
return {
|
||||
total: count,
|
||||
items: Array.from({ length: count }, (_, i) => ({
|
||||
variant_index: i,
|
||||
plan_id: `plan-${i}`,
|
||||
clips: [makeClip({ asset_id: `a${(i % 3) + 1}`, order: 0, start_time: i * 2 })],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreate.mockReset()
|
||||
})
|
||||
|
||||
describe("useBatchVariantPlans (#1750 错误态)", () => {
|
||||
it("批量 N=3 时申请变体计划并返回按索引对齐的 clips/planIds", async () => {
|
||||
mockCreate.mockResolvedValueOnce(buildResp(3))
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
templateId: "tpl-1",
|
||||
assetIds: ["a1", "a2", "a3"],
|
||||
sourcePlanId: "plan-src",
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
template_id: "tpl-1",
|
||||
asset_ids: ["a1", "a2", "a3"],
|
||||
count: 3,
|
||||
source_edit_plan_id: "plan-src",
|
||||
})
|
||||
expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1", "plan-2"])
|
||||
expect(result.current.clipsByVariant[1]?.[0]?.start_time).toBe(2)
|
||||
expect(result.current.error).toBe(false)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it("sourcePlanId 为空时不传 source_edit_plan_id 字段", async () => {
|
||||
mockCreate.mockResolvedValueOnce(buildResp(2))
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
const arg = mockCreate.mock.calls[0][0]
|
||||
expect(arg).not.toHaveProperty("source_edit_plan_id")
|
||||
})
|
||||
|
||||
it("404(后端端点未上线)→ error=true,不再静默降级", async () => {
|
||||
mockCreate.mockRejectedValueOnce({ response: { status: 404 } })
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.error).toBe(true)
|
||||
expect(result.current.ready).toBe(false)
|
||||
expect(result.current.clipsByVariant).toEqual([])
|
||||
})
|
||||
|
||||
it("400(参数错误)→ error=true", async () => {
|
||||
mockCreate.mockRejectedValueOnce({ response: { status: 400, data: { detail: "参数错误" } } })
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.error).toBe(true)
|
||||
expect(result.current.ready).toBe(false)
|
||||
})
|
||||
|
||||
it("网络错误 → error=true", async () => {
|
||||
mockCreate.mockRejectedValue(new Error("Network Error"))
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.error).toBe(true)
|
||||
expect(result.current.ready).toBe(false)
|
||||
})
|
||||
|
||||
it("某变体 clips 为空(数据不完整)→ error=true,严禁假数据冒充", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
items: [
|
||||
{ variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] },
|
||||
{ variant_index: 1, plan_id: "plan-1", clips: [] },
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.error).toBe(true)
|
||||
expect(result.current.ready).toBe(false)
|
||||
expect(result.current.clipsByVariant).toEqual([])
|
||||
})
|
||||
|
||||
it("retry() 失败后重试:成功则 error 清除、ready=true", async () => {
|
||||
mockCreate.mockRejectedValueOnce({ response: { status: 404 } })
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.error).toBe(true))
|
||||
|
||||
mockCreate.mockResolvedValueOnce(buildResp(3))
|
||||
act(() => {
|
||||
result.current.retry()
|
||||
})
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
expect(result.current.error).toBe(false)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("N=1 不发请求(单视频零回归)", () => {
|
||||
renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 1, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("无素材/enabled=false 不发请求", () => {
|
||||
const { rerender } = renderHook(
|
||||
(props: { enabled: boolean; ids: string[] }) =>
|
||||
useBatchVariantPlans({
|
||||
enabled: props.enabled,
|
||||
count: 3,
|
||||
templateId: "tpl-1",
|
||||
assetIds: props.ids,
|
||||
}),
|
||||
{ initialProps: { enabled: true, ids: [] as string[] } },
|
||||
)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
rerender({ enabled: false, ids: ["a1"] })
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("响应乱序/越界 variant_index:越界丢弃、其余按索引对齐", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
items: [
|
||||
{ variant_index: 99, plan_id: "plan-bad", clips: [makeClip({ asset_id: "a9", order: 0 })] },
|
||||
{ variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a2", order: 0 })] },
|
||||
{ variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] },
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
templateId: "tpl-1",
|
||||
assetIds: ["a1", "a2"],
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
// 越界 99 丢弃;乱序返回的 0/1 按索引对齐
|
||||
expect(result.current.error).toBe(false)
|
||||
expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1"])
|
||||
expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1")
|
||||
expect(result.current.clipsByVariant[1]?.[0]?.asset_id).toBe("a2")
|
||||
})
|
||||
|
||||
it("非 ready 状态的 clip 被过滤;过滤后每变体仍有片段则 ready", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
variant_index: 0,
|
||||
plan_id: "plan-0",
|
||||
clips: [
|
||||
makeClip({ asset_id: "a1", order: 0 }),
|
||||
makeClip({ asset_id: "a2", order: 1, status: "pending" }),
|
||||
],
|
||||
},
|
||||
{ variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a3", order: 0 })] },
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
expect(result.current.error).toBe(false)
|
||||
expect(result.current.clipsByVariant[0]).toHaveLength(1)
|
||||
expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1")
|
||||
})
|
||||
})
|
||||
@@ -77,4 +77,25 @@ describe("reload 标记", () => {
|
||||
spy.mockRestore()
|
||||
setSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("URL 带 chunkreload 参数时视为已刷新过(storage 不可用的降级标记)", () => {
|
||||
window.history.replaceState({}, "", "/app/assets?chunkreload=1")
|
||||
expect(getChunkReloadedAt()).not.toBeNull()
|
||||
window.history.replaceState({}, "", "/")
|
||||
})
|
||||
|
||||
it("storage 完全不可用时,reloadForChunkError 走 URL 标记跳转(防无痕死循环)", () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("Storage disabled")
|
||||
})
|
||||
const getSpy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("Storage disabled")
|
||||
})
|
||||
window.history.replaceState({}, "", "/app/assets")
|
||||
// reloadForChunkError 应走 location.replace 带上 ?chunkreload=1(jsdom 不会真导航)
|
||||
expect(() => reloadForChunkError()).not.toThrow()
|
||||
setSpy.mockRestore()
|
||||
getSpy.mockRestore()
|
||||
window.history.replaceState({}, "", "/")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,8 +57,26 @@ export const isChunkLoadError = (error: unknown): boolean => {
|
||||
)
|
||||
}
|
||||
|
||||
/** 读取上次自动刷新时间戳;过期或不存在返回 null */
|
||||
/** URL 降级标记参数:storage 不可用(无痕/禁 Cookie)时靠它在刷新后保留"已刷新"状态 */
|
||||
const RELOAD_QUERY_KEY = "chunkreload"
|
||||
|
||||
/** 判断当前 URL 是否已带"为 chunk 失效刷新过"标记 */
|
||||
const urlHasReloadFlag = (): boolean => {
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).has(RELOAD_QUERY_KEY)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取"已自动刷新过"状态:storage 与 URL 参数双重判定。
|
||||
* storage 在 Safari 无痕等环境可能完全不可用,此时刷新后标记丢失会陷入
|
||||
* "检测到 chunk 失效 → 刷新 → 再检测 → 再刷新"死循环;URL 参数刷新后仍在,
|
||||
* 作为降级标记兜住这种环境。
|
||||
*/
|
||||
export const getChunkReloadedAt = (): number | null => {
|
||||
if (urlHasReloadFlag()) return Date.now()
|
||||
const raw = safeStorage.getItem(RELOAD_FLAG_KEY)
|
||||
if (!raw) return null
|
||||
const ts = Number(raw)
|
||||
@@ -70,6 +88,15 @@ export const getChunkReloadedAt = (): number | null => {
|
||||
/** 标记"已为 chunk 失效自动刷新过",然后刷新页面 */
|
||||
export const reloadForChunkError = (): void => {
|
||||
safeStorage.setItem(RELOAD_FLAG_KEY, String(Date.now()))
|
||||
// 同时在 URL 上带标记:storage 写不进(无痕模式)时刷新后仍能识别已刷新过
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(RELOAD_QUERY_KEY, "1")
|
||||
window.location.replace(url.toString())
|
||||
return
|
||||
} catch {
|
||||
/* URL 构造失败则退回普通刷新 */
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,7 @@ def create_video_record_and_dedup(
|
||||
fps: float = 25.0,
|
||||
name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> dict:
|
||||
"""Returns: {"video_count": int, "is_duplicate": bool, "batch_similarity": float|None,
|
||||
"duplicate_of": str|None} —— batch_similarity 为批次内最高相似度(无批次查重时 None)。"""
|
||||
) -> int:
|
||||
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
|
||||
|
||||
采用两阶段持久化:先计算所有指纹/查重数据(内存),
|
||||
@@ -78,7 +76,6 @@ def create_video_record_and_dedup(
|
||||
# ── Phase 2: 计算指纹 & 查重(全部在内存) ────────────────
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = None
|
||||
batch_similarity: float | None = None
|
||||
|
||||
try:
|
||||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||||
@@ -108,11 +105,9 @@ def create_video_record_and_dedup(
|
||||
)
|
||||
|
||||
# (b) 批次内查重(仅当有 batch_id 时)
|
||||
batch_similarity: float | None = None
|
||||
if not duplicate_result and batch_id:
|
||||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
|
||||
if duplicate_result:
|
||||
batch_similarity = float(duplicate_result.get("similarity", 0.0))
|
||||
|
||||
if duplicate_result:
|
||||
generated_video.is_duplicate = True
|
||||
generated_video.duplicate_of = duplicate_result["duplicate_of"]
|
||||
@@ -166,12 +161,7 @@ def create_video_record_and_dedup(
|
||||
generated_video.is_duplicate,
|
||||
generated_video.duplicate_rate,
|
||||
)
|
||||
return {
|
||||
"video_count": 1,
|
||||
"is_duplicate": bool(generated_video.is_duplicate),
|
||||
"batch_similarity": batch_similarity,
|
||||
"duplicate_of": generated_video.duplicate_of,
|
||||
}
|
||||
return 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create video record / dedup for task %s: %s",
|
||||
@@ -179,4 +169,4 @@ def create_video_record_and_dedup(
|
||||
e,
|
||||
)
|
||||
session.rollback()
|
||||
return {"video_count": 0, "is_duplicate": False, "batch_similarity": None, "duplicate_of": None}
|
||||
return 0
|
||||
|
||||
@@ -47,23 +47,13 @@ class RenderContext:
|
||||
|
||||
|
||||
def clip_effective_duration(clip: ResolvedClip) -> float:
|
||||
"""计算 clip 的有效时长(#1749:目标段长始终为准)。
|
||||
"""计算 clip 的有效时长.
|
||||
|
||||
与 UnifiedRenderService._clip_effective_duration 逻辑一致;素材短于段长的
|
||||
部分由末帧冻结 tpad / 音频 apad 铺满,不在此处钳制。
|
||||
与 UnifiedRenderService._clip_effective_duration 逻辑一致。
|
||||
"""
|
||||
if clip.duration > 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
|
||||
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
|
||||
|
||||
|
||||
def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool:
|
||||
@@ -283,9 +273,6 @@ 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
|
||||
@@ -294,28 +281,20 @@ def concat_main_audio(
|
||||
if not has_speed and not has_reverse:
|
||||
# 无调速无倒放:根据是否需要裁剪/音量选择最高效的路径。
|
||||
vol = _clip_volume(clip)
|
||||
need_trim = (
|
||||
trim_start > 0 or freeze_seconds > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
|
||||
)
|
||||
need_trim = trim_start > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
|
||||
need_volume = abs(vol - 1.0) >= 1e-6
|
||||
|
||||
if need_trim:
|
||||
# 需要裁剪:用 atrim 滤镜在滤镜链中精确裁剪(采样点级精度,不浪费解码)。
|
||||
# 滤镜顺序:atrim → asetpts → apad(冻结补静音) → volume。
|
||||
# 滤镜顺序:atrim → asetpts → volume(先裁剪再调音量,避免处理被丢弃的数据)。
|
||||
af_parts: list[str] = []
|
||||
# 冻结场景:素材内可用时长 = 目标段长 − 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}")
|
||||
if trim_start > 0 and effective_duration > 0:
|
||||
af_parts.append(f"atrim=start={trim_start:.3f}:duration={final_duration:.3f}")
|
||||
elif trim_start > 0:
|
||||
af_parts.append(f"atrim=start={trim_start:.3f}")
|
||||
elif atrim_dur > 0:
|
||||
af_parts.append(f"atrim=duration={atrim_dur:.3f}")
|
||||
elif final_duration > 0:
|
||||
af_parts.append(f"atrim=duration={final_duration:.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 = [
|
||||
@@ -355,13 +334,8 @@ def concat_main_audio(
|
||||
"-ac",
|
||||
"2",
|
||||
]
|
||||
af_simple: list[str] = []
|
||||
if need_volume:
|
||||
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)])
|
||||
command.extend(["-af", f"volume={vol:.4f}"])
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
command.append(str(output_path))
|
||||
@@ -395,10 +369,6 @@ 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")
|
||||
|
||||
@@ -440,13 +410,10 @@ 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={atrim_dur:.3f}")
|
||||
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
audio_filters.append("asetpts=PTS-STARTPTS")
|
||||
|
||||
# 音频调速 — atempo 多级串联
|
||||
@@ -468,10 +435,6 @@ 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:
|
||||
@@ -543,17 +506,12 @@ 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={atrim_dur:.3f}")
|
||||
clip_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.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,45 +518,120 @@ class UnifiedRenderService:
|
||||
layers: list[RenderLayer],
|
||||
voice_duration: float,
|
||||
) -> None:
|
||||
"""#1749:配音对齐仅保留 ±5% 守卫日志,不再做全局裁剪/慢放。
|
||||
"""调整片段时长以对齐配音时长。
|
||||
|
||||
段长已在选片阶段由 voice_duration_planner 按配音时长精确分配
|
||||
(成片总时长=配音,素材不足走末帧冻结 tpad/apad)。渲染期任何全局
|
||||
裁剪会截断配音、全局慢放会导致失真——两者均已删除。此处仅在偏差
|
||||
超阈值时记录 warning 供排查,不修改任何 clip。
|
||||
核心逻辑:
|
||||
- 计算片段总时长与配音时长的比例
|
||||
- ±5% 以内不调整
|
||||
- ratio < 1(片段比配音长):按比例裁剪每段末尾
|
||||
- ratio > 1(片段比配音短):按比例慢放每段
|
||||
|
||||
Args:
|
||||
layers: 渲染图层列表
|
||||
voice_duration: 配音时长(秒)
|
||||
"""
|
||||
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:
|
||||
total_clips_duration += self._clip_adjusted_duration(clip)
|
||||
clip_dur = self._clip_adjusted_duration(clip)
|
||||
total_clips_duration += clip_dur
|
||||
|
||||
if total_clips_duration <= 0:
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
# ±5% 以内不调整
|
||||
if abs(ratio - 1.0) <= 0.05:
|
||||
logger.info(
|
||||
"[voice-align] 比例接近1:1,跳过调整: ratio=%.4f voice=%.3f clips=%.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:
|
||||
logger.info(
|
||||
"[voice-align] 段长与配音偏差≤5%%,无需处理: ratio=%.4f voice=%.3f estimated=%.3f",
|
||||
ratio,
|
||||
voice_duration,
|
||||
estimated,
|
||||
)
|
||||
# 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),
|
||||
)
|
||||
|
||||
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
|
||||
"""根据 plan.config 生成 ASS 字幕文件。
|
||||
@@ -1297,16 +1372,10 @@ 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 trim_dur > 0:
|
||||
filters.append(f"trim=duration={trim_dur}")
|
||||
if effective_duration > 0:
|
||||
filters.append(f"trim=duration={effective_duration}")
|
||||
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)
|
||||
@@ -1355,10 +1424,6 @@ 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")
|
||||
|
||||
# 字幕叠加
|
||||
@@ -1450,10 +1515,6 @@ 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)])
|
||||
|
||||
@@ -1563,9 +1624,7 @@ class UnifiedRenderService:
|
||||
continue
|
||||
|
||||
# 单段裁剪(或无裁剪)
|
||||
# #1749 定稿:目标段长(clip.duration,配音分配结果)始终为准。
|
||||
# trim 只决定素材内截取区间;素材短于段长 → 末帧冻结(tpad)/音频补静音(apad)
|
||||
# 铺满,禁止慢放、禁止截断配音。
|
||||
# 解析裁剪配置:config 优先,否则用 clip.start_time + clip.duration
|
||||
trim_config = extract_trim_from_clip_config(clip_config)
|
||||
if trim_config is None and (clip.start_time > 0 or clip.duration > 0):
|
||||
# 用旧字段构造
|
||||
@@ -1574,48 +1633,42 @@ 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 = target_duration
|
||||
final_duration = clip.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:
|
||||
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)
|
||||
)
|
||||
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
|
||||
else:
|
||||
# 裁剪无效 → 从素材头取
|
||||
logger.warning("裁剪配置无效,使用素材起点: clip_id=%s", clip.id)
|
||||
# 裁剪无效 → 使用完整素材
|
||||
logger.warning("裁剪配置无效,使用完整素材: clip_id=%s", clip.id)
|
||||
effective_trim = None
|
||||
final_start = 0.0
|
||||
avail_in_asset = actual_duration
|
||||
else:
|
||||
final_start = 0.0
|
||||
avail_in_asset = actual_duration if actual_duration > 0 else target_duration
|
||||
final_duration = actual_duration
|
||||
|
||||
# 素材内可用时长不足目标段长 → 末帧冻结(禁慢放)
|
||||
if target_duration > 0 and avail_in_asset < target_duration - 0.05:
|
||||
freeze_seconds = round(target_duration - avail_in_asset, 3)
|
||||
# 素材实际时长不足以覆盖配置的时长时,降低播放速度来补偿
|
||||
# 例如:配置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(
|
||||
"[freeze] clip=%s target=%.3f avail=%.3f → 末帧冻结 %.3fs(禁慢放)",
|
||||
"[debug] clip=%s duration=%.3f actual=%.3f → 减速补偿 speed=%.4f (configured=%.3f)",
|
||||
clip.id,
|
||||
target_duration,
|
||||
avail_in_asset,
|
||||
freeze_seconds,
|
||||
final_duration,
|
||||
actual_duration,
|
||||
final_speed,
|
||||
configured_speed,
|
||||
)
|
||||
final_speed = configured_speed
|
||||
|
||||
# freeze 标记写入 config,供视频 tpad / 音频 apad 读取
|
||||
resolved_config = dict(clip_config)
|
||||
if freeze_seconds > 0:
|
||||
resolved_config["_freeze_seconds"] = freeze_seconds
|
||||
else:
|
||||
final_speed = configured_speed
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=clip.id,
|
||||
@@ -1628,7 +1681,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=resolved_config,
|
||||
config=clip_config,
|
||||
actual_duration=actual_duration,
|
||||
trim_config=effective_trim,
|
||||
)
|
||||
@@ -1637,14 +1690,13 @@ 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 freeze=%.3f",
|
||||
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f",
|
||||
clip.id,
|
||||
final_duration,
|
||||
actual_duration,
|
||||
eff_dur,
|
||||
final_speed,
|
||||
final_start,
|
||||
freeze_seconds,
|
||||
)
|
||||
|
||||
# 按 order 排序
|
||||
@@ -1738,15 +1790,12 @@ 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 trim_dur > 0:
|
||||
if effective_duration > 0:
|
||||
if trim_start > 0:
|
||||
filters.append(f"trim=start={trim_start:.3f}:duration={trim_dur:.3f}")
|
||||
filters.append(f"trim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
else:
|
||||
filters.append(f"trim=duration={trim_dur:.3f}")
|
||||
filters.append(f"trim=duration={effective_duration:.3f}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 调速 — 基于 setpts 改变播放速度
|
||||
@@ -1797,9 +1846,6 @@ 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)
|
||||
@@ -2082,11 +2128,16 @@ class UnifiedRenderService:
|
||||
|
||||
@staticmethod
|
||||
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
||||
"""计算 clip 的有效时长(#1749:目标段长为准,素材不足走末帧冻结)。
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)。
|
||||
|
||||
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration
|
||||
(duration>0 直接返回;慢放补偿已删除)。
|
||||
如果 playback_speed < 1(为补偿素材不足而减速),返回配置的 duration,
|
||||
而非 min(duration, actual_duration)。
|
||||
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
|
||||
"""
|
||||
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)相关方法 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -17,12 +17,6 @@ apply_queue_settings(celery_app)
|
||||
# 长渲染任务预取 1,避免任务被预取占住导致调度不均
|
||||
celery_app.conf.worker_prefetch_multiplier = GENERATION_WORKER_PREFETCH_MULTIPLIER
|
||||
celery_app.conf.task_acks_late = True # worker 崩溃时未完成任务重回队列,由执行前守卫丢弃作废消息
|
||||
# worker 进程被 OOM/容器硬杀时拒绝 ack,消息留在队列由其他 worker 接手
|
||||
celery_app.conf.task_reject_on_worker_lost = True
|
||||
# Redis broker 消息可见性超时(#1714):acks_late 下,消息被预取后 visibility_timeout
|
||||
# 内未 ack 才会重投。长任务(ingest HEVC 转码 20-30 分钟、生成硬超时 11 分钟)
|
||||
# 必须远大于最长执行时间,否则正常任务会在执行中被误重投;4 小时覆盖最长转码 + 余量。
|
||||
celery_app.conf.broker_transport_options = {"visibility_timeout": 4 * 60 * 60}
|
||||
|
||||
celery_app.conf.imports = (
|
||||
"worker_app.tasks.health",
|
||||
@@ -54,11 +48,4 @@ celery_app.conf.beat_schedule = {
|
||||
"schedule": 300.0, # 每 5 分钟(秒)
|
||||
"options": {"expires": 240},
|
||||
},
|
||||
# 上传/转码链路孤儿巡检:worker 重启丢 prefetch 消息后,卡 pending/processing
|
||||
# 的 ingest_job + asset 占位超时标终态(#1714)。转码任务较长,10 分钟一轮
|
||||
"cleanup-stale-ingest-jobs": {
|
||||
"task": "worker.cleanup_stale_ingest_jobs",
|
||||
"schedule": 600.0, # 每 10 分钟(秒)
|
||||
"options": {"expires": 540},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def mark_asset_used_for_generation(asset, times: int = 1) -> None:
|
||||
"""标记素材在成片中被使用,累加使用次数。
|
||||
|
||||
Args:
|
||||
asset: Asset 实体(metadata 就地更新)
|
||||
times: 本次成片实际使用次数(= 最终成片 plan 中引用该素材的片段数)。
|
||||
按「成片实际渲染的片段」计数而非请求传入的 asset_ids 列表——
|
||||
请求列表可能含未被 plan 选用的素材(不应计数),同一素材在多片段
|
||||
复用时应按片段数累加(高频排除/未使用偏好才与真实渲染强度挂钩)。
|
||||
"""
|
||||
times = max(1, int(times or 1))
|
||||
def mark_asset_used_for_generation(asset) -> None:
|
||||
asset.metadata = {
|
||||
**asset.metadata,
|
||||
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + times,
|
||||
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + 1,
|
||||
"last_used_at": datetime.now(timezone.utc).isoformat(),
|
||||
"review_status": asset.metadata.get("review_status") or "pending_review",
|
||||
}
|
||||
|
||||
@@ -257,32 +257,3 @@ def _on_worker_ready(sender, **kwargs): # pragma: no cover
|
||||
result = cleanup_all_stale_tasks()
|
||||
total = result["generation_tasks"] + result["jobs"]
|
||||
logger.info("Worker 启动清理完成,共清理 %d 个孤儿任务", total)
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def _recover_stuck_ingest_jobs_on_ready(sender, **kwargs): # pragma: no cover
|
||||
"""Worker 启动完成后恢复卡死在 processing 的 ingest_job(#1714)。
|
||||
|
||||
容器重启/进程 OOM 导致 transcode 队列 unacked 消息未重投时,processing
|
||||
ingest_job 会永久卡死。启动时扫描 processing 超 10 分钟的 job,CAS 重置
|
||||
pending 并重新派单;Redis 锁保证同容器 generation/transcode 双 worker
|
||||
只有一个执行恢复。旧消息若后来重投,ingest_asset 执行前守卫会丢弃。
|
||||
"""
|
||||
try:
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
make_redis_recovery_lock,
|
||||
recover_stuck_ingest_jobs_on_startup,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
recovered = recover_stuck_ingest_jobs_on_startup(
|
||||
session,
|
||||
lock_acquire=make_redis_recovery_lock(),
|
||||
stuck_minutes=10,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
logger.info("Worker 启动 ingest 恢复完成,共重新派单 %d 个卡死任务", recovered)
|
||||
except Exception as e: # noqa: BLE001 — 启动恢复失败不能阻断 worker 起服
|
||||
logger.error("启动 ingest 恢复扫描失败(beat 巡检仍会兜底标 failed): %s", e, exc_info=True)
|
||||
|
||||
@@ -16,12 +16,6 @@ from worker_app.tasks._startup import (
|
||||
cleanup_stale_pending_tasks,
|
||||
)
|
||||
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -75,53 +69,3 @@ def scheduled_cleanup_stale_running(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_M
|
||||
timeout_minutes,
|
||||
)
|
||||
return {"generation_tasks": gen_count, "jobs": job_count}
|
||||
|
||||
|
||||
@shared_task(name="worker.cleanup_stale_ingest_jobs")
|
||||
def scheduled_cleanup_stale_ingest_jobs(
|
||||
processing_timeout_minutes: int = INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
pending_timeout_minutes: int = INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
orphan_asset_timeout_minutes: int = ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
) -> dict:
|
||||
"""Celery Beat 调度:清理上传/转码链路(IngestJob + Asset)孤儿记录。
|
||||
|
||||
每 10 分钟执行一次。worker 容器重启/进程 OOM 时,已 prefetch 的 transcode
|
||||
celery 消息会丢失(队列里也不存在),ingest_job 永久卡 pending/processing、
|
||||
asset 永久卡 processing/uploading,没有兜底永远不会恢复(#1714)。
|
||||
|
||||
- ingest_job processing > processing_timeout_minutes / pending > pending_timeout_minutes
|
||||
→ 标 failed;关联 asset 占位(processing/uploading)联动标 error
|
||||
- 无 ingest_job 关联、created_at > orphan_asset_timeout_minutes 的占位 asset
|
||||
→ 标 error
|
||||
- 作废 celery 消息 revoke + 物理清除(防重投,执行前守卫是第二道防线)
|
||||
"""
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
cleanup_orphan_processing_assets,
|
||||
cleanup_stale_ingest_jobs,
|
||||
revoke_stale_ingest_messages,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
job_items, asset_ids = cleanup_stale_ingest_jobs(
|
||||
session,
|
||||
processing_timeout_minutes=processing_timeout_minutes,
|
||||
pending_timeout_minutes=pending_timeout_minutes,
|
||||
)
|
||||
orphan_asset_ids = cleanup_orphan_processing_assets(session, timeout_minutes=orphan_asset_timeout_minutes)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
purged = revoke_stale_ingest_messages(job_items) if job_items else 0
|
||||
total_jobs = len(job_items)
|
||||
total_assets = len(set(asset_ids) | set(orphan_asset_ids))
|
||||
if total_jobs or total_assets:
|
||||
logger.warning(
|
||||
"[Beat] 清理 ingest 链路孤儿: stale_jobs=%d, assets→error=%d, 队列清除消息=%d",
|
||||
total_jobs,
|
||||
total_assets,
|
||||
purged,
|
||||
)
|
||||
return {"stale_jobs": total_jobs, "assets_to_error": total_assets, "purged_messages": purged}
|
||||
|
||||
@@ -376,137 +376,42 @@ 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:
|
||||
session.close()
|
||||
|
||||
|
||||
# ── #1743 批量变体重渲/封面判定(纯函数,便于单测) ──────────────────────
|
||||
BATCH_RENDER_SIMILARITY_LIMIT = 0.20
|
||||
"""批次内成片查重相似度阈值:超过则重选独立 plan 重渲一次(20%)。"""
|
||||
|
||||
|
||||
def should_rerender_for_batch_dedup(*, batch_id: str, render_attempt: int, batch_similarity) -> bool:
|
||||
"""批次内查重后判定是否需要重选 plan 重渲。
|
||||
|
||||
条件(全部满足才重渲):批次任务、首版(attempt==0)、查重率已得出、相似度 > 20%。
|
||||
非批次任务 / 已是重渲版 / 查重率缺失 / 相似度达标 → 不重渲。
|
||||
"""
|
||||
if not batch_id:
|
||||
return False
|
||||
if render_attempt >= 1:
|
||||
return False
|
||||
if batch_similarity is None:
|
||||
return False
|
||||
return float(batch_similarity) > BATCH_RENDER_SIMILARITY_LIMIT
|
||||
|
||||
|
||||
def pick_batch_cover_index(task_id: str, candidate_count: int) -> int:
|
||||
"""批次变体封面帧选取:按 task_id md5 稳定哈希分散到候选帧。
|
||||
|
||||
同任务重试结果稳定;批次内不同 task_id 哈希后分散,避免 N 个变体都抽 frame_0
|
||||
导致封面雷同。非批次调用方应直接取 0(主流程按 batch_id 区分)。
|
||||
"""
|
||||
if candidate_count <= 1:
|
||||
return 0
|
||||
import hashlib
|
||||
|
||||
return int(hashlib.md5(task_id.encode()).hexdigest(), 16) % candidate_count
|
||||
|
||||
|
||||
def _count_plan_clip_asset_usage(session, plan_id: str) -> dict[str, int]:
|
||||
"""统计最终成片 plan 中每个素材被片段引用的次数。
|
||||
|
||||
计数口径(#1743):以成片实际渲染的 edit_plan_clips 为准——
|
||||
同一素材在多个片段复用按片段数累加;未被 plan 选用的素材(即使
|
||||
出现在请求 asset_ids 中)不计数。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
|
||||
|
||||
rows = session.query(EditPlanClipModel.asset_id).filter(EditPlanClipModel.plan_id == plan_id).all()
|
||||
counts: dict[str, int] = {}
|
||||
for (asset_id,) in rows:
|
||||
if asset_id:
|
||||
counts[asset_id] = counts.get(asset_id, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _record_rendered_asset_usage(
|
||||
session,
|
||||
plan_id: str,
|
||||
task_id: str,
|
||||
fallback_asset_ids: list[str] | None = None,
|
||||
) -> int:
|
||||
"""按最终成片 plan 的实际片段统计素材使用次数并回写 metadata。
|
||||
|
||||
plan 无有效片段素材(异常数据)时退回 fallback_asset_ids 每个计 1 次,
|
||||
保证使用统计不因数据异常完全丢失。单素材回写失败不影响其他素材。
|
||||
|
||||
Returns: 实际回写次数的素材数量。
|
||||
"""
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
|
||||
used_counts = _count_plan_clip_asset_usage(session, plan_id)
|
||||
if not used_counts and fallback_asset_ids:
|
||||
used_counts = {aid: 1 for aid in fallback_asset_ids if aid}
|
||||
if not used_counts:
|
||||
logger.info("[task_id=%s] 素材使用计数: plan=%s 无有效片段素材,跳过", task_id, plan_id)
|
||||
return 0
|
||||
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
written = 0
|
||||
for aid, times in used_counts.items():
|
||||
try:
|
||||
asset = asset_repo.get(aid)
|
||||
if asset:
|
||||
mark_asset_used_for_generation(asset, times=times)
|
||||
asset_repo.update(asset)
|
||||
written += 1
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 更新素材使用次数失败: asset_id=%s times=%d",
|
||||
task_id,
|
||||
aid,
|
||||
times,
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info(
|
||||
"[task_id=%s] 素材使用计数回写完成(plan=%s): %d 个素材, 片段引用 %d 次",
|
||||
task_id,
|
||||
plan_id,
|
||||
written,
|
||||
sum(used_counts.values()),
|
||||
)
|
||||
return written
|
||||
|
||||
|
||||
def _upload_rendered_video(
|
||||
def _upload_and_record(
|
||||
task_id: str,
|
||||
output_path: Path,
|
||||
project_id: str,
|
||||
*,
|
||||
attempt: int = 0,
|
||||
) -> tuple[str, str]:
|
||||
"""上传成片到 OSS(不落库)。attempt>0 时文件名带轮次后缀,避免覆盖首版。
|
||||
batch_id: str,
|
||||
editing_mode,
|
||||
user_id: str = "",
|
||||
video_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> tuple[str, float, int, int]:
|
||||
"""上传 OSS、创建视频记录并查重。
|
||||
|
||||
Returns: (file_url, storage_key)
|
||||
Returns:
|
||||
(file_url, duration, file_size, video_count)
|
||||
"""
|
||||
suffix = f"_v{attempt}" if attempt > 0 else ""
|
||||
stem = output_path.stem
|
||||
name = f"{stem}{suffix}{output_path.suffix or '.mp4'}"
|
||||
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, name) if p]
|
||||
# project_id 可能为空(模板编辑器草稿不属于任何项目),过滤空段避免 OSS key 出现 //
|
||||
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, output_path.name) if p]
|
||||
storage_key = "/".join(path_parts)
|
||||
file_size = output_path.stat().st_size
|
||||
|
||||
logger.info("[task_id=%s] [OSS上传] 开始上传(attempt=%d): size=%d", task_id, attempt, output_path.stat().st_size)
|
||||
# 上传 OSS
|
||||
logger.info("[task_id=%s] [OSS上传] 开始上传: size=%d", task_id, file_size)
|
||||
upload_start = time.monotonic()
|
||||
file_url = upload_to_oss(output_path, storage_key)
|
||||
upload_elapsed = time.monotonic() - upload_start
|
||||
if not file_url:
|
||||
raise RuntimeError(f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}")
|
||||
|
||||
# 校验 URL 可达性(P0-2: 私有 bucket 用预签名 + object_exists 降级)
|
||||
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
|
||||
if not _verify_url_accessible(verify_url):
|
||||
from video_processing.oss_helpers import normalize_storage_key, oss_bucket
|
||||
@@ -515,64 +420,25 @@ def _upload_rendered_video(
|
||||
key = normalize_storage_key(file_url)
|
||||
if not (bucket and bucket.object_exists(key)):
|
||||
raise RuntimeError(
|
||||
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, storage_key={storage_key}"
|
||||
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, " f"storage_key={storage_key}"
|
||||
)
|
||||
logger.info("URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key)
|
||||
return file_url, storage_key
|
||||
logger.info(
|
||||
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s",
|
||||
key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[task_id=%s] [OSS上传] 成功: 耗时=%.1fs, file_url=%s",
|
||||
task_id,
|
||||
upload_elapsed,
|
||||
file_url,
|
||||
)
|
||||
|
||||
def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict) -> str | None:
|
||||
"""批次内查重超阈值后,为当前任务重新独立选片生成新 plan(#1743 自动重渲)。
|
||||
|
||||
复用 API 侧同一套 EditPlanService.reselect_plan_for_variant(packages 层
|
||||
variant_plan_selector 纯核心),素材池来自任务 asset_ids + 源 plan 素材。
|
||||
成功返回新 plan_id;失败返回 None(调用方放弃重渲,保留首版)。
|
||||
"""
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
db = SessionLocal()
|
||||
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:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 批次重渲前重选 plan 失败,放弃重渲", task_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _record_video_and_dedup(
|
||||
*,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
batch_id: str,
|
||||
editing_mode,
|
||||
user_id: str,
|
||||
file_url: str,
|
||||
file_size: int,
|
||||
video_path: str,
|
||||
video_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> dict:
|
||||
"""成片落库 + 指纹查重(含批次内)。返回查重信息 dict。"""
|
||||
duration = probe_duration(Path(video_path))
|
||||
# 创建 GeneratedVideo 记录 + 查重
|
||||
duration = probe_duration(output_path)
|
||||
dedup_session = SessionLocal()
|
||||
try:
|
||||
result = create_video_record_and_dedup(
|
||||
video_count = create_video_record_and_dedup(
|
||||
generation_task_id=task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
@@ -580,7 +446,7 @@ def _record_video_and_dedup(
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
video_path=video_path,
|
||||
video_path=str(output_path),
|
||||
mode=editing_mode.value,
|
||||
session=dedup_session,
|
||||
name=video_name,
|
||||
@@ -588,12 +454,11 @@ def _record_video_and_dedup(
|
||||
)
|
||||
finally:
|
||||
dedup_session.close()
|
||||
result["duration"] = duration
|
||||
return result
|
||||
|
||||
return file_url, duration, file_size, video_count or 1
|
||||
|
||||
|
||||
# ── Celery Task ──────────────────────────────────────────────────────────────
|
||||
# ── Celery Task ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> str | None:
|
||||
@@ -653,8 +518,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", "")
|
||||
# #1749:voice_ids 冗余字段已移除;配音一律以 voice_library_id 为准(独立配音每变体各自绑定)
|
||||
effective_voice_id = voice_library_id or ""
|
||||
voice_ids = task_info.get("voice_ids", []) or []
|
||||
effective_voice_id = voice_library_id or (voice_ids[0] if voice_ids else "")
|
||||
|
||||
if effective_voice_id:
|
||||
import tempfile
|
||||
@@ -864,29 +729,19 @@ def generate_video(self, task_id: str) -> dict:
|
||||
gen_task.append_log("渲染模式", "从草稿数据渲染(与预览一致)")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
# ── 渲染→上传→查重→(批次超阈值则重选 plan 重渲一次)循环(#1743)──
|
||||
current_plan_id = source_edit_plan_id
|
||||
file_url = ""
|
||||
duration = 0.0
|
||||
file_size = 0
|
||||
video_count = 1
|
||||
file_size_final = 0
|
||||
for render_attempt in range(2): # 首版 + 最多 1 次重渲
|
||||
(
|
||||
output_path,
|
||||
render_duration,
|
||||
cover_candidates,
|
||||
voiceover_tmp_path,
|
||||
render_temp_dir,
|
||||
thumbnail_url,
|
||||
) = _render_from_edit_plan(
|
||||
output_path, render_duration, cover_candidates, voiceover_tmp_path, render_temp_dir, thumbnail_url = (
|
||||
_render_from_edit_plan(
|
||||
task_id=task_id,
|
||||
source_edit_plan_id=current_plan_id,
|
||||
source_edit_plan_id=source_edit_plan_id,
|
||||
task_info=task_info,
|
||||
)
|
||||
)
|
||||
# 从这里开始,render_temp_dir 已赋值,必须确保异常时也能清理
|
||||
try:
|
||||
if gen_task:
|
||||
gen_task.append_log("渲染", f"渲染完成(第{render_attempt + 1}版), 时长={render_duration:.1f}s")
|
||||
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
_update_task_progress(task_id, 80, "渲染完成")
|
||||
|
||||
# ── 3.5 随机边缘裁剪降重(#1664) ──────────────────────────
|
||||
@@ -896,7 +751,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
cropped_path = random_edge_crop(output_path)
|
||||
if cropped_path != output_path:
|
||||
output_path = cropped_path
|
||||
if gen_task and render_attempt == 0:
|
||||
if gen_task:
|
||||
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
|
||||
_flush_logs(task_id, gen_task)
|
||||
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
|
||||
@@ -907,110 +762,45 @@ def generate_video(self, task_id: str) -> dict:
|
||||
crop_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log("边缘裁剪", f"裁剪失败,使用原始视频: {crop_err}")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
# ── 4. 上传 OSS(不落库) ───────────────────────────────
|
||||
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────
|
||||
_update_task_progress(task_id, 85, "开始上传")
|
||||
file_url, _storage_key = _upload_rendered_video(
|
||||
file_url, duration, file_size, video_count = _upload_and_record(
|
||||
task_id=task_id,
|
||||
output_path=output_path,
|
||||
project_id=project_id,
|
||||
attempt=render_attempt,
|
||||
)
|
||||
file_size = output_path.stat().st_size
|
||||
|
||||
# ── 4.5 落库 + 查重(批次任务检查批次内相似度) ───────────
|
||||
dedup_info = _record_video_and_dedup(
|
||||
task_id=task_id,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
editing_mode=editing_mode,
|
||||
user_id=user_id,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
video_path=str(output_path),
|
||||
video_name=task_info.get("video_title", ""),
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
duration = dedup_info.get("duration", render_duration)
|
||||
video_count = dedup_info.get("video_count", 1)
|
||||
batch_sim = dedup_info.get("batch_similarity")
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"OSS上传",
|
||||
f"第{render_attempt + 1}版上传成功, 大小={file_size}"
|
||||
+ (f", 批次相似度={batch_sim:.0%}" if batch_sim is not None else ""),
|
||||
f"上传成功, 大小={file_size}",
|
||||
file_size=file_size,
|
||||
file_url=file_url,
|
||||
)
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
# 非批次 / 相似度达标 / 已是最后一次 → 结束循环
|
||||
if not should_rerender_for_batch_dedup(
|
||||
batch_id=batch_id,
|
||||
render_attempt=render_attempt,
|
||||
batch_similarity=batch_sim,
|
||||
):
|
||||
file_size_final = file_size
|
||||
break
|
||||
|
||||
# 批次内相似度过高:重选独立 plan 后重渲一次
|
||||
logger.warning(
|
||||
"[task_id=%s] 批次内查重相似度 %.2f 超阈值 %.2f,重选 plan 重渲",
|
||||
task_id,
|
||||
batch_sim,
|
||||
BATCH_RENDER_SIMILARITY_LIMIT,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log("批次查重", f"与批次内成片相似度过高({batch_sim:.0%}),重新选片渲染")
|
||||
_flush_logs(task_id, gen_task)
|
||||
new_plan_id = _reselect_plan_for_batch_retry(task_id, current_plan_id, task_info)
|
||||
if not new_plan_id:
|
||||
logger.warning("[task_id=%s] 重选 plan 失败,保留首版", task_id)
|
||||
file_size_final = file_size
|
||||
break
|
||||
# 回写任务关联的 plan(重渲版以新 plan 渲染)
|
||||
try:
|
||||
_ps = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_pr = SQLAlchemyGenerationTaskRepository(_ps)
|
||||
_gt = _pr.get(task_id)
|
||||
if _gt:
|
||||
_gt.source_edit_plan_id = new_plan_id
|
||||
_pr.update(_gt)
|
||||
finally:
|
||||
_ps.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 回写重渲 plan_id 失败", task_id, exc_info=True)
|
||||
current_plan_id = new_plan_id
|
||||
# 清理本轮临时目录,下一轮重新渲染
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
finally:
|
||||
# 清理渲染临时目录(无论后续步骤成功与否都清理)
|
||||
if render_temp_dir:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(render_temp_dir, ignore_errors=True)
|
||||
render_temp_dir = None
|
||||
|
||||
file_size = file_size_final or file_size
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
|
||||
# 渲染结束后清理临时目录(重渲循环内每轮已清理,此处兜底最后一轮)
|
||||
if render_temp_dir:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(render_temp_dir, ignore_errors=True)
|
||||
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
|
||||
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
|
||||
|
||||
# ── 4.5 封面帧持久化 ────────────────────────────────────────────
|
||||
try:
|
||||
if cover_candidates:
|
||||
# #1743:批量变体封面差异化——候选帧按 task_id 稳定哈希分散选取
|
||||
# (同任务重试稳定,批次内不同任务落在不同帧位),非批次取首帧。
|
||||
_cover_idx = pick_batch_cover_index(task_id, len(cover_candidates)) if batch_id else 0
|
||||
first = cover_candidates[_cover_idx]
|
||||
first = cover_candidates[0]
|
||||
cover_frame_url = first.get("image_url") or first.get("url") or ""
|
||||
if cover_frame_url:
|
||||
_cover_session = SessionLocal()
|
||||
@@ -1069,22 +859,35 @@ def generate_video(self, task_id: str) -> dict:
|
||||
logger.warning("[task_id=%s] 更新标题使用次数异常", task_id, exc_info=True)
|
||||
|
||||
# 5.2 更新素材使用次数
|
||||
# 按「最终成片 plan 实际渲染的片段」计数(#1743):不用请求传入的
|
||||
# task.asset_ids(可能含未被 plan 选用的素材),同一素材多片段复用
|
||||
# 按片段数累加,使 unused_bonus / 高频排除与真实渲染强度挂钩。
|
||||
# current_plan_id 是重渲循环结束后最终成片所用 plan(首版或重渲版)。
|
||||
try:
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation
|
||||
|
||||
_asset_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_fallback_ids: list[str] = []
|
||||
_gt_for_assets = SQLAlchemyGenerationTaskRepository(_asset_session).get(task_id)
|
||||
if _gt_for_assets:
|
||||
_fallback_ids = list(_gt_for_assets.asset_ids or [])
|
||||
_record_rendered_asset_usage(_asset_session, current_plan_id, task_id, _fallback_ids)
|
||||
_task_repo = SQLAlchemyGenerationTaskRepository(_asset_session)
|
||||
_asset_repo = SQLAlchemyAssetRepository(_asset_session)
|
||||
_gen_task = _task_repo.get(task_id)
|
||||
if _gen_task and _gen_task.asset_ids:
|
||||
for _aid in _gen_task.asset_ids:
|
||||
try:
|
||||
_asset = _asset_repo.get(_aid)
|
||||
if _asset:
|
||||
mark_asset_used_for_generation(_asset)
|
||||
_asset_repo.update(_asset)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 更新素材使用次数失败: asset_id=%s",
|
||||
task_id,
|
||||
_aid,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
_asset_session.close()
|
||||
except Exception:
|
||||
|
||||
@@ -14,10 +14,6 @@ server {
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy — Production 环境代理到 production API 容器
|
||||
|
||||
@@ -21,10 +21,6 @@ server {
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy — Staging 环境代理到 staging API 容器
|
||||
|
||||
@@ -36,7 +36,6 @@ celery \
|
||||
worker \
|
||||
--loglevel=info \
|
||||
"-B" \
|
||||
-s /tmp/celerybeat-schedule \
|
||||
-Q generation \
|
||||
"--concurrency=${GEN_CONCURRENCY}" \
|
||||
"--max-tasks-per-child=${MAX_TASKS}" \
|
||||
|
||||
@@ -16,10 +16,6 @@ server {
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -23,10 +23,6 @@ server {
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -33,10 +33,6 @@ server {
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -488,25 +488,20 @@ class SQLAlchemyAssetRepository:
|
||||
|
||||
用于旧客户端未传 file_hash/client_upload_id 时,防止 complete 超时重试
|
||||
反复创建 PROCESSING 占位记录。只命中"活动中"的近期记录,READY 历史素材不拦。
|
||||
|
||||
严格模式(#1714 误杀修复):file_size 必须 > 0 且与记录大小严格一致;
|
||||
file_size=0(大小未知)时直接返回 None——宁可漏判(极端情况下多建一条
|
||||
占位)也不可仅凭同名 + processing 误杀内容全新的视频。
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
if not name:
|
||||
return None
|
||||
if not file_size or file_size <= 0:
|
||||
return None
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
|
||||
query = self.session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.name == name,
|
||||
AssetModel.status.in_([AssetStatus.UPLOADING.value, AssetStatus.PROCESSING.value]),
|
||||
AssetModel.created_at >= cutoff,
|
||||
AssetModel.file_size == file_size,
|
||||
)
|
||||
if file_size and file_size > 0:
|
||||
query = query.filter(AssetModel.file_size == file_size)
|
||||
model = query.order_by(AssetModel.created_at.desc()).first()
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
微信同步登录/注册 Use Case
|
||||
|
||||
供 BFF 层调用的系统级接口:
|
||||
- 优先按 unionid 识别用户(跨应用/跨端识别同一微信用户)
|
||||
- 再按 openid 识别(同一应用内)
|
||||
- openid 命中老账号但 unionid 缺失时补写 unionid(开放平台绑定前的存量账号自动关联)
|
||||
- 都未命中则创建新用户
|
||||
- 根据 openid 查找用户,找到则登录返回 token
|
||||
- 没找到则创建新用户并返回 token
|
||||
- 支持 unionid 跨应用关联
|
||||
"""
|
||||
|
||||
@@ -34,7 +32,7 @@ class WechatSyncRequest:
|
||||
):
|
||||
self.openid = openid.strip()
|
||||
self.unionid = unionid.strip() if unionid else ""
|
||||
self.nickname = nickname or "小虾同学" # 微信新规拿不到真实昵称,新用户默认昵称
|
||||
self.nickname = nickname or "微信用户"
|
||||
self.avatar_url = avatar_url or ""
|
||||
self.source = source
|
||||
|
||||
@@ -84,10 +82,10 @@ class WechatSyncResponse:
|
||||
|
||||
|
||||
class WechatSyncUseCase:
|
||||
"""微信登录/注册同步用例
|
||||
"""微信同步登录/注册用例
|
||||
|
||||
系统级接口,由 BFF 通过 API Key 调用。
|
||||
职责:根据 unionid/openid 查找或创建用户,返回 SaaS token。
|
||||
职责:根据 openid 查找或创建用户,返回 SaaS token。
|
||||
"""
|
||||
|
||||
def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None):
|
||||
@@ -107,54 +105,24 @@ class WechatSyncUseCase:
|
||||
return None, "openid is required"
|
||||
|
||||
is_new_user = False
|
||||
user = None
|
||||
openid_user = None
|
||||
unionid_user = None
|
||||
|
||||
# 1. 先按 unionid 查找(跨应用识别同一微信用户,优先级最高)
|
||||
if request.unionid:
|
||||
unionid_user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
# 1. 按 openid 查找用户
|
||||
user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
|
||||
# 2. 再按 openid 查找(同一应用内)
|
||||
openid_user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
# 2. 如果 openid 没找到,尝试 unionid
|
||||
if not user and request.unionid:
|
||||
user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if user:
|
||||
# 找到用户但 openid 为空,绑定一下当前 openid
|
||||
user.wechat_openid = request.openid
|
||||
self.user_repository.save(user)
|
||||
|
||||
if unionid_user and openid_user:
|
||||
# 3a. 两边都命中
|
||||
if unionid_user.id == openid_user.id:
|
||||
# 同一个用户,直接登录
|
||||
user = unionid_user
|
||||
else:
|
||||
# unionid 与 openid 分属两个不同账号:数据异常,拒绝写入,
|
||||
# 交由人工/数据修复合并,避免账号被错误串联
|
||||
return None, ("wechat account conflict: unionid and openid bound to " "different users")
|
||||
elif unionid_user:
|
||||
# 3b. unionid 命中(跨端老用户),当前 openid 未绑定过:
|
||||
# 确认 openid 没有落在其他账号上后,把新 openid 绑到该用户
|
||||
if openid_user is not None and openid_user.id != unionid_user.id:
|
||||
return None, ("wechat account conflict: openid bound to another user")
|
||||
if unionid_user.wechat_openid != request.openid:
|
||||
unionid_user.wechat_openid = request.openid
|
||||
self.user_repository.save(unionid_user)
|
||||
user = unionid_user
|
||||
elif openid_user:
|
||||
# 3c. 仅 openid 命中(开放平台绑定前创建的存量账号):
|
||||
# 本次请求带了 unionid 且该账号还没有 unionid 时补写
|
||||
if request.unionid and not openid_user.wechat_unionid:
|
||||
# 去重:确认该 unionid 没有关联到其他用户
|
||||
conflict = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if conflict is not None and conflict.id != openid_user.id:
|
||||
return None, ("wechat account conflict: unionid already bound to " "another user")
|
||||
openid_user.wechat_unionid = request.unionid
|
||||
self.user_repository.save(openid_user)
|
||||
user = openid_user
|
||||
else:
|
||||
# 4. 都没找到,创建新用户
|
||||
# 额外兜底:若 unionid 已被其他账号占用(理论上上面已查过),
|
||||
# 不创建带冲突 unionid 的新账号
|
||||
# 3. 都没找到则创建新用户
|
||||
if not user:
|
||||
user = self._create_wechat_user(request)
|
||||
is_new_user = True
|
||||
|
||||
# 5. 创建 session 并生成 token
|
||||
# 4. 创建 session 并生成 token
|
||||
session_id = secrets.token_urlsafe(16)
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
@@ -227,7 +195,7 @@ class WechatSyncUseCase:
|
||||
id=user_id,
|
||||
email=email,
|
||||
username=username,
|
||||
display_name=request.nickname or "小虾同学",
|
||||
display_name=request.nickname or "微信用户",
|
||||
password_hash=password_hash,
|
||||
email_verified=True, # 微信登录视为已验证
|
||||
wechat_openid=request.openid,
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 = ""
|
||||
@@ -47,7 +48,7 @@ class CreateGenerationTaskUseCase:
|
||||
template_id=command.template_id,
|
||||
asset_ids=command.asset_ids,
|
||||
title_ids=command.title_ids,
|
||||
voice_ids=[], # #1749:voice_ids 已废弃(冗余 voice_library_id),DB 列保留只读
|
||||
voice_ids=command.voice_ids,
|
||||
status="pending", # type: ignore[arg-type]
|
||||
progress=0.0,
|
||||
result_count=0,
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
"""上传/转码链路(IngestJob + Asset)孤儿清理核心逻辑。
|
||||
|
||||
#1714:generation 链路有 cleanup_stale_running/pending 兜底,但上传链路
|
||||
(ingest_jobs + assets)没有。worker 容器重启/进程 OOM 时,已 prefetch 的
|
||||
celery 消息会丢失(transcode 队列 worker_prefetch_multiplier=1,消息预取后
|
||||
宕机即丢失,Redis 队列里也不再存在),导致:
|
||||
|
||||
- ingest_jobs.status 永久卡 pending/processing
|
||||
- assets.status 永久卡 processing/uploading(complete 阶段预建的占位)
|
||||
|
||||
本模块提供纯核心(session 注入,便于单测):超时阈值内无更新的记录
|
||||
批量标终态(job→failed、asset→error),并返回 (job_id, celery_task_id)
|
||||
列表供调用方 revoke + purge 残留队列消息。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ingest_job PROCESSING 超时阈值:ingest 任务包含下载 + ffprobe + HEVC 转码
|
||||
# (1GB 视频约 10-20 分钟)+ 回传 OSS,正常任务可能跑 20-30 分钟;
|
||||
# 60 分钟阈值覆盖大文件转码 + 抖动,绝不误杀正常任务。
|
||||
INGEST_PROCESSING_TIMEOUT_MINUTES = 60
|
||||
|
||||
# ingest_job PENDING 超时阈值:transcode 队列 concurrency=1,队列积压时
|
||||
# 正常排队可能较久;90 分钟覆盖 worker 短暂停消费 + 排队。
|
||||
INGEST_PENDING_TIMEOUT_MINUTES = 90
|
||||
|
||||
# Asset 占位超时阈值:无关联 ingest_job 的孤儿占位(complete 预建后派单失败等),
|
||||
# 阈值放宽到 120 分钟,避免与 ingest_job 生命周期错杀。
|
||||
ASSET_ORPHAN_TIMEOUT_MINUTES = 120
|
||||
|
||||
_TERMINAL_JOB_STATUSES = ("failed", "completed")
|
||||
_TERMINAL_ASSET_STATUSES = ("ready", "error", "deleted")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def cleanup_stale_ingest_jobs(
|
||||
session: Any,
|
||||
*,
|
||||
processing_timeout_minutes: int = INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
pending_timeout_minutes: int = INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
commit: bool = True,
|
||||
) -> tuple[list[tuple[str, str]], list[str]]:
|
||||
"""清理超时卡 pending/processing 的 ingest_jobs,并联动关联 asset。
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session(或提供 query/commit 的鸭子类型)
|
||||
processing_timeout_minutes: processing 状态超时阈值
|
||||
pending_timeout_minutes: pending 状态超时阈值
|
||||
commit: 是否提交事务
|
||||
|
||||
Returns:
|
||||
(job_items, asset_ids)
|
||||
- job_items: [(job_id, celery_task_id), ...] 供 revoke/purge
|
||||
- asset_ids: 被联动标记为 error 的 asset id 列表
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, IngestJobModel
|
||||
|
||||
now = _now()
|
||||
processing_cutoff = now - timedelta(minutes=processing_timeout_minutes)
|
||||
pending_cutoff = now - timedelta(minutes=pending_timeout_minutes)
|
||||
|
||||
stale_jobs = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(
|
||||
IngestJobModel.status.in_(["pending", "processing"]),
|
||||
(
|
||||
(IngestJobModel.status == "processing") & (IngestJobModel.updated_at < processing_cutoff)
|
||||
| (IngestJobModel.status == "pending") & (IngestJobModel.created_at < pending_cutoff)
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
job_items: list[tuple[str, str]] = []
|
||||
asset_ids: list[str] = []
|
||||
stale_asset_models: list[Any] = []
|
||||
for job_model in stale_jobs:
|
||||
ref_time = job_model.updated_at or job_model.created_at
|
||||
if ref_time.tzinfo is None: # SQLite 读回 naive datetime 的防御
|
||||
ref_time = ref_time.replace(tzinfo=timezone.utc)
|
||||
stale_minutes = int((now - ref_time).total_seconds() // 60)
|
||||
job_model.status = "failed"
|
||||
job_model.error_message = (
|
||||
f"转码任务执行中断(超过超时阈值未更新,疑似 worker 重启/进程退出,已卡死 {stale_minutes} 分钟)"
|
||||
)
|
||||
job_model.updated_at = now
|
||||
job_items.append((job_model.id, getattr(job_model, "celery_task_id", "") or ""))
|
||||
if job_model.asset_id:
|
||||
asset_ids.append(job_model.asset_id)
|
||||
|
||||
if asset_ids:
|
||||
stale_asset_models = (
|
||||
session.query(AssetModel)
|
||||
.filter(
|
||||
AssetModel.id.in_(asset_ids),
|
||||
AssetModel.status.in_(["processing", "uploading"]),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset_model in stale_asset_models:
|
||||
asset_model.status = "error"
|
||||
asset_model.updated_at = now
|
||||
|
||||
if commit and (job_items or stale_asset_models):
|
||||
session.commit()
|
||||
|
||||
if job_items:
|
||||
logger.warning(
|
||||
"[ingest-cleanup] 清理 %d 个超时 ingest_job(processing>%dm / pending>%dm),联动 %d 个 asset 标 error",
|
||||
len(job_items),
|
||||
processing_timeout_minutes,
|
||||
pending_timeout_minutes,
|
||||
len(stale_asset_models),
|
||||
)
|
||||
return job_items, [a.id for a in stale_asset_models]
|
||||
|
||||
|
||||
def cleanup_orphan_processing_assets(
|
||||
session: Any,
|
||||
*,
|
||||
timeout_minutes: int = ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
commit: bool = True,
|
||||
) -> list[str]:
|
||||
"""清理无 ingest_job 关联、超时卡 processing/uploading 的孤儿 asset 占位。
|
||||
|
||||
complete 阶段预建 asset 后若派单失败(或 direct 上传 complete 后
|
||||
未触发 ingest),占位会永久卡住。这类 asset 没有对应 ingest_job,
|
||||
只能按 created_at 超时兜底标 error。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, IngestJobModel
|
||||
|
||||
cutoff = _now() - timedelta(minutes=timeout_minutes)
|
||||
orphan_assets = (
|
||||
session.query(AssetModel)
|
||||
.outerjoin(IngestJobModel, IngestJobModel.asset_id == AssetModel.id)
|
||||
.filter(
|
||||
AssetModel.status.in_(["processing", "uploading"]),
|
||||
AssetModel.created_at < cutoff,
|
||||
IngestJobModel.id.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset_model in orphan_assets:
|
||||
asset_model.status = "error"
|
||||
asset_model.updated_at = _now()
|
||||
if commit and orphan_assets:
|
||||
session.commit()
|
||||
logger.warning("[ingest-cleanup] 清理 %d 个无 job 关联的超时孤儿 asset 占位", len(orphan_assets))
|
||||
return [a.id for a in orphan_assets]
|
||||
|
||||
|
||||
def revoke_stale_ingest_messages(
|
||||
job_items: list[tuple[str, str]],
|
||||
*,
|
||||
celery_app_factory: Callable[[], Any] | None = None,
|
||||
broker_url_factory: Callable[[], str] | None = None,
|
||||
) -> int:
|
||||
"""revoke + 物理清理 ingest 作废消息(transcode/celery 队列)。
|
||||
|
||||
消息可能已在 worker 宕机时丢失(队列里查不到),那也无害;
|
||||
若消息还在(极端重复投递),物理清除防止重投执行。
|
||||
失败不阻断清理(ingest_asset 的执行前状态守卫是第二道防线)。
|
||||
"""
|
||||
biz_ids = [jid for jid, _ in job_items if jid]
|
||||
celery_ids = [cid for _, cid in job_items if cid]
|
||||
if not biz_ids and not celery_ids:
|
||||
return 0
|
||||
try:
|
||||
from packages.shared.celery_orphan_guard import revoke_and_purge
|
||||
|
||||
app = celery_app_factory() if celery_app_factory else None
|
||||
broker_url = broker_url_factory() if broker_url_factory else ""
|
||||
if app is None or not broker_url:
|
||||
from worker_app.celery_app import celery_app as _app
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
app = _app
|
||||
broker_url = get_settings().broker_url
|
||||
return revoke_and_purge(
|
||||
app,
|
||||
broker_url,
|
||||
business_task_ids=biz_ids,
|
||||
celery_task_ids=celery_ids,
|
||||
queue_names=("transcode", "celery"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("撤销作废 ingest 队列消息失败(执行前守卫仍会兜底): %s", e, exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
# ── worker 启动恢复(#1714)──────────────────────────────────────────────
|
||||
#
|
||||
# task_acks_late=True 下,worker 崩溃/容器重启时未 ack 的消息理论上会在
|
||||
# visibility_timeout 到期后重新投递;但 prefork 进程异常、部署窗口跨
|
||||
# visibility 配置边界等场景仍可能留下卡在 processing 的 ingest_job
|
||||
# (staging 实证:03:16 派单、03:45 置 processing 后 worker 重启,
|
||||
# unacked 消息未重投,任务永久卡死)。启动时做一次显式恢复扫描兜底。
|
||||
#
|
||||
# 恢复策略:processing 超过 stuck_minutes(默认 10 分钟,部署中跨进程
|
||||
# 交接的正常窗口 < 10 分钟,不会误抢别的 worker 正在执行的任务)的 job,
|
||||
# CAS 重置为 pending 并重新 send_task;旧消息若后来重投,ingest_asset
|
||||
# 的执行前守卫会把状态不匹配的旧 celery 消息丢弃。
|
||||
|
||||
|
||||
def recover_stuck_ingest_jobs_on_startup(
|
||||
session: Any,
|
||||
*,
|
||||
send_task: Callable[..., Any] | None = None,
|
||||
update_celery_task_id: Callable[[str, str], None] | None = None,
|
||||
lock_acquire: Callable[[], bool] | None = None,
|
||||
stuck_minutes: int = 10,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
"""worker 启动时把卡在 processing 超时的 ingest_job 重新派单。
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session
|
||||
send_task: celery send_task 可调用(注入便于测试);不传则用 worker celery_app
|
||||
update_celery_task_id: 回写新 celery task id 的回调(job_id, new_task_id)
|
||||
lock_acquire: 分布式锁获取回调(多 worker 进程同时启动时只允许一个恢复);
|
||||
返回 False 表示未抢到锁,本次跳过
|
||||
stuck_minutes: processing 超过该分钟数视为卡死
|
||||
|
||||
Returns:
|
||||
重新派单的 job 数
|
||||
"""
|
||||
if lock_acquire is not None and not lock_acquire():
|
||||
logger.info("[ingest-recover] 未抢到恢复锁,跳过(另一进程正在恢复)")
|
||||
return 0
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import IngestJobModel
|
||||
|
||||
cutoff = _now() - timedelta(minutes=stuck_minutes)
|
||||
stuck_jobs = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(IngestJobModel.status == "processing", IngestJobModel.updated_at < cutoff)
|
||||
.order_by(IngestJobModel.updated_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not stuck_jobs:
|
||||
logger.info("[ingest-recover] 无卡死 processing ingest_job 需要恢复")
|
||||
return 0
|
||||
|
||||
if send_task is None:
|
||||
from worker_app.celery_app import celery_app as _app
|
||||
|
||||
send_task = _app.send_task
|
||||
|
||||
recovered = 0
|
||||
for job_model in stuck_jobs:
|
||||
# CAS:只有仍是 processing 才重置(并发/旧消息已回写终态时不碰)
|
||||
updated = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(IngestJobModel.id == job_model.id, IngestJobModel.status == "processing")
|
||||
.update({"status": "pending", "error_message": "", "updated_at": _now()})
|
||||
)
|
||||
if not updated:
|
||||
continue
|
||||
try:
|
||||
result = send_task("worker.ingest_asset", args=[job_model.id])
|
||||
new_task_id = getattr(result, "id", "") or ""
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("[ingest-recover] 重新派单失败 job_id=%s: %s", job_model.id, e)
|
||||
continue
|
||||
if new_task_id:
|
||||
job_model.celery_task_id = new_task_id
|
||||
if update_celery_task_id is not None:
|
||||
update_celery_task_id(job_model.id, new_task_id)
|
||||
logger.warning(
|
||||
"[ingest-recover] 卡死 ingest_job %s 已重置 pending 并重新派单 (new celery task=%s)",
|
||||
job_model.id,
|
||||
new_task_id,
|
||||
)
|
||||
recovered += 1
|
||||
|
||||
if commit and recovered:
|
||||
session.commit()
|
||||
logger.warning("[ingest-recover] 启动恢复完成,共重新派单 %d 个卡死 ingest_job", recovered)
|
||||
return recovered
|
||||
|
||||
|
||||
def make_redis_recovery_lock(lock_key: str = "ingest:recover:startup", ttl_seconds: int = 300):
|
||||
"""构造基于 Redis SET NX 的恢复锁工厂(多 worker 进程互斥)。
|
||||
|
||||
返回一个无参 callable,调用时尝试抢锁:抢到返回 True,未抢到返回 False。
|
||||
Redis 不可用时不阻断启动恢复(返回 True,恢复逻辑自身有 CAS 幂等保护)。
|
||||
"""
|
||||
|
||||
def _acquire() -> bool:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
client = redis_lib.Redis.from_url(get_settings().broker_url)
|
||||
return bool(client.set(lock_key, "1", nx=True, ex=ttl_seconds))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[ingest-recover] Redis 锁不可用,降级为无锁执行(CAS 兜底): %s", e)
|
||||
return True
|
||||
|
||||
return _acquire
|
||||
@@ -94,23 +94,22 @@ def clip_effective_duration(
|
||||
duration: float,
|
||||
actual_duration: float = 0.0,
|
||||
) -> float:
|
||||
"""计算 clip 的有效时长(#1749:目标段长始终为准)。
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)。
|
||||
|
||||
规则:
|
||||
- duration > 0: 直接返回 duration(配音分配的目标段长;素材短于段长的部分
|
||||
由渲染侧末帧冻结 tpad / 音频 apad 铺满,不在此处钳制);
|
||||
- duration <= 0: actual_duration,actual=0 时返回 0。
|
||||
- duration > 0: min(duration, actual_duration),actual=0 时用 duration
|
||||
- duration <= 0: actual_duration,actual=0 时返回 0
|
||||
|
||||
Args:
|
||||
duration: 配置的目标时长(0 表示使用完整素材)
|
||||
duration: 配置的时长(0 表示使用完整素材)
|
||||
actual_duration: 素材实际时长(probe 后的结果)
|
||||
|
||||
Returns:
|
||||
有效时长(秒)
|
||||
"""
|
||||
if duration > 0:
|
||||
return float(duration)
|
||||
return float(actual_duration) if actual_duration > 0 else 0.0
|
||||
return min(duration, actual_duration) if actual_duration > 0 else duration
|
||||
return actual_duration if actual_duration > 0 else 0.0
|
||||
|
||||
|
||||
def clip_playback_speed(playback_speed: Any) -> float:
|
||||
@@ -183,7 +182,7 @@ def estimate_total_duration(
|
||||
if not main_layer or not getattr(main_layer, "clips", None):
|
||||
return 0.0
|
||||
|
||||
clips = list(getattr(main_layer, "clips", []))
|
||||
clips = getattr(main_layer, "clips", [])
|
||||
total = sum(
|
||||
clip_adjusted_duration(
|
||||
duration=getattr(c, "duration", 0),
|
||||
@@ -193,19 +192,12 @@ def estimate_total_duration(
|
||||
for c in clips
|
||||
)
|
||||
|
||||
# #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
|
||||
# 减去转场重叠时间(粗略估算)
|
||||
n_clips = len(clips)
|
||||
if n_clips > 1 and transition_duration > 0:
|
||||
total -= (n_clips - 1) * transition_duration
|
||||
|
||||
return max(0.1, round(total, 3))
|
||||
return max(0.1, total)
|
||||
|
||||
|
||||
# ── 直通 / Stream Copy 判断辅助 ──────────────────────────────────────────────
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -131,7 +130,6 @@ def smart_select_assets(
|
||||
limit: int | None = None,
|
||||
kind: str | None = None,
|
||||
now: datetime | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> list[SmartMatchResult]:
|
||||
"""从素材列表中智能选取素材。
|
||||
|
||||
@@ -140,11 +138,9 @@ def smart_select_assets(
|
||||
limit: 最大返回数量,None 表示不限制
|
||||
kind: 按文件类型过滤(video/image/audio),None 表示不过滤
|
||||
now: 当前时间(用于测试注入)
|
||||
rng: 随机数生成器(用于测试注入,控制排序噪声可复现)
|
||||
|
||||
Returns:
|
||||
按有效得分(综合得分 + 随机噪声)降序排列的 SmartMatchResult 列表。
|
||||
r.score 始终为无噪声的原始综合得分;噪声仅用于排序/分桶顺序。
|
||||
按得分降序排列的 SmartMatchResult 列表
|
||||
"""
|
||||
# Step 1: 过滤 ready 状态
|
||||
ready_assets = [a for a in assets if _get_enum_value(a, "status") == "ready"]
|
||||
@@ -162,39 +158,22 @@ def smart_select_assets(
|
||||
total, breakdown = score_asset(a, now=now)
|
||||
scored.append(SmartMatchResult(asset=a, score=total, breakdown=breakdown))
|
||||
|
||||
# Step 4: 按「得分 + 随机噪声」降序排序
|
||||
# 同分/近分素材(分差 <= SCORE_RANDOM_NOISE_MAX)每次选出的顺序与组合不同,
|
||||
# 从素材组合层面降低成片重复率;分差显著(>20)的高质量素材排名不受影响。
|
||||
# 噪声以 asset.id 为 key 缓存,保证同一次调用内排序与分桶轮询顺序一致。
|
||||
rng = rng or random.Random()
|
||||
noise_by_asset: dict[str, float] = {
|
||||
getattr(a, "id", ""): rng.uniform(0.0, SCORE_RANDOM_NOISE_MAX) for a in ready_assets
|
||||
}
|
||||
# Step 4: 按得分降序排序
|
||||
scored.sort(key=lambda r: r.score, reverse=True)
|
||||
|
||||
def _effective(r: SmartMatchResult) -> float:
|
||||
return r.score + noise_by_asset.get(getattr(r.asset, "id", ""), 0.0)
|
||||
|
||||
scored.sort(key=_effective, reverse=True)
|
||||
|
||||
# Step 5: 多样性保障 — 时长分桶均衡选取(桶内同样按含噪声顺序)
|
||||
# Step 5: 多样性保障 — 时长分桶均衡选取
|
||||
if limit and limit > 0 and len(scored) > limit:
|
||||
scored = _diversity_select(scored, limit, effective_key=_effective)
|
||||
scored = _diversity_select(scored, limit)
|
||||
elif limit and limit > 0:
|
||||
scored = scored[:limit]
|
||||
|
||||
return scored
|
||||
|
||||
|
||||
def _diversity_select(
|
||||
scored: list[SmartMatchResult],
|
||||
limit: int,
|
||||
effective_key: Any | None = None,
|
||||
) -> list[SmartMatchResult]:
|
||||
def _diversity_select(scored: list[SmartMatchResult], limit: int) -> list[SmartMatchResult]:
|
||||
"""从已排序的候选中按分桶均衡选取,避免全选中同一时长档。
|
||||
|
||||
策略:轮流从 short/medium/long 桶中按顺序取,直到凑满 limit。
|
||||
scored 已按含噪声的有效得分排序,桶内直接继承该顺序;
|
||||
effective_key 给出时最终输出也按有效得分排序(同一次调用内噪声一致)。
|
||||
策略:轮流从 short/medium/long 桶中按得分顺序取,直到凑满 limit。
|
||||
"""
|
||||
buckets: dict[str, list[SmartMatchResult]] = {
|
||||
"short": [],
|
||||
@@ -230,6 +209,6 @@ def _diversity_select(
|
||||
if not added:
|
||||
break
|
||||
|
||||
# 按有效得分(含噪声)降序输出;未传 effective_key 时退回原始得分
|
||||
selected.sort(key=effective_key or (lambda r: r.score), reverse=True)
|
||||
# 按原始得分降序输出
|
||||
selected.sort(key=lambda r: r.score, reverse=True)
|
||||
return selected
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
"""批量变体独立选片核心(#1743 起,#1749 强化素材级去重)。
|
||||
|
||||
总原则:多视频 = 单视频逻辑 × N。批量正式生成/批量预览/variant-plans 时,
|
||||
每个变体**完整重跑单视频的选片流程**:
|
||||
|
||||
1. 源 plan 片段骨架(clip_type/order/text/transition)保持不变;
|
||||
2. 素材池选片(#1749 定稿三轮策略):
|
||||
- **第一轮 fresh 素材优先**:本批次尚未被任何变体使用过的素材优先分配,
|
||||
从根上避免跨视频素材重复;
|
||||
- **第二轮受控复用**:fresh 素材不足时才允许复用已用素材,但必须通过
|
||||
起点扫描(_best_start_for_asset,0.25s 窗口)使与批次内已有区间的
|
||||
overlap ≤ 20%(BATCH_CLIP_OVERLAP_LIMIT),且不得完全重叠;
|
||||
- **短素材数学上无法错开**(素材时长 < 段长 ×(1−0.20),任何起点
|
||||
重叠都 >20%)→ **禁止跨变体复用**,跳过该素材继续找;
|
||||
- **第三轮兜底尽力而为**:池子耗尽时取最优(重叠最小)起点,不报错、
|
||||
不打断生成(#1749 铁律:任何情况下不得因素材时长/数量报错打断);
|
||||
3. main 片段之间洗牌顺序;起点走场景镜头洗牌 + 随机起点 + 历史已用区间
|
||||
避让(pick_scene_aware_start / _resolve_start_time,与单视频同一入口);
|
||||
4. target_durations:#1749 配音时长分配后每段目标段长(voice_duration_planner),
|
||||
落库到片段 duration;素材短于段长由渲染侧末帧冻结(tpad/apad)铺满。
|
||||
|
||||
本模块只产出 clips_data(dict 列表),不碰 DB 事务边界;素材时长/场景点/
|
||||
已用区间由调用方注入,便于单测。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
|
||||
from packages.domain.plan_generator_utils import _resolve_start_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 阈值常量 ────────────────────────────────────────────────────────────────
|
||||
BATCH_CLIP_OVERLAP_LIMIT = 0.20
|
||||
"""批次内同一素材片段时间区间重叠占比上限(20%)。超过则重选起点/换素材。"""
|
||||
|
||||
VARIANT_RESELECT_MAX_ATTEMPTS = 6
|
||||
"""单片段重叠避让/起点重选的最大尝试次数。"""
|
||||
|
||||
START_SCAN_STEP = 0.25
|
||||
"""复用素材时起点扫描窗口步长(秒)。"""
|
||||
|
||||
MAIN_CLIP_TYPES = {"main"}
|
||||
"""参与素材洗牌重选的片段类型(intro/outro/overlay 等固定角色片段保持源 plan)。"""
|
||||
|
||||
|
||||
def _clip_overlap_ratio(
|
||||
asset_id: str,
|
||||
start: float,
|
||||
duration: float,
|
||||
batch_segments: dict[str, list[tuple[float, float]]],
|
||||
) -> float:
|
||||
"""计算新区间 [start, start+duration) 与批次内同素材已选区间的重叠占比。
|
||||
|
||||
返回重叠总时长 / 片段时长。
|
||||
"""
|
||||
if not asset_id or duration <= 0:
|
||||
return 0.0
|
||||
end = start + duration
|
||||
overlap = 0.0
|
||||
for seg_start, seg_end in batch_segments.get(asset_id, []):
|
||||
ov = max(0.0, min(end, seg_end) - max(start, seg_start))
|
||||
overlap += ov
|
||||
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],
|
||||
*,
|
||||
asset_durations: dict[str, float],
|
||||
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]:
|
||||
"""为一个变体基于源片段骨架重新独立选片。
|
||||
|
||||
Args:
|
||||
source_clips: 源 plan 片段(dict 列表,每项至少含
|
||||
order/asset_id/start_time/duration/clip_type,可含
|
||||
playback_speed/transition_effect/transition_duration/text_content)。
|
||||
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次任务素材)。
|
||||
asset_durations: {asset_id: 时长秒},起点避让/区间计算必需。
|
||||
asset_scene_points: {asset_id: 场景切换点},有则走镜头洗牌选起点。
|
||||
historical_used_segments: 素材 metadata 中持久化的历史已用区间
|
||||
(跨任务/跨变体避让),函数内会就地追加本变体选中的区间。
|
||||
batch_segments: 本批次已选片段区间(变体间素材级去重 + 20% 重叠检查),
|
||||
函数内会就地追加本变体选中的区间。
|
||||
target_durations: #1749 配音分配后的每段目标时长(按 order 对齐的 list,
|
||||
或 {order: duration} dict);落库到片段 duration,素材不足由渲染冻结铺满。
|
||||
rng: 可选随机数生成器(测试可注入固定种子)。
|
||||
|
||||
Returns:
|
||||
clips_data: 与源片段等长、order 对齐的新片段 dict 列表。
|
||||
|
||||
Raises:
|
||||
ValueError: 源片段为空 / 素材池为空 / 素材时长全为 0(无法差异化选片)。
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
if not source_clips:
|
||||
raise ValueError("源 plan 无片段,无法为变体重新选片")
|
||||
if not candidate_asset_ids:
|
||||
raise ValueError("素材池为空,无法为变体独立选片(不允许退回同源成片)")
|
||||
|
||||
# 仅保留时长可知(>0)的素材;时长未知无法做区间避让/重叠计算
|
||||
usable_assets = [a for a in dict.fromkeys(candidate_asset_ids) if asset_durations.get(a, 0.0) > 0]
|
||||
if not usable_assets:
|
||||
raise ValueError("素材池时长全部未知(0),无法为变体独立选片")
|
||||
|
||||
# 历史已用区间:复制一份,本变体选中的区间就地追加(随 clip record 持久化由调用方负责)
|
||||
used_segments: dict[str, list[tuple[float, float]]] = (
|
||||
{k: list(v) for k, v in (historical_used_segments or {}).items()} if historical_used_segments else {}
|
||||
)
|
||||
batch_segments = batch_segments if batch_segments is not None else {}
|
||||
|
||||
# 按 order 排序源片段,保持骨架顺序
|
||||
ordered = sorted(source_clips, key=lambda c: c.get("order", 0))
|
||||
|
||||
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)
|
||||
|
||||
# ── 2. main 片段之间洗牌顺序(顺序随机) ────────────────────────────────
|
||||
main_indexes = [i for i, c in enumerate(ordered) if c.get("clip_type", "main") in MAIN_CLIP_TYPES]
|
||||
rng.shuffle(main_indexes)
|
||||
|
||||
result: list[dict | None] = [None] * len(ordered)
|
||||
|
||||
for idx in main_indexes:
|
||||
src = ordered[idx]
|
||||
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)), duration=target_dur
|
||||
)
|
||||
continue
|
||||
|
||||
asset_id, start, eff_dur = _pick_asset_and_start(
|
||||
clip_duration=target_dur,
|
||||
shuffled_pool=shuffled_pool,
|
||||
asset_durations=asset_durations,
|
||||
asset_scene_points=asset_scene_points,
|
||||
used_segments=used_segments,
|
||||
batch_segments=batch_segments,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
interval = (start, start + eff_dur)
|
||||
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, duration=target_dur)
|
||||
|
||||
# ── 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", "")
|
||||
target_dur = _target_dur(idx, src)
|
||||
start = float(src.get("start_time", 0.0))
|
||||
total = asset_durations.get(aid, 0.0)
|
||||
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, duration=target_dur)
|
||||
|
||||
return [c for c in result if c is not None]
|
||||
|
||||
|
||||
def _pick_asset_and_start(
|
||||
*,
|
||||
clip_duration: float,
|
||||
shuffled_pool: list[str],
|
||||
asset_durations: dict[str, 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,
|
||||
) -> tuple[str, float, float]:
|
||||
"""三轮选片:fresh 优先 → 受控复用(重叠≤20%,短素材禁复用)→ 兜底尽力而为。
|
||||
|
||||
Returns:
|
||||
(asset_id, start, eff_dur):eff_dur = min(段长, 素材时长),
|
||||
段长超出素材时长的部分由渲染侧末帧冻结铺满。
|
||||
"""
|
||||
# ── 第一轮: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
|
||||
|
||||
# ── 第二轮:受控复用 —— 扫描起点使重叠 ≤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 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, duration: float | None = None) -> dict:
|
||||
"""从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。"""
|
||||
return {
|
||||
"order": src.get("order", 0),
|
||||
"asset_id": asset_id,
|
||||
"start_time": round(float(start), 3),
|
||||
"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"),
|
||||
"transition_duration": float(src.get("transition_duration", 0.0) or 0.0),
|
||||
"text_content": src.get("text_content", ""),
|
||||
"config": src.get("config") or {},
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"""批量变体配音严格守卫与解析(#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
|
||||
@@ -1,120 +0,0 @@
|
||||
"""配音时长 → 片段时长分配纯函数(#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)
|
||||
@@ -347,9 +347,8 @@ class TestCreateGenerationTask:
|
||||
assert mock_celery.send_task.call_args[0][0] == "worker.generate_video"
|
||||
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_create_batch_tasks_without_plan_rejected(self, mock_celery, client):
|
||||
"""#1743:批量 count=3 但无剪辑计划(未传 source_edit_plan_id 且模板兜底无 plan)
|
||||
→ 400 中断,严禁 N 任务兜底共用同一 plan 产出同源成片。"""
|
||||
def test_create_batch_tasks(self, mock_celery, client):
|
||||
"""批量创建多个生成任务。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
resp = client.post(
|
||||
@@ -362,47 +361,17 @@ class TestCreateGenerationTask:
|
||||
"count": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "预览" in resp.json()["detail"] or "剪辑计划" in resp.json()["detail"]
|
||||
mock_celery.send_task.assert_not_called()
|
||||
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_create_batch_tasks_with_independent_variant_plans(self, mock_celery, client):
|
||||
"""#1743:批量 count=3 且有源 plan → 变体 0 用源 plan,变体 1/2 各自 reselect
|
||||
独立选片,3 个任务关联 3 个不同 plan。"""
|
||||
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"),
|
||||
]
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "strategy-default",
|
||||
"voice_library_id": "voice-lib-1",
|
||||
"source_edit_plan_id": "source-plan-1",
|
||||
"count": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["total"] == 3
|
||||
# 验证所有任务都有不同的 ID
|
||||
task_ids = [t["id"] for t in data["items"]]
|
||||
assert len(set(task_ids)) == 3
|
||||
# 同一批次 batch_id 相同
|
||||
# 同一批次应有相同的 batch_id
|
||||
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 各自独立选片;变体 0 clone 源 plan(#1749)
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
assert MockPlanSvc.return_value.clone_plan_for_variant.call_count == 1
|
||||
|
||||
def test_create_task_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
|
||||
@@ -135,8 +135,8 @@ class TestClipEffectiveDuration:
|
||||
assert clip_effective_duration(3.0, 5.0) == 3.0
|
||||
|
||||
def test_duration_greater_than_actual(self):
|
||||
"""#1749:duration > actual 仍取目标段长 duration(素材短走末帧冻结,不钳制)."""
|
||||
assert clip_effective_duration(10.0, 5.0) == 10.0
|
||||
"""duration > actual,取actual."""
|
||||
assert clip_effective_duration(10.0, 5.0) == 5.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):
|
||||
"""#1749:effective=目标段长 10.0(不再钳 actual),2 倍速 → 10/2 = 5.0."""
|
||||
assert clip_adjusted_duration(10.0, 4.0, 2.0) == 5.0
|
||||
"""duration>actual时取actual,再调速."""
|
||||
assert clip_adjusted_duration(10.0, 4.0, 2.0) == 2.0
|
||||
|
||||
def test_default_speed(self):
|
||||
assert clip_adjusted_duration(5.0, 10.0) == 5.0
|
||||
@@ -229,9 +229,6 @@ 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
|
||||
@@ -265,26 +262,13 @@ class TestEstimateTotalDuration:
|
||||
role="main",
|
||||
clips=[
|
||||
FakeClip(duration=5.0),
|
||||
FakeClip(duration=5.0, transition_effect="fade", transition_duration=0.5),
|
||||
FakeClip(duration=5.0),
|
||||
],
|
||||
),
|
||||
]
|
||||
# #1749:总10s - 1个非cut转场 * 0.5s = 9.5s(cut 零重叠)
|
||||
# 总10s - 1个转场 * 0.5s = 9.5s
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 9.5
|
||||
|
||||
def test_cut_clips_have_zero_overlap(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[
|
||||
FakeClip(duration=5.0),
|
||||
FakeClip(duration=5.0),
|
||||
],
|
||||
),
|
||||
]
|
||||
# #1749:cut 转场零重叠 → 总时长 10s,即使传了全局 transition_duration
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 10.0
|
||||
|
||||
def test_transition_with_many_clips(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
@@ -292,17 +276,7 @@ class TestEstimateTotalDuration:
|
||||
clips=[FakeClip(duration=3.0) for _ in range(5)],
|
||||
),
|
||||
]
|
||||
# #1749:全 cut 零重叠 → 5个3s = 15s
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 15.0
|
||||
|
||||
def test_non_cut_transitions_with_many_clips(self):
|
||||
layers = [
|
||||
FakeLayer(
|
||||
role="main",
|
||||
clips=[FakeClip(duration=3.0, transition_effect="fade", transition_duration=0.5) for _ in range(5)],
|
||||
),
|
||||
]
|
||||
# 5个3s = 15s,4个非cut转场 * 0.5s = 2s,总13s(#1749 逐处重叠)
|
||||
# 5个3s = 15s,4个转场 * 0.5s = 2s,总13s
|
||||
assert estimate_total_duration(layers, transition_duration=0.5) == 13.0
|
||||
|
||||
def test_prefers_main_over_broll(self):
|
||||
|
||||
@@ -1,93 +1,75 @@
|
||||
"""测试 #1294 修复:预览视频配音注入(#1749 后重写)。
|
||||
"""测试 #1294 修复:预览视频配音注入。
|
||||
|
||||
#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] 兜底。
|
||||
验证:
|
||||
1. _load_task_info 正确加载 voice_ids
|
||||
3. voice_ids 正确注入到 plan config 中(实际执行代码路径,diff-cover 可达)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
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
|
||||
import pytest
|
||||
|
||||
|
||||
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
|
||||
class TestLoadTaskInfoVoiceIds:
|
||||
"""验证 _load_task_info 包含 voice_ids"""
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
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"]
|
||||
|
||||
result = _load_task_info("test_task_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
|
||||
|
||||
assert result is not None
|
||||
assert result["voice_library_id"] == "voice_lib_abc"
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
|
||||
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
|
||||
assert result is not None
|
||||
assert result["voice_ids"] == ["voice_1", "voice_2"]
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
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
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
assert "voice_ids" not in result
|
||||
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
|
||||
|
||||
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"] == ""
|
||||
result = _load_task_info("test_task_id")
|
||||
assert result["voice_ids"] == []
|
||||
|
||||
@@ -97,7 +97,7 @@ class TestVariantArrayValidation:
|
||||
"""voice_library_ids 长度非法 → 报错"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError, match="配音|视频数量"):
|
||||
with pytest.raises(ValidationError, match="voice_library_ids"):
|
||||
_make_preview_request(preview_count=4, voice_library_ids=["v1", "v2"])
|
||||
|
||||
def test_preview_empty_arrays_ok(self):
|
||||
@@ -189,34 +189,30 @@ class TestBatchPreviewRoute:
|
||||
for i, item in enumerate(resp.items):
|
||||
assert item.variant_index == i
|
||||
|
||||
def test_preview_count_3_reselects_independent_variant_plans(self):
|
||||
"""#1743:有源 plan 时 N=3,变体0保留源 plan,变体1/2 各自独立选片(reselect)。"""
|
||||
def test_preview_count_3_clones_three_variant_plans(self):
|
||||
"""有源 plan 时,N=3 克隆 3 个独立变体 plan(预览全部克隆,不用源 plan)"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
|
||||
tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)]
|
||||
repo = _repo_mock()
|
||||
reselect_plan_ids = ["reselect_1", "reselect_2"]
|
||||
cloned_plan_ids = ["clone_1", "clone_2", "clone_3"]
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = tasks
|
||||
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
|
||||
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 = {}
|
||||
clone_results = [MagicMock(id=pid) for pid in cloned_plan_ids]
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.side_effect = clone_results
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
# #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_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"
|
||||
# 克隆被调用 3 次
|
||||
assert MockPlanSvc.return_value.clone_plan_for_variant.call_count == 3
|
||||
# 每个任务关联到不同的克隆 plan
|
||||
for i, task in enumerate(tasks):
|
||||
assert task.source_edit_plan_id == cloned_plan_ids[i]
|
||||
|
||||
def test_preview_variant_titles_injected_per_variant(self):
|
||||
"""titles[] 按变体注入 title_config.text"""
|
||||
@@ -339,8 +335,8 @@ class TestBatchPreviewRoute:
|
||||
)
|
||||
assert exc.value.status_code == 429
|
||||
|
||||
def test_preview_reselect_failure_marks_all_failed(self):
|
||||
"""#1743:变体独立选片(reselect)重试仍失败 → 已创建任务全部标记 failed 并 500"""
|
||||
def test_preview_clone_failure_marks_all_failed(self):
|
||||
"""克隆变体 plan 失败 → 已创建任务全部标记 failed 并 500"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -349,7 +345,7 @@ class TestBatchPreviewRoute:
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = tasks
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
@@ -361,73 +357,6 @@ class TestBatchPreviewRoute:
|
||||
# 所有已创建任务都被标记 failed
|
||||
assert all(t.status == GenerationTaskStatus.FAILED for t in tasks)
|
||||
|
||||
def test_preview_reselect_value_error_returns_400(self):
|
||||
"""#1743:预览 count>1 reselect 素材不足(ValueError)→ 400,已建任务标 failed。"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
from fastapi import HTTPException
|
||||
|
||||
tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)]
|
||||
repo = _repo_mock()
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = tasks
|
||||
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = ValueError("素材池为空")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert "无法独立选片" in exc.value.detail
|
||||
assert all(t.status == GenerationTaskStatus.FAILED for t in tasks)
|
||||
|
||||
def test_preview_reselect_retry_exhausted_returns_500(self):
|
||||
"""#1743:预览 count>1 reselect 连续失败(非 ValueError)→ 500,已建任务标 failed。"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
from fastapi import HTTPException
|
||||
|
||||
tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)]
|
||||
repo = _repo_mock()
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = tasks
|
||||
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
assert exc.value.status_code == 500
|
||||
assert all(t.status == GenerationTaskStatus.FAILED for t in tasks)
|
||||
|
||||
def test_preview_count1_with_source_plan_clones(self):
|
||||
"""#1743 零回归:预览 count=1 且有源 plan 仍走 clone(不 reselect)。"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
|
||||
task = _make_task(task_id="task_1", source_plan_id="source_plan")
|
||||
repo = _repo_mock()
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.return_value = task
|
||||
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_1")
|
||||
resp = create_preview_generation_task(
|
||||
_make_preview_request(preview_count=1),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
assert resp.total == 1
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.assert_called_once()
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.assert_not_called()
|
||||
assert task.source_edit_plan_id == "clone_1"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 批量正式生成:变体配置注入
|
||||
@@ -437,7 +366,7 @@ class TestBatchPreviewRoute:
|
||||
class TestBatchGenerationVariantConfig:
|
||||
"""POST /tasks count=N 时变体独立配置。"""
|
||||
|
||||
def _call_create_tasks(self, request, repo=None, db_latest_plan=None):
|
||||
def _call_create_tasks(self, request, repo=None):
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
repo = repo or MagicMock()
|
||||
@@ -450,9 +379,9 @@ class TestBatchGenerationVariantConfig:
|
||||
asset_repo = MagicMock()
|
||||
asset_repo.find_by_id.return_value = None
|
||||
|
||||
# db.query().filter()...first():db_latest_plan 非空时模拟模板兜底查到最新 plan
|
||||
# db.query().filter()...first() 返回 None:不走兜底关联编辑计划
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = db_latest_plan
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
return create_generation_task(
|
||||
request,
|
||||
@@ -478,33 +407,20 @@ class TestBatchGenerationVariantConfig:
|
||||
t.title_config = cmd.title_config
|
||||
t.voice_library_id = cmd.voice_library_id
|
||||
t.cover_url = cmd.cover_url
|
||||
t.source_edit_plan_id = cmd.source_edit_plan_id # #1743:usecase 落库关联 plan
|
||||
return t
|
||||
|
||||
MockUC.return_value.execute.side_effect = _execute
|
||||
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
|
||||
# #1743:count>1 必须有源 plan,变体 1..N-1 走 reselect 独立选片
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
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",
|
||||
title_config={"font": "宋体"},
|
||||
titles=["成片标题1", "成片标题2", "成片标题3"],
|
||||
voice_library_ids=["v1", "v2", "v3"],
|
||||
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()
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
title_config={"font": "宋体"},
|
||||
titles=["成片标题1", "成片标题2", "成片标题3"],
|
||||
voice_library_ids=["v1", "v2", "v3"],
|
||||
cover_urls=["http://c1", "http://c2", "http://c3"],
|
||||
)
|
||||
resp = self._call_create_tasks(req)
|
||||
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"]
|
||||
@@ -551,128 +467,21 @@ class TestBatchGenerationVariantConfig:
|
||||
|
||||
def _execute(cmd):
|
||||
captured.append(cmd)
|
||||
t = tasks[len(captured) - 1]
|
||||
t.source_edit_plan_id = cmd.source_edit_plan_id # #1743:usecase 落库关联 plan
|
||||
return t
|
||||
return tasks[len(captured) - 1]
|
||||
|
||||
MockUC.return_value.execute.side_effect = _execute
|
||||
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
|
||||
# #1743:count>1 必须有源 plan,变体 1..N-1 走 reselect 独立选片
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
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_id="shared_voice",
|
||||
cover_urls=["http://shared"],
|
||||
)
|
||||
self._call_create_tasks(req)
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
voice_library_ids=["shared_voice"],
|
||||
cover_urls=["http://shared"],
|
||||
)
|
||||
self._call_create_tasks(req)
|
||||
assert all(c.voice_library_id == "shared_voice" for c in captured)
|
||||
assert all(c.cover_url == "http://shared" for c in captured)
|
||||
|
||||
def test_count3_template_fallback_plan_used(self):
|
||||
"""#1743:未传 source_edit_plan_id 时,模板兜底查到最新 plan 即作为批量源 plan。"""
|
||||
from app.api.routes import generation_tasks as routes
|
||||
|
||||
tasks = [_make_task(task_id=f"gen_fb_{i}") for i in range(3)]
|
||||
|
||||
def _execute(cmd):
|
||||
t = tasks[len([c for c in getattr(_execute, "caps", [])])]
|
||||
t.source_edit_plan_id = cmd.source_edit_plan_id
|
||||
_execute.caps.append(cmd)
|
||||
return t
|
||||
|
||||
_execute.caps = []
|
||||
|
||||
latest = MagicMock(id="fallback_plan_id")
|
||||
req = CreateGenerationTaskRequest(template_id="tpl_1", asset_ids=["a1"], count=3)
|
||||
|
||||
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = _execute
|
||||
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
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)
|
||||
|
||||
# #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"
|
||||
|
||||
def test_count3_reselect_value_error_returns_400(self):
|
||||
"""#1743:reselect 素材不足(ValueError)→ 400 明确报错,零任务入队。"""
|
||||
from app.api.routes import generation_tasks as routes
|
||||
from fastapi import HTTPException
|
||||
|
||||
enqueue = MagicMock(return_value=True)
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan"
|
||||
)
|
||||
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="should_not_run")
|
||||
with patch.object(routes, "safe_enqueue_generation_task", enqueue):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = ValueError("素材池为空")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
self._call_create_tasks(req)
|
||||
assert exc.value.status_code == 400
|
||||
assert "无法独立选片" in exc.value.detail
|
||||
enqueue.assert_not_called()
|
||||
|
||||
def test_count3_reselect_retry_exhausted_returns_500(self):
|
||||
"""#1743:reselect 连续 2 次都非 ValueError 失败 → 500,零任务入队。"""
|
||||
from app.api.routes import generation_tasks as routes
|
||||
from fastapi import HTTPException
|
||||
|
||||
enqueue = MagicMock(return_value=True)
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan"
|
||||
)
|
||||
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="should_not_run")
|
||||
with patch.object(routes, "safe_enqueue_generation_task", enqueue):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
self._call_create_tasks(req)
|
||||
assert exc.value.status_code == 500
|
||||
enqueue.assert_not_called()
|
||||
|
||||
def test_count3_missing_plan_after_prebuild_raises_500(self):
|
||||
"""#1743 兜底守卫:任务落库时 plan 丢失(usecase 未透传)→ 500,严禁静默共用。"""
|
||||
from app.api.routes import generation_tasks as routes
|
||||
from fastapi import HTTPException
|
||||
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan"
|
||||
)
|
||||
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
|
||||
# usecase 返回的任务 source_edit_plan_id 为空(模拟落库丢 plan)
|
||||
MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="lost_plan")
|
||||
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
MagicMock(id="reselect_1"),
|
||||
MagicMock(id="reselect_2"),
|
||||
]
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
self._call_create_tasks(req)
|
||||
assert exc.value.status_code == 500
|
||||
assert "变体剪辑计划缺失" in exc.value.detail
|
||||
|
||||
|
||||
class TestVariantValueHelper:
|
||||
"""_variant_value 取值逻辑。"""
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"""#1749 EditPlanService 新增方法单元测试。
|
||||
|
||||
覆盖 get_asset_durations / apply_voice_duration_to_plan / ensure_variant_plans。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_svc():
|
||||
"""构造 EditPlanService,mock 掉 DB/repos。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
db = MagicMock()
|
||||
svc = EditPlanService.__new__(EditPlanService)
|
||||
svc._plan_repo = MagicMock()
|
||||
svc._clip_repo = MagicMock()
|
||||
svc._clip_repo.session = db
|
||||
svc._generation_task_repo = MagicMock()
|
||||
return svc, db
|
||||
|
||||
|
||||
def _make_clip(
|
||||
order=0,
|
||||
asset_id="a1",
|
||||
start_time=0.0,
|
||||
duration=5.0,
|
||||
clip_type="main",
|
||||
transition_effect="cut",
|
||||
transition_duration=0.0,
|
||||
playback_speed=1.0,
|
||||
text_content="",
|
||||
config=None,
|
||||
):
|
||||
c = MagicMock()
|
||||
c.order = order
|
||||
c.asset_id = asset_id
|
||||
c.start_time = start_time
|
||||
c.duration = duration
|
||||
c.clip_type = clip_type
|
||||
c.transition_effect = transition_effect
|
||||
c.transition_duration = transition_duration
|
||||
c.playback_speed = playback_speed
|
||||
c.text_content = text_content
|
||||
c.config = config or {}
|
||||
return c
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# get_asset_durations
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetAssetDurations:
|
||||
def test_empty_ids_returns_empty(self):
|
||||
svc, _db = _make_svc()
|
||||
assert svc.get_asset_durations([]) == {}
|
||||
assert svc.get_asset_durations(None) == {}
|
||||
|
||||
def test_dedup_and_query(self):
|
||||
svc, db = _make_svc()
|
||||
m1 = MagicMock()
|
||||
m1.id = "a1"
|
||||
m1.duration = 10.5
|
||||
m2 = MagicMock()
|
||||
m2.id = "a2"
|
||||
m2.duration = None # -> 0.0
|
||||
|
||||
db.query.return_value.filter.return_value.all.return_value = [m1, m2]
|
||||
|
||||
result = svc.get_asset_durations(["a1", "a2", "a1"])
|
||||
assert result == {"a1": 10.5, "a2": 0.0}
|
||||
|
||||
def test_bad_duration_type_returns_zero(self):
|
||||
svc, db = _make_svc()
|
||||
m = MagicMock()
|
||||
m.id = "a1"
|
||||
m.duration = "not-a-number"
|
||||
db.query.return_value.filter.return_value.all.return_value = [m]
|
||||
result = svc.get_asset_durations(["a1"])
|
||||
assert result == {"a1": 0.0}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# apply_voice_duration_to_plan
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestApplyVoiceDurationToPlan:
|
||||
def test_voice_le_zero_returns_none(self):
|
||||
svc, _ = _make_svc()
|
||||
assert svc.apply_voice_duration_to_plan("p1", 0) is None
|
||||
assert svc.apply_voice_duration_to_plan("p1", -5) is None
|
||||
|
||||
def test_plan_not_found_returns_none(self):
|
||||
svc, _ = _make_svc()
|
||||
svc.get_plan = MagicMock(return_value=None)
|
||||
assert svc.apply_voice_duration_to_plan("p1", 30.0) is None
|
||||
|
||||
def test_no_clips_returns_none(self):
|
||||
svc, _ = _make_svc()
|
||||
plan = MagicMock()
|
||||
svc.get_plan = MagicMock(return_value=plan)
|
||||
svc._clip_repo.list_by_plan.return_value = []
|
||||
assert svc.apply_voice_duration_to_plan("p1", 30.0) is None
|
||||
|
||||
def test_happy_path_two_clips(self):
|
||||
svc, db = _make_svc()
|
||||
plan = MagicMock()
|
||||
plan.total_duration = 0.0
|
||||
svc.get_plan = MagicMock(return_value=plan)
|
||||
|
||||
clips = [
|
||||
_make_clip(order=0, asset_id="a1", start_time=0.0),
|
||||
_make_clip(order=1, asset_id="a2", start_time=0.0),
|
||||
]
|
||||
svc._clip_repo.list_by_plan.return_value = clips
|
||||
|
||||
# get_asset_durations: a1=100s, a2=50s
|
||||
m1 = MagicMock(id="a1", duration=100.0)
|
||||
m2 = MagicMock(id="a2", duration=50.0)
|
||||
db.query.return_value.filter.return_value.all.return_value = [m1, m2]
|
||||
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=2)
|
||||
|
||||
result = svc.apply_voice_duration_to_plan("p1", 30.0)
|
||||
assert result is plan
|
||||
svc.replace_all_clips_transactional.assert_called_once()
|
||||
|
||||
def test_invalid_voice_returns_none(self):
|
||||
svc, _ = _make_svc()
|
||||
assert svc.apply_voice_duration_to_plan("p1", "bad") is None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# ensure_variant_plans
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEnsureVariantPlans:
|
||||
def _setup_svc(self):
|
||||
svc, _ = _make_svc()
|
||||
plan0 = MagicMock()
|
||||
plan0.id = "plan_v0"
|
||||
svc.clone_plan_for_variant = MagicMock(return_value=plan0)
|
||||
|
||||
plan1 = MagicMock()
|
||||
plan1.id = "plan_v1"
|
||||
svc.reselect_plan_for_variant = MagicMock(return_value=plan1)
|
||||
|
||||
svc.apply_voice_duration_to_plan = MagicMock(return_value=plan0)
|
||||
return svc
|
||||
|
||||
def test_count_1_no_voice(self):
|
||||
svc = self._setup_svc()
|
||||
result = svc.ensure_variant_plans("src", 1, ["a1", "a2"])
|
||||
assert result == ["plan_v0"]
|
||||
svc.clone_plan_for_variant.assert_called_once()
|
||||
svc.apply_voice_duration_to_plan.assert_not_called()
|
||||
|
||||
def test_count_1_with_voice(self):
|
||||
svc = self._setup_svc()
|
||||
result = svc.ensure_variant_plans("src", 1, ["a1"], voice_durations=[30.0])
|
||||
assert result == ["plan_v0"]
|
||||
svc.apply_voice_duration_to_plan.assert_called_once_with("plan_v0", 30.0)
|
||||
|
||||
def test_count_3_with_voice(self):
|
||||
svc = self._setup_svc()
|
||||
svc.reselect_plan_for_variant = MagicMock(
|
||||
side_effect=[
|
||||
MagicMock(id="plan_v1"),
|
||||
MagicMock(id="plan_v2"),
|
||||
]
|
||||
)
|
||||
|
||||
result = svc.ensure_variant_plans("src", 3, ["a1", "a2"], voice_durations=[30.0, 20.0, 15.0])
|
||||
assert len(result) == 3
|
||||
assert result[0] == "plan_v0"
|
||||
# v0 配音
|
||||
svc.apply_voice_duration_to_plan.assert_called_once_with("plan_v0", 30.0)
|
||||
# reselect 被调 2 次
|
||||
assert svc.reselect_plan_for_variant.call_count == 2
|
||||
|
||||
def test_voice_apply_failure_not_blocking(self):
|
||||
svc = self._setup_svc()
|
||||
svc.apply_voice_duration_to_plan.side_effect = RuntimeError("boom")
|
||||
result = svc.ensure_variant_plans("src", 1, ["a1"], voice_durations=[30.0])
|
||||
assert result == ["plan_v0"]
|
||||
@@ -1,145 +0,0 @@
|
||||
"""#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"]
|
||||
@@ -1,137 +0,0 @@
|
||||
"""#1749 POST /api/v1/generation/variant-plans 路由单元测试。
|
||||
|
||||
直接调用路由函数 + mock 依赖(不使用 TestClient,因 httpx2 冲突)。
|
||||
|
||||
覆盖:
|
||||
- 200: 合法请求返回 items
|
||||
- 400: voice_library_ids 长度不符 (schema validator)
|
||||
- 400: 缺少 source_edit_plan_id 且无模板兜底 plan
|
||||
- 400: 选片失败 ValueError -> HTTPException(400)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _call_route(request_obj, db=None):
|
||||
"""直接调用 create_variant_plans 路由函数。"""
|
||||
from app.api.routes.generation_variant_plans import create_variant_plans
|
||||
|
||||
user = MagicMock()
|
||||
user.user.id = "u1"
|
||||
if db is None:
|
||||
db = MagicMock()
|
||||
return create_variant_plans(request_obj, authenticated_user=user, db=db)
|
||||
|
||||
|
||||
def _base_payload(**kw):
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
d = {
|
||||
"template_id": "tpl_1",
|
||||
"asset_ids": ["a1", "a2"],
|
||||
"count": 1,
|
||||
"source_edit_plan_id": "src_plan_1",
|
||||
}
|
||||
d.update(kw)
|
||||
return VariantPlanRequest(**d)
|
||||
|
||||
|
||||
class TestVariantPlansRoute:
|
||||
def test_200_happy_path(self):
|
||||
"""合法请求:ensure_variant_plans 返回 plan_id,list_clips 返回 clips。"""
|
||||
req = _base_payload()
|
||||
db = MagicMock()
|
||||
|
||||
mock_svc_inst = MagicMock()
|
||||
mock_svc_inst.ensure_variant_plans.return_value = ["plan_v0"]
|
||||
clip = MagicMock()
|
||||
clip.id = "c1"
|
||||
clip.order = 0
|
||||
clip.asset_id = "a1"
|
||||
clip.start_time = 0.0
|
||||
clip.duration = 10.0
|
||||
clip.clip_type = "main"
|
||||
clip.transition_effect = "cut"
|
||||
clip.transition_duration = 0.0
|
||||
clip.playback_speed = 1.0
|
||||
clip.text_content = ""
|
||||
mock_svc_inst.list_clips.return_value = [clip]
|
||||
|
||||
mock_svc_cls = MagicMock(return_value=mock_svc_inst)
|
||||
|
||||
with patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=["voice_1"],
|
||||
), patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[30.0],
|
||||
), patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
mock_svc_cls,
|
||||
):
|
||||
resp = _call_route(req, db)
|
||||
|
||||
assert resp.total == 1
|
||||
assert resp.items[0].plan_id == "plan_v0"
|
||||
assert len(resp.items[0].clips) == 1
|
||||
|
||||
def test_400_voice_library_ids_length_mismatch(self):
|
||||
"""voice_library_ids 长度 != count -> pydantic model_validator 抛异常。"""
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
VariantPlanRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
source_edit_plan_id="src_plan_1",
|
||||
voice_library_ids=["v1", "v2"], # len=2 != count=3
|
||||
)
|
||||
|
||||
def test_400_no_source_plan_no_template_fallback(self):
|
||||
"""无 source_edit_plan_id 且 DB 查不到模板 plan -> HTTPException(400)。"""
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
|
||||
req = VariantPlanRequest(
|
||||
template_id="tpl_nonexist",
|
||||
asset_ids=[],
|
||||
count=1,
|
||||
source_edit_plan_id="",
|
||||
)
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
with patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=[""],
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_call_route(req, db)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_400_selection_failure_value_error(self):
|
||||
"""ensure_variant_plans 抛 ValueError -> HTTPException(400)。"""
|
||||
req = _base_payload()
|
||||
db = MagicMock()
|
||||
|
||||
mock_svc_inst = MagicMock()
|
||||
mock_svc_inst.ensure_variant_plans.side_effect = ValueError("素材池为空")
|
||||
mock_svc_cls = MagicMock(return_value=mock_svc_inst)
|
||||
|
||||
with patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=["voice_1"],
|
||||
), patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[30.0],
|
||||
), patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
mock_svc_cls,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_call_route(req, db)
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -420,9 +420,8 @@ class TestSmartMatchFlatStructure:
|
||||
"""item.id 直接在顶层可读,不存在 item.asset 包装层。"""
|
||||
assets = [_fresh_asset("a-flat-1"), _fresh_asset("a-flat-2")]
|
||||
resp = TestSmartMatchFiltersExhausted()._call(assets)
|
||||
ids = {item.id for item in resp.items}
|
||||
# 同分素材排序含随机噪声(#1743),只断言集合不断言顺序
|
||||
assert ids == {"a-flat-1", "a-flat-2"}
|
||||
ids = [item.id for item in resp.items]
|
||||
assert ids == ["a-flat-1", "a-flat-2"]
|
||||
# 嵌套 asset 字段已移除
|
||||
assert all(not hasattr(item, "asset") for item in resp.items)
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""#1714 find_recent_active_by_library_and_name 严格模式测试。
|
||||
|
||||
file_size=0(未知)时必须返回 None(宁可漏判不可误杀);
|
||||
大小严格匹配;只命中近期 UPLOADING/PROCESSING 记录。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository # noqa: E402
|
||||
from packages.adapters.sqlalchemy_impl.models import Base # noqa: E402
|
||||
from packages.domain import Asset, AssetStatus # noqa: E402
|
||||
|
||||
|
||||
def _repository():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
return SQLAlchemyAssetRepository(session)
|
||||
|
||||
|
||||
def _mk_asset(name="IMG_2285.MOV", file_size=5_000_000, status=AssetStatus.PROCESSING, minutes_ago=5):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=name,
|
||||
storage_key=f"uploads/x/{name}",
|
||||
mime_type="video/quicktime",
|
||||
file_size=file_size,
|
||||
)
|
||||
asset.status = status
|
||||
asset.created_at = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
||||
return asset
|
||||
|
||||
|
||||
def test_returns_none_when_file_size_zero():
|
||||
"""file_size=0(大小未知)直接返回 None——不许仅凭同名 + processing 判重。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=0))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="IMG_2285.MOV", file_size=0)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_matches_when_name_size_strict_equal():
|
||||
"""同名 + 同大小 + processing 近期记录 → 命中。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=5_000_000))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="IMG_2285.MOV", file_size=5_000_000)
|
||||
assert result is not None
|
||||
assert result.name == "IMG_2285.MOV"
|
||||
|
||||
|
||||
def test_no_match_when_same_name_but_different_size():
|
||||
"""同名但大小不同 → 不命中(内容全新的视频不能误杀)。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=5_000_000))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="IMG_2285.MOV", file_size=9_999_999)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_no_match_ready_history_even_with_same_size():
|
||||
"""READY 历史同名素材不命中(允许再次上传同名文件)。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=5_000_000, status=AssetStatus.READY))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="IMG_2285.MOV", file_size=5_000_000)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_no_match_when_window_expired():
|
||||
"""超过 30 分钟窗口的活动记录不命中。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=5_000_000, minutes_ago=45))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(
|
||||
library_id="lib-1", name="IMG_2285.MOV", within_minutes=30, file_size=5_000_000
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_returns_none_when_name_empty():
|
||||
repo = _repository()
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="", file_size=100)
|
||||
assert result is None
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,20 +19,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.generation_tasks import _select_assets_from_library
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
|
||||
class _ZeroNoiseRandom(random.Random):
|
||||
"""零噪声随机源:uniform(0, NOISE_MAX) 恒返回 0,smart 排序确定可复现。"""
|
||||
|
||||
def uniform(self, a, b):
|
||||
if a == 0.0 and b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return super().uniform(a, b)
|
||||
|
||||
|
||||
_ZERO_NOISE = _ZeroNoiseRandom(0)
|
||||
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
@@ -121,7 +106,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("high", "high.mp4", quality_score=90),
|
||||
_asset("mid", "mid.mp4", quality_score=60),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0, rng=_ZERO_NOISE)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["high", "mid", "low"]
|
||||
|
||||
def test_smart_duration_optimal_beats_too_short(self):
|
||||
@@ -130,7 +115,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("too_short", "short.mp4", quality_score=80, duration=1.0),
|
||||
_asset("optimal", "optimal.mp4", quality_score=80, duration=15.0),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0, rng=_ZERO_NOISE)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
# Both: quality=80*0.4=32, recency/unused equal
|
||||
# optimal(15s): duration_fitness=30 → total=62+
|
||||
# too_short(1s): duration_fitness=20+(1/5)*80=36 → 36*0.3=10.8 → total=42.8+
|
||||
@@ -142,7 +127,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("a2", "v2.mp4", quality_score=70),
|
||||
_asset("a3", "v3.mp4", quality_score=50),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=2, rng=_ZERO_NOISE)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=2)
|
||||
assert result == ["a1", "a2"]
|
||||
|
||||
def test_smart_null_quality_treated_as_default(self):
|
||||
@@ -151,7 +136,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("scored", "scored.mp4", quality_score=80),
|
||||
_asset("unscored", "unscored.mp4", quality_score=None),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0, rng=_ZERO_NOISE)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
# scored(80): quality=80*0.4=32; unscored(None→50): quality=50*0.4=20
|
||||
assert result == ["scored", "unscored"]
|
||||
|
||||
@@ -161,7 +146,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("a2", "v2.mp4", quality_score=90),
|
||||
_asset("a3", "v3.mp4", quality_score=50),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0, rng=_ZERO_NOISE)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["a2", "a3", "a1"]
|
||||
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
"""#1743 素材使用次数回写测试。
|
||||
|
||||
计数口径修复:
|
||||
- mark_asset_used_for_generation 支持 times 参数,按成片实际片段引用次数累加
|
||||
- _count_plan_clip_asset_usage 从最终成片 plan 的 clips 统计 {asset_id: 片段引用次数}
|
||||
- _record_rendered_asset_usage 回写 metadata:plan clips 为准、空 plan 兜底任务 asset_ids、
|
||||
未被 plan 选用的素材不计数、单素材失败不影响其他素材
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for sub in ("apps/worker", "apps/api", "packages", ""):
|
||||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
import worker_app.tasks.generation as gen_mod # noqa: E402
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation # noqa: E402
|
||||
|
||||
|
||||
class FakeAsset:
|
||||
def __init__(self, aid: str, metadata: dict | None = None):
|
||||
self.id = aid
|
||||
self.metadata = metadata or {}
|
||||
|
||||
|
||||
# ── mark_asset_used_for_generation ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMarkAssetUsed:
|
||||
def test_default_times_is_one(self):
|
||||
a = FakeAsset("a1", metadata={})
|
||||
mark_asset_used_for_generation(a)
|
||||
assert a.metadata["generation_use_count"] == 1
|
||||
assert "last_used_at" in a.metadata
|
||||
|
||||
def test_times_accumulates_by_clip_count(self):
|
||||
"""同一素材被 3 个片段引用 → 一次回写 +3。"""
|
||||
a = FakeAsset("a1", metadata={"generation_use_count": 2})
|
||||
mark_asset_used_for_generation(a, times=3)
|
||||
assert a.metadata["generation_use_count"] == 5
|
||||
|
||||
def test_times_zero_or_negative_floored_to_one(self):
|
||||
a = FakeAsset("a1", metadata={})
|
||||
mark_asset_used_for_generation(a, times=0)
|
||||
assert a.metadata["generation_use_count"] == 1
|
||||
|
||||
def test_preserves_existing_metadata(self):
|
||||
a = FakeAsset("a1", metadata={"tags": ["travel"], "generation_use_count": 4})
|
||||
mark_asset_used_for_generation(a, times=2)
|
||||
assert a.metadata["tags"] == ["travel"]
|
||||
assert a.metadata["generation_use_count"] == 6
|
||||
|
||||
|
||||
# ── _count_plan_clip_asset_usage ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_session_with_clips(clip_asset_ids: list[str]):
|
||||
"""构造 mock session:query(EditPlanClipModel).filter().all() 返回片段 asset_id 行。"""
|
||||
rows = [(aid,) for aid in clip_asset_ids]
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.all.return_value = rows
|
||||
return session
|
||||
|
||||
|
||||
class TestCountPlanClipUsage:
|
||||
def test_counts_asset_occurrences_across_clips(self):
|
||||
"""plan clips 中 asset-x 出现 2 次、asset-y 1 次 → {x:2, y:1}。"""
|
||||
session = _mock_session_with_clips(["asset-x", "asset-y", "asset-x", ""])
|
||||
with _patch_clip_model():
|
||||
counts = gen_mod._count_plan_clip_asset_usage(session, "plan-1")
|
||||
assert counts == {"asset-x": 2, "asset-y": 1}
|
||||
|
||||
def test_empty_clips_returns_empty(self):
|
||||
session = _mock_session_with_clips([])
|
||||
with _patch_clip_model():
|
||||
counts = gen_mod._count_plan_clip_asset_usage(session, "plan-9")
|
||||
assert counts == {}
|
||||
|
||||
def test_blank_asset_ids_skipped(self):
|
||||
session = _mock_session_with_clips(["", None, "asset-z"])
|
||||
with _patch_clip_model():
|
||||
counts = gen_mod._count_plan_clip_asset_usage(session, "plan-1")
|
||||
assert counts == {"asset-z": 1}
|
||||
|
||||
|
||||
class _patch_clip_model:
|
||||
"""patch generation 模块内 EditPlanClipModel 的导入路径(函数内 import)。"""
|
||||
|
||||
def __enter__(self):
|
||||
import unittest.mock as mock
|
||||
|
||||
self._patches = [
|
||||
mock.patch("packages.adapters.sqlalchemy_impl.models.EditPlanClipModel", MagicMock()),
|
||||
]
|
||||
for p in self._patches:
|
||||
p.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
for p in self._patches:
|
||||
p.stop()
|
||||
return False
|
||||
|
||||
|
||||
# ── _record_rendered_asset_usage ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecordRenderedAssetUsage:
|
||||
def _run(self, clip_ids, fallback_ids=None, repo_get_override=None, repo_update_side=None):
|
||||
session = _mock_session_with_clips(clip_ids)
|
||||
assets: dict[str, FakeAsset] = {}
|
||||
|
||||
def fake_repo_init(sess):
|
||||
repo = MagicMock()
|
||||
|
||||
def fake_get(aid):
|
||||
if repo_get_override and aid in repo_get_override:
|
||||
return repo_get_override[aid]
|
||||
return assets.setdefault(aid, FakeAsset(aid, metadata={}))
|
||||
|
||||
repo.get.side_effect = fake_get
|
||||
if repo_update_side:
|
||||
repo.update.side_effect = repo_update_side
|
||||
else:
|
||||
repo.update.side_effect = lambda a: a
|
||||
return repo
|
||||
|
||||
import unittest.mock as mock
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
|
||||
side_effect=fake_repo_init,
|
||||
),
|
||||
_patch_clip_model(),
|
||||
):
|
||||
written = gen_mod._record_rendered_asset_usage(session, "plan-1", "task-1", fallback_asset_ids=fallback_ids)
|
||||
return written, assets
|
||||
|
||||
def test_writes_by_plan_clips_not_request_asset_ids(self):
|
||||
"""核心口径:plan clips 用 x×2 + y×1;请求里的 z(未被 plan 选用)不计数。"""
|
||||
written, assets = self._run(
|
||||
clip_ids=["asset-x", "asset-x", "asset-y"],
|
||||
fallback_ids=["asset-x", "asset-y", "asset-z"],
|
||||
)
|
||||
assert written == 2
|
||||
assert assets["asset-x"].metadata["generation_use_count"] == 2
|
||||
assert assets["asset-y"].metadata["generation_use_count"] == 1
|
||||
assert "asset-z" not in assets, "未被 plan 选用的素材不应被计数"
|
||||
|
||||
def test_fallback_to_task_asset_ids_when_plan_empty(self):
|
||||
"""plan 无有效片段(异常数据)→ 兜底任务 asset_ids 每个计 1 次。"""
|
||||
written, assets = self._run(clip_ids=[""], fallback_ids=["asset-a", "asset-b"])
|
||||
assert written == 2
|
||||
assert assets["asset-a"].metadata["generation_use_count"] == 1
|
||||
assert assets["asset-b"].metadata["generation_use_count"] == 1
|
||||
|
||||
def test_no_clips_no_fallback_returns_zero(self):
|
||||
written, assets = self._run(clip_ids=[], fallback_ids=[])
|
||||
assert written == 0
|
||||
assert assets == {}
|
||||
|
||||
def test_single_asset_failure_does_not_block_others(self):
|
||||
"""单素材 update 抛异常不影响其他素材回写。"""
|
||||
|
||||
def update_side(a):
|
||||
if a.id == "bad":
|
||||
raise RuntimeError("DB boom")
|
||||
return a
|
||||
|
||||
written, assets = self._run(
|
||||
clip_ids=["good1", "bad", "good2"],
|
||||
fallback_ids=None,
|
||||
repo_update_side=update_side,
|
||||
)
|
||||
assert written == 2
|
||||
assert assets["good1"].metadata["generation_use_count"] == 1
|
||||
assert assets["good2"].metadata["generation_use_count"] == 1
|
||||
|
||||
def test_missing_asset_skipped(self):
|
||||
"""repo.get 返回 None 的素材跳过,不报错。"""
|
||||
written, assets = self._run(
|
||||
clip_ids=["ghost", "real"],
|
||||
fallback_ids=None,
|
||||
repo_get_override={"ghost": None},
|
||||
)
|
||||
assert written == 1
|
||||
assert assets["real"].metadata["generation_use_count"] == 1
|
||||
@@ -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."""
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
"""#1743 dedup_helpers 批次查重 + worker 重选 plan 重试函数测试。
|
||||
|
||||
覆盖:
|
||||
- 批次任务且无历史重复时走 check_batch_duplicate,batch_similarity 透传到返回 dict
|
||||
- 批次任务历史已重复 → 不再做批次查重,is_duplicate=True / duplicate_of 透传
|
||||
- 非批次任务 batch_similarity 恒为 None
|
||||
- _reselect_plan_for_batch_retry:成功返回新 plan_id;异常返回 None(保留首版)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for sub in ("apps/worker", "apps/api", "packages", ""):
|
||||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
from packages.adapters.sqlalchemy_impl.models import Base # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
s = sessionmaker(bind=engine)()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def _dedup_kwargs(batch_id):
|
||||
return dict(
|
||||
generation_task_id="task-batch-1",
|
||||
project_id="proj-1",
|
||||
user_id="user-1",
|
||||
batch_id=batch_id,
|
||||
file_url="https://oss.example.com/v.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
|
||||
class TestBatchDedupInHelpers:
|
||||
"""dedup_helpers 内部 lazy import video_processing.dedup(依赖 cv2),
|
||||
复用 test_generated_video_creation_logic 的 mock 模块注册模式。"""
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
import sys
|
||||
|
||||
if "cv2" not in sys.modules:
|
||||
sys.modules["cv2"] = MagicMock()
|
||||
|
||||
# 保存 setup 前的真实模块引用,teardown 原样还原(沙箱无 cv2 时 reimport 会失败,
|
||||
# 若直接 pop 掉 MagicMock 会让后续测试(如 test_dedup_1702)拿到残缺模块)
|
||||
import video_processing
|
||||
|
||||
cls._orig_dedup = sys.modules.get("video_processing.dedup")
|
||||
cls._orig_dedup_attr = getattr(video_processing, "dedup", None)
|
||||
cls._orig_thumb = sys.modules.get("video_processing.thumbnail_generator")
|
||||
cls._orig_thumb_attr = getattr(video_processing, "thumbnail_generator", None)
|
||||
|
||||
mock_dedup = MagicMock()
|
||||
mock_dedup.VideoDeduplicator = MagicMock()
|
||||
sys.modules["video_processing.dedup"] = mock_dedup
|
||||
|
||||
mock_thumb = MagicMock()
|
||||
mock_thumb.extract_first_frame = MagicMock()
|
||||
sys.modules["video_processing.thumbnail_generator"] = mock_thumb
|
||||
|
||||
video_processing.dedup = mock_dedup
|
||||
video_processing.thumbnail_generator = mock_thumb
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
import sys
|
||||
|
||||
import video_processing
|
||||
|
||||
# 还原 setup 前状态:原本有真模块→放回;原本没有→移除 mock
|
||||
if cls._orig_dedup is not None:
|
||||
sys.modules["video_processing.dedup"] = cls._orig_dedup
|
||||
else:
|
||||
sys.modules.pop("video_processing.dedup", None)
|
||||
if cls._orig_dedup_attr is not None:
|
||||
video_processing.dedup = cls._orig_dedup_attr
|
||||
elif hasattr(video_processing, "dedup"):
|
||||
delattr(video_processing, "dedup")
|
||||
|
||||
if cls._orig_thumb is not None:
|
||||
sys.modules["video_processing.thumbnail_generator"] = cls._orig_thumb
|
||||
else:
|
||||
sys.modules.pop("video_processing.thumbnail_generator", None)
|
||||
if cls._orig_thumb_attr is not None:
|
||||
video_processing.thumbnail_generator = cls._orig_thumb_attr
|
||||
elif hasattr(video_processing, "thumbnail_generator"):
|
||||
delattr(video_processing, "thumbnail_generator")
|
||||
|
||||
def test_batch_similarity_returned_when_batch_duplicate_found(self, session):
|
||||
"""批次任务 + 无历史重复 + 批次查重命中 → 返回 batch_similarity 与 is_duplicate。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||||
dedup = mock_cls.return_value
|
||||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
dedup.check_duplicate.return_value = None # 历史无重复
|
||||
dedup.check_batch_duplicate.return_value = {
|
||||
"duplicate_of": "video-existing",
|
||||
"reason": "batch_similar",
|
||||
"similarity": 0.601,
|
||||
}
|
||||
dedup.compute_duplicate_rate.return_value = {
|
||||
"duplicate_rate": 0.0,
|
||||
"visual_similarity": 0.0,
|
||||
"match_count": 0,
|
||||
}
|
||||
|
||||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-abc"))
|
||||
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] == pytest.approx(0.601)
|
||||
assert result["is_duplicate"] is True
|
||||
assert result["duplicate_of"] == "video-existing"
|
||||
# 批次查重确实被调用(历史查重为 None 才走批次)
|
||||
dedup.check_batch_duplicate.assert_called_once()
|
||||
|
||||
def test_batch_check_skipped_when_historical_duplicate(self, session):
|
||||
"""历史查重已命中 → 不再批次查重,batch_similarity 为 None。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||||
dedup = mock_cls.return_value
|
||||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
dedup.check_duplicate.return_value = {
|
||||
"duplicate_of": "video-old",
|
||||
"reason": "global",
|
||||
"similarity": 0.85,
|
||||
}
|
||||
dedup.compute_duplicate_rate.return_value = {
|
||||
"duplicate_rate": 85.0,
|
||||
"visual_similarity": 0.85,
|
||||
"match_count": 3,
|
||||
}
|
||||
|
||||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-abc"))
|
||||
|
||||
assert result["is_duplicate"] is True
|
||||
assert result["duplicate_of"] == "video-old"
|
||||
assert result["batch_similarity"] is None
|
||||
dedup.check_batch_duplicate.assert_not_called()
|
||||
|
||||
def test_non_batch_never_runs_batch_check(self, session):
|
||||
"""非批次任务(batch_id 为空)→ 不调用批次查重,batch_similarity None。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||||
dedup = mock_cls.return_value
|
||||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
dedup.check_duplicate.return_value = None
|
||||
dedup.check_batch_duplicate.return_value = {"similarity": 0.99}
|
||||
dedup.compute_duplicate_rate.return_value = {
|
||||
"duplicate_rate": 0.0,
|
||||
"visual_similarity": 0.0,
|
||||
"match_count": 0,
|
||||
}
|
||||
|
||||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs(""))
|
||||
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] is None
|
||||
assert result["is_duplicate"] is False
|
||||
dedup.check_batch_duplicate.assert_not_called()
|
||||
|
||||
def test_batch_no_duplicate_returns_none_similarity(self, session):
|
||||
"""批次任务但批次查重也未命中 → batch_similarity None(驱动不重渲)。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||||
dedup = mock_cls.return_value
|
||||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
dedup.check_duplicate.return_value = None
|
||||
dedup.check_batch_duplicate.return_value = None
|
||||
dedup.compute_duplicate_rate.return_value = {
|
||||
"duplicate_rate": 0.0,
|
||||
"visual_similarity": 0.0,
|
||||
"match_count": 0,
|
||||
}
|
||||
|
||||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-xyz"))
|
||||
|
||||
assert result["batch_similarity"] is None
|
||||
assert result["is_duplicate"] is False
|
||||
|
||||
|
||||
class TestReselectPlanForBatchRetry:
|
||||
def test_success_returns_new_plan_id(self):
|
||||
"""重选成功 → 返回新 plan_id。"""
|
||||
from worker_app.tasks.generation import _reselect_plan_for_batch_retry
|
||||
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.reselect_plan_for_variant.return_value = MagicMock(id="new-plan-999")
|
||||
with patch("app.services.edit_plan_service.EditPlanService", return_value=fake_svc):
|
||||
with patch("worker_app.tasks.generation.SessionLocal") as mock_session_local:
|
||||
mock_session_local.return_value = MagicMock()
|
||||
new_id = _reselect_plan_for_batch_retry(
|
||||
"task-1",
|
||||
"old-plan",
|
||||
{"task_asset_ids": ["a1", "a2"], "user_id": "u-1"},
|
||||
)
|
||||
assert new_id == "new-plan-999"
|
||||
fake_svc.reselect_plan_for_variant.assert_called_once()
|
||||
args, kwargs = fake_svc.reselect_plan_for_variant.call_args
|
||||
assert args[0] == "old-plan"
|
||||
assert args[1] == ["a1", "a2"]
|
||||
assert kwargs["created_by_user_id"] == "u-1"
|
||||
|
||||
def test_failure_returns_none_and_keeps_first_version(self):
|
||||
"""重选抛异常 → 返回 None(调用方放弃重渲、保留首版),不抛出。"""
|
||||
from worker_app.tasks.generation import _reselect_plan_for_batch_retry
|
||||
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
with patch("app.services.edit_plan_service.EditPlanService", return_value=fake_svc):
|
||||
with patch("worker_app.tasks.generation.SessionLocal") as mock_session_local:
|
||||
mock_session_local.return_value = MagicMock()
|
||||
result = _reselect_plan_for_batch_retry("task-1", "old-plan", {"task_asset_ids": [], "user_id": "u-1"})
|
||||
assert result is None
|
||||
@@ -1,9 +1,7 @@
|
||||
"""#1743 批量生成无可用 plan 守卫。
|
||||
"""AI Review 回归:批量生成 count>1 但 source_edit_plan_id 为空时不应 IndexError。
|
||||
|
||||
新规则(P0 降重):count>1 批量生成时必须存在源 plan(前端传入或按模板兜底
|
||||
解析到最新 plan),为每个变体独立选片;**无任何可用 plan 时直接 4xx 中断、
|
||||
不创建任务**,严禁 N 个任务兜底共用同一 plan 产出同源成片。
|
||||
N=1 单视频不受影响(无 plan 时走原有单任务流程)。
|
||||
变体 plan 预克隆仅在 source_edit_plan_id 非空时执行;无源 plan 时
|
||||
variant_plan_ids 为空,循环中禁止索引访问,各任务走自身随机选片流程。
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -11,9 +9,6 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
@@ -23,117 +18,78 @@ def _make_user():
|
||||
return SimpleNamespace(user=SimpleNamespace(id="user-1"))
|
||||
|
||||
|
||||
def _make_request(count, **overrides):
|
||||
def _make_request(count):
|
||||
from app.schemas.generation_task import CreateGenerationTaskRequest
|
||||
|
||||
fields = dict(
|
||||
return CreateGenerationTaskRequest(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="one_take",
|
||||
asset_ids=["a1"],
|
||||
count=count,
|
||||
source_edit_plan_id="",
|
||||
source_edit_plan_id="", # 关键:无源 plan(空字符串为假值)
|
||||
)
|
||||
fields.update(overrides)
|
||||
return CreateGenerationTaskRequest(**fields)
|
||||
|
||||
|
||||
def _common_patches(latest_plan=None):
|
||||
"""构造通用 patch 上下文(repo/usecase/enqueue 等)。latest_plan 为模板兜底 plan 或 None。"""
|
||||
repo = MagicMock()
|
||||
repo.count_pending_by_user.return_value = 0
|
||||
repo.count_pending_total.return_value = 0
|
||||
repo.create.side_effect = lambda t: t
|
||||
repo.update.side_effect = lambda t: t
|
||||
|
||||
created = []
|
||||
|
||||
def _fake_execute(cmd):
|
||||
task = MagicMock()
|
||||
task.id = f"task-{len(created) + 1}"
|
||||
task.source_edit_plan_id = cmd.source_edit_plan_id
|
||||
task.status = "pending"
|
||||
task.progress = 0.0
|
||||
task.strategy_id = "one_take"
|
||||
task.error_message = ""
|
||||
task.cover_url = ""
|
||||
task.title_config = {}
|
||||
task.created_at = None
|
||||
task.batch_id = "batch-1"
|
||||
created.append(task)
|
||||
return task
|
||||
|
||||
db = MagicMock()
|
||||
# 模板兜底查最新 plan:返回 latest_plan(None 表示查不到)
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = latest_plan
|
||||
|
||||
mock_uc = patch("app.api.routes.generation_tasks.CreateGenerationTaskUseCase")
|
||||
other_patches = [
|
||||
patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True),
|
||||
patch("app.api.routes.generation_tasks._writeback_edit_plan_config"),
|
||||
patch(
|
||||
"app.api.routes.generation_tasks._resolve_project_and_library",
|
||||
return_value=("proj-1", ""),
|
||||
),
|
||||
]
|
||||
return repo, db, created, mock_uc, other_patches, _fake_execute
|
||||
|
||||
|
||||
class TestBatchNoSourcePlanGuard:
|
||||
def test_count3_without_any_plan_rejects_4xx_and_creates_nothing(self):
|
||||
"""count=3 且无源 plan、模板兜底也查不到 → 400 中断,零任务创建(严禁同源成片)。"""
|
||||
class TestBatchNoSourcePlanNoIndexError:
|
||||
def test_count3_without_source_plan_creates_three_tasks(self):
|
||||
"""count=3 且无 source_edit_plan_id:不克隆、不 IndexError、创建 3 个任务。"""
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
repo, db, created, mock_uc, other_patches, fake_exec = _common_patches(latest_plan=None)
|
||||
MockUC = mock_uc.start()
|
||||
MockUC.return_value.execute.side_effect = fake_exec
|
||||
for p in other_patches:
|
||||
p.start()
|
||||
all_patches = [mock_uc] + other_patches
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_generation_task(
|
||||
_make_request(3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
project_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
db=db,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
finally:
|
||||
for p in reversed(all_patches):
|
||||
p.stop()
|
||||
assert len(created) == 0, "无 plan 批量必须零任务创建"
|
||||
repo = MagicMock()
|
||||
repo.count_pending_by_user.return_value = 0
|
||||
repo.count_pending_total.return_value = 0
|
||||
repo.create.side_effect = lambda t: t
|
||||
repo.update.side_effect = lambda t: t
|
||||
|
||||
def test_count1_without_plan_still_works(self):
|
||||
"""N=1 单视频无 plan 不触发批量守卫(向后兼容,不 4xx)。"""
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
created = []
|
||||
|
||||
repo, db, created, mock_uc, other_patches, fake_exec = _common_patches(latest_plan=None)
|
||||
MockUC = mock_uc.start()
|
||||
MockUC.return_value.execute.side_effect = fake_exec
|
||||
for p in other_patches:
|
||||
p.start()
|
||||
all_patches = [mock_uc] + other_patches
|
||||
try:
|
||||
create_generation_task(
|
||||
_make_request(1),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
project_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
db=db,
|
||||
)
|
||||
except HTTPException as e:
|
||||
assert e.status_code != 400, f"N=1 不应被批量守卫拦截: {e.detail}"
|
||||
except Exception:
|
||||
# MagicMock 任务对象下游响应序列化可能抛 ValidationError 等,与批量守卫无关;
|
||||
# 任务已在 usecase.execute 中创建,下方断言 created==1 即证明守卫未拦截。
|
||||
pass
|
||||
finally:
|
||||
for p in reversed(all_patches):
|
||||
p.stop()
|
||||
assert len(created) == 1
|
||||
def _fake_execute(cmd):
|
||||
task = MagicMock()
|
||||
task.id = f"task-{len(created) + 1}"
|
||||
task.source_edit_plan_id = cmd.source_edit_plan_id
|
||||
task.status = "pending"
|
||||
task.progress = 0.0
|
||||
task.strategy_id = "one_take"
|
||||
task.error_message = ""
|
||||
task.cover_url = None
|
||||
task.title_config = {}
|
||||
task.created_at = None
|
||||
task.batch_id = "batch-1"
|
||||
created.append(task)
|
||||
return task
|
||||
|
||||
with patch("app.api.routes.generation_tasks.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = _fake_execute
|
||||
with patch(
|
||||
"app.api.routes.generation_tasks.safe_enqueue_generation_task",
|
||||
return_value=True,
|
||||
):
|
||||
with patch("app.api.routes.generation_tasks._writeback_edit_plan_config"):
|
||||
with patch(
|
||||
"app.api.routes.generation_tasks._resolve_project_and_library",
|
||||
return_value=("proj-1", ""),
|
||||
):
|
||||
# 核心断言:不得抛 IndexError(变体 plan 索引守卫)。
|
||||
# 响应序列化字段与本回归无关,ValidationError 可接受,
|
||||
# 但 IndexError 必须不出现。
|
||||
try:
|
||||
create_generation_task(
|
||||
_make_request(3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
project_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
db=MagicMock(),
|
||||
)
|
||||
except IndexError as exc: # pragma: no cover - 不应发生
|
||||
pytest.fail(f"无源 plan 批量生成触发 IndexError: {exc}")
|
||||
except Exception:
|
||||
# 响应序列化等其他异常与本次守卫无关,忽略
|
||||
pass
|
||||
|
||||
# 3 个任务全部创建(未因 IndexError 中断)
|
||||
assert len(created) == 3
|
||||
# 无源 plan 时所有任务 source_edit_plan_id 均为空
|
||||
assert all(not t.source_edit_plan_id for t in created)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""#1743 worker 批量重渲判定 + 封面哈希选帧纯函数测试。
|
||||
|
||||
- should_rerender_for_batch_dedup:批次首版查重率 >20% 才重渲(非批次/重渲版/无查重率不重渲)
|
||||
- pick_batch_cover_index:task_id md5 稳定哈希分散候选帧(同任务稳定、跨任务分散)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for sub in ("apps/worker", "apps/api", "packages", ""):
|
||||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from worker_app.tasks.generation import ( # noqa: E402
|
||||
BATCH_RENDER_SIMILARITY_LIMIT,
|
||||
pick_batch_cover_index,
|
||||
should_rerender_for_batch_dedup,
|
||||
)
|
||||
|
||||
|
||||
class TestShouldRerenderForBatchDedup:
|
||||
def test_non_batch_never_rerenders(self):
|
||||
"""非批次任务(batch_id 为空)任何查重率都不重渲。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="", render_attempt=0, batch_similarity=0.99) is False
|
||||
assert should_rerender_for_batch_dedup(batch_id="", render_attempt=0, batch_similarity=None) is False
|
||||
|
||||
def test_batch_first_version_over_threshold_rerenders(self):
|
||||
"""批次首版(attempt=0)查重率 >20% → 重渲。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.601) is True
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.21) is True
|
||||
|
||||
def test_batch_first_version_under_threshold_no_rerender(self):
|
||||
"""批次首版查重率 ≤20% → 不重渲。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.20) is False
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.0) is False
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.05) is False
|
||||
|
||||
def test_rerendered_version_never_rerenders_again(self):
|
||||
"""重渲版(attempt=1)即使仍超阈值也不再重渲(最多重渲一次)。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=1, batch_similarity=0.99) is False
|
||||
|
||||
def test_missing_similarity_no_rerender(self):
|
||||
"""查重率缺失(None,非批次查重路径)→ 不重渲。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=None) is False
|
||||
|
||||
def test_threshold_constant_is_20_percent(self):
|
||||
assert BATCH_RENDER_SIMILARITY_LIMIT == 0.20
|
||||
|
||||
|
||||
class TestPickBatchCoverIndex:
|
||||
def test_stable_for_same_task(self):
|
||||
"""同一 task_id 多次调用结果稳定(重试封面不变)。"""
|
||||
first = pick_batch_cover_index("task-abc", 3)
|
||||
for _ in range(5):
|
||||
assert pick_batch_cover_index("task-abc", 3) == first
|
||||
|
||||
def test_zero_or_one_candidate_returns_zero(self):
|
||||
assert pick_batch_cover_index("task-x", 0) == 0
|
||||
assert pick_batch_cover_index("task-x", 1) == 0
|
||||
|
||||
def test_index_within_range(self):
|
||||
for i in range(20):
|
||||
idx = pick_batch_cover_index(f"task-{i}", 3)
|
||||
assert 0 <= idx < 3
|
||||
|
||||
def test_matches_md5_formula(self):
|
||||
"""与主流程公式一致:md5(task_id) % 候选数。"""
|
||||
task_id = "task-formula-check"
|
||||
expected = int(hashlib.md5(task_id.encode()).hexdigest(), 16) % 3
|
||||
assert pick_batch_cover_index(task_id, 3) == expected
|
||||
|
||||
def test_batch_tasks_spread_across_frames(self):
|
||||
"""30 个批次任务在 3 个候选帧上分散(不能全部落在 frame_0——旧 bug 回归守卫)。"""
|
||||
indexes = {pick_batch_cover_index(f"batch-task-{i}", 3) for i in range(30)}
|
||||
assert len(indexes) >= 2, f"封面帧应分散到多个帧位,实际全部落在: {indexes}"
|
||||
@@ -1,75 +0,0 @@
|
||||
"""#1714 beat 任务 scheduled_cleanup_stale_ingest_jobs 薄封装测试。
|
||||
|
||||
mock SessionLocal 和清理核心,验证 beat 任务正确串联
|
||||
cleanup_stale_ingest_jobs → cleanup_orphan_processing_assets → revoke 消息。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test_beat.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import worker_app.tasks.cleanup as cleanup # noqa: E402
|
||||
|
||||
|
||||
def test_beat_cleanup_calls_core_and_revokes():
|
||||
"""beat 任务串联三个核心步骤,返回汇总计数。"""
|
||||
fake_session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("worker_app.db.SessionLocal", return_value=fake_session) as m_db,
|
||||
patch(
|
||||
"packages.application.ingest_orphan_cleanup.cleanup_stale_ingest_jobs",
|
||||
return_value=([("job-1", "cel-1"), ("job-2", "")], ["a-1"]),
|
||||
) as m_jobs,
|
||||
patch(
|
||||
"packages.application.ingest_orphan_cleanup.cleanup_orphan_processing_assets",
|
||||
return_value=["a-2"],
|
||||
) as m_assets,
|
||||
patch(
|
||||
"packages.shared.celery_orphan_guard.revoke_and_purge",
|
||||
return_value=1,
|
||||
) as m_revoke,
|
||||
):
|
||||
result = cleanup.scheduled_cleanup_stale_ingest_jobs()
|
||||
|
||||
m_db.assert_called_once()
|
||||
m_jobs.assert_called_once()
|
||||
assert m_jobs.call_args.kwargs["processing_timeout_minutes"] == 60
|
||||
m_assets.assert_called_once()
|
||||
m_revoke.assert_called_once()
|
||||
# 队列名只传 transcode/celery(不传 generation)
|
||||
assert m_revoke.call_args.kwargs["queue_names"] == ("transcode", "celery")
|
||||
fake_session.close.assert_called_once()
|
||||
assert result == {"stale_jobs": 2, "assets_to_error": 2, "purged_messages": 1}
|
||||
|
||||
|
||||
def test_beat_cleanup_no_op_when_nothing_stale():
|
||||
"""无孤儿时不调 revoke,返回全 0。"""
|
||||
fake_session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("worker_app.db.SessionLocal", return_value=fake_session),
|
||||
patch(
|
||||
"packages.application.ingest_orphan_cleanup.cleanup_stale_ingest_jobs",
|
||||
return_value=([], []),
|
||||
),
|
||||
patch(
|
||||
"packages.application.ingest_orphan_cleanup.cleanup_orphan_processing_assets",
|
||||
return_value=[],
|
||||
),
|
||||
patch("packages.shared.celery_orphan_guard.revoke_and_purge") as m_revoke,
|
||||
):
|
||||
result = cleanup.scheduled_cleanup_stale_ingest_jobs()
|
||||
|
||||
m_revoke.assert_not_called()
|
||||
assert result == {"stale_jobs": 0, "assets_to_error": 0, "purged_messages": 0}
|
||||
@@ -356,8 +356,7 @@ class TestConfirmGenerationErrors:
|
||||
assert item["voice_library_id"] == "voice-001"
|
||||
assert item["template_id"] == "tmpl-001"
|
||||
assert item["title_ids"] == ["title-1", "title-2"]
|
||||
# #1749:voice_ids 已废弃(配音走 voice_library_id),确认生成不再保留原值
|
||||
assert item["voice_ids"] == []
|
||||
assert item["voice_ids"] == ["voice-a"]
|
||||
|
||||
def test_confirm_cover_and_title(
|
||||
self,
|
||||
|
||||
@@ -85,10 +85,7 @@ class TestTwoPhaseCommit:
|
||||
session=session,
|
||||
)
|
||||
|
||||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||||
assert isinstance(result, dict)
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||||
assert result == 1
|
||||
# create() should be called exactly once with the complete video object
|
||||
mock_repo.create.assert_called_once()
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
@@ -125,10 +122,7 @@ class TestTwoPhaseCommit:
|
||||
session=session,
|
||||
)
|
||||
|
||||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||||
assert isinstance(result, dict)
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||||
assert result == 1
|
||||
mock_repo.create.assert_called_once()
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.duplicate_rate is None
|
||||
@@ -167,10 +161,7 @@ class TestTwoPhaseCommit:
|
||||
session=session,
|
||||
)
|
||||
|
||||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||||
assert isinstance(result, dict)
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||||
assert result == 1
|
||||
mock_repo.create.assert_called_once()
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
# Fingerprint should be set
|
||||
@@ -238,7 +229,6 @@ class TestTwoPhaseCommit:
|
||||
session=session,
|
||||
)
|
||||
|
||||
# #1743:失败路径返回 dict(video_count=0)
|
||||
assert result["video_count"] == 0
|
||||
assert result == 0
|
||||
session.commit.assert_not_called()
|
||||
session.rollback.assert_called_once()
|
||||
|
||||
@@ -10,11 +10,8 @@ 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_target_duration(self):
|
||||
"""#1749:speed=1.0 时 effective_duration 始终为目标段长 duration。
|
||||
|
||||
素材短于段长不再慢放补偿,差值由末帧冻结 tpad / 音频 apad 铺满。
|
||||
"""
|
||||
def test_normal_speed_returns_min(self):
|
||||
"""When speed=1.0, effective_duration = min(duration, actual_duration)."""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
@@ -26,10 +23,11 @@ class TestClipEffectiveDurationWithSpeedCompensation:
|
||||
actual_duration=3.0, # shorter than configured
|
||||
playback_speed=1.0,
|
||||
)
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
|
||||
# Without speed compensation, effective = min(4, 3) = 3
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 3.0
|
||||
|
||||
def test_explicit_speed_does_not_change_effective_duration(self):
|
||||
"""#1749:effective_duration 只认目标段长,与 playback_speed 无关(慢放补偿已删除)。"""
|
||||
def test_compensated_speed_returns_configured_duration(self):
|
||||
"""When speed < 1 (compensated), effective_duration = configured duration."""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
@@ -39,8 +37,9 @@ class TestClipEffectiveDurationWithSpeedCompensation:
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=3.0, # shorter than configured
|
||||
playback_speed=0.75,
|
||||
playback_speed=0.75, # compensated: 3/4 = 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):
|
||||
@@ -77,10 +76,10 @@ class TestClipEffectiveDurationWithSpeedCompensation:
|
||||
class TestClipAdjustedDurationWithSpeedCompensation:
|
||||
"""Test _clip_adjusted_duration accounts for compensated speed."""
|
||||
|
||||
def test_adjusted_duration_with_explicit_speed(self):
|
||||
"""#1749:adjusted duration = 目标段长 / speed(用户显式调速仍生效;短素材不再自动慢放)。
|
||||
|
||||
4.0 / 0.75 ≈ 5.333。
|
||||
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.
|
||||
"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
@@ -91,8 +90,8 @@ class TestClipAdjustedDurationWithSpeedCompensation:
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=3.0,
|
||||
playback_speed=0.75,
|
||||
playback_speed=0.75, # compensated
|
||||
)
|
||||
adjusted = UnifiedRenderService._clip_adjusted_duration(clip)
|
||||
# 4.0 / 0.75 = 5.333...
|
||||
assert abs(adjusted - (4.0 / 0.75)) < 0.01
|
||||
# min(4,3)/0.75 = 3/0.75 = 4.0 (matches configured duration)
|
||||
assert abs(adjusted - 4.0) < 0.01
|
||||
|
||||
@@ -381,8 +381,7 @@ class TestThumbnailInDedupHelpers:
|
||||
thumbnail_url=pre_thumb_url,
|
||||
)
|
||||
|
||||
# #1743:返回 dict(非批次 batch_similarity=None)
|
||||
assert result["video_count"] == 1
|
||||
assert result == 1
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
@@ -428,8 +427,7 @@ class TestThumbnailInDedupHelpers:
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
# #1743:返回 dict(非批次 batch_similarity=None)
|
||||
assert result["video_count"] == 1
|
||||
assert result == 1
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
@@ -476,7 +474,7 @@ class TestThumbnailInDedupHelpers:
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
assert result["video_count"] == 1 # 不阻断(#1743 dict 返回)
|
||||
assert result == 1 # 不阻断
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
|
||||
@@ -57,29 +57,19 @@ 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: list[str] = []
|
||||
all_paths = []
|
||||
for route in api_router.routes:
|
||||
_collect_route_paths(route, all_paths)
|
||||
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)
|
||||
|
||||
# 应该能找到 generate-cover 路径
|
||||
cover_paths = [p for p in all_paths if "generate-cover" in p]
|
||||
|
||||
@@ -159,6 +159,7 @@ 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 == {}
|
||||
@@ -174,16 +175,15 @@ class TestCreatePreviewGenerationTaskRequest:
|
||||
assert req.template_id == "tmpl_123"
|
||||
assert req.title_ids == ["title_1"]
|
||||
|
||||
def test_valid_request_with_voice_library_id_and_asset_ids(self):
|
||||
"""有效请求:template_id + asset_ids + 统一配音 voice_library_id(#1749 替换旧 voice_ids 字段)"""
|
||||
def test_valid_request_with_voice_ids_only(self):
|
||||
"""有效请求:template_id + voice_ids"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
voice_library_id="voice_1",
|
||||
voice_ids=["voice_1"],
|
||||
)
|
||||
assert req.voice_library_id == "voice_1"
|
||||
assert req.voice_ids == ["voice_1"]
|
||||
|
||||
def test_missing_template_id_raises(self):
|
||||
"""缺少 template_id 报错"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user