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

679 lines
25 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 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)