This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
+157
View File
@@ -0,0 +1,157 @@
"""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}