diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index fa244a9d7..b66695bfa 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -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) diff --git a/apps/worker/video_processing/bgm_mixer.py b/apps/worker/video_processing/bgm_mixer.py index 28aed58c5..6839f977d 100755 --- a/apps/worker/video_processing/bgm_mixer.py +++ b/apps/worker/video_processing/bgm_mixer.py @@ -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", diff --git a/packages/domain/bgm_pool.py b/packages/domain/bgm_pool.py new file mode 100644 index 000000000..95ee21795 --- /dev/null +++ b/packages/domain/bgm_pool.py @@ -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 diff --git a/tests/unit/test_bgm_pool.py b/tests/unit/test_bgm_pool.py new file mode 100644 index 000000000..26eeaca85 --- /dev/null +++ b/tests/unit/test_bgm_pool.py @@ -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]