移植自 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 带网关原话 / 正常返回)。
608 lines
26 KiB
Python
608 lines
26 KiB
Python
"""
|
||
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)
|