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 带网关原话 / 正常返回)。
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user