277 lines
8.1 KiB
Python
277 lines
8.1 KiB
Python
|
|
"""FFmpeg/ffprobe helpers for batch uploads and browser-ready MP4 (infrastructure only)."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from loguru import logger
|
||
|
|
|
||
|
|
VISUALIZATION_MAX_WIDTH = 1920
|
||
|
|
|
||
|
|
|
||
|
|
def visualization_ffmpeg_scale_filter() -> str:
|
||
|
|
return f"scale='min({VISUALIZATION_MAX_WIDTH},iw)':-2"
|
||
|
|
|
||
|
|
|
||
|
|
def browser_transcode_tmp_path(output_path: Path) -> Path:
|
||
|
|
return output_path.with_name(f"{output_path.stem}.part{output_path.suffix}")
|
||
|
|
|
||
|
|
|
||
|
|
def is_readable_mp4(path: Path) -> bool:
|
||
|
|
ffprobe = shutil.which("ffprobe")
|
||
|
|
if ffprobe is None or not path.is_file() or path.stat().st_size < 4096:
|
||
|
|
return False
|
||
|
|
proc = subprocess.run(
|
||
|
|
[
|
||
|
|
ffprobe,
|
||
|
|
"-v",
|
||
|
|
"error",
|
||
|
|
"-select_streams",
|
||
|
|
"v:0",
|
||
|
|
"-show_entries",
|
||
|
|
"stream=codec_name",
|
||
|
|
"-of",
|
||
|
|
"csv=p=0",
|
||
|
|
str(path),
|
||
|
|
],
|
||
|
|
check=False,
|
||
|
|
text=True,
|
||
|
|
capture_output=True,
|
||
|
|
)
|
||
|
|
return proc.returncode == 0 and bool((proc.stdout or "").strip())
|
||
|
|
|
||
|
|
|
||
|
|
def ffprobe_fields(path: Path, entries: str) -> dict[str, str]:
|
||
|
|
ffprobe = shutil.which("ffprobe")
|
||
|
|
if ffprobe is None or not path.is_file():
|
||
|
|
return {}
|
||
|
|
proc = subprocess.run(
|
||
|
|
[
|
||
|
|
ffprobe,
|
||
|
|
"-v",
|
||
|
|
"error",
|
||
|
|
"-select_streams",
|
||
|
|
"v:0",
|
||
|
|
"-show_entries",
|
||
|
|
entries,
|
||
|
|
"-of",
|
||
|
|
"default=noprint_wrappers=1",
|
||
|
|
str(path),
|
||
|
|
],
|
||
|
|
check=False,
|
||
|
|
text=True,
|
||
|
|
capture_output=True,
|
||
|
|
)
|
||
|
|
if proc.returncode != 0:
|
||
|
|
return {}
|
||
|
|
fields: dict[str, str] = {}
|
||
|
|
for line in proc.stdout.splitlines():
|
||
|
|
if "=" not in line:
|
||
|
|
continue
|
||
|
|
key, value = line.split("=", 1)
|
||
|
|
fields[key.strip().lower()] = value.strip().lower()
|
||
|
|
return fields
|
||
|
|
|
||
|
|
|
||
|
|
def ffprobe_container_format(path: Path) -> str:
|
||
|
|
ffprobe = shutil.which("ffprobe")
|
||
|
|
if ffprobe is None or not path.is_file():
|
||
|
|
return ""
|
||
|
|
proc = subprocess.run(
|
||
|
|
[
|
||
|
|
ffprobe,
|
||
|
|
"-v",
|
||
|
|
"error",
|
||
|
|
"-show_entries",
|
||
|
|
"format=format_name",
|
||
|
|
"-of",
|
||
|
|
"default=noprint_wrappers=1:nokey=1",
|
||
|
|
str(path),
|
||
|
|
],
|
||
|
|
check=False,
|
||
|
|
text=True,
|
||
|
|
capture_output=True,
|
||
|
|
)
|
||
|
|
if proc.returncode != 0:
|
||
|
|
return ""
|
||
|
|
return (proc.stdout or "").strip().lower()
|
||
|
|
|
||
|
|
|
||
|
|
def is_browser_compatible_mp4(path: Path) -> bool:
|
||
|
|
fields = ffprobe_fields(path, "stream=codec_name,pix_fmt")
|
||
|
|
return fields.get("codec_name") == "h264" and fields.get("pix_fmt") in {"yuv420p", "yuvj420p"}
|
||
|
|
|
||
|
|
|
||
|
|
def batch_input_needs_normalize(path: Path) -> bool:
|
||
|
|
if not is_readable_mp4(path):
|
||
|
|
return True
|
||
|
|
if not is_browser_compatible_mp4(path):
|
||
|
|
return True
|
||
|
|
container = ffprobe_container_format(path)
|
||
|
|
if container and "mpeg" in container:
|
||
|
|
return True
|
||
|
|
fields = ffprobe_fields(path, "stream=codec_name,width,height")
|
||
|
|
try:
|
||
|
|
width = int(fields.get("width") or "0")
|
||
|
|
except ValueError:
|
||
|
|
width = 0
|
||
|
|
return width > 1920
|
||
|
|
|
||
|
|
|
||
|
|
def normalize_batch_input_video(source_path: Path, output_path: Path) -> bool:
|
||
|
|
ffmpeg = shutil.which("ffmpeg")
|
||
|
|
if ffmpeg is None or not source_path.is_file():
|
||
|
|
return False
|
||
|
|
if not is_readable_mp4(source_path):
|
||
|
|
logger.warning("skip batch input normalize: unreadable source {}", source_path)
|
||
|
|
return False
|
||
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
tmp_path = browser_transcode_tmp_path(output_path)
|
||
|
|
if tmp_path.exists():
|
||
|
|
tmp_path.unlink()
|
||
|
|
logger.info("ffmpeg batch input normalize starting: {} -> {}", source_path, output_path)
|
||
|
|
proc = subprocess.run(
|
||
|
|
[
|
||
|
|
ffmpeg,
|
||
|
|
"-y",
|
||
|
|
"-hide_banner",
|
||
|
|
"-loglevel",
|
||
|
|
"error",
|
||
|
|
"-fflags",
|
||
|
|
"+genpts",
|
||
|
|
"-i",
|
||
|
|
str(source_path),
|
||
|
|
"-map",
|
||
|
|
"0:v:0",
|
||
|
|
"-an",
|
||
|
|
"-f",
|
||
|
|
"mp4",
|
||
|
|
"-c:v",
|
||
|
|
"libx264",
|
||
|
|
"-pix_fmt",
|
||
|
|
"yuv420p",
|
||
|
|
"-preset",
|
||
|
|
"veryfast",
|
||
|
|
"-crf",
|
||
|
|
"23",
|
||
|
|
"-vf",
|
||
|
|
"scale='min(1920,iw)':-2",
|
||
|
|
"-movflags",
|
||
|
|
"+faststart",
|
||
|
|
str(tmp_path),
|
||
|
|
],
|
||
|
|
check=False,
|
||
|
|
text=True,
|
||
|
|
capture_output=True,
|
||
|
|
)
|
||
|
|
if proc.returncode != 0:
|
||
|
|
stderr = (proc.stderr or "").strip()
|
||
|
|
logger.warning("ffmpeg batch input normalize failed: {}", stderr[-3000:])
|
||
|
|
if tmp_path.exists():
|
||
|
|
tmp_path.unlink()
|
||
|
|
return False
|
||
|
|
if not tmp_path.is_file() or tmp_path.stat().st_size <= 0:
|
||
|
|
logger.warning("ffmpeg batch input normalize produced empty file: {}", tmp_path)
|
||
|
|
if tmp_path.exists():
|
||
|
|
tmp_path.unlink()
|
||
|
|
return False
|
||
|
|
tmp_path.replace(output_path)
|
||
|
|
if not is_browser_compatible_mp4(output_path):
|
||
|
|
logger.warning("ffmpeg batch input normalize output not h264/yuv420p: {}", output_path)
|
||
|
|
output_path.unlink(missing_ok=True)
|
||
|
|
return False
|
||
|
|
logger.info(
|
||
|
|
"ffmpeg batch input normalize complete: {} ({} bytes)",
|
||
|
|
output_path,
|
||
|
|
output_path.stat().st_size,
|
||
|
|
)
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def ensure_batch_pipeline_input_video(*, source_path: Path, dest_path: Path) -> None:
|
||
|
|
import shutil as sh
|
||
|
|
|
||
|
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
if dest_path.is_file() and dest_path.stat().st_size > 0 and not batch_input_needs_normalize(dest_path):
|
||
|
|
return
|
||
|
|
if batch_input_needs_normalize(source_path):
|
||
|
|
if normalize_batch_input_video(source_path, dest_path):
|
||
|
|
return
|
||
|
|
logger.warning(
|
||
|
|
"batch input normalize failed, falling back to raw copy: {} -> {}",
|
||
|
|
source_path,
|
||
|
|
dest_path,
|
||
|
|
)
|
||
|
|
if not dest_path.is_file():
|
||
|
|
sh.copy2(source_path, dest_path)
|
||
|
|
|
||
|
|
|
||
|
|
def transcode_visualization_for_browser(source_path: Path, output_path: Path) -> bool:
|
||
|
|
ffmpeg = shutil.which("ffmpeg")
|
||
|
|
if ffmpeg is None or not source_path.is_file():
|
||
|
|
return False
|
||
|
|
if not is_readable_mp4(source_path):
|
||
|
|
logger.warning("skip visualization transcode: unreadable source {}", source_path)
|
||
|
|
return False
|
||
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
tmp_path = browser_transcode_tmp_path(output_path)
|
||
|
|
if tmp_path.exists():
|
||
|
|
tmp_path.unlink()
|
||
|
|
logger.info("ffmpeg visualization transcode starting: {} -> {}", source_path, output_path)
|
||
|
|
proc = subprocess.run(
|
||
|
|
[
|
||
|
|
ffmpeg,
|
||
|
|
"-y",
|
||
|
|
"-hide_banner",
|
||
|
|
"-loglevel",
|
||
|
|
"error",
|
||
|
|
"-i",
|
||
|
|
str(source_path),
|
||
|
|
"-map",
|
||
|
|
"0:v:0",
|
||
|
|
"-an",
|
||
|
|
"-f",
|
||
|
|
"mp4",
|
||
|
|
"-c:v",
|
||
|
|
"libx264",
|
||
|
|
"-pix_fmt",
|
||
|
|
"yuv420p",
|
||
|
|
"-preset",
|
||
|
|
"ultrafast",
|
||
|
|
"-crf",
|
||
|
|
"23",
|
||
|
|
"-vf",
|
||
|
|
visualization_ffmpeg_scale_filter(),
|
||
|
|
"-movflags",
|
||
|
|
"+faststart",
|
||
|
|
str(tmp_path),
|
||
|
|
],
|
||
|
|
check=False,
|
||
|
|
text=True,
|
||
|
|
capture_output=True,
|
||
|
|
)
|
||
|
|
if proc.returncode != 0:
|
||
|
|
stderr = (proc.stderr or "").strip()
|
||
|
|
logger.warning("ffmpeg visualization transcode failed: {}", stderr[-3000:])
|
||
|
|
if tmp_path.exists():
|
||
|
|
tmp_path.unlink()
|
||
|
|
return False
|
||
|
|
if not tmp_path.is_file() or tmp_path.stat().st_size <= 0:
|
||
|
|
logger.warning("ffmpeg visualization transcode produced empty file: {}", tmp_path)
|
||
|
|
if tmp_path.exists():
|
||
|
|
tmp_path.unlink()
|
||
|
|
return False
|
||
|
|
tmp_path.replace(output_path)
|
||
|
|
if not is_browser_compatible_mp4(output_path):
|
||
|
|
logger.warning("ffmpeg output is not browser-compatible h264/yuv420p: {}", output_path)
|
||
|
|
output_path.unlink(missing_ok=True)
|
||
|
|
return False
|
||
|
|
logger.info(
|
||
|
|
"ffmpeg visualization transcode complete: {} ({} bytes)",
|
||
|
|
output_path,
|
||
|
|
output_path.stat().st_size,
|
||
|
|
)
|
||
|
|
return True
|