feat: #1767 BGM 池差异化分配,打破变体间音频指纹一致性 #1785
@@ -932,6 +932,17 @@ class EditPlanService:
|
||||
rhythm_templates_for_variants.append(template)
|
||||
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
|
||||
|
||||
# #1767:BGM 池差异化分配(让批量变体使用不同 BGM / 段落 / 音量)
|
||||
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
|
||||
|
||||
source_bgm_config = {}
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and source_plan.config:
|
||||
source_bgm_config = source_plan.config.get("bgm", {}) or {}
|
||||
|
||||
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in plan_ids]
|
||||
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
|
||||
|
||||
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
|
||||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||||
|
||||
@@ -950,8 +961,20 @@ class EditPlanService:
|
||||
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
config_update["pixel_perturbation"] = pixel_pert
|
||||
# #1767:写入 BGM 池分配(覆盖 bgm 配置中的 preset_id / audio_offset / volume_adjust_db)
|
||||
if idx < len(bgm_pool_assignments):
|
||||
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
|
||||
existing_bgm.update(bgm_pool_assignments[idx])
|
||||
config_update["bgm"] = existing_bgm
|
||||
self.update_plan_config(pid, config_update)
|
||||
logger.info("变体 %d 视觉扰动+像素扰动: plan=%s vis=%s pix=%s", idx, pid, perturbation, pixel_pert)
|
||||
logger.info(
|
||||
"变体 %d 视觉扰动+像素扰动+BGM池: plan=%s vis=%s pix=%s bgm=%s",
|
||||
idx,
|
||||
pid,
|
||||
perturbation,
|
||||
pixel_pert,
|
||||
bgm_pool_assignments[idx] if idx < len(bgm_pool_assignments) else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ class BGMConfig:
|
||||
sidechain_attack: float = 0.02 # 攻击时间
|
||||
sidechain_release: float = 0.5 # 释放时间
|
||||
sidechain_threshold: float = -25.0 # 触发阈值(dB)
|
||||
audio_offset: float = 0.0 # BGM 段落起始偏移(秒),#1767 策略二
|
||||
volume_adjust_db: float = 0.0 # 音量微调 dB(-3~+3),#1767 策略三
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, bgm_path: str, config: dict) -> "BGMConfig":
|
||||
@@ -54,6 +56,8 @@ class BGMConfig:
|
||||
sidechain_attack=float(config.get("sidechain_attack", 0.02)),
|
||||
sidechain_release=float(config.get("sidechain_release", 0.5)),
|
||||
sidechain_threshold=float(config.get("sidechain_threshold", -25.0)),
|
||||
audio_offset=float(config.get("audio_offset", 0.0)),
|
||||
volume_adjust_db=float(config.get("volume_adjust_db", 0.0)),
|
||||
)
|
||||
|
||||
|
||||
@@ -84,14 +88,18 @@ def prepare_bgm_track(
|
||||
target_duration = 5.0 # 兜底
|
||||
|
||||
bgm_dur = probe_duration(bgm.bgm_path)
|
||||
needs_loop = bgm.loop_enabled and bgm_dur > 0 and bgm_dur < target_duration * 0.9
|
||||
# #1767:seek 后有效时长 = 总时长 - 偏移
|
||||
effective_dur = (
|
||||
max(1.0, bgm_dur - bgm.audio_offset) if bgm.audio_offset > 0 and bgm_dur > bgm.audio_offset else bgm_dur
|
||||
)
|
||||
needs_loop = bgm.loop_enabled and effective_dur > 0 and effective_dur < target_duration * 0.9
|
||||
|
||||
# 构建滤镜链
|
||||
filter_parts: list[str] = []
|
||||
|
||||
if needs_loop:
|
||||
# 计算需要循环多少次才能铺满
|
||||
loop_count = max(1, int(target_duration / bgm_dur) + 2)
|
||||
# 计算需要循环多少次才能铺满(基于 seek 后有效时长)
|
||||
loop_count = max(1, int(target_duration / effective_dur) + 2)
|
||||
# aloop 滤镜:循环指定次数
|
||||
filter_parts.append(f"aloop=loop={loop_count}:size=0")
|
||||
|
||||
@@ -113,11 +121,30 @@ def prepare_bgm_track(
|
||||
filter_parts.append(f"atrim=0:{target_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB") # 重置时间戳
|
||||
|
||||
filter_str = ",".join(filter_parts)
|
||||
# #1767:BGM 段落差异化 — 使用 -ss 从偏移位置开始(seek 效率高,不读跳过部分)
|
||||
seek_args: list[str] = []
|
||||
if bgm.audio_offset > 0 and bgm_dur > bgm.audio_offset:
|
||||
seek_args = ["-ss", f"{bgm.audio_offset:.3f}"]
|
||||
logger.info("[bgm] #1767 audio_offset=%.1fs(段落差异化)", bgm.audio_offset)
|
||||
|
||||
# #1767:音量微调 — dB 转线性系数(10^(dB/20))
|
||||
db_adjust_filter = ""
|
||||
if abs(bgm.volume_adjust_db) > 0.01:
|
||||
linear_factor = 10.0 ** (bgm.volume_adjust_db / 20.0)
|
||||
db_adjust_filter = f",volume={linear_factor:.4f}"
|
||||
logger.info("[bgm] #1767 volume_adjust=%.0fdB → linear=%.4f", bgm.volume_adjust_db, linear_factor)
|
||||
|
||||
# #1767:追加 dB 微调到滤镜链末尾
|
||||
if db_adjust_filter:
|
||||
filter_str_base = ",".join(filter_parts)
|
||||
filter_str = filter_str_base + db_adjust_filter
|
||||
else:
|
||||
filter_str = ",".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*seek_args,
|
||||
"-i",
|
||||
bgm.bgm_path,
|
||||
"-filter:a",
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""BGM 池差异化分配 — 打破变体间音频指纹一致性 (Issue #1767).
|
||||
|
||||
三层递进策略:
|
||||
1. **BGM 池分配(核心)**:维护风格匹配的 BGM 池,每个变体基于 variant_seed
|
||||
随机分配一首不同 BGM,保证变体间音频指纹不同。
|
||||
2. **段落差异化(池不够时的补充)**:同一首 BGM 做差异化裁剪,不同变体使用
|
||||
不同起始点/段落,进一步降低音频相似度。
|
||||
3. **音量微调**:不同变体 BGM 音量 ±3dB 微调,混音比例有微小差异。
|
||||
|
||||
约束:
|
||||
- 不破坏现有单视频 BGM 选择逻辑(单视频不走池分配)
|
||||
- BGM 情绪/风格与视频内容匹配(基于源 plan 的 BGM style 做风格筛选)
|
||||
- 分配可复现(同 seed 同结果)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── BGM 池条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BGMPoolEntry:
|
||||
"""BGM 池条目"""
|
||||
|
||||
id: str
|
||||
preset_id: str # 关联 PRESET_BGM_LIBRARY 中的 ID(用于渲染侧解析音频路径)
|
||||
mood: str # 情绪/风格:upbeat / relax / tech / commerce / emotional / cinematic
|
||||
duration: float # 时长(秒)
|
||||
audio_url: str = "" # CDN/OSS 直链(优先级高于 preset_id)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── BGM 池(10 首,覆盖 6 种风格) ──────────────────────────────────────────
|
||||
|
||||
BGM_POOL: list[BGMPoolEntry] = [
|
||||
# upbeat (轻快)
|
||||
BGMPoolEntry(
|
||||
id="pool_upbeat_001", preset_id="bgm_upbeat_001", mood="upbeat", duration=120.0, tags=["轻快", "阳光", "vlog"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_upbeat_002", preset_id="bgm_upbeat_002", mood="upbeat", duration=95.0, tags=["轻快", "电子", "运动"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_upbeat_003", preset_id="bgm_upbeat_003", mood="upbeat", duration=110.0, tags=["轻快", "夏日", "旅行"]
|
||||
),
|
||||
# relax (治愈)
|
||||
BGMPoolEntry(
|
||||
id="pool_relax_001", preset_id="bgm_relax_001", mood="relax", duration=180.0, tags=["治愈", "钢琴", "冥想"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_relax_002", preset_id="bgm_relax_002", mood="relax", duration=150.0, tags=["治愈", "自然", "放松"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_relax_003", preset_id="bgm_relax_003", mood="relax", duration=200.0, tags=["治愈", "古典", "钢琴"]
|
||||
),
|
||||
# tech (科技)
|
||||
BGMPoolEntry(
|
||||
id="pool_tech_001", preset_id="bgm_tech_001", mood="tech", duration=85.0, tags=["科技", "电子", "数码"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_tech_002", preset_id="bgm_tech_002", mood="tech", duration=100.0, tags=["科技", "极简", "AI"]
|
||||
),
|
||||
# commerce (电商)
|
||||
BGMPoolEntry(
|
||||
id="pool_commerce_001",
|
||||
preset_id="bgm_commerce_001",
|
||||
mood="commerce",
|
||||
duration=75.0,
|
||||
tags=["电商", "时尚", "带货"],
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_commerce_002",
|
||||
preset_id="bgm_commerce_002",
|
||||
mood="commerce",
|
||||
duration=90.0,
|
||||
tags=["电商", "品牌", "品质"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── 风格 → 情绪映射 ────────────────────────────────────────────────────────
|
||||
# preset_bgm.py 中 style 字段 → bgm_pool.py 中 mood 字段
|
||||
|
||||
STYLE_TO_MOOD: dict[str, str] = {
|
||||
"upbeat": "upbeat",
|
||||
"relax": "relax",
|
||||
"tech": "tech",
|
||||
"commerce": "commerce",
|
||||
"emotional": "emotional",
|
||||
"cinematic": "cinematic",
|
||||
}
|
||||
|
||||
|
||||
# ── 策略一:BGM 池分配 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_bgm_pool_candidates(source_mood: str | None = None) -> list[BGMPoolEntry]:
|
||||
"""获取 BGM 池候选列表。
|
||||
|
||||
如果指定了 source_mood,优先返回同 mood 的条目;
|
||||
如果同 mood 条目不足 2 个,降级返回全池(保证有足够候选)。
|
||||
|
||||
Args:
|
||||
source_mood: 源 BGM 的情绪/风格(来自 preset_bgm.py 的 style 字段)
|
||||
|
||||
Returns:
|
||||
候选 BGM 列表(至少 2 个条目)
|
||||
"""
|
||||
if not source_mood:
|
||||
return list(BGM_POOL)
|
||||
|
||||
mood = STYLE_TO_MOOD.get(source_mood, source_mood)
|
||||
matched = [e for e in BGM_POOL if e.mood == mood]
|
||||
|
||||
# 同 mood 至少要有 2 首,否则无法"差异化",降级全池
|
||||
if len(matched) >= 2:
|
||||
return matched
|
||||
return list(BGM_POOL)
|
||||
|
||||
|
||||
def select_bgm_from_pool(
|
||||
variant_seed: int,
|
||||
candidates: list[BGMPoolEntry] | None = None,
|
||||
) -> BGMPoolEntry:
|
||||
"""基于 variant_seed 从候选池中选一首 BGM(可复现)。
|
||||
|
||||
Args:
|
||||
variant_seed: 变体随机种子
|
||||
candidates: 候选池(None 时使用全池)
|
||||
|
||||
Returns:
|
||||
选中的 BGM 条目
|
||||
"""
|
||||
pool = candidates if candidates is not None else list(BGM_POOL)
|
||||
if not pool:
|
||||
pool = list(BGM_POOL)
|
||||
rng = random.Random(variant_seed)
|
||||
return rng.choice(pool)
|
||||
|
||||
|
||||
# ── 策略二:段落差异化 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_bgm_segment_offset(variant_seed: int, bgm_duration: float) -> float:
|
||||
"""为变体生成 BGM 段落起始偏移(策略二)。
|
||||
|
||||
不同变体从同一首 BGM 的不同位置开始播放,进一步降低音频指纹相似度。
|
||||
|
||||
偏移范围 [0, max_offset],max_offset = min(30s, bgm_duration * 0.3)。
|
||||
量化到 5 秒整数倍,便于复现和调试。
|
||||
|
||||
Args:
|
||||
variant_seed: 变体随机种子
|
||||
bgm_duration: BGM 总时长(秒)
|
||||
|
||||
Returns:
|
||||
起始偏移(秒),0 ~ max_offset 之间,5s 步长
|
||||
"""
|
||||
rng = random.Random(variant_seed + 7919) # 加素数偏移,避免与 BGM 选择 seed 序列重合
|
||||
max_offset = min(30.0, bgm_duration * 0.3)
|
||||
steps = int(max_offset // 5.0)
|
||||
if steps <= 0:
|
||||
return 0.0
|
||||
return float(rng.randint(0, steps) * 5)
|
||||
|
||||
|
||||
# ── 策略三:音量微调 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_bgm_volume_adjust(variant_seed: int) -> float:
|
||||
"""为变体生成 BGM 音量微调值(策略三)。
|
||||
|
||||
±3dB 微调,让不同变体的 BGM/配音混音比例有微小差异。
|
||||
离散步长:-3, -2, -1, 0, 1, 2, 3 dB。
|
||||
|
||||
Args:
|
||||
variant_seed: 变体随机种子
|
||||
|
||||
Returns:
|
||||
音量调整值(dB),-3.0 ~ 3.0
|
||||
"""
|
||||
rng = random.Random(variant_seed + 104729) # 另一个素数偏移
|
||||
return float(rng.choice([-3, -2, -1, 0, 1, 2, 3]))
|
||||
|
||||
|
||||
# ── 批量分配入口 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def allocate_bgm_pool_for_variants(
|
||||
source_bgm_config: dict,
|
||||
variant_seeds: list[int],
|
||||
) -> list[dict]:
|
||||
"""为批量变体分配不同的 BGM 池配置。
|
||||
|
||||
整合三层策略:池分配 + 段落偏移 + 音量微调。
|
||||
每个变体得到一个 dict,可直接合并到 plan.config["bgm"] 中。
|
||||
|
||||
Args:
|
||||
source_bgm_config: 源 plan 的 BGM 配置(用于风格匹配)
|
||||
variant_seeds: 每个变体的随机种子列表
|
||||
|
||||
Returns:
|
||||
每个变体的 BGM 池配置 dict 列表(与 variant_seeds 等长),
|
||||
每项包含 preset_id / audio_url / audio_offset / volume_adjust_db。
|
||||
如果源 BGM 未启用,返回空列表。
|
||||
"""
|
||||
if not source_bgm_config or not source_bgm_config.get("enabled", False):
|
||||
return []
|
||||
if not variant_seeds:
|
||||
return []
|
||||
|
||||
# 从源 BGM 配置中推断风格
|
||||
source_mood = _infer_source_mood(source_bgm_config)
|
||||
|
||||
# 策略一:获取候选池
|
||||
candidates = get_bgm_pool_candidates(source_mood)
|
||||
|
||||
# 为每个变体分配不同的 BGM(尽量不重复)
|
||||
assignments = _assign_unique_bgm(candidates, variant_seeds)
|
||||
|
||||
results = []
|
||||
for i, (entry, seed) in enumerate(zip(assignments, variant_seeds, strict=False)):
|
||||
# 策略二:段落偏移
|
||||
offset = generate_bgm_segment_offset(seed, entry.duration)
|
||||
|
||||
# 策略三:音量微调
|
||||
volume_adj = generate_bgm_volume_adjust(seed)
|
||||
|
||||
result = {
|
||||
"preset_id": entry.preset_id,
|
||||
"audio_url": entry.audio_url,
|
||||
"audio_offset": offset,
|
||||
"volume_adjust_db": volume_adj,
|
||||
"bgm_pool_entry_id": entry.id,
|
||||
"bgm_pool_mood": entry.mood,
|
||||
}
|
||||
results.append(result)
|
||||
logger.info(
|
||||
"变体 %d BGM 池分配: seed=%d bgm=%s mood=%s offset=%.1fs vol_adj=%+.0fdB",
|
||||
i,
|
||||
seed,
|
||||
entry.id,
|
||||
entry.mood,
|
||||
offset,
|
||||
volume_adj,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _infer_source_mood(source_bgm_config: dict) -> str | None:
|
||||
"""从源 BGM 配置推断风格/情绪。
|
||||
|
||||
优先级:
|
||||
1. preset_id → 查 preset_bgm 库获取 style
|
||||
2. bgm_pool_mood → 上游已设置过(二次分配场景)
|
||||
3. 无法推断 → None(返回全池候选)
|
||||
"""
|
||||
preset_id = source_bgm_config.get("preset_id", "")
|
||||
if preset_id:
|
||||
from packages.domain.preset_bgm import get_preset_bgm
|
||||
|
||||
preset = get_preset_bgm(preset_id)
|
||||
if preset:
|
||||
return STYLE_TO_MOOD.get(preset.style, preset.style)
|
||||
|
||||
# 如果之前已经分配过 BGM 池,直接用 mood
|
||||
pool_mood = source_bgm_config.get("bgm_pool_mood", "")
|
||||
if pool_mood:
|
||||
return pool_mood
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _assign_unique_bgm(
|
||||
candidates: list[BGMPoolEntry],
|
||||
variant_seeds: list[int],
|
||||
) -> list[BGMPoolEntry]:
|
||||
"""尽量让每个变体选到不同的 BGM。
|
||||
|
||||
策略:用 seed 选 BGM,如果与前面变体重复,用递增 seed 重试。
|
||||
如果候选池大小 < 变体数,允许重复但不连续。
|
||||
"""
|
||||
if not candidates or not variant_seeds:
|
||||
return []
|
||||
|
||||
assignments: list[BGMPoolEntry] = []
|
||||
used_ids: set[str] = set()
|
||||
|
||||
for i, seed in enumerate(variant_seeds):
|
||||
rng = random.Random(seed)
|
||||
# 先尝试选一个没用过的
|
||||
chosen = None
|
||||
for _attempt in range(len(candidates)):
|
||||
candidate = rng.choice(candidates)
|
||||
if candidate.id not in used_ids:
|
||||
chosen = candidate
|
||||
break
|
||||
if chosen is None:
|
||||
# 候选池已用完,允许重复但取下一个(循环)
|
||||
idx = i % len(candidates)
|
||||
chosen = candidates[idx]
|
||||
|
||||
assignments.append(chosen)
|
||||
used_ids.add(chosen.id)
|
||||
|
||||
return assignments
|
||||
@@ -0,0 +1,281 @@
|
||||
"""BGM 池差异化分配单元测试 (Issue #1767).
|
||||
|
||||
覆盖:
|
||||
- BGM 池定义(10 首,覆盖 4 种风格)
|
||||
- 风格匹配:get_bgm_pool_candidates 按 mood 筛选
|
||||
- 变体分配:select_bgm_from_pool 基于 seed 可复现选择
|
||||
- 批量分配:allocate_bgm_pool_for_variants 确保不同变体不同 BGM
|
||||
- 段落差异化:generate_bgm_segment_offset 生成不同偏移
|
||||
- 音量微调:generate_bgm_volume_adjust ±3dB
|
||||
- 边界条件:空配置/未启用 BGM/未知 mood
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.bgm_pool import (
|
||||
BGM_POOL,
|
||||
STYLE_TO_MOOD,
|
||||
BGMPoolEntry,
|
||||
allocate_bgm_pool_for_variants,
|
||||
generate_bgm_segment_offset,
|
||||
generate_bgm_volume_adjust,
|
||||
get_bgm_pool_candidates,
|
||||
select_bgm_from_pool,
|
||||
)
|
||||
|
||||
|
||||
class TestBGMPoolDefinition:
|
||||
"""BGM 池定义测试。"""
|
||||
|
||||
def test_pool_has_at_least_10_entries(self):
|
||||
"""BGM 池至少 10 首(满足 5-10 首需求)。"""
|
||||
assert len(BGM_POOL) >= 10
|
||||
|
||||
def test_all_entries_have_required_fields(self):
|
||||
"""每条 BGM 池条目都有 id/preset_id/mood/duration。"""
|
||||
for entry in BGM_POOL:
|
||||
assert entry.id, "Missing id"
|
||||
assert entry.preset_id, f"Missing preset_id in {entry.id}"
|
||||
assert entry.mood, f"Missing mood in {entry.id}"
|
||||
assert entry.duration > 0, f"Duration must be > 0 in {entry.id}"
|
||||
|
||||
def test_pool_covers_multiple_moods(self):
|
||||
"""池覆盖至少 3 种不同 mood。"""
|
||||
moods = {e.mood for e in BGM_POOL}
|
||||
assert len(moods) >= 3, f"Expected >= 3 moods, got {moods}"
|
||||
|
||||
def test_each_mood_has_at_least_2_entries(self):
|
||||
"""每种 mood 至少有 2 首(保证差异化有意义)。"""
|
||||
mood_counts: dict[str, int] = {}
|
||||
for entry in BGM_POOL:
|
||||
mood_counts[entry.mood] = mood_counts.get(entry.mood, 0) + 1
|
||||
for mood, count in mood_counts.items():
|
||||
assert count >= 2, f"Mood '{mood}' only has {count} entries (need >= 2)"
|
||||
|
||||
|
||||
class TestGetBGMPoolCandidates:
|
||||
"""风格匹配测试。"""
|
||||
|
||||
def test_no_mood_returns_full_pool(self):
|
||||
"""不指定 mood 时返回全池。"""
|
||||
candidates = get_bgm_pool_candidates(None)
|
||||
assert len(candidates) == len(BGM_POOL)
|
||||
|
||||
def test_matching_mood_filters(self):
|
||||
"""指定已知 mood 时返回同 mood 条目。"""
|
||||
candidates = get_bgm_pool_candidates("upbeat")
|
||||
assert all(c.mood == "upbeat" for c in candidates)
|
||||
assert len(candidates) >= 2
|
||||
|
||||
def test_unknown_mood_returns_full_pool(self):
|
||||
"""未知 mood 降级返回全池。"""
|
||||
candidates = get_bgm_pool_candidates("nonexistent_style")
|
||||
# 如果没有匹配到同 mood 的(>=2),降级全池
|
||||
assert len(candidates) >= 2
|
||||
|
||||
def test_style_to_mood_mapping(self):
|
||||
"""STYLE_TO_MOOD 覆盖所有 preset_bgm 的 style。"""
|
||||
assert "upbeat" in STYLE_TO_MOOD
|
||||
assert "relax" in STYLE_TO_MOOD
|
||||
assert "tech" in STYLE_TO_MOOD
|
||||
assert "commerce" in STYLE_TO_MOOD
|
||||
|
||||
|
||||
class TestSelectBGMPool:
|
||||
"""变体 BGM 选择测试。"""
|
||||
|
||||
def test_same_seed_same_result(self):
|
||||
"""相同 seed 返回相同 BGM(可复现)。"""
|
||||
result1 = select_bgm_from_pool(42)
|
||||
result2 = select_bgm_from_pool(42)
|
||||
assert result1.id == result2.id
|
||||
|
||||
def test_different_seeds_may_differ(self):
|
||||
"""不同 seed 可能返回不同 BGM。"""
|
||||
results = set()
|
||||
for seed in range(50):
|
||||
entry = select_bgm_from_pool(seed)
|
||||
results.add(entry.id)
|
||||
assert len(results) >= 3, "Expected >= 3 different BGMs from 50 seeds"
|
||||
|
||||
def test_respects_candidates_filter(self):
|
||||
"""传入候选池时只从中选择。"""
|
||||
candidates = [e for e in BGM_POOL if e.mood == "tech"]
|
||||
for seed in range(20):
|
||||
entry = select_bgm_from_pool(seed, candidates)
|
||||
assert entry.mood == "tech"
|
||||
|
||||
|
||||
class TestBGMSegmentOffset:
|
||||
"""段落差异化测试(策略二)。"""
|
||||
|
||||
def test_offset_non_negative(self):
|
||||
"""偏移 >= 0。"""
|
||||
for seed in range(50):
|
||||
offset = generate_bgm_segment_offset(seed, 120.0)
|
||||
assert offset >= 0.0
|
||||
|
||||
def test_offset_bounded(self):
|
||||
"""偏移 <= min(30s, duration * 0.3)。"""
|
||||
for seed in range(50):
|
||||
duration = 100.0
|
||||
max_expected = min(30.0, duration * 0.3)
|
||||
offset = generate_bgm_segment_offset(seed, duration)
|
||||
assert offset <= max_expected + 0.1 # 容差
|
||||
|
||||
def test_different_seeds_different_offsets(self):
|
||||
"""不同 seed 产生不同偏移(统计验证)。"""
|
||||
offsets = set()
|
||||
for seed in range(30):
|
||||
offsets.add(generate_bgm_segment_offset(seed, 120.0))
|
||||
assert len(offsets) >= 3, "Expected >= 3 distinct offsets"
|
||||
|
||||
def test_offset_is_quantized(self):
|
||||
"""偏移是 5s 的整数倍。"""
|
||||
for seed in range(20):
|
||||
offset = generate_bgm_segment_offset(seed, 120.0)
|
||||
assert offset % 5.0 == 0.0
|
||||
|
||||
def test_short_bgm_zero_offset(self):
|
||||
"""极短 BGM 偏移为 0。"""
|
||||
offset = generate_bgm_segment_offset(42, 5.0)
|
||||
# max_offset = min(30, 5*0.3) = 1.5, steps = int(1.5//5) = 0 → return 0
|
||||
assert offset == 0.0
|
||||
|
||||
def test_offset_different_from_bgm_selection(self):
|
||||
"""偏移的 seed 序列与 BGM 选择的 seed 序列不同(加素数偏移)。"""
|
||||
# 同一 seed,偏移和 BGM 选择应该独立
|
||||
bgm = select_bgm_from_pool(42)
|
||||
offset = generate_bgm_segment_offset(42, 120.0)
|
||||
# 只是验证能正常运行,不直接断言独立性(统计测试需要大样本)
|
||||
assert isinstance(offset, float)
|
||||
|
||||
|
||||
class TestBGMVolumeAdjust:
|
||||
"""音量微调测试(策略三)。"""
|
||||
|
||||
def test_volume_adjust_in_range(self):
|
||||
"""音量调整在 -3 ~ +3 dB 范围内。"""
|
||||
for seed in range(50):
|
||||
adj = generate_bgm_volume_adjust(seed)
|
||||
assert -3.0 <= adj <= 3.0
|
||||
assert adj == int(adj) # 整数 dB 步进
|
||||
|
||||
def test_different_seeds_different_volumes(self):
|
||||
"""不同 seed 产生不同音量调整值。"""
|
||||
values = set()
|
||||
for seed in range(50):
|
||||
values.add(generate_bgm_volume_adjust(seed))
|
||||
assert len(values) >= 3, "Expected >= 3 distinct volume values"
|
||||
|
||||
def test_includes_zero(self):
|
||||
"""音量调整值集合包含 0(不变)。"""
|
||||
values = {generate_bgm_volume_adjust(seed) for seed in range(100)}
|
||||
assert 0.0 in values
|
||||
|
||||
|
||||
class TestAllocateBGMPoolForVariants:
|
||||
"""批量分配入口测试。"""
|
||||
|
||||
def test_disabled_bgm_returns_empty(self):
|
||||
"""源 BGM 未启用时返回空列表。"""
|
||||
result = allocate_bgm_pool_for_variants({"enabled": False}, [1, 2, 3])
|
||||
assert result == []
|
||||
|
||||
def test_empty_config_returns_empty(self):
|
||||
"""空配置返回空列表。"""
|
||||
result = allocate_bgm_pool_for_variants({}, [1, 2, 3])
|
||||
assert result == []
|
||||
|
||||
def test_empty_seeds_returns_empty(self):
|
||||
"""无变体时返回空列表。"""
|
||||
result = allocate_bgm_pool_for_variants({"enabled": True, "preset_id": "bgm_upbeat_001"}, [])
|
||||
assert result == []
|
||||
|
||||
def test_returns_correct_count(self):
|
||||
"""返回与 variant_seeds 等长的列表。"""
|
||||
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
|
||||
seeds = [100, 200, 300, 400]
|
||||
result = allocate_bgm_pool_for_variants(config, seeds)
|
||||
assert len(result) == 4
|
||||
|
||||
def test_each_entry_has_required_keys(self):
|
||||
"""每项都包含必要字段。"""
|
||||
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
|
||||
seeds = [100, 200, 300]
|
||||
result = allocate_bgm_pool_for_variants(config, seeds)
|
||||
for entry in result:
|
||||
assert "preset_id" in entry
|
||||
assert "audio_offset" in entry
|
||||
assert "volume_adjust_db" in entry
|
||||
assert "bgm_pool_entry_id" in entry
|
||||
assert "bgm_pool_mood" in entry
|
||||
|
||||
def test_batch_3_variants_at_least_2_different_bgm(self):
|
||||
"""批量 3 个变体,至少 2 个不同 BGM。"""
|
||||
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
|
||||
seeds = [100, 200, 300]
|
||||
result = allocate_bgm_pool_for_variants(config, seeds)
|
||||
bgm_ids = {r["bgm_pool_entry_id"] for r in result}
|
||||
assert len(bgm_ids) >= 2, f"Expected >= 2 different BGMs, got {bgm_ids}"
|
||||
|
||||
def test_style_matching_with_preset_id(self):
|
||||
"""源 BGM 有 preset_id 时按风格筛选。"""
|
||||
config = {"enabled": True, "preset_id": "bgm_tech_001"} # tech 风格
|
||||
seeds = [100, 200, 300]
|
||||
result = allocate_bgm_pool_for_variants(config, seeds)
|
||||
# tech mood 至少有 2 首,所以应该筛选到 tech
|
||||
for entry in result:
|
||||
assert entry["bgm_pool_mood"] == "tech"
|
||||
|
||||
def test_reproducible_with_same_seeds(self):
|
||||
"""相同 seeds 产生相同分配(可复现)。"""
|
||||
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
|
||||
seeds = [42, 100, 200]
|
||||
result1 = allocate_bgm_pool_for_variants(config, seeds)
|
||||
result2 = allocate_bgm_pool_for_variants(config, seeds)
|
||||
for r1, r2 in zip(result1, result2, strict=False):
|
||||
assert r1["bgm_pool_entry_id"] == r2["bgm_pool_entry_id"]
|
||||
assert r1["audio_offset"] == r2["audio_offset"]
|
||||
assert r1["volume_adjust_db"] == r2["volume_adjust_db"]
|
||||
|
||||
|
||||
class TestIssue1767Acceptance:
|
||||
"""Issue #1767 验收测试。"""
|
||||
|
||||
def test_batch_3_videos_bgm_different_or_segment_different(self):
|
||||
"""批量生成 3 个视频,BGM 不同或起始段落不同。"""
|
||||
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
|
||||
seeds = [100, 200, 300]
|
||||
result = allocate_bgm_pool_for_variants(config, seeds)
|
||||
|
||||
# 检查:BGM 不同 或 段落偏移不同
|
||||
unique_combos = set()
|
||||
for r in result:
|
||||
combo = (r["bgm_pool_entry_id"], r["audio_offset"])
|
||||
unique_combos.add(combo)
|
||||
|
||||
assert len(unique_combos) >= 2, f"Expected >= 2 unique (bgm, offset) combos, got {unique_combos}"
|
||||
|
||||
def test_bgm_volume_micro_adjust_doesnt_affect_voice_clarity(self):
|
||||
"""BGM 音量微调在 ±3dB 内,不影响配音清晰度。"""
|
||||
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
|
||||
seeds = [100, 200, 300]
|
||||
result = allocate_bgm_pool_for_variants(config, seeds)
|
||||
|
||||
for r in result:
|
||||
# ±3dB 是安全的微调范围,不会让 BGM 盖过配音
|
||||
assert abs(r["volume_adjust_db"]) <= 3.0
|
||||
|
||||
def test_single_video_mode_unaffected(self):
|
||||
"""单视频模式不受影响(不走池分配)。"""
|
||||
# 单视频不调用 allocate_bgm_pool_for_variants
|
||||
# 只要不主动调用,就不会改变行为
|
||||
# 这个测试验证函数签名和行为不会意外影响单视频
|
||||
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
|
||||
result = allocate_bgm_pool_for_variants(config, [42]) # 单变体
|
||||
assert len(result) == 1
|
||||
# 单项分配仍然有完整配置(不影响功能,只是差异化)
|
||||
assert "preset_id" in result[0]
|
||||
Reference in New Issue
Block a user