up
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user