diff --git a/autorender.sh b/autorender.sh index 4215fa5..a53c6b2 100755 --- a/autorender.sh +++ b/autorender.sh @@ -65,10 +65,13 @@ fi log "autorender start (HEAD $(git -C "$GNOMMO_DIR" rev-parse --short HEAD 2>/dev/null), user $(whoami))" -# ── Step 2: render loop — pending `gnommo auto` ──────────────────────────────── -# When gnommo auto lands, replace the placeholder with: -# cd "$GNOMMO_DIR" && ./venv/bin/python -m gnommo auto 2>&1 | tee -a "$LOG" \ -# || notify "autorender: gnommo auto reported failures" -log "render step not wired yet (gnommo auto pending) — code-deploy path is live" - -log "autorender done" +# ── Step 2: render loop — per-project down → gated render → handoff ──────────── +# `gnommo auto` scans video* under the cwd, so cd into the repo first. It returns +# non-zero if any project failed; we ping on that. +cd "$GNOMMO_DIR" +if ./venv/bin/python -m gnommo auto 2>&1 | tee -a "$LOG"; then + log "autorender done (all projects ok)" +else + log "autorender done WITH FAILURES (see log above)" + notify "autorender: one or more projects failed on the rig — check autorender.log" +fi diff --git a/gnommo/cli.py b/gnommo/cli.py index 8bff113..781e0e5 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -131,6 +131,7 @@ Examples: "grade", "all", "align", + "auto", "import", "description", "archive", @@ -341,6 +342,8 @@ Examples: if args.project is None: if action == "pexels" and args.search: project_path = Path.cwd() # placeholder; cmd_pexels won't use it in search mode + elif action == "auto": + project_path = Path.cwd() # auto scans this root for video* projects else: parser.error("argument -p/--project is required") return 1 @@ -425,6 +428,8 @@ Examples: return cmd_all( project_path, args.verbose, args.dry_run, args.res, args.force ) + elif action == "auto": + return cmd_auto(project_path, args.verbose, args.dry_run, args.res) elif action == "description": return cmd_description(project_path, args.verbose) elif action == "archive": @@ -6171,6 +6176,100 @@ def cmd_all( return cmd_up(project_path, verbose, dry_run) +# ============================================================================= +# Auto Command — unattended render driver for the rig (called by autorender.sh) +# ============================================================================= + + +def cmd_auto( + root: Path, + verbose: bool, + dry_run: bool, + res: str = "full", + prod: bool = True, +) -> int: + """Unattended per-project driver: down → (commit-gated) render → handoff. + + For every ``video*`` project under ``root`` (default: the current directory, + which autorender.sh cd's into), pull the latest tree from the relay, then — + only when the project's commits.log has an entry newer than the one this rig + last handled — run the gated pipeline (preprocess → trim → render, each of + which self-skips when its own inputs are unchanged) and hand the result off. + The commit timestamp is the explicit "this is ready" trigger, so incidental + file touches don't cause spurious re-renders or version bumps. + + Returns non-zero if any project failed, so autorender.sh can ping on failure. + """ + from .transfer import cmd_down, _read_log_lines, _last_timestamp, _COMMITS_LOG + from .handoff import cmd_handoff + from . import state as _state + + projects = sorted( + d for d in Path(root).glob("video*") + if d.is_dir() and (d / "project.json").exists() + ) + if not projects: + print(f" No video* projects with project.json under {root}") + return 0 + + print(f"=== auto: {len(projects)} project(s) under {root} ===") + rendered: list[str] = [] + skipped: list[str] = [] + failed: list[tuple[str, str]] = [] + + for proj in projects: + name = proj.name + print(f"\n--- {name} ---") + + # 1. Sync from the relay (brings the newest commits.log + inputs). Skipped + # in dry-run so it stays side-effect-free — we just report intent. + if not dry_run and cmd_down(proj, verbose, dry_run=False) != 0: + print(f" {name}: down failed") + failed.append((name, "down")) + continue + + # 2. Commit trigger: is commits.log newer than what we last handled here? + latest = _last_timestamp(_read_log_lines(proj / _COMMITS_LOG)) + handled = _state.get_items(proj, "auto").get("handled") + if latest is None: + print(f" {name}: no commits.log — skipping") + skipped.append(name) + continue + if latest == handled: + print(f" {name}: unchanged since {handled} — skipping") + skipped.append(name) + continue + + print(f" {name}: new commit {latest} (last handled: {handled or 'never'})") + if dry_run: + print(" [dry-run] would preprocess → trim → render → handoff") + rendered.append(name) + continue + + # 3. Gated pipeline — each stage self-skips when its inputs are unchanged; + # `or` short-circuits at the first non-zero (failing) stage. + rc = ( + cmd_preprocess(proj, verbose, False, force=False, workers=1, res=res) + or cmd_trim(proj, verbose, force=False) + or cmd_render(proj, verbose, False, res=res, force=False) + or cmd_handoff(proj, verbose, None, prod, res) + ) + if rc != 0: + print(f" {name}: pipeline failed (rc={rc})") + failed.append((name, f"rc={rc}")) + continue + + # 4. Record this commit as handled so we don't re-render it next run. + _state.record_items(proj, "auto", {"handled": latest}) + rendered.append(name) + + print("\n=== auto summary ===") + print(f" rendered: {', '.join(rendered) or '—'}") + print(f" skipped: {', '.join(skipped) or '—'}") + print(f" failed: {', '.join(f'{n}({r})' for n, r in failed) or '—'}") + return 1 if failed else 0 + + # ============================================================================= # Description Command # =============================================================================