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

700 lines
24 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 — FFmpeg 合成模块
职责:
1. 音频混音: 人声 + BGM → 混合音频
2. 视频合成: 素材片段拼接 + 画面过渡
3. 字幕叠加: ASS 格式中文字幕(带重音高亮)
4. 最终编码: 输出 final.mp4
5. 分段渲染: 3分钟以上视频自动分段
依赖:
- ffmpeg (需安装)
- Python: ffmpeg-python
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import asyncio
import json
import os
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("composer")
# ── 常量 ──────────────────────────────────────────────────
DEFAULT_RESOLUTION = "1080x1920" # 竖屏
DEFAULT_FPS = 30
DEFAULT_BITRATE = "8M"
SEGMENT_DURATION_MAX = 180 # 3分钟分段
# ── 公共 API ──────────────────────────────────────────────
def mix_audio(
voice_path: str,
bgm_path: Optional[str] = None,
output_path: str = "mixed_audio.wav",
voice_volume: float = 1.0,
bgm_volume: float = 0.3,
) -> str:
"""
混音: 人声 + BGM → 混合音频
- voice_path: 人声 WAV 路径
- bgm_path: BGM 路径 (None 则只输出人声)
- voice_volume: 人声音量倍率
- bgm_volume: BGM 音量倍率
- 返回: 混合音频路径
"""
if not os.path.exists(voice_path):
raise FileNotFoundError(f"人声文件不存在: {voice_path}")
if bgm_path and not os.path.exists(bgm_path):
logger.warning(f"BGM 文件不存在,跳过背景音乐: {bgm_path}")
bgm_path = None
if not bgm_path:
# 无人声时,直接复制人声
subprocess.run(
[FFMPEG, "-y", "-i", voice_path, "-acodec", "pcm_s16le", output_path],
capture_output=True, check=True
)
logger.info(f"音频已输出(无BGM): {output_path}")
return output_path
# 双轨混音
cmd = [
FFMPEG, "-y",
"-i", voice_path,
"-i", bgm_path,
"-filter_complex",
f"[0:a]volume={voice_volume}[v];[1:a]volume={bgm_volume}[b];[v][b]amix=inputs=2:duration=first[a]",
"-map", "[a]",
"-acodec", "pcm_s16le",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
logger.info(f"混音完成: {output_path}")
return output_path
def duck_bgm(
voice_path: str,
bgm_path: str,
output_path: str = "ducked_bgm.wav",
low_volume: float = 0.15,
high_volume: float = 0.50,
fade_ms: int = 300,
word_timestamps: list | None = None,
) -> str:
"""
BGM 音量避让 (Audio Ducking), 基于字级时间戳
说话时 BGM 压低 (low_volume), 间隙时恢复 (high_volume)。
使用 word_timestamps 精确控制每个字的避让区间,
比 silencedetect 更准确 (CosyVoice 连续语音无停顿).
参数:
word_timestamps: [{begin_time, end_time}, ...] 单位毫秒
来自 synthesize_with_timestamps()
"""
if not os.path.exists(voice_path):
raise FileNotFoundError(f"人声文件不存在: {voice_path}")
if not os.path.exists(bgm_path):
raise FileNotFoundError(f"BGM 文件不存在: {bgm_path}")
# 如果没有时间戳, 回退到固定音量
if not word_timestamps:
logger.info(" 无时间戳, 使用固定音量 0.25")
subprocess.run([
FFMPEG, "-y", "-i", bgm_path,
"-af", f"volume={high_volume}",
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=30)
return output_path
duration = _get_media_duration(voice_path)
total_ms = duration * 1000
fade_ms_f = fade_ms / 1000.0
logger.info(f"BGM 避让: {low_volume}→{high_volume}, 基于 {len(word_timestamps)} 个字")
# 构建音量区间: [(start_sec, end_sec, volume), ...]
# 每个字的区间: 字开始前 fade_ms 开始压低, 字结束后 fade_ms 恢复
segments = []
# 先确定所有活跃区间 (字的时间范围 + 过渡)
active_zones = [] # [(start, end), ...] 单位为秒
for w in word_timestamps:
w_start = max(0, (w["begin_time"] - 50) / 1000.0) # 字前 50ms
w_end = min(duration, (w["end_time"] + 50) / 1000.0) # 字后 50ms
active_zones.append((w_start, w_end))
# 合并重叠的活跃区间
active_zones.sort()
merged = []
for start, end in active_zones:
if merged and start <= merged[-1][1] + 0.05: # 50ms 内视为连续
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
# 构建音量分段: 活跃期低音量, 间隙期高音量
prev_end = 0.0
for a_start, a_end in merged:
# 间隙: prev_end → a_start (高音量)
if a_start > prev_end + 0.05:
gap_mid = (prev_end + a_start) / 2
segments.append((prev_end, gap_mid, high_volume))
segments.append((gap_mid, a_start, high_volume))
# 说话: a_start → a_end (低音量, 带过渡)
segments.append((a_start, a_end, low_volume))
prev_end = a_end
# 最后间隙
if duration > prev_end + 0.05:
segments.append((prev_end, duration, high_volume))
if not segments:
logger.info(" 无分段, 使用固定音量")
subprocess.run([
FFMPEG, "-y", "-i", bgm_path,
"-af", f"volume={high_volume}",
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=30)
return output_path
# 渲染每段再拼接
import tempfile, shutil
seg_dir = tempfile.mkdtemp(prefix="ave_duck_")
seg_wavs = []
for idx, (st, en, vol) in enumerate(segments):
dur = en - st
if dur < 0.05:
continue
seg_out = os.path.join(seg_dir, f"seg_{idx:03d}.wav")
fade_dur = min(fade_ms_f, dur / 3)
subprocess.run([
FFMPEG, "-y",
"-ss", str(st), "-i", bgm_path,
"-t", str(dur),
"-af", f"volume={vol},afade=t=in:d={fade_dur},afade=t=out:st={dur-fade_dur}:d={fade_dur}",
"-acodec", "pcm_s16le", seg_out,
], capture_output=True, timeout=60, check=True)
seg_wavs.append(seg_out)
# 拼接
concat_file = _create_concat_list(seg_wavs)
if len(seg_wavs) == 1:
subprocess.run([FFMPEG, "-y", "-i", seg_wavs[0], "-acodec", "pcm_s16le", output_path],
capture_output=True, check=True)
else:
subprocess.run([
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=60)
shutil.rmtree(seg_dir, ignore_errors=True)
logger.info(f"BGM 避让完成: {output_path} ({len(seg_wavs)} 段)")
return output_path
def image_to_clip(
input_path: str,
output_path: str,
duration: float = 5.0,
resolution: str = DEFAULT_RESOLUTION,
ken_burns: bool = True,
) -> str:
"""
将静态图片转换为视频分镜(带 Ken Burns 动效)
处理横图→竖屏转换(如 1248×832→1080×1920),用于 AVE 流水线中
图片生成阶段→合成阶段之间的转换步骤。
策略:先按目标高度等比缩放填满画面,然后 crop 到目标宽度,
再添加 Ken Burns 式平滑平移动效。
"""
target_w, target_h = [int(x) for x in resolution.split("x")]
if ken_burns:
# 使用 cos 实现平滑左右平移: 从右到左的平滑摆动
filter_str = (
f"scale='max({target_w},iw*{target_h}/ih)':{target_h}:flags=lanczos,"
f"crop={target_w}:{target_h}:"
f"'(iw-{target_w})*(0.5-0.45*cos(PI*t/{duration}))':'0',"
f"setsar=1,fps={DEFAULT_FPS}"
)
else:
# 无动效:居中裁剪
filter_str = (
f"scale='max({target_w},iw*{target_h}/ih)':{target_h}:flags=lanczos,"
f"crop={target_w}:{target_h}:'(iw-{target_w})/2':'(ih-{target_h})/2',"
f"setsar=1,fps={DEFAULT_FPS}"
)
cmd = [
FFMPEG, "-y",
"-loop", "1", "-t", str(duration),
"-i", input_path,
"-vf", filter_str,
"-c:v", "libx264", "-preset", "medium", "-crf", "23",
"-pix_fmt", "yuv420p", "-an",
output_path,
]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
logger.info(f"图片→视频分镜: {os.path.basename(input_path)} → {os.path.basename(output_path)} ({duration}s)")
return output_path
def _apply_ken_burns(input_path: str, output_path: str, resolution: str = DEFAULT_RESOLUTION) -> str:
"""
对视频素材施加随机 Ken Burns 效果(缩放+平移),让画面动起来。
策略:先获取原始尺寸,判断横竖屏,选择合适的缩放+裁切方案。
竖屏素材(720x1280等):放大后以宽度为基准裁切到1080。
"""
import random
duration = _get_media_duration(input_path)
if duration < 1.0:
duration = 5.0
target_w, target_h = [int(x) for x in resolution.split("x")]
# 获取原视频尺寸
try:
from lib.ffmpeg import probe_info
_info = probe_info(input_path)
src_w, src_h = _info.get("width") or target_w, _info.get("height") or target_h
except Exception:
src_w, src_h = target_w, target_h
# 判断方向
is_portrait = src_h > src_w # 竖屏
if is_portrait and src_w >= target_w:
# 竖屏且宽度够 → 只用缩放覆盖高度,平移只在竖直方向
zoom = random.uniform(1.10, 1.25)
d = f"max(t,0.001)/{max(duration,1)}"
trajectory = random.choice(["center_zoom", "pan_up", "pan_down"])
if trajectory == "center_zoom":
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})/2"
elif trajectory == "pan_up":
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})*(1-{d})"
else: # pan_down
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})*{d}"
kb_type = trajectory
else:
# 横屏或小尺寸 → 仅缩放填充,不做平移(避免负值)
zoom = max(target_w / max(src_w, 1), target_h / max(src_h, 1)) * 1.05
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})/2"
kb_type = "center"
filter_str = (
f"scale=iw*{zoom}:ih*{zoom}:flags=lanczos,"
f"crop={target_w}:{target_h}:{x}:{y},"
f"setsar=1,fps=30"
)
cmd = [
FFMPEG, "-y", "-i", input_path,
"-vf", filter_str,
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "25",
"-an", output_path,
]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
logger.debug(f"Ken Burns [{kb_type} @ {zoom:.2f}x] → {output_path}")
return output_path
def compose_video(
material_clips: list[str],
audio_path: str,
output_path: str = "final.mp4",
resolution: str = DEFAULT_RESOLUTION,
fps: int = DEFAULT_FPS,
transitions: list[str] | None = None,
subtitles_path: str | None = None,
total_duration: float | None = None,
ken_burns: bool = True,
) -> str:
"""
视频合成: 素材片段 + 音频 + 字幕 → final.mp4
- material_clips: 素材视频文件路径列表
- audio_path: 混音音频路径
- output_path: 输出文件路径
- transitions: 每段之间的过渡效果 (fade/cut/dissolve),长度=clips数-1
- subtitles_path: ASS/SRT 字幕文件路径 (可选)
- total_duration: 视频总时长(秒),用于裁剪素材匹配音频
- ken_burns: 是否对每个素材施加随机缩放/平移动效(默认开启)
**素材时长自动匹配策略**:
如果所有素材总时长 < 音频时长 → 末尾慢放/循环最后一段
如果素材总时长 > 音频时长 → 裁剪素材到恰好匹配
"""
if not material_clips:
raise ValueError("素材列表为空")
if not os.path.exists(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
# 1. 可选择对素材施加 Ken Burns 动效(逐素材预处理)
final_clips = material_clips
if ken_burns:
import tempfile, shutil
ken_dir = tempfile.mkdtemp(prefix="ave_kenburns_")
processed = []
for idx, clip in enumerate(material_clips):
out = os.path.join(ken_dir, f"kb_{idx:03d}.mp4")
_apply_ken_burns(clip, out, resolution)
processed.append(out)
final_clips = processed
logger.info(f"Ken Burns 已应用于 {len(final_clips)} 个素材")
# 2. 创建素材拼接文件列表
concat_file = _create_concat_list(final_clips)
# 3. 获取音频总时长
audio_duration = _get_media_duration(audio_path)
# 4. 构建 FFmpeg 命令
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-i", audio_path,
]
filters = []
# 5. 如果素材总时长 > 音频时长,裁剪
materials_duration = sum(_get_media_duration(c) for c in final_clips)
output_duration = audio_duration
# 6. 视频流处理(Ken Burns 已预处理,这里只做字幕叠加)
filters.append(f"[0:v]setpts=PTS-STARTPTS,fps={fps}[v]")
filters.append("[1:a]volume=1.0[a]")
# 7. 字幕叠加
if subtitles_path and os.path.exists(subtitles_path):
filters.append(f"[v]subtitles={subtitles_path}[vo]")
map_v = "[vo]"
else:
map_v = "[v]"
filter_complex = ";".join(filters)
cmd.extend(["-filter_complex", filter_complex])
cmd.extend(["-map", map_v, "-map", "[a]"])
# 8. 时长匹配
logger.info(f" 素材总时长: {materials_duration:.1f}s, 音频时长: {audio_duration:.1f}s")
if materials_duration < audio_duration:
logger.warning(f"素材不足,尾部会黑屏补齐")
cmd.extend(["-shortest"])
# 9. 编码参数
cmd.extend([
"-c:v", "libx264",
"-preset", "medium",
"-b:v", DEFAULT_BITRATE,
"-c:a", "aac",
"-b:a", "192k",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
output_path,
])
try:
subprocess.run(cmd, capture_output=True, timeout=600)
logger.info(f"视频合成完成: {output_path}")
except subprocess.TimeoutExpired:
logger.error("渲染超时(>600s),建议分段渲染")
raise
except subprocess.CalledProcessError as e:
logger.error(f"FFmpeg 执行失败: {e.stderr.decode(errors='replace')[:500]}")
raise
# 清理临时文件
if ken_burns:
shutil.rmtree(ken_dir, ignore_errors=True)
return output_path
def create_subtitles(
segments: list[dict],
output_path: str = "subtitles.ass",
resolution: tuple = (1080, 1920),
) -> str:
"""
从分段信息生成 ASS 格式字幕
segments 格式:
[{"text": "...", "start_sec": 0.0, "end_sec": 8.0, "emotion": "..."}]
"""
width, height = resolution
# 字幕位置: 由下往上 20% (竖屏 1080x1920)
margin_v = int(height * 0.2)
font_size = max(48, int(height * 0.044)) # 1920 → ~84px
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 seg in segments:
start = _sec_to_ass_time(seg.get("start_sec", 0))
end = _sec_to_ass_time(seg.get("end_sec", 8))
text = seg.get("text", "").replace("\n", "\\N")
lines.append(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}")
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
logger.info(f"字幕生成: {output_path} ({len(segments)} 段)")
return output_path
def segment_render(
material_clips: list[str],
audio_path: str,
output_dir: str = ".",
segment_duration: int = SEGMENT_DURATION_MAX,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
) -> list[str]:
"""
分段渲染: 长视频自动分段 → 分别渲染 → 返回分段文件列表
后续由 FFmpeg concat 拼接
"""
audio_duration = _get_media_duration(audio_path)
if audio_duration <= segment_duration:
# 无需分段
out = os.path.join(output_dir, "segment_000.mp4")
compose_video(material_clips, audio_path, out,
subtitles_path=subtitles_path,
resolution=resolution)
return [out]
# 需要分段
segments = []
for i in range(0, int(audio_duration), segment_duration):
start = i
end = min(i + segment_duration, audio_duration)
out = os.path.join(output_dir, f"segment_{i:03d}.mp4")
_render_segment(material_clips, audio_path, start, end, out,
subtitles_path=subtitles_path,
resolution=resolution)
segments.append(out)
logger.info(f"分段渲染完成: {len(segments)} 段")
return segments
def compose_with_anchors(
material_clips: list[str],
audio_path: str,
output_path: str,
silence_periods: list[dict],
total_duration: float,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
) -> str:
"""
锚点驱动的画面切换合成
在每段静音处切换素材片段,添加交叉淡变过渡。
素材按顺序循环使用,避免单一素材贯穿全片。
参数:
material_clips: 素材视频列表
audio_path: 混合音频路径
silence_periods: [{"start": s, "end": e, "duration": d}, ...]
total_duration: 音频总时长(秒)
"""
num_clips = len(material_clips)
if num_clips == 0:
raise ValueError("素材列表为空")
if not silence_periods:
logger.info("无锚点,回落普通合成")
return compose_video(material_clips, audio_path, output_path,
subtitles_path=subtitles_path, resolution=resolution)
import tempfile
# 1. 在静音中点附近切割素材段落
transitions = [0.0] # 每个切换点的时间(秒)
for s in silence_periods:
mid = (s["start"] + s["end"]) / 2
if mid > transitions[-1] + 0.5: # 至少间隔0.5s
transitions.append(mid)
transitions.append(total_duration)
# 2. 为每段分配素材(循环使用)
seg_info = [] # [(clip_path, start, end), ...]
clip_idx = 0
for i in range(len(transitions) - 1):
seg_start = transitions[i]
seg_end = transitions[i + 1]
dur = seg_end - seg_start
if dur < 0.3:
continue # 跳过过短段落
seg_info.append((material_clips[clip_idx % num_clips], seg_start, seg_end))
clip_idx += 1
logger.info(f"锚点分段: {len(seg_info)} 段, 使用 {min(clip_idx, num_clips)}/{num_clips} 个素材")
# 3. 渲染每段为独立视频
temp_dir = tempfile.mkdtemp(prefix="ave_anchor_")
seg_files = []
for idx, (clip, seg_s, seg_e) in enumerate(seg_info):
out = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
dur = seg_e - seg_s
# 从该素材中截取一段 (用素材的一部分,避免只用开头)
clip_dur = _get_media_duration(clip)
offset_in_clip = (seg_s * 15) % max(clip_dur, 1) # 取素材中不同位置
offset_in_clip = min(offset_in_clip, max(clip_dur - dur, 0))
# 用 FFmpeg 截取该素材的一段
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset_in_clip),
"-i", clip,
"-t", str(dur),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps=30",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
out,
], capture_output=True, timeout=120, check=True)
seg_files.append(out)
# 4. 简单 concat 拼接 (含交叉淡变的 xfade 链 >10段容易 FFmpeg 崩溃)
# 用 concat demuxer 直接拼接,素材切换本身已提供视觉变化
concat_list = _create_concat_list(seg_files)
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_list,
"-i", audio_path,
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=1".format(max(0, total_duration - 1)),
]
if subtitles_path and os.path.exists(subtitles_path):
cmd += ["-vf", f"subtitles={subtitles_path}"]
cmd += ["-shortest", "-c:v", "libx264", "-preset", "medium", "-b:v", DEFAULT_BITRATE,
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", output_path]
subprocess.run(cmd, capture_output=True, check=True, timeout=600)
# 清理
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info(f"锚点驱动合成完成: {output_path}")
return output_path
def concat_segments(segment_files: list[str], output_path: str = "final.mp4") -> str:
"""拼接分段视频"""
concat_file = _create_concat_list(segment_files)
subprocess.run([
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-c", "copy",
output_path,
], capture_output=True, check=True)
logger.info(f"分段拼接完成: {output_path}")
return output_path
# ── 内部工具 ──────────────────────────────────────────────
def _create_concat_list(file_paths: list[str]) -> str:
"""创建 FFmpeg concat 文件列表"""
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
)
for fp in file_paths:
abs_path = os.path.abspath(fp)
tmp.write(f"file '{abs_path}'\n")
tmp.close()
return tmp.name
def _get_media_duration(path: str) -> float:
"""获取媒体文件时长(秒) —— 用 lib.ffmpeg(本机 ffprobe 实为 ffmpeg 符号链接)"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
if d > 0:
return d
except Exception:
pass
logger.warning(f"无法获取时长,默认 30s: {path}")
return 30.0
def _sec_to_ass_time(seconds: float) -> str:
"""秒 → ASS 时间格式 H:MM:SS.cc"""
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
cs = int((seconds - int(seconds)) * 100)
return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
def _render_segment(
material_clips: list[str],
audio_path: str,
start_sec: float,
end_sec: float,
output_path: str,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
):
"""渲染单个分段(裁剪音频+对齐素材)"""
duration = end_sec - start_sec
# 用 concat 合并所有素材作为视频源
concat_file = _create_concat_list(material_clips)
filters = [
f"[0:v]setpts=PTS-STARTPTS,scale={resolution.replace('x',':')},fps=30[v]",
f"[1:a]atrim=start={start_sec}:duration={duration},asetpts=PTS-STARTPTS[a]",
]
if subtitles_path and os.path.exists(subtitles_path):
filters.append(f"[v]subtitles={subtitles_path}[vo]")
map_v = "[vo]"
else:
map_v = "[v]"
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-i", audio_path,
"-filter_complex", ";".join(filters),
"-map", map_v, "-map", "[a]",
"-shortest",
"-c:v", "libx264", "-preset", "medium", "-b:v", DEFAULT_BITRATE,
"-c:a", "aac", "-b:a", "192k",
"-pix_fmt", "yuv420p",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=600)