"""FFmpeg HLS 转码 + 封面抽帧服务""" import asyncio import logging import subprocess from datetime import datetime from pathlib import Path from app.config import BASE_DIR as _SERVER_BASE logger = logging.getLogger(__name__) # 项目根(server 上级,即 /root/project/video) _PROJECT_ROOT = _SERVER_BASE.parent # 源上传文件根目录(服务端 uploads) UPLOAD_ROOT = _SERVER_BASE / "uploads" # 转码产物根目录(nginx h5.256kb.top 用 alias 指向这里分发) HLS_ROOT = _PROJECT_ROOT / "runtime" / "videos" # h5 域名(转码产物对外访问地址) HLS_BASE = "https://h5.256kb.top/videos" # ffmpeg 二进制(imageio-ffmpeg 随 venv 安装自动带上静态编译 libx264+aac+hls, # 开发/生产通用,无需系统包管理器) try: import imageio_ffmpeg # noqa: F401 _FFMPEG = str(Path(imageio_ffmpeg.get_ffmpeg_exe())) except Exception: # pragma: no cover - 降级:PATH 里找系统 ffmpeg import shutil _FFMPEG = shutil.which("ffmpeg") or "ffmpeg" # HLS 参数 _HLS_TIME = 6 # ts 切片 6s _HLS_LIST_SIZE = 0 # 保留所有切片,支持回放 _HLS_SEG_FORMAT = "seg_%05d.ts" # 封面:从源视频 1s 处抽帧 JPEG,宽度 1280px _COVER_AT_SEC = 1 _COVER_WIDTH = 1280 def _probe_duration(path: Path) -> int: """ffprobe 读时长(秒,取整);失败返回 0""" try: out = subprocess.run( [_FFMPEG.replace("ffmpeg", "ffprobe"), "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path)], capture_output=True, text=True, timeout=30, ) return int(float(out.stdout.strip() or 0)) except Exception: return 0 def extract_cover(source_path: str | Path, out_dir: Path) -> Path | None: """从源视频 1s 处抽帧存 JPEG;返回输出文件 Path(失败返回 None)。""" src = Path(source_path) if not src.exists(): return None out_dir.mkdir(parents=True, exist_ok=True) out = out_dir / "cover.jpg" args = [ _FFMPEG, "-y", "-ss", str(_COVER_AT_SEC), "-i", str(src), "-frames:v", "1", "-vf", f"scale={_COVER_WIDTH}:-2", "-q:v", "5", str(out), ] try: proc = subprocess.run(args, capture_output=True, text=True, timeout=60) if proc.returncode == 0 and out.exists() and out.stat().st_size > 0: return out logger.warning("[cover] 抽帧失败 rc=%d src=%s: %s", proc.returncode, src, proc.stderr[-300:]) except Exception as e: logger.warning("[cover] 抽帧异常 src=%s: %s", src, e) return None def resolve_source_path(video_url_or_object_key: str) -> Path | None: """从 DB video_url(/static/upload/...)或 upload object_key(upload/...)还原本地源文件路径""" raw = video_url_or_object_key if not raw: return None if raw.startswith("/static/"): rel = raw.replace("/static/", "", 1) elif raw.startswith("upload/"): rel = raw else: return None p = (UPLOAD_ROOT / rel).resolve() return p if p.exists() else None async def transcode_to_hls(source_path: str | Path, video_id: int) -> dict: """MP4 → HLS + 封面。返回 {"m3u8_url", "cover_url", "duration", "ts_count"};异常直接 raise。 所有 subprocess 都跑在线程池,不阻塞事件循环。 """ src = Path(source_path) if not src.exists(): raise FileNotFoundError(f"源文件不存在: {src}") out_dir = HLS_ROOT / str(video_id) out_dir.mkdir(parents=True, exist_ok=True) m3u8_path = out_dir / "index.m3u8" cover_path = out_dir / "cover.jpg" # 已转码直接返回(幂等) if m3u8_path.exists(): logger.info("[transcode] video_id=%d 已存在 %s,跳过", video_id, m3u8_path) return { "m3u8_url": f"{HLS_BASE}/{video_id}/index.m3u8", "cover_url": f"{HLS_BASE}/{video_id}/cover.jpg" if cover_path.exists() else "", "duration": _probe_duration(m3u8_path), "ts_count": len(list(out_dir.glob("*.ts"))), } args = [ _FFMPEG, "-y", "-i", str(src), "-codec:v", "libx264", "-preset", "veryfast", "-crf", "23", "-codec:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-hls_time", str(_HLS_TIME), "-hls_list_size", str(_HLS_LIST_SIZE), "-hls_segment_filename", str(out_dir / _HLS_SEG_FORMAT), "-hls_playlist_type", "vod", "-f", "hls", str(m3u8_path), ] logger.info("[transcode] video_id=%d 开始转码: %s → %s", video_id, src, m3u8_path) start = datetime.now() proc = await asyncio.to_thread( subprocess.run, args, capture_output=True, text=True, timeout=600 ) if proc.returncode != 0 or not m3u8_path.exists(): logger.error("[transcode] video_id=%d 失败 rc=%s\nstderr=%s", video_id, proc.returncode, proc.stderr[-2000:]) raise RuntimeError(f"ffmpeg 转码失败 rc={proc.returncode}: {proc.stderr[-500:]}") # 转码成功 → 同步抽封面(独立线程) cover = await asyncio.to_thread(extract_cover, src, out_dir) cover_url = f"{HLS_BASE}/{video_id}/cover.jpg" if cover else "" dur = _probe_duration(m3u8_path) ts_count = len(list(out_dir.glob("*.ts"))) cost = (datetime.now() - start).total_seconds() m3u8_url = f"{HLS_BASE}/{video_id}/index.m3u8" logger.info( "[transcode] video_id=%d 完成 cost=%.1fs dur=%ds ts=%d cover=%s → %s", video_id, cost, dur, ts_count, "ok" if cover else "-", m3u8_url, ) return {"m3u8_url": m3u8_url, "cover_url": cover_url, "duration": dur, "ts_count": ts_count}