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,699 @@
|
||||
"""
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user