Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/composer/dubbing.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

608 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
composer/dubbing.py — 配音合成(TTS 人声 + 环境音铺底 + 时间轴对齐)
背景(2026-10-02 实测确认):
· 可灵生成的镜头**没有音轨**(payload 无音频参数)
· 分镜的 Shot.text(台词)此前只拼进提示词,不产生任何声音
· 导演台出片链路(pipeline.render)没有音频环节 → 成片无声
本模块补齐「声音」:
按镜头时间轴,把台词逐句合成为一条完整音轨;每个镜头一段,段长严格等于
镜头时长(短则补静音、长则加速),因此天然对齐画面。
音色来源(优先级):
1. 演员表指定的 voice_id(CastMember.voice_id)
2. 按角色代号稳定分配的路人音色池(同一角色全片同一音色,不会听起来换人)
环境音来源:
分镜的 Shot.audio_cue(如"远处车流声")→ 与环境音库资产做关键词匹配 →
该镜头期间低音量循环铺底。
输出:一条 44.1kHz 立体声 wav(可直接交给 StoryboardPipeline.mux_final 的 voice_path)
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Callable, Optional
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
from lib.ffmpeg import FFMPEG # noqa: E402 # 统一 ffmpeg 定位(绝对路径)
log = get_logger("dubbing")
API_BASE_SR = 44100
DEFAULT_SFX_VOLUME = 0.85 # 音效默认音量(非语言发声为主,比环境音高)
DEFAULT_AMBIENCE_VOLUME = 0.15
DEFAULT_VOICE_VOLUME = 1.0
ProgressCB = Optional[Callable[[str, str, str, str], None]]
# ════════════════════════════════════════════════════════════
# 音色分配
# ════════════════════════════════════════════════════════════
# 路人音色池(未指定音色的角色按代号稳定分配;来自 config/cosyvoice_voices.yaml 的 pools)
FALLBACK_MALE = ["longze_v3", "longzhe_v3", "longanlang_v3"]
FALLBACK_FEMALE = ["longxing_v3", "longmiao_v3", "longanqin_v3"]
NARRATOR_VOICE = "longxiu_v3"
def _is_female(slot: str, note: str = "") -> bool:
"""粗判角色性别(用于选路人音色池):代号/备注里出现女/她/妈/姐/妹/婆 → 女声"""
t = f"{slot} {note}"
return bool(re.search(r"女|她|妈|姐|妹|婆|娘|太太|女士|girl|woman", t))
def resolve_voice_map(sb, cast_voice_map: dict | None = None) -> dict:
"""角色代号 → voice_id(确定性分配)
cast_voice_map: 演员表里显式指定的 {slot: voice_id}
未指定的角色 → 按代号 hash 从路人池稳定取一个(同名永远同音色)
"""
cast_voice_map = cast_voice_map or {}
out: dict[str, str] = {}
# ⚠️ 字段名是 global_anchors(不是 anchors)—— 写错会导致演员表里的 voice_id 读不到,
# 角色音色全部退化为"路人池按 hash 分配"(用户实测发现)
_ga = getattr(sb, "global_anchors", None)
_cast = []
if _ga is not None and hasattr(_ga, "effective_cast"):
try:
_cast = _ga.effective_cast() or []
except Exception:
_cast = []
for m in _cast:
slot = getattr(m, "slot", "") or ""
if not slot:
continue
vid = (cast_voice_map.get(slot) or getattr(m, "voice_id", "") or "").strip()
if not vid:
pool = FALLBACK_FEMALE if _is_female(slot, getattr(m, "note", "")) else FALLBACK_MALE
vid = pool[sum(ord(c) for c in slot) % len(pool)]
out[slot] = vid
# 演员表未覆盖的出场代号(导演写的)也要有音色
for shot in getattr(sb, "shots", []) or []:
for slot in (getattr(shot, "cast_slots", None) or []):
if slot and slot not in out:
pool = FALLBACK_FEMALE if _is_female(slot) else FALLBACK_MALE
out[slot] = pool[sum(ord(c) for c in slot) % len(pool)]
return out
def pick_voice_for_shot(shot, voice_map: dict, narrator: str = NARRATOR_VOICE) -> str:
"""该镜头台词用谁的音色:出场人物第一顺位;无出场 → 旁白音色"""
slots = getattr(shot, "cast_slots", None) or []
for s in slots:
if s in voice_map:
return voice_map[s]
if slots:
# 演员表里没有的代号 → 用其自身 hash 分配
pool = FALLBACK_FEMALE if _is_female(slots[0]) else FALLBACK_MALE
return pool[sum(ord(c) for c in slots[0]) % len(pool)]
return narrator
# ════════════════════════════════════════════════════════════
# 音频工具
# ════════════════════════════════════════════════════════════
def _run(cmd: list[str], timeout: int = 600) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def probe_audio_duration(path: str) -> float:
"""音频/视频时长(秒);用 ffmpeg -i 解析(本机无真 ffprobe,见 lib/ffmpeg.py)"""
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return float(d or 0.0)
def synthesize_line(text: str, voice_id: str, out_path: str,
api_key: str = "", speed: float = 1.0) -> str:
"""单句 TTS(阿里 CosyVoice)"""
from voice_synthesizer.aliyun import synthesize
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
synthesize(text, out_path, api_key=api_key, voice_id=voice_id, speed=speed)
return out_path
def make_silence(duration_sec: float, dst: str) -> str:
"""生成指定时长的静音段(44.1k 立体声 wav)"""
r = _run([FFMPEG, "-y", "-f", "lavfi",
"-i", f"anullsrc=channel_layout=stereo:sample_rate={API_BASE_SR}",
"-t", f"{max(0.05, duration_sec):.3f}",
"-c:a", "pcm_s16le", dst], timeout=120)
if r.returncode != 0:
raise RuntimeError(f"生成静音失败: {r.stderr[-300:]}")
return dst
def fit_audio_to_duration(src: str, dst: str, target_sec: float,
max_speedup: float = 1.6) -> dict:
"""把音频适配到目标时长
· 短于目标 → 补静音(apad),保持原速
· 长于目标 → 加速(atempo,最多 max_speedup 倍);仍超长则截断
返回 {out, src_dur, target_dur, action}
"""
src_dur = probe_audio_duration(src)
target = max(0.3, float(target_sec))
action = "keep"
afilters: list[str] = []
if src_dur <= 0:
# 探不到时长 → 直接按目标截断 + 补静音,交给 ffmpeg
afilters = ["apad"]
action = "unknown-duration"
elif src_dur > target * 1.02:
ratio = src_dur / target
if ratio <= max_speedup:
afilters = [f"atempo={ratio:.4f}"]
action = f"加速×{ratio:.2f}"
else:
afilters = [f"atempo={max_speedup:.4f}", "apad"]
action = f"加速×{max_speedup:.2f}+截断"
else:
afilters = ["apad"]
action = "补静音"
af = ",".join(afilters) if afilters else "anull"
r = _run([FFMPEG, "-y", "-i", src, "-af", af,
"-t", f"{target:.3f}", "-ar", str(API_BASE_SR), "-ac", "2",
"-c:a", "pcm_s16le", dst], timeout=300)
if r.returncode != 0:
raise RuntimeError(f"音频适配失败: {r.stderr[-300:]}")
return {"out": dst, "src_dur": round(src_dur, 2), "target_dur": round(target, 2), "action": action}
def mix_segment(voice_path: str, ambience_path: str, duration_sec: float,
dst: str, ambience_volume: float = DEFAULT_AMBIENCE_VOLUME,
sfx_items: list | None = None) -> str:
"""把「人声 + 环境音(循环)+ 音效(按时点插入)」混成一个镜头段,严格等于目标时长
sfx_items: [(音效文件路径, 插入秒数, 音量)] —— 非语言发声(惨叫/闷哼/关门声…)
"""
dur = max(0.3, float(duration_sec))
sfx_items = [x for x in (sfx_items or [])
if x and x[0] and Path(x[0]).exists()]
has_v = bool(voice_path and Path(voice_path).exists())
has_a = bool(ambience_path and Path(ambience_path).exists())
if not (has_v or has_a or sfx_items):
return make_silence(dur, dst)
inputs: list[str] = []
if has_v:
inputs += ["-i", voice_path]
if has_a:
inputs += ["-stream_loop", "-1", "-i", ambience_path]
for f, _at, _vol in sfx_items:
inputs += ["-i", f]
parts: list[str] = []
labels: list[str] = []
idx = 0
if has_v:
parts.append(f"[{idx}:a]volume={DEFAULT_VOICE_VOLUME},apad[v]")
labels.append("[v]"); idx += 1
if has_a:
parts.append(f"[{idx}:a]volume={ambience_volume},atrim=0:{dur:.3f},asetpts=N/SR/TB[a]")
labels.append("[a]"); idx += 1
for k, (f, at, vol) in enumerate(sfx_items):
ms = int(max(0.0, min(float(at), dur - 0.05)) * 1000)
parts.append(f"[{idx}:a]volume={vol},adelay={ms}|{ms}[s{k}]")
labels.append(f"[s{k}]"); idx += 1
if len(labels) == 1:
parts.append(f"{labels[0]}anull[out]")
else:
parts.append(f"{''.join(labels)}amix=inputs={len(labels)}:duration=first:normalize=0[out]")
r = _run([FFMPEG, "-y", *inputs, "-filter_complex", ";".join(parts),
"-map", "[out]", "-t", f"{dur:.3f}",
"-ar", str(API_BASE_SR), "-ac", "2", "-c:a", "pcm_s16le", dst], timeout=300)
if r.returncode != 0:
raise RuntimeError(f"段混音失败: {r.stderr[-300:]}")
return dst
def concat_audio(seg_paths: list[str], out_path: str) -> str:
"""按顺序拼接音频段(同规格 wav → concat demuxer)"""
if not seg_paths:
raise ValueError("没有音频段可拼接")
lst = Path(out_path).with_suffix(".txt")
lst.write_text("".join(f"file '{Path(p).resolve()}'\n" for p in seg_paths), encoding="utf-8")
r = _run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", str(lst),
"-c:a", "pcm_s16le", "-ar", str(API_BASE_SR), "-ac", "2", out_path], timeout=600)
lst.unlink(missing_ok=True)
if r.returncode != 0:
raise RuntimeError(f"音频拼接失败: {r.stderr[-300:]}")
return out_path
# ════════════════════════════════════════════════════════════
# 环境音匹配(分镜 audio_cue ↔ 环境音库)
# ════════════════════════════════════════════════════════════
def load_ambience_assets() -> list[dict]:
"""读取环境音库资产(含本地文件路径)"""
try:
from assets_manager import AssetStore
st = AssetStore("ambience")
out = []
for a in st.list():
f = ""
for slot in ("audio", "file", "sample"):
v = a.get(slot) or ""
if v:
try:
f = str(Path(st.kind_dir(a["id"])) / Path(v).name)
except Exception:
f = ""
break
if not f or not Path(f).exists():
# 退一步:目录下第一个音频文件
try:
d = Path(st.kind_dir(a["id"]))
cands = [p for p in d.glob("*") if p.suffix.lower() in (".mp3", ".wav", ".m4a", ".aac", ".ogg")]
f = str(cands[0]) if cands else ""
except Exception:
f = ""
a["_file"] = f
out.append(a)
return out
except Exception as e:
log.warning("环境音库读取失败: %s", e)
return []
def load_sfx_assets() -> list[dict]:
"""读取音效库资产(含本地文件路径)—— 结构同环境音库"""
try:
from assets_manager import AssetStore
st = AssetStore("sfx")
out = []
for a in st.list():
f = ""
for slot in ("audio", "file", "sample"):
v = a.get(slot) or ""
if v:
try:
f = str(Path(st.kind_dir(a["id"])) / Path(v).name)
except Exception:
f = ""
break
if not f or not Path(f).exists():
try:
d = Path(st.kind_dir(a["id"]))
cands = [p for p in d.glob("*")
if p.suffix.lower() in (".mp3", ".wav", ".m4a", ".aac", ".ogg")]
f = str(cands[0]) if cands else ""
except Exception:
f = ""
a["_file"] = f
out.append(a)
return out
except Exception as e:
log.warning("音效库读取失败: %s", e)
return []
def match_sfx(desc: str, assets: list[dict]) -> str:
"""用音效描述匹配音效资产(名称/标签最长子串命中)→ 文件路径
例:desc「痛苦的闷哼」→ 命中音效「闷哼」/「痛呼」
"""
if not desc or not assets:
return ""
d = str(desc)
best, best_len = "", 0
for a in assets:
f = a.get("_file") or ""
if not f:
continue
for key in [a.get("name", "")] + list(a.get("tags") or []):
k = str(key or "").strip()
if len(k) >= 2 and (k in d or d in k) and len(k) > best_len:
best, best_len = f, len(k)
return best
def match_ambience(cue: str, assets: list[dict]) -> str:
"""用 audio_cue 文本匹配环境音资产(名称/标签最长子串命中)→ 文件路径"""
if not cue or not assets:
return ""
best, best_len = "", 0
for a in assets:
f = a.get("_file") or ""
if not f:
continue
for key in [a.get("name", "")] + list(a.get("tags") or []):
k = str(key or "").strip()
if len(k) >= 2 and k in cue and len(k) > best_len:
best, best_len = f, len(k)
return best
# ════════════════════════════════════════════════════════════
# 主入口
# ════════════════════════════════════════════════════════════
def pre_synthesize(sb, work_dir: str, api_key: str = "",
cast_voice_map: dict | None = None,
min_dur: float = 2.0, max_dur: float = 10.0,
pad: float = 0.4, progress_cb: ProgressCB = None) -> dict:
"""【音频先行】逐镜合成台词 → 按音频**真实时长**更新镜头时长
为什么:画面时长若拍脑袋定,台词长了就得加速/截断 → 口型节奏必然对不上。
音频先行 = 让画面跟着声音走。
· 有台词的镜头:TTS → 探测时长 → shot.duration = clamp(音频时长 + 气口, min, max)
· 无台词镜头:保持原时长
· 音频存到 {work_dir}/{shot_id}.mp3 → 后续混流**直接复用,不重复 TTS**
返回 {updated, shots:{shot_id:{audio,audio_dur,old_dur,new_dur,voice_id}}, notes}
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
from lib.config import load_config
cfg = load_config()
api_key = api_key or (cfg.get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise RuntimeError("音频先行需要百炼 API Key(TTS)")
voice_map = resolve_voice_map(sb, cast_voice_map)
out: dict = {}
notes: list = []
updated = 0
shots = list(getattr(sb, "shots", []) or [])
for idx, shot in enumerate(shots, 1):
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
text = (getattr(shot, "text", "") or "").strip()
if not text:
continue
vid = pick_voice_for_shot(shot, voice_map)
mp3 = work / f"{sid}.mp3"
if not (mp3.exists() and mp3.stat().st_size > 1000): # 已有则复用
synthesize_line(text, vid, str(mp3), api_key=api_key)
adur = probe_audio_duration(str(mp3))
if adur <= 0:
notes.append(f"{sid}: 音频时长探测失败,保持原时长")
continue
old = float(getattr(shot, "duration", 5) or 5)
new = max(min_dur, min(max_dur, round(adur + pad, 2)))
shot.duration = new
if abs(new - old) > 0.01:
updated += 1
notes.append(f"{sid}: {old}s → {new}s(音频 {adur:.2f}s · {vid})")
out[sid] = {"audio": str(mp3), "audio_dur": round(adur, 2),
"old_dur": old, "new_dur": new, "voice_id": vid,
"has_cast": bool([s for s in (getattr(shot, "cast_slots", None) or []) if s])}
if progress_cb:
progress_cb("tts", sid, "done", f"{adur:.1f}s → 镜头 {new}s")
log.info(" 🎙 音频先行:%d/%d 个镜头时长按台词音频调整", updated, len(shots))
return {"updated": updated, "shots": out, "notes": notes, "voice_map": voice_map}
def build_voice_track(sb, out_path: str, work_dir: str,
api_key: str = "",
cast_voice_map: dict | None = None,
use_ambience: bool = True,
ambience_volume: float = DEFAULT_AMBIENCE_VOLUME,
progress_cb: ProgressCB = None,
reuse_dir: str = "") -> dict:
"""按分镜时间轴合成完整音轨
返回 {output, segments, voice_seconds, ambience_hits, voices:[...], notes:[...]}
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.config import load_config
cfg = load_config()
api_key = api_key or (cfg.get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise RuntimeError("未配置百炼 API Key(TTS 需要)")
voice_map = resolve_voice_map(sb, cast_voice_map)
amb_assets = load_ambience_assets() if use_ambience else []
sfx_assets = load_sfx_assets() # 音效库(非语言发声:惨叫/闷哼/关门…)
log.info("配音: 音色映射 %s | 环境音库 %d 条 | 音效库 %d 条",
voice_map, len(amb_assets), len(sfx_assets))
segs: list[str] = []
notes: list[str] = []
voice_seconds = 0.0
amb_hits = 0
sfx_hits = 0
# ★ 分轨(用户 2026-10-03 提出的声音模型):
# · 台词轨(dialogue):有出场人物的镜头 → 与画面强绑定(还要用于对口型)
# · 旁白轨(narration):无出场人物 → 独立一条声轨,允许与画面有轻微差异
dialogue_segs: list[str] = []
narration_segs: list[str] = []
shots = list(getattr(sb, "shots", []) or [])
for idx, shot in enumerate(shots, 1):
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
dur = float(getattr(shot, "duration", 3) or 3)
text = (getattr(shot, "text", "") or "").strip()
if progress_cb:
progress_cb("dub", sid, "running", f"{idx}/{len(shots)}")
voice_path = ""
if text:
vid = pick_voice_for_shot(shot, voice_map)
# ★ 优先复用「音频先行」已合成的音频(不重复 TTS = 不重复计费)
_pre = Path(reuse_dir) / f"{sid}.mp3" if reuse_dir else None
if _pre and _pre.exists() and _pre.stat().st_size > 1000:
raw = _pre
notes.append(f"{sid}: 复用先行音频")
else:
raw = work / f"tts_{idx:02d}.mp3"
synthesize_line(text, vid, str(raw), api_key=api_key)
try:
fitted = work / f"voice_{idx:02d}.wav"
info = fit_audio_to_duration(str(raw), str(fitted), dur)
voice_path = str(fitted)
voice_seconds += min(info["src_dur"], dur)
notes.append(f"{sid}: {vid} · {info['action']}({len(text)}字)")
except Exception as e:
log.warning(" ⚠️ %s 台词合成失败: %s", sid, e)
notes.append(f"{sid}: 台词合成失败({str(e)[:40]})")
voice_path = ""
# 环境音(该镜头的 audio_cue)
amb_path = ""
if amb_assets:
_a = getattr(shot, "audio", None)
cue = ""
if _a is not None:
cue = (_a.get("audio_cue", "") if isinstance(_a, dict)
else getattr(_a, "audio_cue", "")) or ""
cue = str(cue).strip()
if cue:
amb_path = match_ambience(cue, amb_assets)
if amb_path:
amb_hits += 1
notes.append(f"{sid}: 环境音「{cue}」匹配 ✓")
# 音效(非语言发声):按 desc 匹配素材,按 at_second 插入该镜头内
sfx_items: list = []
if sfx_assets:
_a = getattr(shot, "audio", None)
raw_sfx = []
if _a is not None:
raw_sfx = ((_a.get("sfx", []) if isinstance(_a, dict)
else getattr(_a, "sfx", [])) or [])
if isinstance(raw_sfx, (str, dict)):
raw_sfx = [raw_sfx]
for s in raw_sfx:
if isinstance(s, dict):
desc = str(s.get("desc") or s.get("name") or "").strip()
at = float(s.get("at_second") or 0)
vol = float(s.get("volume") or DEFAULT_SFX_VOLUME)
else:
desc, at, vol = str(s or "").strip(), 0.0, DEFAULT_SFX_VOLUME
if not desc:
continue
f = match_sfx(desc, sfx_assets)
if f:
sfx_items.append((f, at, vol))
sfx_hits += 1
notes.append(f"{sid}: 音效「{desc}」@{at}s ✓")
else:
notes.append(f"{sid}: 音效「{desc}」未匹配到素材(音效库为空?)")
# ★ 分轨:判断这一段是「角色台词」还是「旁白」
# 台词(有出场人物)→ 主轨;旁白(无出场人物)→ 单独轨(不重复放进主轨)
_slots = [x for x in (getattr(shot, "cast_slots", None) or []) if x]
is_narration = bool(text) and not _slots
voice_for_main = "" if is_narration else voice_path
seg = work / f"seg_{idx:02d}.wav"
mix_segment(voice_for_main, amb_path, dur, str(seg),
ambience_volume=ambience_volume, sfx_items=sfx_items)
segs.append(str(seg))
# 旁白轨:该镜头是旁白 → 只放人声;否则留空(拼接时补等长静音)
if is_narration:
narr_seg = work / f"narr_{idx:02d}.wav"
try:
mix_segment(voice_path, "", dur, str(narr_seg))
narration_segs.append(str(narr_seg))
notes.append(f"{sid}: 旁白(单独轨)")
except Exception as e:
log.warning(" ⚠️ %s 旁白轨生成失败: %s", sid, e)
narration_segs.append("")
else:
narration_segs.append("")
if progress_cb:
progress_cb("dub", sid, "done",
"旁白" if is_narration else ("角色台词" if text else "无台词"))
concat_audio(segs, out_path)
# ★ 旁白轨(独立输出):无旁白的镜头补等长静音,保证与主轨时间轴对齐
narr_out = ""
if any(narration_segs):
narr_parts = []
for i, (n, shot) in enumerate(zip(narration_segs, shots), 1):
if n:
narr_parts.append(n)
else:
dur_i = float(getattr(shot, "duration", 3) or 3)
sil = work / f"narr_sil_{i:02d}.wav"
try:
if not sil.exists():
make_silence(dur_i, str(sil))
narr_parts.append(str(sil))
except Exception:
pass
if narr_parts:
narr_out = str(Path(out_path).with_name("narration_track.wav"))
try:
concat_audio(narr_parts, narr_out)
log.info(" 🎙 旁白轨单独输出: %s(%d 段)",
Path(narr_out).name, len([x for x in narration_segs if x]))
except Exception as e:
log.warning(" ⚠️ 旁白轨拼接失败: %s", e)
narr_out = ""
total = sum(float(getattr(s, "duration", 3) or 3) for s in shots)
log.info(" 🎙 配音完成: %s(%d 段 / %.1fs 音轨)", Path(out_path).name, len(segs), total)
return {"output": out_path, "narration": narr_out, "segments": len(segs),
"voice_seconds": round(voice_seconds, 2), "ambience_hits": amb_hits,
"sfx_hits": sfx_hits,
"narration_segments": len([x for x in narration_segs if x]),
"total_seconds": round(total, 2), "voices": voice_map, "notes": notes}
# ── CLI 自测:从已保存分镜生成音轨 ──
if __name__ == "__main__":
import argparse, json
ap = argparse.ArgumentParser(description="从分镜生成配音音轨(有 TTS 费用)")
ap.add_argument("--storyboard", required=True, help="分镜 JSON 路径或 project_id")
ap.add_argument("--output", default="", help="输出 wav 路径")
ap.add_argument("--no-ambience", action="store_true", help="不使用环境音")
args = ap.parse_args()
p = Path(args.storyboard)
if not p.exists():
p = Path.home() / "workbuddy-agent-os/agent-local/runtime/ave/storyboards" / f"{args.storyboard}.json"
data = json.loads(p.read_text(encoding="utf-8"))
from schemas import Storyboard
sb = Storyboard.from_dict(data)
out = args.output or str(Path.home() / "workbuddy-agent-os/agent-local/runtime/ave/dub_test.wav")
r = build_voice_track(sb, out, str(Path(out).parent / "dub_work"),
use_ambience=not args.no_ambience)
print(json.dumps({k: v for k, v in r.items() if k != "notes"}, ensure_ascii=False, indent=1))
for n in r["notes"]:
print(" ·", n)