"""ffmpeg / ffprobe 的薄封装:找可执行文件、拼抽音命令、探时长。 批量抽取的调度不在这里 —— `pipeline.py` 自己按条编排(下载→抽音→上传→删), 所以这个模块只提供单次调用要用的零件。 从 mp4 里提取音轨。 源音频是 AAC 44.1kHz 立体声 128kbps,据此有两种模式: mp3 转码成 MP3。有损转有损,必然掉质量。码率给太高只是浪费空间 (源就 128k),默认 192k 是留余量避免二次编码劣化,不是"更清晰"。 copy 直接把 AAC 流拷进 .m4a 容器。无损、秒完、体积更小。 只有在必须是 .mp3 后缀时才用 mp3 模式。 依赖 ffmpeg,需在 PATH 里。 """ from __future__ import annotations import shutil import subprocess from pathlib import Path from typing import List, Optional class FFmpegMissing(RuntimeError): pass def ensure_ffmpeg(exe: str = "ffmpeg") -> str: path = shutil.which(exe) if not path: raise FFmpegMissing( f"找不到 {exe}。装一个并加进 PATH,或用 --ffmpeg 指定完整路径。") return path def build_cmd(ffmpeg: str, src: Path, dst: Path, mode: str, bitrate: str) -> List[str]: cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(src), "-vn"] if mode == "copy": # 不重新编码,直接搬运 AAC 流 cmd += ["-acodec", "copy"] else: cmd += ["-acodec", "libmp3lame", "-b:a", bitrate] cmd.append(str(dst)) return cmd def probe(path, ffprobe: str = "ffprobe", timeout: float = 60.0): """探一个媒体文件的容器时长,探不出来返回 None。 时长取自 mp4(源),不取 mp3 —— mp3 是转码产物,虽然实测两者一致, 但源文件才是权威。 注意这里读的是容器声明的 format=duration,对 mp4 没问题, 但**不能拿去判断 mp3 有没有被截断**:LAME 写的 Xing 头声明的是原始 总帧数,ffprobe 直接信它而不解码,实测把 mp3 截到 20% 它报的还是原时长。 那种场景要用 pipeline.decoded_seconds()(-count_frames 真解码)。 """ cmd = [ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path)] try: p_ = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: return None try: d = float((p_.stdout or "").strip()) except ValueError: return None return d if d > 0 else None