mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-14 21:16:36 +03:00
Adds evidence-bounded video and podcast editing references, reusable templates, deterministic media workflow helpers, and tests. Closes #438.
298 lines
11 KiB
Python
Executable File
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())
|