376edd7819
Per sub-project-2 plan Task 12 / spec §8.2. Walks proposed records (fields + coords + rationale + best-effort ffmpeg preview), prompts accept/edit/skip/quit, and persists each approval via save_catalog rewrite. I/O seams (input_fn/now_fn/ out) and --no-preview make the walk testable hermetically; decision logic lives in the tested review core. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
5.1 KiB
Python
155 lines
5.1 KiB
Python
"""Interactive review walk: `python -m tools.review_cli`.
|
|
|
|
Thin shell over the tested review core (tools.review). Walks each proposed
|
|
record showing its mechanical fields, the proposed coordinates + rationale, and
|
|
a best-effort preview frame, then prompts accept/edit/skip/quit. Approvals are
|
|
persisted (full rewrite) after each one, so an interrupted session keeps its
|
|
progress.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from hef.catalog import load_catalog, save_catalog, validate_catalog
|
|
from tools.mediatools import extract_frame
|
|
from tools.review import approve, proposed_records
|
|
|
|
DEFAULT_CATALOG = "catalog/library.jsonl"
|
|
DEFAULT_MEDIA_ROOT = "./media"
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(
|
|
prog="tools.review_cli",
|
|
description="Walk proposed records and approve/correct their coordinates.",
|
|
)
|
|
p.add_argument("--catalog", default=DEFAULT_CATALOG)
|
|
p.add_argument(
|
|
"--media-root",
|
|
default=os.environ.get("HEF_MEDIA_ROOT", DEFAULT_MEDIA_ROOT),
|
|
)
|
|
p.add_argument("--no-preview", action="store_true", help="skip preview rendering")
|
|
return p
|
|
|
|
|
|
def _show(rec, out) -> None:
|
|
print(f"\n{'=' * 60}", file=out)
|
|
print(f" id {rec.id}", file=out)
|
|
print(f" title {rec.title}", file=out)
|
|
print(f" source {rec.source_archive} {rec.source_url}", file=out)
|
|
attr = f" ({rec.attribution})" if rec.attribution else ""
|
|
print(f" license {rec.license}{attr}", file=out)
|
|
print(f" media mode={rec.mode} {rec.duration_s}s {rec.resolution}", file=out)
|
|
if rec.dominant_color:
|
|
print(f" color {rec.dominant_color}", file=out)
|
|
print(
|
|
f" proposed left={rec.left} right={rec.right} dark={rec.dark} light={rec.light}",
|
|
file=out,
|
|
)
|
|
print(f" rationale {rec.rationale}", file=out)
|
|
if rec.notes:
|
|
print(f" notes {rec.notes}", file=out)
|
|
|
|
|
|
def _open_cmd():
|
|
return "open" if platform.system() == "Darwin" else "xdg-open"
|
|
|
|
|
|
def _preview(rec, media_root, out) -> None:
|
|
"""Best-effort preview; never fatal. Requires ffmpeg for video frames."""
|
|
if not shutil.which("ffmpeg"):
|
|
return
|
|
src = Path(media_root) / rec.file_path
|
|
if not src.exists():
|
|
print(f" (preview: media not found at {src})", file=out)
|
|
return
|
|
try:
|
|
midpoint = rec.duration_s / 2 if rec.duration_s else 0.0
|
|
if rec.mode in ("video", "av"):
|
|
dest = Path(tempfile.gettempdir()) / f"hef-preview-{rec.id}.png"
|
|
extract_frame(src, dest, midpoint_s=midpoint)
|
|
else: # audio -> waveform thumbnail
|
|
dest = Path(tempfile.gettempdir()) / f"hef-preview-{rec.id}.png"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-v", "quiet", "-y", "-i", str(src),
|
|
"-filter_complex", "showwavespic=s=640x240", "-frames:v", "1",
|
|
str(dest),
|
|
],
|
|
check=True,
|
|
)
|
|
opener = shutil.which(_open_cmd())
|
|
if opener:
|
|
subprocess.run([opener, str(dest)], check=False)
|
|
except Exception as exc: # noqa: BLE001 - preview is best-effort
|
|
print(f" (preview failed: {exc})", file=out)
|
|
|
|
|
|
def _prompt_coords(rec, input_fn, out):
|
|
from hef.selection import Coordinate
|
|
|
|
def _ask(axis, current):
|
|
raw = input_fn(f" {axis} [{current}]: ").strip()
|
|
if not raw:
|
|
return current
|
|
try:
|
|
return max(0, min(4, int(raw)))
|
|
except ValueError:
|
|
print(f" (not an int, keeping {current})", file=out)
|
|
return current
|
|
|
|
return Coordinate(
|
|
_ask("left", rec.left),
|
|
_ask("right", rec.right),
|
|
_ask("dark", rec.dark),
|
|
_ask("light", rec.light),
|
|
)
|
|
|
|
|
|
def main(argv=None, *, input_fn=input, now_fn=_now, out=sys.stdout) -> int:
|
|
args = _build_parser().parse_args(argv)
|
|
|
|
records = load_catalog(args.catalog)
|
|
validate_catalog(records)
|
|
pending = proposed_records(records)
|
|
if not pending:
|
|
print("no proposed records to review", file=out)
|
|
return 0
|
|
|
|
pos = {r.id: i for i, r in enumerate(records)}
|
|
print(f"{len(pending)} proposed record(s) to review", file=out)
|
|
|
|
for rec in pending:
|
|
_show(rec, out)
|
|
if not args.no_preview:
|
|
_preview(rec, args.media_root, out)
|
|
choice = input_fn("[a]ccept / [e]dit / [s]kip / [q]uit > ").strip().lower()
|
|
if choice == "q":
|
|
break
|
|
if choice == "s" or choice not in ("a", "e"):
|
|
continue
|
|
coordinate = _prompt_coords(rec, input_fn, out) if choice == "e" else None
|
|
approved = approve(records[pos[rec.id]], reviewed_at=now_fn(), coordinate=coordinate)
|
|
records[pos[rec.id]] = approved
|
|
save_catalog(records, args.catalog)
|
|
print(f" approved {rec.id}", file=out)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(main())
|