feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,678 @@
"""
AVE 06_composer/beat_sync — BGM 节拍驱动的卡点视频合成
流程:
1. BGM 节拍检测 (librosa beat_track)
2. 按 N拍 分组 (默认 4拍/组)
3. 每组分配素材 (Pexels搜索 或 用户提供)
4. 精确裁剪到组时长
5. xfade 过渡拼接 (≤8组避免 FFmpeg 崩溃)
6. 文字/字幕叠加
用法:
from composer.beat_sync import compose_beat_sync
compose_beat_sync(bgm_path="bgm.wav", output="beat_sync.mp4")
"""
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("beat_sync")
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache")))
BEAT_CACHE = CACHE_DIR / "beat_sync"
BEAT_CACHE.mkdir(parents=True, exist_ok=True)
def detect_beats(
audio_path: str,
group_size: int = 4,
enable_energy: bool = False,
detect_structure: bool = False,
) -> dict:
"""
检测 BGM 节拍
参数:
audio_path: BGM 音频路径
group_size: 每组节拍数 (默认4拍/组)
enable_energy: 是否提取 RMS/频谱能量特征 (用于变速卡点)
detect_structure: 是否检测歌曲结构 (Intro/Verse/Chorus/Bridge/Outro)
返回:
{
"bpm": 120.0,
"beat_times": [0.0, 0.5, 1.0, ...],
"segments": [{"start": 0.0, "end": 2.0, "beats": 4, "energy": 0.8, ...}, ...],
"structure": [{"label": "Intro", "start": 0.0, "end": 8.0, ...}],
"total_duration": 30.0,
}
"""
import librosa
import numpy as np
y, sr = librosa.load(audio_path, sr=22050, mono=True)
duration = librosa.get_duration(y=y, sr=sr)
# 节拍检测
tempo_arr, beats = librosa.beat.beat_track(y=y, sr=sr)
tempo_val = float(tempo_arr.item() if hasattr(tempo_arr, 'item') else tempo_arr)
beat_times = librosa.frames_to_time(beats, sr=sr).tolist()
# 如果没检测到拍点, 按 BPM 估算
if len(beat_times) < 2:
bpm = tempo_val if tempo_val > 0 else 120.0
interval = 60.0 / bpm
beat_times = [i * interval for i in range(int(duration / interval))]
logger.info(f" 无拍点, 按 BPM={bpm:.0f} 估算 {len(beat_times)} 拍")
else:
logger.info(f" 检测到 BPM={tempo_val:.1f}, {len(beat_times)} 拍")
# 提取 RMS 和频谱能量
rms_energy = None
spectral_features = None
if enable_energy:
# 用短时傅立叶提取帧级能量
hop_length = 512
frame_dur = hop_length / sr
rms = librosa.feature.rms(y=y, hop_length=hop_length)[0]
rms_energy = rms.tolist()
logger.debug(f" 能量帧: {len(rms_energy)} 帧, 帧时长={frame_dur:.3f}s")
# 频谱 flux
spec = np.abs(librosa.stft(y, hop_length=hop_length))
flux = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
spectral_features = {
"rms": rms_energy,
"flux": flux.tolist() if hasattr(flux, 'tolist') else [],
"hop_length": hop_length,
"frame_duration": frame_dur,
}
# 歌曲结构检测
song_structure = None
if detect_structure:
song_structure = _detect_song_structure(y, sr, duration)
if song_structure:
logger.info(f" 歌曲结构: {len(song_structure)} 段")
for s in song_structure:
logger.info(f" {s['label']:8s} {s['start']:.0f}s→{s['end']:.0f}s")
# 按 N拍 分组
segments = []
max_beats_per_seg = 0
for i in range(0, len(beat_times), group_size):
start = beat_times[i]
end = beat_times[min(i + group_size, len(beat_times) - 1)]
actual_beats = min(group_size, len(beat_times) - i)
if end - start > 0.3:
seg = {
"start": round(start, 2),
"end": round(end, 2),
"duration": round(end - start, 2),
"beats": actual_beats,
}
# 计算该段平均能量
if enable_energy and rms_energy is not None and spectral_features:
seg["energy"] = _segment_energy(start, end, spectral_features)
segments.append(seg)
max_beats_per_seg = max(max_beats_per_seg, actual_beats)
# 最后一段: 最后一个拍点到音频结束
if segments and segments[-1]["end"] < duration - 0.3:
seg = {
"start": segments[-1]["end"],
"end": round(duration, 2),
"duration": round(duration - segments[-1]["end"], 2),
"beats": 0,
}
if enable_energy and spectral_features:
seg["energy"] = _segment_energy(seg["start"], seg["end"], spectral_features)
segments.append(seg)
# 限制 ≤8 组 (xfade 链限制)
if len(segments) > 8:
keep = segments[:7]
remaining = segments[7:]
merged = {
"start": keep[-1]["end"],
"end": round(duration, 2),
"duration": round(duration - keep[-1]["end"], 2),
"beats": sum(s["beats"] for s in remaining),
}
if enable_energy and "energy" in remaining[0]:
merged["energy"] = sum(
s.get("energy", 0) * s["duration"] for s in remaining
) / max(merged["duration"], 0.1)
keep.append(merged)
segments = keep
logger.info(f" 合并为 {len(segments)} 组 (≤8)")
result = {
"bpm": tempo_val,
"beat_count": len(beat_times),
"beat_times": [round(float(t), 3) for t in beat_times], # 文档承诺的字段(此前漏返回)
"segments": segments,
"total_duration": round(duration, 2),
}
if song_structure:
result["structure"] = song_structure
if spectral_features:
result["spectral"] = spectral_features
return result
def _segment_energy(start_sec: float, end_sec: float,
spectral: dict) -> float:
"""计算某时间段的平均归一化能量 (基于 RMS + Flux)"""
frame_dur = spectral["frame_duration"]
rms = spectral.get("rms", [])
flux = spectral.get("flux", [])
if not rms:
return 0.5
start_frame = int(start_sec / max(frame_dur, 0.001))
end_frame = int(end_sec / max(frame_dur, 0.001))
start_frame = max(0, min(start_frame, len(rms) - 1))
end_frame = max(start_frame + 1, min(end_frame, len(rms)))
seg_rms = rms[start_frame:end_frame]
if not seg_rms:
return 0.5
avg_rms = sum(seg_rms) / len(seg_rms) if seg_rms else 0
avg_flux = sum(flux[start_frame:end_frame]) / len(seg_rms) if flux and len(seg_rms) > 0 else 0
# 归一化: 用全局均值和 std
rms_mean = sum(rms) / len(rms) if rms else 0.1
rms_std = (sum((v - rms_mean)**2 for v in rms) / len(rms)) ** 0.5 if rms and len(rms) > 1 else rms_mean
rms_std = max(rms_std, rms_mean * 0.01)
energy = (avg_rms - rms_mean) / rms_std * 0.3 + 0.5
return max(0.0, min(1.0, energy))
def _detect_song_structure(y, sr, duration) -> list[dict]:
"""
检测歌曲结构段落
基于 chroma 特征 + 自相似矩阵 + 时序聚类,
输出: Intro / Verse / Chorus / Bridge / Outro
"""
import numpy as np
import librosa
try:
# 提取 chroma 特征
hop_length = 2048
chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=hop_length)
frame_dur = hop_length / sr
# 自相似矩阵 + 递归分割
ssm = librosa.segment.recurrence_matrix(chroma, axis=0)
bounds = librosa.segment.agglomerative(ssm, k=min(8, ssm.shape[0]))
bounds_frames = librosa.segment.subsegment(ssm, bounds, n_segments=2)
# 转换为时间
boundaries = [0.0]
for bf in bounds_frames:
t = float(bf) * frame_dur
if t < duration - 1:
boundaries.append(round(t, 1))
boundaries.append(round(duration, 1))
# 按时间比例分配标签
labels = _label_structure(boundaries, duration)
result = []
for i in range(len(boundaries) - 1):
result.append({
"label": labels[i] if i < len(labels) else "Section",
"start": boundaries[i],
"end": boundaries[i + 1],
"duration": round(boundaries[i + 1] - boundaries[i], 1),
})
return result
except Exception as e:
logger.warning(f" 歌曲结构检测失败: {e}")
return []
def _label_structure(boundaries: list[float], total_duration: float) -> list[str]:
"""根据段落在歌曲中的位置分配典型结构标签"""
n = len(boundaries) - 1
labels = []
# 按位置比例分配
positions = []
for i in range(n):
mid = (boundaries[i] + boundaries[i + 1]) / 2 / max(total_duration, 1)
positions.append(mid)
for i, pos in enumerate(positions):
if i == 0 and pos < 0.05:
labels.append("Intro")
elif i >= n - 2 and pos > 0.85:
labels.append("Outro")
elif 0.15 <= pos <= 0.65:
# 很可能在 Chorus 区
labels.append("Chorus" if i % 2 == 1 else "Verse")
elif pos < 0.15:
labels.append("Verse")
elif pos < 0.4:
labels.append("Pre-Chorus" if i % 2 == 0 else "Chorus")
else:
labels.append("Bridge" if pos < 0.75 else "Outro")
return labels
def compose_beat_sync(
bgm_path: str,
output_path: str = "beat_sync.mp4",
material_clips: list[str] | None = None,
group_size: int = 4,
resolution: str = "1080x1920",
texts: list[str] | None = None,
pexels_api_key: str = "",
pexels_search: str = "",
enable_speed_ramp: bool = False,
frame_locked: bool = True,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
target_bpm: float = 0.0,
) -> str:
"""
BGM 节拍驱动的卡点视频合成 (V2: 能量感知 + 帧锁定)
参数:
bgm_path: BGM 音频路径
output_path: 输出视频路径
material_clips: 素材视频/图片列表 (可选, 不提供则 Pexels 搜索)
group_size: 每组节拍数
resolution: 输出分辨率
texts: 每段叠加的文字 (可选)
pexels_api_key: Pexels API Key
pexels_search: Pexels 搜索关键词
enable_speed_ramp: 是否启用能量驱动变速 (高能加速, 低能减速)
frame_locked: 是否启用帧锁定时间线 (消除 xfade 漂移)
base_speed: 基础速度 (默认 1.0)
high_speed: 高能段速度 (默认 1.5)
low_speed: 低能段速度 (默认 0.7)
target_bpm: 目标 BPM (>0 时用 AudioLine StemAligner 预处理 BGM)
返回: output_path
"""
if not os.path.exists(bgm_path):
raise FileNotFoundError(f"BGM 文件不存在: {bgm_path}")
# 0. BPM 标准化预处理 (使用 AudioLine StemAligner)
if target_bpm > 0:
try:
from audio_line.layer4_alignment.stem_aligner import StemAligner
import librosa
y_detect, sr_detect = librosa.load(bgm_path, sr=None, mono=True)
tempo_arr, _ = librosa.beat.beat_track(y=y_detect, sr=sr_detect)
source_bpm = float(tempo_arr.item() if hasattr(tempo_arr, 'item') else tempo_arr)
logger.info(f" BPM 标准化: {source_bpm:.0f} → {target_bpm:.0f}")
if source_bpm > 0 and abs(source_bpm - target_bpm) > 1.0:
aligner = StemAligner(method="phase_vocoder")
result = aligner.align_to_bpm(
stems={"bgm": bgm_path},
source_bpm=source_bpm,
target_bpm=target_bpm,
output_dir=str(BEAT_CACHE / "aligned"),
)
if result.aligned_paths:
bgm_path = result.aligned_paths["bgm"]
logger.info(f" ✅ 标准化完成: {bgm_path}")
else:
logger.info(f" ⏭️ 无需标准化 (相近 BPM)")
except ImportError:
logger.info(f" ⏭️ AudioLine 不可用, 跳过 BPM 标准化")
except Exception as e:
logger.warning(f" ⚠ BPM 标准化失败: {e} (继续使用原 BGM)")
# 1. 节拍检测 (启用能量)
logger.info("=== Beat-Sync 卡点模式 ===")
logger.info(f"BGM: {bgm_path}")
beat_info = detect_beats(bgm_path, group_size, enable_energy=enable_speed_ramp)
segments = beat_info["segments"]
total_dur = beat_info["total_duration"]
logger.info(f"BPM: {beat_info['bpm']}, {beat_info['beat_count']}拍, {len(segments)}组")
if enable_speed_ramp and "energy" in segments[0]:
energies = [s.get("energy", 0.5) for s in segments]
avg_e = sum(energies) / len(energies) if energies else 0.5
logger.info(f" 能量感知变速: ON (均值 {avg_e:.2f}, 范围 {min(energies):.2f}~{max(energies):.2f})")
# 2. 准备素材
clips = material_clips or []
if len(clips) < len(segments) and pexels_api_key:
from material_producer.pexels.search import search_videos
needed = len(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("无可用素材")
# 3. 为每组分配素材 (循环使用)
seg_materials = [] # [(clip_path, adjusted_start, adjusted_end, adjusted_duration, speed), ...]
for i, seg in enumerate(segments):
clip = clips[i % len(clips)]
# 计算本段速度 (能量驱动变速)
speed = base_speed
if enable_speed_ramp:
energy = seg.get("energy", 0.5)
if energy > 0.6:
speed = base_speed + (high_speed - base_speed) * min((energy - 0.6) / 0.4, 1.0)
elif energy < 0.3:
speed = low_speed + (base_speed - low_speed) * (energy / 0.3)
speed = max(0.5, min(2.0, speed))
# 调整时长 (变速后实际画面时长)
orig_dur = seg["duration"]
if frame_locked:
# 帧锁定: 以 BGM 节拍为准, 用 fps 对齐到整帧
fps = 30
orig_frames = round(orig_dur * fps)
adjusted_dur = orig_frames / fps # 帧对齐后的时长
else:
adjusted_dur = orig_dur
# 变速后的素材播放时长 = adjusted_dur / speed
# 即: 如果加速 (speed>1), 需要更短的素材
material_time_needed = adjusted_dur / speed if speed > 0 else adjusted_dur
seg_materials.append((clip, seg["start"], seg["end"],
adjusted_dur, speed, material_time_needed))
logger.info(f"分配素材: {len(seg_materials)} 段, {len(clips)} 素材循环"
+ (f", 变速范围 {low_speed:.1f}~{high_speed:.1f}x" if enable_speed_ramp else ""))
# 4. 帧锁定: 预计算精确的 xfade 偏移
if frame_locked:
fps = 30
# 精确计算每段的累计偏移
cumulative_frames = 0
for idx, (clip, start, end, dur, speed, mt) in enumerate(seg_materials):
seg_frames = round(dur * fps)
seg_materials[idx] = (clip, start, dur, speed, mt, seg_frames)
logger.info(f" 帧锁定: ON (30fps)")
# 5. 渲染每组为独立视频片段 (带变速)
temp_dir = tempfile.mkdtemp(prefix="ave_beat_")
seg_files = []
for idx in range(len(seg_materials)):
clip, start_orig, dur, speed, mt, *rest = seg_materials[idx]
if frame_locked:
seg_frames = rest[0] if rest else round(dur * 30)
# 帧对齐时长
dur_aligned = seg_frames / 30.0
else:
dur_aligned = dur
seg_frames = 0
seg_file = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
# 从素材中取一段 (考虑变速需要)
clip_dur = _get_duration(clip)
offset = (start_orig * 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=30",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-an", raw_seg,
], capture_output=True, timeout=120, check=True)
if speed != 1.0 and speed >= 0.5 and 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)
# 6. 使用帧锁定时间线拼接
if len(seg_files) == 1:
subprocess.run([FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", output_path],
capture_output=True, check=True, timeout=60)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
if frame_locked:
return _concat_frame_locked(seg_files, bgm_path, seg_materials,
output_path, texts, resolution, temp_dir)
else:
return _concat_xfade(seg_files, bgm_path, seg_materials,
output_path, texts, resolution, temp_dir)
def _concat_frame_locked(
seg_files: list[str],
bgm_path: str,
seg_materials: list,
output_path: str,
texts: list[str] | None,
resolution: str,
temp_dir: str,
) -> str:
"""帧锁定拼接: 用 concat demuxer 精确拼接, 无 xfade 漂移"""
if len(seg_files) == 1:
# 只有一段, 直接合并 BGM
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
# 用 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)
# 叠加 BGM
cmd2 = [FFMPEG, "-y", "-i", video_only, "-i", bgm_path,
"-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)
os.unlink(video_only)
logger.info(f" ✅ 帧锁定拼接完成: {len(seg_files)} 段")
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
def _concat_xfade(
seg_files: list[str],
bgm_path: str,
seg_materials: list,
output_path: str,
texts: list[str] | None,
resolution: str,
temp_dir: str,
) -> str:
"""xfade 过渡拼接: ≤8 段, 逐段交叉淡入淡出"""
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
# 构建 xfade 链
# xfade=transition=fade:duration=0.5:offset=offset1
# 每段重叠 0.5s, 累计偏移
filter_parts = []
inputs = []
offset = 0.0
prev_label = "v0"
for i in range(len(seg_files)):
inputs += ["-i", seg_files[i]]
if i == 0:
continue
# 前一段的时长
seg_dur = seg_materials[i - 1][2] if i - 1 < len(seg_materials) else 1.0
offset += float(seg_dur) - 0.5 # 减去过渡重叠
label = f"v{i}"
filter_parts.append(
f"[{prev_label}][{label}]xfade=transition=fade:duration=0.5:offset={offset:.2f}[v{i}]"
)
prev_label = f"v{i}"
# 最终输出标签是 v{len(seg_files)-1}
last_label = f"v{len(seg_files) - 1}"
full_filter = ";".join(filter_parts)
video_xfade = output_path.replace(".mp4", "_xfade.mp4")
cmd = [FFMPEG, "-y"] + inputs + [
"-filter_complex", full_filter,
"-map", f"[{last_label}]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p",
video_xfade,
]
try:
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
except subprocess.CalledProcessError as e:
logger.warning(f" xfade 失败, 回落 concat: {e.stderr[:200]}")
# 回落: 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")
cmd_fb = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", video_xfade]
subprocess.run(cmd_fb, capture_output=True, timeout=300, check=True)
os.unlink(concat_file)
# 叠加 BGM
cmd2 = [FFMPEG, "-y", "-i", video_xfade, "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(video_xfade)
logger.info(f" ✅ xfade 拼接完成: {len(seg_files)} 段")
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
def _finalize(
output_path: str,
texts: list[str] | None,
seg_materials: list,
resolution: str,
temp_dir: str,
) -> str:
"""最终处理: 叠加文字字幕 + 清理临时目录"""
import shutil
# 叠加文字
if texts and len(texts) > 0 and seg_materials:
_add_text_subtitles(output_path, texts, seg_materials, resolution)
logger.info(f" ✅ 文字叠加: {len(texts)} 段")
# 清理临时目录
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
logger.debug(f" 临时目录已清理: {temp_dir}")
return output_path
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
def _add_text_subtitles(video_path: str, texts: list[str],
seg_materials: list, 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_materials):
break
start = seg_materials[i][1]
end = seg_materials[i][2]
h = int(start); m = int((start - h) * 60); s = int(start - h * 3600 - m * 60)
he = int(end); me = int((end - he) * 60); se = int(end - he * 3600 - me * 60)
lines.append(
f"Dialogue: 0,{h}:{m:02d}:{s:02d}.00,{he}:{me:02d}:{se:02d}.00,Default,,0,0,0,,{text}"
)
ass_path = video_path.replace(".mp4", "_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)