98cf571ab3
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>
315 lines
11 KiB
Python
315 lines
11 KiB
Python
"""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
|