Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/composer/hybrid.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

458 lines
15 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.
"""
AVE hybrid — 口播+卡点融合管线
功能:
将口播的语音锚点驱动画面切换 + 卡点的BGM能量驱动变速结合起来。
人声决定"什么时候切画面", BGM决定"画面怎么动"。
流程:
1. 检测人声锚点 (静音段 → 段边界)
2. 检测 BGM 节拍 + 能量特征
3. 混音 (人声+BGM, BGM 音量避让)
4. 为每段分配素材 + 能量驱动变速
5. 渲染每段 + 字幕叠加
6. 帧锁定拼接
用法:
from composer.hybrid import compose_hybrid
compose_hybrid(
voice_path="narration.wav",
bgm_path="bgm.wav",
output_path="hybrid.mp4",
texts=["第一段文案", "第二段文案", ...],
)
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("hybrid")
def compose_hybrid(
voice_path: str,
bgm_path: str,
output_path: str = "hybrid.mp4",
material_clips: list[str] | None = None,
resolution: str = "1080x1920",
texts: list[str] | None = None,
pexels_api_key: str = "",
pexels_search: str = "",
# 口播锚点参数
min_silence_sec: float = 0.15,
voice_volume: float = 1.0,
bgm_volume: float = 0.35,
# 卡点速度参数
enable_speed_ramp: bool = True,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
# 节拍分组
group_size: int = 4,
) -> str:
"""
口播+卡点融合管线
参数:
voice_path: 人声 WAV 路径
bgm_path: BGM 音频路径
output_path: 输出视频路径
material_clips: 素材视频/图片列表 (可选)
resolution: 输出分辨率
texts: 每段文字 (用于字幕, 可选)
pexels_api_key: Pexels API Key (素材不够时补充)
pexels_search: Pexels 搜索关键词
min_silence_sec: 最小静音时长 (秒), 用于分割段
voice_volume: 人声音量 (默认 1.0)
bgm_volume: BGM 音量 (默认 0.35)
enable_speed_ramp: 是否启用能量驱动变速
base_speed: 基础速度
high_speed: 高能段速度
low_speed: 低能段速度
group_size: BGM 节拍分组数
返回: output_path
"""
logger.info("=== 口播+卡点融合模式 ===")
logger.info(f"人声: {voice_path}")
logger.info(f"BGM: {bgm_path}")
# ── 1. 检测人声锚点 ──
from anchor_extractor.extractor import get_silence_periods
silences = get_silence_periods(voice_path, min_silence_sec=min_silence_sec)
# 获取人声总时长
voice_duration = _get_duration(voice_path)
# 从静音段反推活跃段 (说话段)
voice_segments = _silences_to_segments(silences, voice_duration)
logger.info(f" 锚点检测: {len(silences)} 个静音 → {len(voice_segments)} 个说话段")
# ── 2. 检测 BGM 节拍 (含能量) ──
from composer.beat_sync import detect_beats
beat_info = detect_beats(bgm_path, group_size=group_size, enable_energy=enable_speed_ramp)
bgm_segments = beat_info["segments"]
logger.info(f" BPM: {beat_info['bpm']:.0f}, {beat_info['beat_count']}拍, {len(bgm_segments)}组")
# ── 3. 混音 (人声+BGM, 避让) ──
temp_dir = tempfile.mkdtemp(prefix="ave_hybrid_")
mixed_audio = os.path.join(temp_dir, "mixed.wav")
# BGM 避让: 说话时压低BGM
mixed_audio = _duck_and_mix(
voice_path, bgm_path, voice_segments, mixed_audio,
voice_volume=voice_volume, bgm_volume=bgm_volume,
)
# ── 4. 准备素材 ──
clips = list(material_clips) if material_clips else []
if len(clips) < len(voice_segments) and pexels_api_key:
from material_producer.pexels.search import search_videos
needed = len(voice_segments) - len(clips)
logger.info(f" 素材不足, 从 Pexels 搜索 {needed} 个...")
kw = pexels_search or "background video"
found = search_videos(kw, count=needed, api_key=pexels_api_key, orientation="portrait")
for f in found:
clips.append(f["path"])
logger.info(f" 共 {len(clips)} 个素材")
if not clips:
raise ValueError("无可用素材")
# ── 5. 为每段分配素材 + 计算速度 ──
# 将 BGM 节拍能量映射到每个说话段上
fps = 30
seg_plans = [] # [(clip_path, adjusted_dur, speed), ...]
for idx, vseg in enumerate(voice_segments):
clip = clips[idx % len(clips)]
v_start = vseg["start"]
v_end = vseg["end"]
v_dur = v_end - v_start
if v_dur < 0.5:
continue
# 在该时间段内计算 BGM 平均能量
avg_energy = _average_energy_in_range(bgm_segments, v_start, v_end)
speed = base_speed
if enable_speed_ramp:
if avg_energy > 0.6:
speed = base_speed + (high_speed - base_speed) * min((avg_energy - 0.6) / 0.4, 1.0)
elif avg_energy < 0.3:
speed = low_speed + (base_speed - low_speed) * (avg_energy / 0.3)
speed = max(0.5, min(2.0, speed))
# 帧锁定时长
frames = round(v_dur * fps)
dur_aligned = frames / fps
seg_plans.append({
"clip": clip,
"start": v_start,
"end": v_end,
"duration": dur_aligned,
"speed": speed,
"energy": avg_energy,
})
logger.info(f" 分配 {len(seg_plans)} 段素材"
+ (f", 变速范围 {low_speed:.1f}~{high_speed:.1f}x" if enable_speed_ramp else ""))
# ── 6. 渲染每段 ──
seg_files = []
for idx, plan in enumerate(seg_plans):
clip = plan["clip"]
dur = plan["duration"]
speed = plan["speed"]
mt = dur / max(speed, 0.01) # 素材需要的时间长度
seg_file = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
# 从素材中截取一段 (考虑变速)
clip_dur = _get_duration(clip)
offset = (plan["start"] * 17) % max(clip_dur - mt, 0.1) if clip_dur > mt else 0
raw_seg = os.path.join(temp_dir, f"seg_{idx:03d}_raw.mp4")
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset),
"-i", clip,
"-t", str(mt),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps={fps}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-an", raw_seg,
], capture_output=True, timeout=120, check=True)
if speed != 1.0 and 0.5 <= speed <= 2.0:
subprocess.run([
FFMPEG, "-y", "-i", raw_seg,
"-filter_complex", f"setpts={1/speed}*PTS[v]",
"-map", "[v]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p", seg_file,
], capture_output=True, timeout=120, check=True)
else:
os.replace(raw_seg, seg_file)
seg_files.append(seg_file)
if not seg_files:
raise RuntimeError("渲染失败: 无有效段")
# ── 7. 拼接 ──
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
else:
_concat_hybrid_segments(seg_files, mixed_audio, output_path, seg_plans, fps)
# ── 8. 字幕叠加 ──
if texts and len(texts) > 0 and seg_plans:
_add_text_subtitles(output_path, texts, seg_plans, resolution)
# ── 9. 清理 ──
shutil.rmtree(temp_dir, ignore_errors=True)
final_size = os.path.getsize(output_path) / 1024 / 1024
logger.info(f"✅ 融合完成: {output_path} ({final_size:.1f}MB, "
f"{len(seg_files)} 段{', 能量变速' if enable_speed_ramp else ''})")
return output_path
# ── 内部辅助函数 ──────────────────────────────────────────
def _silences_to_segments(silences: list[dict], total_duration: float) -> list[dict]:
"""从静音段反推人声活跃段"""
if not silences:
return [{"start": 0.0, "end": total_duration}]
segments = []
prev_end = 0.0
for s in silences:
s_start = s.get("start", 0)
s_end = s.get("end", 0)
if s_start > prev_end + 0.1:
segments.append({"start": prev_end, "end": s_start})
prev_end = s_end
if prev_end < total_duration - 0.3:
segments.append({"start": prev_end, "end": total_duration})
if not segments:
return [{"start": 0.0, "end": total_duration}]
return segments
def _average_energy_in_range(bgm_segments: list[dict], start: float, end: float) -> float:
"""计算时间范围 [start, end] 内 BGM 的平均能量"""
energies = []
for seg in bgm_segments:
seg_start = seg["start"]
seg_end = seg["end"]
# 检查重叠
if seg_end < start or seg_start > end:
continue
energy = seg.get("energy", 0.5)
overlap = min(seg_end, end) - max(seg_start, start)
if overlap > 0:
energies.append((energy, overlap))
if not energies:
return 0.5
# 加权平均 (按重叠时长)
total_weight = sum(w for _, w in energies) or 1.0
avg = sum(e * w for e, w in energies) / total_weight
return max(0.0, min(1.0, avg))
def _duck_and_mix(
voice_path: str,
bgm_path: str,
voice_segments: list[dict],
output_path: str,
voice_volume: float = 1.0,
bgm_volume: float = 0.35,
) -> str:
"""
BGM 音量避让混音:
- 说话时: BGM 音量降到 bgm_volume * 0.4
- 间隙时: BGM 恢复到 bgm_volume
"""
import numpy as np
import soundfile as sf
# 加载音频
voice, sr_v = sf.read(voice_path)
bgm, sr_b = sf.read(bgm_path)
# 重采样 BGM 到相同 sr
if sr_b != sr_v:
import librosa
bgm = librosa.resample(bgm, orig_sr=sr_b, target_sr=sr_v)
# 补齐 BGM 到人声长度
if len(bgm) < len(voice):
repeats = int(np.ceil(len(voice) / len(bgm)))
bgm = np.tile(bgm, repeats)
bgm = bgm[:len(voice)]
# 创建音量包络: 说话段压低 BGM
bgm_envelope = np.ones(len(voice))
for seg in voice_segments:
s = int(seg["start"] * sr_v)
e = int(seg["end"] * sr_v)
bgm_envelope[s:e] = 0.4 # 说话时 BGM 降到 40%
# 平滑过渡 (0.05s fade)
fade_samples = int(0.05 * sr_v)
for i in range(1, len(bgm_envelope) - fade_samples * 2):
if bgm_envelope[i] != bgm_envelope[i - 1]:
# 在变化点做淡变
start = max(0, i - fade_samples)
end = min(len(bgm_envelope), i + fade_samples)
for j in range(start, end):
frac = (j - start) / max(end - start, 1)
bgm_envelope[j] = bgm_envelope[start] + frac * (bgm_envelope[end - 1] - bgm_envelope[start])
# 混音
voice_float = voice.astype(np.float32)
bgm_float = bgm.astype(np.float32)
# 归一化到 [-1, 1]
voice_float /= max(np.max(np.abs(voice_float)), 0.001)
bgm_float /= max(np.max(np.abs(bgm_float)), 0.001)
mixed = voice_float * voice_volume + bgm_float * bgm_volume * bgm_envelope
# 限制防止爆音
peak = np.max(np.abs(mixed))
if peak > 0.99:
mixed /= peak * 1.02
sf.write(output_path, mixed, sr_v)
logger.debug(f" 混音完成: {output_path} ({sr_v}Hz, {len(voice)/sr_v:.1f}s)")
return output_path
def _concat_hybrid_segments(
seg_files: list[str],
mixed_audio: str,
output_path: str,
seg_plans: list[dict],
fps: int = 30,
) -> str:
"""帧锁定拼接混合音频"""
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return output_path
# concat demuxer 拼接视频
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
video_only = output_path.replace(".mp4", "_novideo.mp4")
cmd = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", "-an", video_only]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
# 叠加混合音频
cmd2 = [FFMPEG, "-y", "-i", video_only, "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(concat_file)
if os.path.exists(video_only):
os.unlink(video_only)
logger.info(f" ✅ 拼接完成: {len(seg_files)} 段")
return output_path
def _add_text_subtitles(
video_path: str,
texts: list[str],
seg_plans: list[dict],
resolution: str,
):
"""为每段叠加文字字幕 (ASS 格式)"""
width, height = (int(x) for x in resolution.split("x"))
font_size = max(48, int(height * 0.05))
margin_v = int(height * 0.3)
lines = [
"[Script Info]", "ScriptType: v4.00+",
f"PlayResX: {width}", f"PlayResY: {height}",
"ScaledBorderAndShadow: yes", "",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
f"Style: Default,Noto Sans SC,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,100,100,{margin_v},1",
"", "[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
]
for i, text in enumerate(texts):
if i >= len(seg_plans):
break
start = seg_plans[i]["start"]
dur = seg_plans[i]["duration"]
end = start + dur
def _fmt_ts(sec: float) -> str:
h = int(sec // 3600)
m = int((sec % 3600) // 60)
s = sec % 60
return f"{h}:{m:02d}:{s:05.2f}"
lines.append(
f"Dialogue: 0,{_fmt_ts(start)},{_fmt_ts(end)},Default,,0,0,0,,{text}"
)
ass_path = video_path.replace(".mp4", "_hybrid_text.ass")
with open(ass_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
tmp = video_path.replace(".mp4", "_tmp.mp4")
subprocess.run([
FFMPEG, "-y", "-i", video_path,
"-vf", f"subtitles={ass_path}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "copy", "-pix_fmt", "yuv420p", tmp,
], capture_output=True, timeout=120, check=True)
os.replace(tmp, video_path)
os.remove(ass_path)
logger.info(f" ✅ 字幕叠加: {len(texts)} 段")
def _get_duration(path: str) -> float:
"""获取媒体文件时长(秒)
用 lib.ffmpeg.probe_duration(ffmpeg -i 解析)——
本机 ffprobe 实为 ffmpeg 的符号链接,不支持 -show_entries(会静默失败)
"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return d if d > 0 else 30.0
except Exception:
return 30.0