Files
magnus919_agent-skills/ffmpeg/scripts/extract-review-frames
T
Magnus HedemarkandGitHub 3b416dda7e feat: enrich FFmpeg with evidence-driven media editing
Adds evidence-bounded video and podcast editing references, reusable templates, deterministic media workflow helpers, and tests. Closes #438.
2026-09-01 21:20:51 -04:00

298 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Extract a bounded set of explicitly requested review frames."""
from __future__ import annotations
import argparse
from decimal import Decimal, InvalidOperation
import json
import math
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from typing import Any
MAX_FRAMES_HARD = 100
MAX_TIMESTAMP_HARD = Decimal("604800")
MAX_TIMEOUT_HARD = 300.0
MAX_DIAGNOSTIC_BYTES = 1024 * 1024
class CLIError(Exception):
def __init__(self, code: str, message: str, exit_code: int = 2, **details: Any) -> None:
super().__init__(message)
self.code = code
self.message = message
self.exit_code = exit_code
self.details = details
class JSONArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> None:
if "--json" in sys.argv[1:]:
print(json.dumps({"ok": False, "error": {"code": "invalid_arguments", "message": message}}, sort_keys=True))
raise SystemExit(2)
super().error(message)
def emit(payload: dict[str, Any], as_json: bool, *, error: bool = False) -> None:
if as_json:
print(json.dumps(payload, sort_keys=True, separators=(",", ":")))
elif error:
err = payload.get("error", {})
print(f"{err.get('code', 'error')}: {err.get('message', 'unknown error')}", file=sys.stderr)
else:
print(json.dumps(payload, sort_keys=True, indent=2))
def resolve_tool(command: str) -> str | None:
expanded = os.path.expanduser(command)
if os.path.sep in expanded or (os.path.altsep and os.path.altsep in expanded):
path = Path(expanded)
if path.is_file() and os.access(path, os.X_OK):
return str(path.resolve())
return None
return shutil.which(command)
def canonical_decimal(value: Decimal) -> str:
rendered = format(value, "f")
if "." in rendered:
rendered = rendered.rstrip("0").rstrip(".")
return "0" if rendered in {"", "-0"} else rendered
def parse_timestamp(raw: str) -> Decimal:
if len(raw) > 64:
raise CLIError("invalid_timestamp", f"timestamp is too long: {raw[:32]}…")
parts = raw.split(":")
try:
if len(parts) == 1:
value = Decimal(parts[0])
elif len(parts) == 2:
minutes = Decimal(parts[0])
seconds = Decimal(parts[1])
if minutes != minutes.to_integral_value() or minutes < 0 or not 0 <= seconds < 60:
raise InvalidOperation
value = minutes * 60 + seconds
elif len(parts) == 3:
hours = Decimal(parts[0])
minutes = Decimal(parts[1])
seconds = Decimal(parts[2])
if (
hours != hours.to_integral_value()
or minutes != minutes.to_integral_value()
or hours < 0
or not 0 <= minutes < 60
or not 0 <= seconds < 60
):
raise InvalidOperation
value = hours * 3600 + minutes * 60 + seconds
else:
raise InvalidOperation
except (InvalidOperation, ValueError):
raise CLIError("invalid_timestamp", f"invalid timestamp: {raw}") from None
if not value.is_finite() or value < 0:
raise CLIError("invalid_timestamp", f"timestamp must be finite and nonnegative: {raw}")
return value
def run_bounded(argv: list[str], timeout: float) -> tuple[int, str]:
with tempfile.TemporaryFile() as stderr_file:
try:
process = subprocess.Popen(
argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=stderr_file,
)
except OSError as exc:
raise CLIError("tool_start_failed", f"could not start {argv[0]}: {exc}", 3) from exc
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired as exc:
process.kill()
process.wait()
raise CLIError("extract_timeout", f"ffmpeg exceeded {timeout:g} seconds", 4) from exc
size = stderr_file.tell()
if size > MAX_DIAGNOSTIC_BYTES:
raise CLIError(
"diagnostic_output_too_large",
f"ffmpeg stderr exceeded {MAX_DIAGNOSTIC_BYTES} bytes",
4,
stderr_bytes=size,
)
stderr_file.seek(0)
return process.returncode, stderr_file.read().decode("utf-8", errors="replace").strip()
def build_parser() -> argparse.ArgumentParser:
parser = JSONArgumentParser(description=__doc__)
parser.add_argument("input", help="source media file")
parser.add_argument("-o", "--output-dir", required=True, help="directory for extracted JPEG frames")
parser.add_argument(
"-t",
"--timestamp",
action="append",
default=[],
help="timestamp in seconds or [HH:]MM:SS; repeat for each frame",
)
parser.add_argument(
"--timestamps",
nargs="+",
default=[],
metavar="TIME",
help="additional explicit timestamps",
)
parser.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg executable (default: ffmpeg from PATH)")
parser.add_argument("--max-frames", type=int, default=24, help="maximum requested frames (1-100)")
parser.add_argument(
"--max-timestamp",
default="86400",
help="maximum allowed timestamp in seconds (default: 86400; hard maximum: 604800)",
)
parser.add_argument("--timeout", type=float, default=30.0, help="per-frame ffmpeg timeout in seconds")
parser.add_argument("--overwrite", action="store_true", help="allow replacing existing frame files")
parser.add_argument("--json", action="store_true", help="emit compact JSON")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if not 1 <= args.max_frames <= MAX_FRAMES_HARD:
raise CLIError("invalid_limit", f"--max-frames must be between 1 and {MAX_FRAMES_HARD}")
if not math.isfinite(args.timeout) or not 0 < args.timeout <= MAX_TIMEOUT_HARD:
raise CLIError("invalid_limit", f"--timeout must be greater than 0 and at most {MAX_TIMEOUT_HARD:g}")
max_timestamp = parse_timestamp(args.max_timestamp)
if max_timestamp <= 0 or max_timestamp > MAX_TIMESTAMP_HARD:
raise CLIError(
"invalid_limit",
f"--max-timestamp must be greater than 0 and at most {canonical_decimal(MAX_TIMESTAMP_HARD)}",
)
raw_timestamps = [*args.timestamp, *args.timestamps]
if not raw_timestamps:
raise CLIError("timestamps_required", "at least one explicit --timestamp is required")
if len(raw_timestamps) > args.max_frames:
raise CLIError(
"frame_limit_exceeded",
f"requested {len(raw_timestamps)} frames; limit is {args.max_frames}",
requested=len(raw_timestamps),
limit=args.max_frames,
)
timestamps = [parse_timestamp(raw) for raw in raw_timestamps]
for raw, timestamp in zip(raw_timestamps, timestamps):
if timestamp > max_timestamp:
raise CLIError(
"timestamp_limit_exceeded",
f"timestamp {raw} exceeds limit {canonical_decimal(max_timestamp)} seconds",
timestamp=raw,
limit_seconds=canonical_decimal(max_timestamp),
)
source = Path(args.input).expanduser()
if not source.exists():
raise CLIError("input_not_found", f"input does not exist: {source}")
if not source.is_file():
raise CLIError("invalid_input", f"input is not a regular file: {source}")
source = source.resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
if output_dir.exists() and not output_dir.is_dir():
raise CLIError("invalid_output_directory", f"output path is not a directory: {output_dir}")
outputs = [output_dir / f"frame-{index:04d}.jpg" for index in range(1, len(timestamps) + 1)]
existing = [str(path) for path in outputs if path.exists()]
if existing and not args.overwrite:
raise CLIError(
"output_exists",
"one or more output frames already exist; use --overwrite to replace them",
4,
paths=existing,
)
tool = resolve_tool(args.ffmpeg)
if tool is None:
raise CLIError("tool_not_found", f"ffmpeg executable not found: {args.ffmpeg}", 3, tool=args.ffmpeg)
output_dir.mkdir(parents=True, exist_ok=True)
results: list[dict[str, Any]] = []
for timestamp, output in zip(timestamps, outputs):
timestamp_text = canonical_decimal(timestamp)
command = [
tool,
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-ss",
timestamp_text,
"-i",
str(source),
"-frames:v",
"1",
"-q:v",
"2",
"-y" if args.overwrite else "-n",
str(output),
]
returncode, diagnostic = run_bounded(command, args.timeout)
if returncode != 0:
raise CLIError(
"extract_failed",
diagnostic or f"ffmpeg exited {returncode}",
1,
returncode=returncode,
timestamp=timestamp_text,
output=str(output),
)
if not output.is_file():
raise CLIError(
"output_missing",
"ffmpeg reported success but did not create the requested frame",
1,
timestamp=timestamp_text,
output=str(output),
)
results.append(
{
"timestamp_seconds": timestamp_text,
"output": str(output),
"size_bytes": output.stat().st_size,
}
)
emit(
{
"ok": True,
"input": str(source),
"output_directory": str(output_dir),
"frame_count": len(results),
"overwrite": args.overwrite,
"frames": results,
},
args.json,
)
return 0
except CLIError as exc:
emit(
{"ok": False, "error": {"code": exc.code, "message": exc.message, **exc.details}},
args.json,
error=True,
)
return exc.exit_code
except OSError as exc:
emit(
{"ok": False, "error": {"code": "io_error", "message": str(exc)}},
args.json,
error=True,
)
return 2
if __name__ == "__main__":
raise SystemExit(main())