FastApi-ISS/utils/media_processing.py
2025-09-16 20:14:01 +03:30

194 lines
6.5 KiB
Python

import io
import os
import re
import subprocess
import tempfile
from typing import Tuple, List
from PIL import Image
import requests
FFMPEG = os.getenv("FFMPEG_BIN", "ffmpeg")
FFPROBE = os.getenv("FFPROBE_BIN", "ffprobe")
# ------------------------
# Shared helpers
# ------------------------
def _bool(name: str, default: bool) -> bool:
v = os.getenv(name)
if v is None:
return default
return v.strip().lower() in ("1", "true", "yes", "y")
def _int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except Exception:
return default
def _float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
except Exception:
return default
def _parse_max_size(default: Tuple[int, int] = (1600, 1600)) -> Tuple[int, int]:
raw = os.getenv("IMAGE_MAX_SIZE", f"{default[0]}x{default[1]}").strip().lower()
m = re.match(r"^\s*(\d+)\s*[x,]\s*(\d+)\s*$", raw)
if not m:
return default
return (int(m.group(1)), int(m.group(2)))
def download_bytes(url: str, timeout: int = 30) -> bytes:
r = requests.get(url, stream=True, timeout=timeout)
r.raise_for_status()
return r.content
# ------------------------
# Image processing (WebP)
# ------------------------
def process_image_to_webp_single(image_bytes: bytes) -> Tuple[str, bytes, str]:
"""
Resize image to:
- scale percent from .env (default 0.85)
- and cap to max size from .env (default 1600x1600)
Save as WebP with quality from .env (default 85).
Returns (variant_name, data, content_type)
variant_name: 'webp'
"""
scale_percent = _float("IMAGE_SCALE_PERCENT", 0.85)
max_w, max_h = _parse_max_size((1600, 1600))
quality = _int("IMAGE_QUALITY", 85)
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
orig_w, orig_h = img.size
# First apply percentage scaling
target_w = max(1, int(orig_w * scale_percent))
target_h = max(1, int(orig_h * scale_percent))
# Then cap to max size if still too large
ratio_w = max_w / target_w if target_w > max_w else 1.0
ratio_h = max_h / target_h if target_h > max_h else 1.0
cap_ratio = min(ratio_w, ratio_h)
if cap_ratio < 1.0:
target_w = max(1, int(target_w * cap_ratio))
target_h = max(1, int(target_h * cap_ratio))
# Resize using LANCZOS
resized = img.resize((target_w, target_h), Image.LANCZOS)
out = io.BytesIO()
# method=6 is a good quality/speed balance for WebP
resized.save(out, format="WEBP", quality=quality, method=6)
return ("webp", out.getvalue(), "image/webp")
# ------------------------
# Video processing (H.264 @ 480p)
# ------------------------
def _probe_streams(path: str) -> dict:
"""Return ffprobe json for streams (video/audio codecs, dimensions)."""
# Minimal probing; not strictly required but helps with SIMPLE mode decisions
cmd = [
FFPROBE, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_name,width,height",
"-of", "json", path
]
try:
out = subprocess.run(cmd, check=True, capture_output=True).stdout
import json
return json.loads(out.decode("utf-8"))
except Exception:
return {}
def transcode_video_480_h264(video_bytes: bytes) -> Tuple[str, bytes, str]:
"""
Transcode to MP4 (H.264/AAC), height=VIDEO_HEIGHT (default 480), CRF=VIDEO_CRF (default 23).
Adds +faststart unless disabled. Uses PRESET (default veryfast).
If VIDEO_SIMPLE_MODE=true:
- Use 'ultrafast' preset automatically
- If input already H.264/AAC and <= target height, attempt stream-copy with -movflags +faststart
(very quick) — otherwise fallback to a minimal transcode.
Returns (variant_name, data, content_type) where variant_name='480p'
"""
height = _int("VIDEO_HEIGHT", 480)
crf = _int("VIDEO_CRF", 23)
preset = os.getenv("VIDEO_PRESET", "veryfast")
faststart = _bool("VIDEO_FASTSTART", True)
simple = _bool("VIDEO_SIMPLE_MODE", False)
with tempfile.TemporaryDirectory() as td:
in_path = os.path.join(td, "in.bin")
out_path = os.path.join(td, f"out-{height}p.mp4")
with open(in_path, "wb") as f:
f.write(video_bytes)
vf = f"scale=-2:{height}"
# SIMPLE MODE quick path
if simple:
# Try to detect if we can safely stream copy
meta = _probe_streams(in_path)
v_ok = False
try:
st = meta.get("streams", [{}])[0]
vcodec = st.get("codec_name")
v_h = int(st.get("height", 10**9))
v_ok = (vcodec == "h264" and v_h <= height)
except Exception:
v_ok = False
if v_ok:
# Quick remux with faststart; try copy video/audio
cmd = [FFMPEG, "-y", "-i", in_path, "-c", "copy"]
if faststart:
cmd += ["-movflags", "+faststart"]
cmd += [out_path]
try:
subprocess.run(cmd, check=True, capture_output=True)
with open(out_path, "rb") as f:
return (f"{height}p", f.read(), "video/mp4")
except Exception:
# fall back to quick transcode below
pass
# Quick transcode with ultrafast
cmd = [
FFMPEG, "-y",
"-i", in_path,
"-vf", vf,
"-c:v", "libx264",
"-preset", "ultrafast", # force quickest in simple mode
"-crf", str(crf),
"-c:a", "aac",
"-b:a", "128k",
]
if faststart:
cmd += ["-movflags", "+faststart"]
cmd += [out_path]
subprocess.run(cmd, check=True, capture_output=True)
with open(out_path, "rb") as f:
return (f"{height}p", f.read(), "video/mp4")
# Normal path (preset configurable)
cmd = [
FFMPEG, "-y",
"-i", in_path,
"-vf", vf,
"-c:v", "libx264",
"-preset", preset,
"-crf", str(crf),
"-c:a", "aac",
"-b:a", "128k",
]
if faststart:
cmd += ["-movflags", "+faststart"]
cmd += [out_path]
subprocess.run(cmd, check=True, capture_output=True)
with open(out_path, "rb") as f:
return (f"{height}p", f.read(), "video/mp4")