feat: #1767 BGM 池差异化分配,打破变体间音频指纹一致性 (#1785)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #1785.
This commit is contained in:
2026-09-08 10:12:52 +08:00
committed by auto-approve-bot
parent 12a9efc65d
commit 98cf571ab3
4 changed files with 650 additions and 5 deletions
+24 -1
View File
@@ -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)
+31 -4
View File
@@ -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
# #1767seek 后有效时长 = 总时长 - 偏移
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)
# #1767BGM 段落差异化 — 使用 -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",
+314
View File
@@ -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
+281
View File
@@ -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]