mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-15 21:46:29 +03:00
Adds evidence-bounded video and podcast editing references, reusable templates, deterministic media workflow helpers, and tests. Closes #438.
219 lines
8.1 KiB
Python
Executable File
219 lines
8.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Inventory files and attach bounded ffprobe JSON metadata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from typing import Any, Iterator
|
|
|
|
MAX_FILES_HARD = 1000
|
|
MAX_TIMEOUT_HARD = 300.0
|
|
MAX_OUTPUT_BYTES_HARD = 16 * 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 run_bounded(argv: list[str], timeout: float, max_output_bytes: int) -> tuple[int, bytes, bytes]:
|
|
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
|
try:
|
|
process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=stdout_file, 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("probe_timeout", f"ffprobe exceeded {timeout:g} seconds", 4) from exc
|
|
|
|
stdout_size = stdout_file.tell()
|
|
stderr_size = stderr_file.tell()
|
|
if stdout_size > max_output_bytes or stderr_size > max_output_bytes:
|
|
raise CLIError(
|
|
"probe_output_too_large",
|
|
f"ffprobe output exceeded {max_output_bytes} bytes",
|
|
4,
|
|
stdout_bytes=stdout_size,
|
|
stderr_bytes=stderr_size,
|
|
)
|
|
stdout_file.seek(0)
|
|
stderr_file.seek(0)
|
|
return process.returncode, stdout_file.read(), stderr_file.read()
|
|
|
|
|
|
def iter_regular_files(root: Path) -> Iterator[Path]:
|
|
if root.is_file():
|
|
yield root
|
|
return
|
|
for current, dirnames, filenames in os.walk(root, followlinks=False):
|
|
dirnames[:] = sorted(name for name in dirnames if not (Path(current) / name).is_symlink())
|
|
for filename in sorted(filenames):
|
|
candidate = Path(current) / filename
|
|
if candidate.is_file():
|
|
yield candidate
|
|
|
|
|
|
def probe_file(tool: str, path: Path, timeout: float, max_output_bytes: int) -> dict[str, Any]:
|
|
argv = [
|
|
tool,
|
|
"-v",
|
|
"error",
|
|
"-print_format",
|
|
"json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
str(path),
|
|
]
|
|
returncode, stdout, stderr = run_bounded(argv, timeout, max_output_bytes)
|
|
if returncode != 0:
|
|
message = stderr.decode("utf-8", errors="replace").strip()
|
|
raise CLIError("probe_failed", message or f"ffprobe exited {returncode}", 1, returncode=returncode)
|
|
try:
|
|
document = json.loads(stdout.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise CLIError("invalid_probe_json", f"ffprobe returned invalid JSON: {exc}", 1) from exc
|
|
if not isinstance(document, dict):
|
|
raise CLIError("invalid_probe_json", "ffprobe JSON root must be an object", 1)
|
|
return document
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = JSONArgumentParser(description=__doc__)
|
|
parser.add_argument("path", help="file or directory to inventory")
|
|
parser.add_argument("--ffprobe", default="ffprobe", help="ffprobe executable (default: ffprobe from PATH)")
|
|
parser.add_argument("--max-files", type=int, default=100, help="maximum files to inventory (1-1000)")
|
|
parser.add_argument("--timeout", type=float, default=15.0, help="per-file ffprobe timeout in seconds")
|
|
parser.add_argument(
|
|
"--max-output-bytes",
|
|
type=int,
|
|
default=1024 * 1024,
|
|
help="maximum stdout or stderr bytes accepted from each ffprobe call",
|
|
)
|
|
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_files <= MAX_FILES_HARD:
|
|
raise CLIError("invalid_limit", f"--max-files must be between 1 and {MAX_FILES_HARD}")
|
|
if 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}")
|
|
if not 1024 <= args.max_output_bytes <= MAX_OUTPUT_BYTES_HARD:
|
|
raise CLIError(
|
|
"invalid_limit",
|
|
f"--max-output-bytes must be between 1024 and {MAX_OUTPUT_BYTES_HARD}",
|
|
)
|
|
|
|
source = Path(args.path).expanduser()
|
|
if not source.exists():
|
|
raise CLIError("input_not_found", f"input does not exist: {source}")
|
|
if not source.is_file() and not source.is_dir():
|
|
raise CLIError("invalid_input", f"input is not a regular file or directory: {source}")
|
|
source = source.resolve()
|
|
|
|
tool = resolve_tool(args.ffprobe)
|
|
if tool is None:
|
|
raise CLIError("tool_not_found", f"ffprobe executable not found: {args.ffprobe}", 3, tool=args.ffprobe)
|
|
|
|
selected: list[Path] = []
|
|
truncated = False
|
|
for candidate in iter_regular_files(source):
|
|
if len(selected) == args.max_files:
|
|
truncated = True
|
|
break
|
|
selected.append(candidate.resolve())
|
|
|
|
entries: list[dict[str, Any]] = []
|
|
failures = 0
|
|
for candidate in selected:
|
|
stat = candidate.stat()
|
|
entry: dict[str, Any] = {
|
|
"path": str(candidate),
|
|
"size_bytes": stat.st_size,
|
|
"mtime_ns": stat.st_mtime_ns,
|
|
}
|
|
try:
|
|
entry["ffprobe"] = probe_file(tool, candidate, args.timeout, args.max_output_bytes)
|
|
entry["probe_ok"] = True
|
|
except CLIError as exc:
|
|
failures += 1
|
|
entry["probe_ok"] = False
|
|
entry["probe_error"] = {"code": exc.code, "message": exc.message, **exc.details}
|
|
entries.append(entry)
|
|
|
|
payload = {
|
|
"ok": failures == 0,
|
|
"schema_version": 1,
|
|
"root": str(source),
|
|
"limits": {
|
|
"max_files": args.max_files,
|
|
"timeout_seconds": args.timeout,
|
|
"max_output_bytes": args.max_output_bytes,
|
|
},
|
|
"file_count": len(entries),
|
|
"probe_failures": failures,
|
|
"truncated": truncated,
|
|
"files": entries,
|
|
}
|
|
emit(payload, args.json)
|
|
return 0 if failures == 0 else 1
|
|
except CLIError as exc:
|
|
error = {"code": exc.code, "message": exc.message, **exc.details}
|
|
emit({"ok": False, "error": error}, 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())
|