140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
"""ffmpeg/ffprobe wrappers: read a video's metadata, cut a range into frames.
|
|
|
|
ffmpeg is a hard dependency rather than an OpenCV fallback because seeking to a
|
|
timestamp in a long recording has to be exact — an off-by-a-few-seconds trim
|
|
silently produces frames of the wrong thing (REQ-020…022).
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from typing import Callable, List, Optional
|
|
|
|
VIDEO_EXTS = (".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v")
|
|
|
|
_probe_cache: dict = {}
|
|
|
|
|
|
class VideoError(Exception):
|
|
pass
|
|
|
|
|
|
def available() -> bool:
|
|
return shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
|
|
|
|
|
|
def probe(path: str) -> dict:
|
|
"""Duration, resolution and fps of a video, cached by (path, mtime, size).
|
|
|
|
The cache matters: the Library page probes every file in a date folder, and
|
|
ffprobe on a cold cache over dozens of long recordings is slow (REQ-012).
|
|
"""
|
|
try:
|
|
stat = os.stat(path)
|
|
except OSError as exc:
|
|
raise VideoError(str(exc))
|
|
|
|
key = (os.path.realpath(path), stat.st_mtime, stat.st_size)
|
|
if key in _probe_cache:
|
|
return _probe_cache[key]
|
|
|
|
if not available():
|
|
raise VideoError("ffprobe is not installed in this environment")
|
|
|
|
result = subprocess.run(
|
|
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
"-show_entries", "stream=width,height,avg_frame_rate",
|
|
"-show_entries", "format=duration",
|
|
"-of", "json", path],
|
|
capture_output=True, text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise VideoError(result.stderr.strip().splitlines()[-1] if result.stderr else "ffprobe failed")
|
|
|
|
payload = json.loads(result.stdout or "{}")
|
|
streams = payload.get("streams") or [{}]
|
|
info = {
|
|
"duration": float(payload.get("format", {}).get("duration") or 0.0),
|
|
"width": int(streams[0].get("width") or 0),
|
|
"height": int(streams[0].get("height") or 0),
|
|
"fps": _parse_fps(streams[0].get("avg_frame_rate")),
|
|
"size": stat.st_size,
|
|
}
|
|
_probe_cache[key] = info
|
|
return info
|
|
|
|
|
|
def _parse_fps(value: Optional[str]) -> float:
|
|
# ffprobe reports "30000/1001", and "0/0" for streams it cannot work out.
|
|
if not value or "/" not in value:
|
|
return 0.0
|
|
numerator, denominator = value.split("/", 1)
|
|
try:
|
|
return round(float(numerator) / float(denominator), 3) if float(denominator) else 0.0
|
|
except ValueError:
|
|
return 0.0
|
|
|
|
|
|
def frame_count(start_sec: float, end_sec: float, fps: float) -> int:
|
|
"""How many frames a trim will produce — shown before extraction (REQ-021)."""
|
|
span = max(0.0, end_sec - start_sec)
|
|
return max(0, int(span * fps))
|
|
|
|
|
|
def extract_frames(
|
|
video_path: str,
|
|
out_dir: str,
|
|
start_sec: float,
|
|
end_sec: float,
|
|
fps: float,
|
|
on_progress: Optional[Callable[[int], None]] = None,
|
|
should_stop: Optional[Callable[[], bool]] = None,
|
|
) -> List[str]:
|
|
"""Cut [start, end] at `fps` into numbered JPEGs. Returns their filenames.
|
|
|
|
`-ss` before `-i` seeks by keyframe (fast) and ffmpeg then decodes accurately
|
|
from there, which is what makes a two-minute cut out of a two-hour recording
|
|
quick instead of a full decode.
|
|
"""
|
|
if not available():
|
|
raise VideoError("ffmpeg is not installed in this environment")
|
|
if end_sec <= start_sec:
|
|
raise VideoError("The end of the range must be after its start")
|
|
if fps <= 0:
|
|
raise VideoError("fps must be greater than 0")
|
|
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
command = [
|
|
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
|
"-ss", f"{start_sec:.3f}", "-to", f"{end_sec:.3f}", "-i", video_path,
|
|
"-vf", f"fps={fps}", "-q:v", "2",
|
|
os.path.join(out_dir, "%06d.jpg"),
|
|
]
|
|
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
|
|
expected = frame_count(start_sec, end_sec, fps)
|
|
while process.poll() is None:
|
|
if should_stop is not None and should_stop():
|
|
process.terminate()
|
|
process.wait(timeout=10)
|
|
raise VideoError("cancelled")
|
|
if on_progress is not None:
|
|
on_progress(min(_written(out_dir), expected))
|
|
try:
|
|
process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
if process.returncode != 0:
|
|
raise VideoError((process.stderr.read() or "ffmpeg failed").strip().splitlines()[-1])
|
|
|
|
return sorted(name for name in os.listdir(out_dir) if name.endswith(".jpg"))
|
|
|
|
|
|
def _written(out_dir: str) -> int:
|
|
try:
|
|
return sum(1 for name in os.listdir(out_dir) if name.endswith(".jpg"))
|
|
except OSError:
|
|
return 0
|