mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-22 00:56:35 +03:00
299 lines
12 KiB
Python
Executable File
299 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Prepare a bounded, privacy-safe frame packet for attributed visual review."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from decimal import Decimal, InvalidOperation
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
MAX_FRAMES_HARD = 100
|
|
MAX_RANGE_HARD = Decimal("604800")
|
|
MAX_OUTPUT_BYTES_HARD = 1024 * 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
|
|
|
|
|
|
def decimal_value(raw: str, label: str) -> Decimal:
|
|
try:
|
|
value = Decimal(raw)
|
|
except InvalidOperation:
|
|
raise CLIError("invalid_number", f"{label} must be a decimal number: {raw}") from None
|
|
if not value.is_finite() or value < 0:
|
|
raise CLIError("invalid_number", f"{label} must be finite and nonnegative: {raw}")
|
|
return value
|
|
|
|
|
|
def rendered(value: Decimal) -> str:
|
|
result = format(value, "f").rstrip("0").rstrip(".")
|
|
return result or "0"
|
|
|
|
|
|
def cadence_points(raw: str) -> list[Decimal]:
|
|
parts = raw.split(":")
|
|
if len(parts) != 3:
|
|
raise CLIError("invalid_cadence", "cadence must be START:END:STEP in decimal seconds")
|
|
start, end, step = (decimal_value(value, "cadence value") for value in parts)
|
|
if step <= 0 or end < start:
|
|
raise CLIError("invalid_cadence", "cadence requires STEP > 0 and END >= START")
|
|
points: list[Decimal] = []
|
|
cursor = start
|
|
while cursor <= end and len(points) <= MAX_FRAMES_HARD:
|
|
points.append(cursor)
|
|
cursor += step
|
|
return points
|
|
|
|
|
|
def safe_version(tool: str) -> str:
|
|
result = subprocess.run(
|
|
[tool, "-version"], capture_output=True, text=True, check=False, timeout=10
|
|
)
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
raise CLIError("version_probe_failed", "could not record the FFmpeg build", 3)
|
|
return result.stdout.splitlines()[0]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("source")
|
|
parser.add_argument("--asset-id", required=True)
|
|
parser.add_argument("--question", required=True)
|
|
parser.add_argument("--stream", default="0:v:0")
|
|
parser.add_argument("--timestamp", action="append", default=[])
|
|
parser.add_argument("--cadence", action="append", default=[])
|
|
parser.add_argument("--neighbor-seconds", action="append", default=[])
|
|
parser.add_argument("--output-dir", required=True)
|
|
parser.add_argument("--max-frames", type=int, default=24)
|
|
parser.add_argument("--max-range-seconds", default="3600")
|
|
parser.add_argument("--max-output-bytes", type=int, default=50 * 1024 * 1024)
|
|
parser.add_argument("--scale", default="none")
|
|
parser.add_argument("--crop", default="none")
|
|
parser.add_argument("--color-transform", default="none")
|
|
parser.add_argument("--ffmpeg", default="ffmpeg")
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
try:
|
|
source = Path(args.source).expanduser().resolve()
|
|
output = Path(args.output_dir).expanduser().resolve()
|
|
if not source.is_file():
|
|
raise CLIError("input_not_found", "source is not a regular file")
|
|
if output.exists():
|
|
raise CLIError(
|
|
"output_exists", "output directory already exists; choose a new task-local path", 4
|
|
)
|
|
if not 1 <= args.max_frames <= MAX_FRAMES_HARD:
|
|
raise CLIError("invalid_limit", f"max frames must be between 1 and {MAX_FRAMES_HARD}")
|
|
max_range = decimal_value(args.max_range_seconds, "max range")
|
|
if max_range <= 0 or max_range > MAX_RANGE_HARD:
|
|
raise CLIError("invalid_limit", f"max range must be in (0, {rendered(MAX_RANGE_HARD)}]")
|
|
if not 0 < args.max_output_bytes <= MAX_OUTPUT_BYTES_HARD:
|
|
raise CLIError(
|
|
"invalid_limit", f"max output bytes must be in (0, {MAX_OUTPUT_BYTES_HARD}]"
|
|
)
|
|
|
|
explicit = [decimal_value(value, "timestamp") for value in args.timestamp]
|
|
if not explicit and not args.cadence:
|
|
raise CLIError("sampling_required", "provide an explicit timestamp or bounded cadence")
|
|
requested: list[tuple[Decimal, str, Decimal | None]] = [
|
|
(point, "explicit", None) for point in explicit
|
|
]
|
|
for specification in args.cadence:
|
|
requested.extend((point, "cadence", None) for point in cadence_points(specification))
|
|
offsets = [decimal_value(value, "neighbor seconds") for value in args.neighbor_seconds]
|
|
for boundary in explicit:
|
|
for offset in offsets:
|
|
if offset == 0:
|
|
continue
|
|
requested.append((max(Decimal(0), boundary - offset), "neighbor_before", boundary))
|
|
requested.append((boundary + offset, "neighbor_after", boundary))
|
|
|
|
by_timestamp: dict[Decimal, dict[str, Any]] = {}
|
|
for point, reason, boundary in requested:
|
|
entry = by_timestamp.setdefault(point, {"reasons": [], "boundary_seconds": []})
|
|
entry["reasons"].append(reason)
|
|
if boundary is not None:
|
|
entry["boundary_seconds"].append(rendered(boundary))
|
|
timestamps = sorted(by_timestamp)
|
|
if len(timestamps) > args.max_frames:
|
|
raise CLIError(
|
|
"frame_limit_exceeded",
|
|
"sampling plan exceeds the declared frame limit",
|
|
requested=len(timestamps),
|
|
limit=args.max_frames,
|
|
)
|
|
span = timestamps[-1] - timestamps[0]
|
|
if span > max_range:
|
|
raise CLIError(
|
|
"range_limit_exceeded",
|
|
"sampling plan exceeds the declared timestamp range",
|
|
observed=rendered(span),
|
|
limit=rendered(max_range),
|
|
)
|
|
|
|
ffmpeg = shutil.which(args.ffmpeg)
|
|
if ffmpeg is None:
|
|
raise CLIError("tool_not_found", f"FFmpeg executable not found: {args.ffmpeg}", 3)
|
|
build = safe_version(ffmpeg)
|
|
extractor = Path(__file__).with_name("extract-review-frames")
|
|
command = [
|
|
sys.executable,
|
|
str(extractor),
|
|
str(source),
|
|
"--output-dir",
|
|
str(output / "frames"),
|
|
"--ffmpeg",
|
|
ffmpeg,
|
|
"--stream",
|
|
args.stream,
|
|
"--max-frames",
|
|
str(args.max_frames),
|
|
"--max-timestamp",
|
|
rendered(timestamps[-1] or Decimal("0.001")),
|
|
"--json",
|
|
]
|
|
video_filters = []
|
|
if args.scale != "none":
|
|
video_filters.append(f"scale={args.scale}")
|
|
if args.crop != "none":
|
|
video_filters.append(f"crop={args.crop}")
|
|
if args.color_transform != "none":
|
|
video_filters.append(args.color_transform)
|
|
if video_filters:
|
|
command.extend(["--video-filter", ",".join(video_filters)])
|
|
for timestamp in timestamps:
|
|
command.extend(["--timestamp", rendered(timestamp)])
|
|
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
if result.returncode != 0:
|
|
try:
|
|
detail = json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
detail = {"stderr": result.stderr[-2000:]}
|
|
raise CLIError("extraction_failed", "bounded frame extraction failed", 1, detail=detail)
|
|
extraction = json.loads(result.stdout)
|
|
total_bytes = sum(frame["size_bytes"] for frame in extraction["frames"])
|
|
if total_bytes > args.max_output_bytes:
|
|
shutil.rmtree(output)
|
|
raise CLIError(
|
|
"output_limit_exceeded",
|
|
"extracted packet exceeds the declared byte limit",
|
|
4,
|
|
observed=total_bytes,
|
|
limit=args.max_output_bytes,
|
|
)
|
|
|
|
artifacts = []
|
|
for timestamp, frame in zip(timestamps, extraction["frames"], strict=True):
|
|
artifact = Path(frame["output"])
|
|
artifacts.append(
|
|
{
|
|
"artifact": str(Path("frames") / artifact.name),
|
|
"source_asset_id": args.asset_id,
|
|
"stream": args.stream,
|
|
"source_timestamp_seconds": rendered(timestamp),
|
|
"sampling_reasons": sorted(set(by_timestamp[timestamp]["reasons"])),
|
|
"boundary_seconds": sorted(set(by_timestamp[timestamp]["boundary_seconds"])),
|
|
"size_bytes": frame["size_bytes"],
|
|
"sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
|
"extraction_command": [
|
|
ffmpeg,
|
|
"-ss",
|
|
rendered(timestamp),
|
|
"-i",
|
|
f"<SOURCE:{args.asset_id}>",
|
|
"-map",
|
|
args.stream,
|
|
"-frames:v",
|
|
"1",
|
|
*(["-vf", ",".join(video_filters)] if video_filters else []),
|
|
f"<PACKET>/frames/{artifact.name}",
|
|
],
|
|
"transforms": {
|
|
"scale": args.scale,
|
|
"crop": args.crop,
|
|
"color": args.color_transform,
|
|
},
|
|
}
|
|
)
|
|
manifest = {
|
|
"schema_version": 1,
|
|
"packet_id": hashlib.sha256(
|
|
f"{args.asset_id}:{','.join(rendered(x) for x in timestamps)}".encode()
|
|
).hexdigest()[:16],
|
|
"asset_id": args.asset_id,
|
|
"stream": args.stream,
|
|
"question": args.question,
|
|
"ffmpeg_build": build,
|
|
"sampling": {
|
|
"timestamps_seconds": [rendered(value) for value in timestamps],
|
|
"explicit_timestamps_seconds": [rendered(value) for value in explicit],
|
|
"cadence_specs": args.cadence,
|
|
"neighbor_offsets_seconds": [rendered(value) for value in offsets],
|
|
"coverage_statement": "Only the listed timestamps were sampled; unsampled intervals and whole-asset absence claims are not established.",
|
|
"blind_spots": [
|
|
"motion between samples",
|
|
"unsampled intervals",
|
|
"target display and color pipeline",
|
|
"audio and transcript content",
|
|
],
|
|
},
|
|
"limits": {
|
|
"max_frames": args.max_frames,
|
|
"max_range_seconds": rendered(max_range),
|
|
"max_output_bytes": args.max_output_bytes,
|
|
},
|
|
"total_output_bytes": total_bytes,
|
|
"artifacts": artifacts,
|
|
"review": {
|
|
"status": "pending",
|
|
"required_fields": [
|
|
"reviewer",
|
|
"observations",
|
|
"blind_spots",
|
|
"editorial_consequence",
|
|
],
|
|
},
|
|
}
|
|
manifest_path = output / "manifest.json"
|
|
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
|
payload = {"ok": True, "manifest": str(manifest_path), **manifest}
|
|
print(
|
|
json.dumps(
|
|
payload,
|
|
separators=(",", ":") if args.json else None,
|
|
indent=None if args.json else 2,
|
|
)
|
|
)
|
|
return 0
|
|
except (CLIError, OSError, subprocess.TimeoutExpired) as exc:
|
|
if isinstance(exc, CLIError):
|
|
payload = {
|
|
"ok": False,
|
|
"error": {"code": exc.code, "message": exc.message, **exc.details},
|
|
}
|
|
code = exc.exit_code
|
|
else:
|
|
payload = {"ok": False, "error": {"code": "io_error", "message": str(exc)}}
|
|
code = 2
|
|
print(
|
|
json.dumps(payload, separators=(",", ":") if args.json else None),
|
|
file=sys.stdout if args.json else sys.stderr,
|
|
)
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|