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