Files
video/server/app/services/transcode_service.py
T
2026-10-16 13:51:00 +08:00

158 lines
5.6 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.
"""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}