From 4e2f8d0fecd55a7baa9d8a57aaa9ccbc64bffbe6 Mon Sep 17 00:00:00 2001 From: Seanmcd15 <168387522+Seanmcd15@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:17:50 -0400 Subject: [PATCH 1/3] Add a proof movie of the Hermes function-calling toolkit (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a rebuildable proof movie of the function-calling toolkit Vendors the proving-it-works pipeline and a filmed CLI that exercises real schema conversion, parsing, argument validation, and code_interpreter — without loading the GPU model or faking Yahoo quotes. Co-authored-by: Seanmcd15 * Fix card rendering and burn-in so the proof movie actually ships Render title/end cards with PIL instead of a hanging Chrome file:// URL, point ffmpeg's subtitles filter at the SRT directory so libass burns them into the picture, and keep those cards in-tree for a rebuild without a browser. Co-authored-by: Seanmcd15 --------- Co-authored-by: Cursor Agent Co-authored-by: Seanmcd15 --- .gitignore | 14 ++ proof/README.md | 42 ++++++ proof/cards/end.png | Bin 0 -> 20358 bytes proof/cards/title.png | Bin 0 -> 17438 bytes proof/film.py | 219 +++++++++++++++++++++++++++ proof/make-movie.sh | 55 +++++++ proof/render_cards.py | 76 ++++++++++ proof/scenes.yaml | 57 +++++++ proof/scripts/assemble | 209 ++++++++++++++++++++++++++ proof/scripts/burn-subtitles | 100 ++++++++++++ proof/scripts/check-movie | 261 ++++++++++++++++++++++++++++++++ proof/scripts/make-subtitles | 125 +++++++++++++++ proof/scripts/narrate | 284 +++++++++++++++++++++++++++++++++++ proof/show.py | 145 ++++++++++++++++++ 14 files changed, 1587 insertions(+) create mode 100644 .gitignore create mode 100644 proof/README.md create mode 100644 proof/cards/end.png create mode 100644 proof/cards/title.png create mode 100755 proof/film.py create mode 100755 proof/make-movie.sh create mode 100755 proof/render_cards.py create mode 100644 proof/scenes.yaml create mode 100755 proof/scripts/assemble create mode 100755 proof/scripts/burn-subtitles create mode 100755 proof/scripts/check-movie create mode 100755 proof/scripts/make-subtitles create mode 100755 proof/scripts/narrate create mode 100755 proof/show.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab1ce79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +*.pyc +inference_logs/ +proof/frames/ +proof/narration/ +proof/segments/ +proof/work/ +proof/chrome-profile/ +proof/*.mp4 +proof/*.srt +proof/*-check/ +*.egg-info/ +.python-version diff --git a/proof/README.md b/proof/README.md new file mode 100644 index 0000000..c21f7b6 --- /dev/null +++ b/proof/README.md @@ -0,0 +1,42 @@ +# Proof movie — Hermes Function Calling + +A rebuildable movie that proves the function-calling **toolkit** works on +this machine. It does **not** load Hermes-2-Pro (that needs a GPU and +several gigabytes of weights). Yahoo Finance also 429'd from this network, +so the live tool shown is `code_interpreter` rather than a faked stock quote. + +Pipeline is the proving-it-works skill (`prime-radiant-inc/proving-it-works`): +terminal capture via ttyd + tmux + Chrome, local narration, burned-in +subtitles, then `check-movie`. + +## What the movie shows + +| Scene | Real code path | +|---|---| +| tools | `functions.get_openai_tools()` | +| parse | `utils.validate_and_extract_tool_calls()` | +| validate | `validator.validate_function_call_schema()` | +| execute | `functions.code_interpreter.invoke(...)` | + +## Rebuild + +```bash +# once: lightweight venv (no torch / flash-attn) +uv venv .venv +uv pip install --python .venv/bin/python \ + langchain==0.1.9 pydantic==2.6.2 jsonschema==4.21.1 \ + yfinance==0.2.36 pandas==2.2.0 beautifulsoup4 requests art pyyaml \ + pillow websockets + +# ttyd + uv on PATH, Google Chrome installed +proof/make-movie.sh +``` + +`proof/scripts/` are the proving-it-works helpers (MIT), vendored so the +movie can be re-cut without a plugin install. Title/end cards are PNG +stills from `render_cards.py` (PIL) so assemble does not depend on a +browser. + +Frames, narration wavs, and the encoded movie stay out of git — they are +scratch. `scenes.yaml`, `show.py`, `film.py`, `render_cards.py`, +`cards/*.png`, and `make-movie.sh` are the source of truth. diff --git a/proof/cards/end.png b/proof/cards/end.png new file mode 100644 index 0000000000000000000000000000000000000000..8cde32b2df37ead950078fe2bf7ce530f5362164 GIT binary patch literal 20358 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 str: + r = subprocess.run( + ["tmux", "display-message", "-p", "-t", SESSION, "#{pane_current_command}"], + capture_output=True, + text=True, + ) + return r.stdout.strip() + + +def wait_for_shell(timeout: float = 120) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if pane_command() in ("bash", "sh", "zsh"): + return + time.sleep(0.4) + raise SystemExit("shell never came back; refusing to type into a running program") + + +def send(keys: str, enter: bool = True) -> None: + cmd = ["tmux", "send-keys", "-t", SESSION, keys] + if enter: + cmd.append("Enter") + subprocess.run(cmd, check=True, capture_output=True) + + +def send_and_wait(keys: str, timeout: float = 120) -> None: + """Type a command, wait for it to start, then wait for the shell to return.""" + send(keys) + deadline = time.time() + 8 + while time.time() < deadline and pane_command() in ("bash", "sh", "zsh"): + time.sleep(0.1) + wait_for_shell(timeout=timeout) + + +async def cdp(ws, mid, method, params=None): + await ws.send(json.dumps({"id": mid, "method": method, "params": params or {}})) + while True: + msg = json.loads(await ws.recv()) + if msg.get("id") == mid: + return msg.get("result", {}) + + +async def shoot(ws, outdir: Path, stop: asyncio.Event, counter: list[int]) -> None: + import websockets # noqa: F401 — imported by caller + + mid = 1000 + while not stop.is_set(): + mid += 1 + try: + res = await asyncio.wait_for( + cdp(ws, mid, "Page.captureScreenshot", {"format": "png"}), timeout=3 + ) + data = res.get("data") + if data: + (outdir / f"f{counter[0]:05d}.png").write_bytes(base64.b64decode(data)) + counter[0] += 1 + except asyncio.TimeoutError: + pass + await asyncio.sleep(1 / FPS) + + +async def preflight(ws) -> None: + wait_for_shell() + send("echo PREFLIGHT_OK") + await asyncio.sleep(1.5) + res = await cdp(ws, 900, "Page.captureScreenshot", {"format": "png"}) + png = base64.b64decode(res["data"]) + from io import BytesIO + + from PIL import Image + + im = Image.open(BytesIO(png)).convert("L") + px = list(im.getdata()) + lit = sum(1 for v in px if v > 90) / len(px) + if lit < 0.002: + raise SystemExit( + f"preflight failed: terminal renders blank ({lit:.4%} lit pixels). " + "Check software GL flags before filming." + ) + print(f"preflight ok: {lit:.2%} of pixels lit") + send("clear") + await asyncio.sleep(0.8) + + +async def beats_tools() -> None: + await asyncio.sleep(1.2) + send_and_wait("python proof/show.py tools") + await asyncio.sleep(5) + + +async def beats_parse() -> None: + await asyncio.sleep(1.0) + send("clear") + await asyncio.sleep(0.6) + send_and_wait("python proof/show.py parse") + await asyncio.sleep(5) + + +async def beats_validate() -> None: + await asyncio.sleep(1.0) + send("clear") + await asyncio.sleep(0.6) + send_and_wait("python proof/show.py validate") + await asyncio.sleep(6) + + +async def beats_execute() -> None: + await asyncio.sleep(1.0) + send("clear") + await asyncio.sleep(0.6) + send_and_wait("python proof/show.py execute") + await asyncio.sleep(6) + + +BEATS = { + "tools": beats_tools, + "parse": beats_parse, + "validate": beats_validate, + "execute": beats_execute, +} + + +async def main(segment: str, url: str) -> None: + import websockets + + if segment not in BEATS: + raise SystemExit(f"unknown segment {segment!r}; expected {sorted(BEATS)}") + + outdir = HERE / "frames" / segment + outdir.mkdir(parents=True, exist_ok=True) + for old in outdir.glob("*.png"): + old.unlink() + + # Drop a leftover debug Chrome so this take owns DEBUG_PORT. + subprocess.run( + ["pkill", "-f", f"--remote-debugging-port={DEBUG_PORT}"], + capture_output=True, + ) + time.sleep(0.4) + + profile = HERE / "chrome-profile" + profile.mkdir(exist_ok=True) + chrome = subprocess.Popen( + [ + CHROME, + f"--remote-debugging-port={DEBUG_PORT}", + "--headless=new", + "--user-data-dir=" + str(profile), + "--window-size=1280,800", + "--hide-scrollbars", + "--no-sandbox", + "--force-device-scale-factor=2", + "--use-gl=angle", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "about:blank", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + time.sleep(3) + tabs = json.loads(urllib.request.urlopen(f"http://127.0.0.1:{DEBUG_PORT}/json").read()) + ws_url = [t for t in tabs if t["type"] == "page"][0]["webSocketDebuggerUrl"] + async with websockets.connect(ws_url, max_size=40 * 1024 * 1024) as ws: + await cdp(ws, 1, "Page.enable") + await cdp(ws, 3, "Page.navigate", {"url": url}) + await asyncio.sleep(4) + subprocess.run( + ["tmux", "refresh-client", "-t", SESSION], capture_output=True + ) + await asyncio.sleep(1) + await preflight(ws) + + stop = asyncio.Event() + counter = [0] + task = asyncio.create_task(shoot(ws, outdir, stop, counter)) + await BEATS[segment]() + stop.set() + await task + print(f"{segment}: {counter[0]} frames -> {outdir}") + finally: + chrome.terminate() + try: + chrome.wait(timeout=5) + except Exception: + chrome.kill() + + +if __name__ == "__main__": + if len(sys.argv) != 3: + raise SystemExit("usage: film.py ") + asyncio.run(main(sys.argv[1], sys.argv[2])) diff --git a/proof/make-movie.sh b/proof/make-movie.sh new file mode 100755 index 0000000..a060fc7 --- /dev/null +++ b/proof/make-movie.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Rebuild the Hermes Function Calling proof movie. +# Requires: ffmpeg, ffprobe, uv, ttyd, Google Chrome, the repo .venv +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROOF="$ROOT/proof" +SKILL="$PROOF/scripts" +SESSION=hermes-demo +TTYD_PORT=7681 +export PATH="$HOME/.local/bin:$ROOT/.venv/bin:$PATH" + +cd "$ROOT" + +if [[ ! -x "$ROOT/.venv/bin/python" ]]; then + echo "missing $ROOT/.venv — create it and pip-install langchain, pydantic, jsonschema, yfinance, pandas, art, pyyaml, pillow, websockets" >&2 + exit 1 +fi + +# --- terminal session served over HTTP --------------------------------- +tmux has-session -t "$SESSION" 2>/dev/null || \ + tmux new-session -d -s "$SESSION" -x 125 -y 34 -- bash -l + +# Point the pane at the repo with a short prompt. +tmux send-keys -t "$SESSION" "cd $ROOT" Enter +tmux send-keys -t "$SESSION" "export PATH=$ROOT/.venv/bin:\$HOME/.local/bin:\$PATH" Enter +tmux send-keys -t "$SESSION" "export PYTHONUNBUFFERED=1 PYTHONWARNINGS=ignore" Enter +tmux send-keys -t "$SESSION" "export PS1='$ '" Enter +sleep 0.4 + +if ! curl -fsS "http://127.0.0.1:${TTYD_PORT}/" >/dev/null 2>&1; then + ttyd -p "$TTYD_PORT" \ + -t fontSize=17 \ + -t 'fontFamily=DejaVu Sans Mono,monospace' \ + -t 'theme={"background":"#101014","foreground":"#e8e6e1"}' \ + tmux attach -t "$SESSION" >/tmp/ttyd-hermes.log 2>&1 & + # wait until it answers + for _ in $(seq 1 30); do + curl -fsS "http://127.0.0.1:${TTYD_PORT}/" >/dev/null 2>&1 && break + sleep 0.3 + done +fi + +python "$PROOF/render_cards.py" +python "$PROOF/film.py" tools "http://127.0.0.1:${TTYD_PORT}/" +python "$PROOF/film.py" parse "http://127.0.0.1:${TTYD_PORT}/" +python "$PROOF/film.py" validate "http://127.0.0.1:${TTYD_PORT}/" +python "$PROOF/film.py" execute "http://127.0.0.1:${TTYD_PORT}/" + +"$SKILL/narrate" "$PROOF/scenes.yaml" "$PROOF/narration/" +"$SKILL/assemble" "$PROOF/scenes.yaml" "$PROOF/silent-cut.mp4" +"$SKILL/make-subtitles" "$PROOF/narration/manifest.json" "$PROOF/movie.srt" \ + --offsets-json "$PROOF/segments/offsets.json" +"$SKILL/burn-subtitles" "$PROOF/silent-cut.mp4" "$PROOF/movie.srt" "$PROOF/movie.mp4" +"$SKILL/check-movie" "$PROOF/movie.mp4" diff --git a/proof/render_cards.py b/proof/render_cards.py new file mode 100755 index 0000000..c66bd7d --- /dev/null +++ b/proof/render_cards.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Render title/end cards as PNG so assemble does not need a browser.""" + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +HERE = Path(__file__).resolve().parent +OUT = HERE / "cards" +W, H = 1280, 800 +BG = (16, 16, 20) +FG = (242, 242, 245) +SUB = (154, 154, 166) + +MONO = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf" +SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" + + +def wrap(draw, text, font, max_width): + words = text.split() + lines, cur = [], "" + for w in words: + trial = (cur + " " + w).strip() + if draw.textlength(trial, font=font) <= max_width: + cur = trial + else: + if cur: + lines.append(cur) + cur = w + if cur: + lines.append(cur) + return lines + + +def card(title: str, subtitle: str, dest: Path) -> None: + img = Image.new("RGB", (W, H), BG) + draw = ImageDraw.Draw(img) + title_font = ImageFont.truetype(MONO, 42) + sub_font = ImageFont.truetype(SANS, 22) + max_w = int(W * 0.84) + t_lines = wrap(draw, title, title_font, max_w) + s_lines = wrap(draw, subtitle, sub_font, max_w) + gap = 22 + t_h = 50 + s_h = 32 + block = len(t_lines) * t_h + gap + len(s_lines) * s_h + y = (H - block) // 2 + for line in t_lines: + tw = draw.textlength(line, font=title_font) + draw.text(((W - tw) / 2, y), line, font=title_font, fill=FG) + y += t_h + y += gap + for line in s_lines: + tw = draw.textlength(line, font=sub_font) + draw.text(((W - tw) / 2, y), line, font=sub_font, fill=SUB) + y += s_h + dest.parent.mkdir(parents=True, exist_ok=True) + img.save(dest) + print(dest, dest.stat().st_size) + + +def main() -> None: + card( + "Hermes Function Calling", + "proving the toolkit — schemas, parser, validator, live tool execution", + OUT / "title.png", + ) + card( + "the contract holds without the GPU", + "schemas · parse · validate · execute — github.com/NousResearch/Hermes-Function-Calling", + OUT / "end.png", + ) + + +if __name__ == "__main__": + main() diff --git a/proof/scenes.yaml b/proof/scenes.yaml new file mode 100644 index 0000000..8a2f117 --- /dev/null +++ b/proof/scenes.yaml @@ -0,0 +1,57 @@ +fps: 30 +resolution: + width: 1280 + height: 800 +scenes: + - id: title + kind: image + src: cards/title.png + duration: 4 + narration: >- + This is Hermes Function Calling. The eight billion parameter model is + not on this machine. The toolkit that wraps it is, and that is what we + are about to run for real. + + - id: tools + kind: frames + src: frames/tools + rate: 2.0 + narration: >- + functions.py turns Python callables into OpenAI tool schemas. + Eleven tools come back. Here is get stock fundamentals: a symbol in, + a typed object out. + + - id: parse + kind: frames + src: frames/parse + rate: 2.0 + narration: >- + Hermes writes function calls inside tool call XML tags. The parser + pulls the JSON out of that markup so the rest of the stack can run it. + + - id: validate + kind: frames + src: frames/validate + rate: 2.0 + narration: >- + The validator checks every call against those live schemas. A Tesla + fundamentals request with a symbol is accepted. The same call with no + symbol is rejected. A function that does not exist is rejected too. + + - id: execute + kind: frames + src: frames/execute + rate: 2.0 + narration: >- + Yahoo finance rate limited this machine, so we did not fake a stock + quote. Instead we run the other first party tool, code interpreter, + on real Python. Squares of one through seven, total one hundred forty. + + - id: end + kind: image + src: cards/end.png + duration: 4.5 + narration: >- + Schema conversion, XML parse, argument checks, and a live tool. That + is the function calling contract. The language model is a caller, not + the implementation. diff --git a/proof/scripts/assemble b/proof/scripts/assemble new file mode 100755 index 0000000..c7834d0 --- /dev/null +++ b/proof/scripts/assemble @@ -0,0 +1,209 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["pyyaml"] +# /// +"""Assemble scenes into one movie, each segment held to max(narration, visuals). + +Reads the same scenes file narrate does, so the narration you rendered and +the picture you recorded stay in step by construction: a segment lasts as +long as whichever of its two halves is longer, and the short one is padded +(video freezes its last frame, audio pads with silence). + +It also writes segments/offsets.json — where each scene starts in the final +cut — which make-subtitles consumes. Hand-computing those offsets is the +step that silently breaks every time you insert or reorder a scene. + +Scene kinds: + card title/caption rendered as HTML and screenshotted (needs a browser) + image a still you already have (a contact sheet, a diagram) + frames a directory of PNGs, played at `rate` fps + movie an existing movie, played as itself with its own audio + +Usage: + assemble SCENES.yaml OUT.mp4 [--narration DIR] [--work DIR] [--browser PATH] +""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import yaml + +BROWSERS = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "chromium", "chromium-browser", "google-chrome", "google-chrome-stable", +] + +CARD_HTML = """ +

{TITLE}

{SUB}

+""" + + +def die(msg): + print(f"assemble: {msg}", file=sys.stderr) + sys.exit(1) + + +def run(cmd, timeout=None): + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + die(f"timed out ({timeout}s): {' '.join(map(str, cmd))[:240]}") + if r.returncode != 0: + die(f"{' '.join(map(str, cmd))}\n{r.stderr.strip()[:500]}") + return r + + +def dur(path): + r = run(["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", str(path)]) + return float(r.stdout.strip()) + + +def find_browser(explicit): + for cand in ([explicit] if explicit else []) + BROWSERS: + if not cand: + continue + if os.path.sep in cand and Path(cand).exists(): + return cand + found = shutil.which(cand) + if found: + return found + return None + + +def make_card(scene, png, w, h, browser): + if not browser: + die("a `card` scene needs a browser (Chrome/Chromium) to render text; " + "pass --browser, or use an `image` scene you rendered yourself") + html = CARD_HTML.format( + w=w, h=h, bg=scene.get("background", "#101014"), + gap=max(16, h // 44), title=scene.get("title_size", max(28, h // 14)), + sub=scene.get("subtitle_size", max(16, h // 32)), + TITLE=scene.get("title", ""), SUB=scene.get("subtitle", "")) + tmp = png.with_suffix(".html") + tmp.write_text(html) + # as_uri() yields file:///abs/path — quote(str(path)) becomes file://rel + # which Chrome treats as a host and hangs on ERR_INVALID_URL. + url = Path(tmp).resolve().as_uri() + run([browser, "--headless=new", "--no-sandbox", "--disable-gpu", + "--hide-scrollbars", f"--screenshot={png}", f"--window-size={w},{h}", + "--force-device-scale-factor=1", url], timeout=45) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("scenes", type=Path) + ap.add_argument("out", type=Path) + ap.add_argument("--narration", type=Path, default=None) + ap.add_argument("--work", type=Path, default=None) + ap.add_argument("--browser", default=None) + args = ap.parse_args() + + for tool in ("ffmpeg", "ffprobe"): + if not shutil.which(tool): + die(f"{tool} not on PATH") + + doc = yaml.safe_load(args.scenes.read_text()) + base = args.scenes.parent + res = doc.get("resolution", {}) or {} + W, H = int(res.get("width", 1920)), int(res.get("height", 1080)) + FPS = int(doc.get("fps", 30)) + narration = args.narration or (base / "narration") + work = args.work or (base / "segments") + work.mkdir(parents=True, exist_ok=True) + browser = find_browser(args.browser) + + fit = (f"scale={W}:{H}:force_original_aspect_ratio=decrease," + f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:color=#101014,setsar=1") + + offsets, clock, concat_lines = {}, 0.0, [] + for sc in doc["scenes"]: + sid = sc["id"] + kind = sc.get("kind", "frames") + seg = work / f"{sid}.mp4" + nar = narration / f"{sid}.wav" + nard = dur(nar) if nar.exists() else 0.0 + + if kind == "movie": + src = base / sc["src"] + target = dur(src) + inner_h = int(sc.get("height", int(H * 0.82))) + run(["ffmpeg", "-nostdin", "-y", "-v", "error", "-i", str(src), + "-vf", f"scale=-2:{inner_h},pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:" + f"color=#101014,setsar=1", + "-af", f"volume={sc.get('gain_db', 0)}dB,apad", + "-r", str(FPS), "-t", f"{target:.3f}", + "-c:v", "libx264", "-preset", "medium", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-ar", "44100", "-ac", "2", str(seg)]) + else: + if kind == "frames": + src = base / sc["src"] + n = len(list(Path(src).glob("*.png"))) + if not n: + die(f"scene {sid}: no PNGs in {src}") + rate = float(sc.get("rate", FPS)) + vis = n / rate + target = max(nard, vis) + vin = ["-framerate", str(rate), "-pattern_type", "glob", + "-i", str(Path(src) / "*.png")] + # freeze the last frame when narration outlasts the action + vf = fit + f",tpad=stop_mode=clone:stop_duration={max(0.0, target - vis):.3f}" + else: + if kind == "card": + img = work / f"card-{sid}.png" + make_card(sc, img, W, H, browser) + elif kind == "image": + img = base / sc["src"] + if not img.exists(): + die(f"scene {sid}: no such image {img}") + else: + die(f"scene {sid}: unknown kind {kind!r}") + target = max(nard, float(sc.get("duration", 3))) + vin = ["-loop", "1", "-i", str(img)] + vf = fit + + ain = (["-i", str(nar)] if nar.exists() + else ["-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo"]) + run(["ffmpeg", "-nostdin", "-y", "-v", "error", *vin, *ain, + "-vf", vf, "-af", "apad", "-r", str(FPS), "-t", f"{target:.3f}", + "-map", "0:v:0", "-map", "1:a:0", + "-c:v", "libx264", "-preset", "medium", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-ar", "44100", "-ac", "2", str(seg)]) + + actual = dur(seg) + # only scenes that speak get a subtitle offset; a movie played as + # itself carries its own subtitles already + if nar.exists() and kind != "movie": + offsets[sid] = round(clock, 3) + clock += actual + concat_lines.append(f"file '{seg.resolve()}'") + print(f"{sid}: {actual:.1f}s{' (own audio)' if kind == 'movie' else ''}") + + listing = work / "concat.txt" + listing.write_text("\n".join(concat_lines) + "\n") + run(["ffmpeg", "-nostdin", "-y", "-v", "error", "-f", "concat", "-safe", "0", + "-i", str(listing), "-c", "copy", str(args.out)]) + (work / "offsets.json").write_text(json.dumps(offsets, indent=2)) + print(f"\nassembled {args.out} ({dur(args.out):.1f}s)") + print(f"scene offsets -> {work / 'offsets.json'} " + f"(feed to make-subtitles --offsets-json)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proof/scripts/burn-subtitles b/proof/scripts/burn-subtitles new file mode 100755 index 0000000..06b5f20 --- /dev/null +++ b/proof/scripts/burn-subtitles @@ -0,0 +1,100 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.10" +# /// +"""Put subtitles on a movie, by whichever route this ffmpeg supports. + +Burning them into the picture is what you want: subtitles survive Slack, +PR previews, phones, and anything that plays video without a subtitle UI. +That needs an ffmpeg built with libass, which many are not — Homebrew's +default macOS build has no `subtitles` filter at all, while Debian's does. +Rather than emit a command that works on half of machines, this checks and +falls back to an embedded soft-subtitle track, telling you which you got. + +Usage: + burn-subtitles IN.mp4 SUBS.srt OUT.mp4 [--font NAME] [--size N] + [--soft] [--margin PX] +""" + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +def has_libass(): + out = subprocess.run(["ffmpeg", "-hide_banner", "-filters"], + capture_output=True, text=True) + return any(line.split()[1:2] == ["subtitles"] + for line in out.stdout.splitlines() if line.strip()) + + +def run(cmd, cwd=None): + r = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) + if r.returncode != 0: + print(" ".join(map(str, cmd)), file=sys.stderr) + print(r.stderr.strip()[:600], file=sys.stderr) + return r.returncode == 0 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("movie", type=Path) + ap.add_argument("subs", type=Path) + ap.add_argument("out", type=Path) + ap.add_argument("--font", default="DejaVu Sans") + ap.add_argument("--size", type=int, default=16) + ap.add_argument("--margin", type=int, default=30) + ap.add_argument("--soft", action="store_true", + help="embed a soft track even if burning is available") + args = ap.parse_args() + + if not shutil.which("ffmpeg"): + sys.exit("ffmpeg not on PATH") + for f in (args.movie, args.subs): + if not f.exists(): + sys.exit(f"no such file: {f}") + + if not args.soft and has_libass(): + # ffmpeg 8 dropped positional filter options, so name it explicitly: + # `subtitles=movie.srt` parses on 5.x and fails on 8.x, but + # `subtitles=filename=movie.srt` works on both + # BorderStyle=3 draws a filled box behind the text. Outline-only + # subtitles are legible over a dark terminal and marginal over a + # white app screenshot; a demo movie cuts between both. + style = (f"FontName={args.font},Fontsize={args.size}," + f"BorderStyle=3,Outline=1,Shadow=0,MarginV={args.margin}," + f"PrimaryColour=&H00FFFFFF&,OutlineColour=&HB0101014&," + f"BackColour=&HB0101014&") + # run from the subtitle's directory: the filter treats ':' and '\' in + # paths as its own syntax, and quoting around that is a losing game + ok = run(["ffmpeg", "-nostdin", "-y", "-v", "error", + "-i", str(args.movie.resolve()), + "-vf", f"subtitles=filename={args.subs.name}:" + f"force_style='{style}'", + "-c:a", "copy", "-c:v", "libx264", "-preset", "medium", + "-pix_fmt", "yuv420p", str(args.out.resolve())], + cwd=str(args.subs.resolve().parent)) + if ok: + print(f"burned into the picture -> {args.out}") + return 0 + print("burn failed; falling back to a soft track", file=sys.stderr) + + ok = run(["ffmpeg", "-nostdin", "-y", "-v", "error", + "-i", str(args.movie), "-i", str(args.subs), + "-c", "copy", "-c:s", "mov_text", + "-metadata:s:s:0", "language=eng", str(args.out)]) + if not ok: + return 1 + print(f"embedded a soft subtitle track -> {args.out}") + if not args.soft: + print("NOTE: this ffmpeg has no libass, so the subtitles are a track a " + "player must choose to show, not pixels. Anything that autoplays " + "without subtitle UI (Slack, PR previews) will show none. Install " + "an ffmpeg with libass to burn them in.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proof/scripts/check-movie b/proof/scripts/check-movie new file mode 100755 index 0000000..ad4cf05 --- /dev/null +++ b/proof/scripts/check-movie @@ -0,0 +1,261 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["pillow"] +# /// +"""Mechanical gate for a proof/demo movie: catches the silent defects that +per-frame inspection structurally cannot see. + +A movie can pass every frame check and still be unwatchable, because the +defects live *between* frames: action crammed into the first seconds, a +narrator talking over a picture that died, a silent audio track. This +samples the picture and the sound on the same timeline and compares them. + +Thresholds are heuristics tuned against real good and bad movies. They +catch the egregious cases; they cannot tell you a movie is *right*. That is +what the contact sheet is for, and you have to actually look at it. + +Known blind spot: the picture is sampled at 1 Hz, so a visual beat shorter +than a second (a flash, a blank frame during a reload) falls between samples +and reads as "no change". Hold anything that matters for >1s. + +Usage: + check-movie MOVIE [--out DIR] [--no-expect-audio] + [--no-expect-subtitles] [--subs FILE] [--json] +""" + +import argparse +import array +import json +import math +import shutil +import subprocess +import sys +from pathlib import Path + +from PIL import Image + +THUMB_W = 320 # sampling width; the metric is a pixel fraction, so scale-free +PIXEL_DELTA = 8 # per-pixel grey delta that counts as "this pixel moved" +CHANGE_FRAC = 0.002 # >0.2% of pixels moved => the picture reached a new state +SPEECH_DB = -45.0 # windowed RMS above this counts as "someone is talking" +EARLY_ACTION = 0.40 # last change before this fraction of runtime => front-loaded +TAIL_TALK_S = 5.0 # ...and this many seconds of narration after it => broken +WARN_TAIL_S = 15.0 # frozen tail worth mentioning even when it passes +WARN_GAP_S = 30.0 # hold this long mid-movie and a viewer wonders if it froze + + +def die(msg): + print(f"FAIL {msg}") + sys.exit(2) + + +def grey(path): + with Image.open(path) as im: + return list(im.convert("L").tobytes()) + + +def sample_picture(movie, workdir): + """Per-second: fraction of pixels that moved since the previous second.""" + frames = workdir / "samples" + frames.mkdir(parents=True, exist_ok=True) + for old in frames.glob("*.png"): + old.unlink() + out = subprocess.run( + ["ffmpeg", "-nostdin", "-v", "error", "-i", str(movie), + "-vf", f"fps=1,scale={THUMB_W}:-1", "-f", "image2", str(frames / "s%05d.png")], + capture_output=True, text=True) + if out.returncode != 0: + die(f"frame sampling failed: {out.stderr.strip()[:200]}") + paths = sorted(frames.glob("s*.png")) + if not paths: + die("no video frames could be sampled") + fracs, prev = [], None + for p in paths: + px = grey(p) + if prev is not None: + n = min(len(px), len(prev)) + moved = sum(1 for i in range(n) if abs(px[i] - prev[i]) > PIXEL_DELTA) + fracs.append(moved / n) + prev = px + return paths, fracs + + +def sample_sound(movie, has_audio): + """Per-second RMS in dBFS.""" + if not has_audio: + return [] + out = subprocess.run( + ["ffmpeg", "-nostdin", "-v", "error", "-i", str(movie), + "-map", "0:a:0", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"], + capture_output=True) + if out.returncode != 0 or not out.stdout: + die(f"audio decode failed: {out.stderr.decode()[:200]}") + pcm = array.array("h") + pcm.frombytes(out.stdout[: len(out.stdout) // 2 * 2]) + levels = [] + for start in range(0, len(pcm), 8000): + chunk = pcm[start:start + 8000] + if not chunk: + break + rms = math.sqrt(sum(float(s) * s for s in chunk) / len(chunk)) + levels.append(20 * math.log10(rms / 32768.0) if rms > 0 else -120.0) + return levels + + +def contact_sheet(paths, out_path, count=12): + picks = paths if len(paths) <= count else [ + paths[round(i * (len(paths) - 1) / (count - 1))] for i in range(count)] + thumbs = [Image.open(p).convert("RGB") for p in picks] + w, h = thumbs[0].size + # pick a column count that fills the grid exactly where possible: an + # empty cell reads as a black *frame*, which is a defect signal, and a + # sheet that lies about the movie defeats the point of the sheet + n = len(thumbs) + cols = next((c for c in (4, 3, 5, 2) if n % c == 0), min(4, n)) + rows = math.ceil(n / cols) + sheet = Image.new("RGB", (cols * w, rows * h), (48, 48, 52)) + for i, t in enumerate(thumbs): + sheet.paste(t, ((i % cols) * w, (i // cols) * h)) + sheet.save(out_path) + return [paths.index(p) for p in picks] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("movie", type=Path) + ap.add_argument("--out", type=Path, default=None) + ap.add_argument("--no-expect-audio", dest="expect_audio", + action="store_false", default=True) + ap.add_argument("--no-expect-subtitles", dest="expect_subs", + action="store_false", default=True) + ap.add_argument("--subs", type=Path, default=None, + help="sidecar .srt (default: MOVIE.srt beside the movie)") + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + if not args.movie.exists(): + die(f"no such movie: {args.movie}") + for tool in ("ffmpeg", "ffprobe"): + if not shutil.which(tool): + die(f"{tool} not on PATH") + + workdir = args.out or args.movie.parent / f"{args.movie.stem}-check" + workdir.mkdir(parents=True, exist_ok=True) + + meta = subprocess.run( + ["ffprobe", "-v", "error", "-print_format", "json", + "-show_format", "-show_streams", str(args.movie)], + capture_output=True, text=True) + if meta.returncode != 0: + die(f"ffprobe failed: {meta.stderr.strip()[:200]}") + info = json.loads(meta.stdout) + vs = [s for s in info["streams"] if s["codec_type"] == "video"] + as_ = [s for s in info["streams"] if s["codec_type"] == "audio"] + if not vs: + die("no video stream") + duration = float(info["format"].get("duration", 0)) + + paths, fracs = sample_picture(args.movie, workdir) + levels = sample_sound(args.movie, bool(as_)) + changes = [i for i, f in enumerate(fracs) if f > CHANGE_FRAC] + talking = [i for i, lv in enumerate(levels) if lv >= SPEECH_DB] + span = len(fracs) or 1 + last_change = changes[-1] if changes else None + last_talk = talking[-1] if talking else None + + print(f"container {vs[0]['codec_name']} {vs[0]['width']}x{vs[0]['height']}, " + f"{duration:.1f}s, audio={'yes' if as_ else 'no'}") + print(f"picture reaches a new state in {len(changes)} of {span} seconds" + + (f"; last at {last_change}s" if last_change is not None else "")) + if levels: + print(f"sound audible in {len(talking)} of {len(levels)} seconds" + + (f"; last at {last_talk}s" if last_talk is not None else "")) + + failures, warnings = [], [] + if duration < 1: + failures.append(f"duration is {duration:.2f}s - that is not a movie") + if args.expect_audio and not as_: + failures.append("expected narration but there is no audio stream") + if levels and not talking: + failures.append("the audio track is silent end to end") + + # a narrated movie with no subtitles fails for everyone watching it muted + if as_ and args.expect_subs: + srt = args.subs or args.movie.with_suffix(".srt") + embedded = any(s["codec_type"] == "subtitle" for s in info["streams"]) + if srt.exists(): + last = 0.0 + for line in srt.read_text(errors="replace").splitlines(): + if "-->" in line: + end = line.split("-->")[1].strip().split()[0] + hh, mm, rest = end.split(":") + ss, _, ms = rest.partition(",") + last = max(last, int(hh) * 3600 + int(mm) * 60 + int(ss) + + int(ms or 0) / 1000) + # compare against where the narration ends, not the runtime: a + # silent end card is normal and must not read as missing subtitles + speech_end = float(last_talk + 1) if last_talk is not None else duration + print(f"subtitles {srt.name}, last cue ends at {last:.1f}s " + f"(narration ends {speech_end:.0f}s)") + if last < speech_end - 3.0: + failures.append( + f"subtitles stop at {last:.0f}s but the narration runs to " + f"{speech_end:.0f}s - {speech_end - last:.0f}s of speech " + f"has no subtitles") + elif embedded: + print("subtitles embedded subtitle stream present") + else: + failures.append( + f"narrated, but no subtitles: expected {srt.name} beside the " + f"movie (or an embedded track). Run make-subtitles and burn " + f"them in; pass --no-expect-subtitles only for a movie nobody " + f"will ever watch muted.") + if not changes: + failures.append("the picture never reaches a new state - this is a still, " + "not a movie") + else: + tail_talk = (last_talk - last_change) if last_talk is not None else 0 + frozen_frac = (span - last_change) / span + if last_change < EARLY_ACTION * span and tail_talk > TAIL_TALK_S: + failures.append( + f"every visible change happens in the first {last_change}s " + f"({100*last_change/span:.0f}% of runtime), then the picture is " + f"frozen for {span - last_change}s while narration keeps talking " + f"for {tail_talk:.0f}s of it. The demo is over before the " + f"explanation starts: pace the action to the narration.") + elif tail_talk > WARN_TAIL_S: + warnings.append(f"{tail_talk:.0f}s of narration after the last visible " + f"change ({100*frozen_frac:.0f}% of runtime frozen)") + gaps = [changes[i + 1] - changes[i] for i in range(len(changes) - 1)] + if gaps and max(gaps) > WARN_GAP_S: + warnings.append(f"{max(gaps)}s with no visible change mid-movie - " + f"intentional hold, or did something hang?") + + sheet = workdir / "contact-sheet.png" + idxs = contact_sheet(paths, sheet) + print(f"sheet {sheet}") + print(f" sampled at {', '.join(str(i) + 's' for i in idxs)}") + + for w in warnings: + print(f"WARN {w}") + for f in failures: + print(f"FAIL {f}") + + if args.json: + (workdir / "check.json").write_text(json.dumps( + {"duration": duration, "change_seconds": changes, + "talk_seconds": talking, "failures": failures, + "warnings": warnings}, indent=2)) + + if failures: + print("\nNOT SHIPPABLE. Fix, regenerate, re-run.") + return 1 + print("\nMechanical checks pass. NOW OPEN THE CONTACT SHEET AND LOOK AT IT: " + "this script cannot see wrong content, unreadable text, a missing " + "cursor, or narration that says something the picture contradicts.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proof/scripts/make-subtitles b/proof/scripts/make-subtitles new file mode 100755 index 0000000..7c73473 --- /dev/null +++ b/proof/scripts/make-subtitles @@ -0,0 +1,125 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.10" +# /// +"""Build an SRT from narrate's manifest, timed to the measured clips. + +Subtitles are not decoration. A movie gets watched muted - in a PR, on a +phone, in an open-plan office, by someone who is deaf - and an unsubtitled +narrated movie simply doesn't communicate to those viewers. They also make +the movie searchable and let a reviewer check what was said without +listening. + +Cue timing is proportional to character count within each scene's measured +audio, which tracks speech closely enough for reading. If you need +word-exact timing, transcribe the rendered audio with a word-timestamp API +and use those offsets instead. + +Usage: + make-subtitles MANIFEST.json OUT.srt [--offsets SCENE=SECONDS ...] + [--max-chars N] [--max-secs S] +""" + +import argparse +import json +import sys +from pathlib import Path + +MAX_CHARS = 84 # two comfortable lines +MAX_SECS = 5.5 +MIN_SECS = 1.0 + + +def cue_chunks(text, max_chars): + """Split into cue-sized pieces on sentence, then clause, then word.""" + words, chunks, cur = text.split(), [], "" + for w in words: + candidate = f"{cur} {w}".strip() + if len(candidate) > max_chars and cur: + chunks.append(cur) + cur = w + else: + cur = candidate + if cur.endswith((".", "!", "?")) and len(cur) > max_chars * 0.45: + chunks.append(cur) + cur = "" + if cur: + chunks.append(cur) + return chunks or [text] + + +def wrap(line, width=42): + words, out, cur = line.split(), [], "" + for w in words: + if len(f"{cur} {w}".strip()) > width and cur: + out.append(cur) + cur = w + else: + cur = f"{cur} {w}".strip() + if cur: + out.append(cur) + return "\n".join(out[:2]) if len(out) <= 2 else "\n".join( + [" ".join(out[:len(out) // 2]), " ".join(out[len(out) // 2:])]) + + +def ts(seconds): + ms = int(round(seconds * 1000)) + h, ms = divmod(ms, 3600000) + m, ms = divmod(ms, 60000) + s, ms = divmod(ms, 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("manifest", type=Path) + ap.add_argument("out", type=Path) + ap.add_argument("--offsets", nargs="*", default=[], + help="SCENE=SECONDS start overrides; without these, scenes " + "are assumed to run back to back in manifest order") + ap.add_argument("--offsets-json", type=Path, default=None, + help="segments/offsets.json from assemble - the reliable " + "way to time cues against the finished cut") + ap.add_argument("--max-chars", type=int, default=MAX_CHARS) + ap.add_argument("--max-secs", type=float, default=MAX_SECS) + args = ap.parse_args() + + manifest = json.loads(args.manifest.read_text()) + overrides = {} + if args.offsets_json: + overrides.update({k: float(v) for k, v in + json.loads(args.offsets_json.read_text()).items()}) + for spec in args.offsets: + k, _, v = spec.partition("=") + overrides[k] = float(v) + + # a scene with no offset and no place in the cut would silently land at + # the wrong time; skip it rather than mistime it + if overrides: + manifest = [e for e in manifest if e["id"] in overrides] + cues, clock = [], 0.0 + for entry in manifest: + start = overrides.get(entry["id"], clock) + dur = float(entry["duration"]) + chunks = cue_chunks(entry["text"], args.max_chars) + total_chars = sum(len(c) for c in chunks) or 1 + t = start + for chunk in chunks: + share = dur * (len(chunk) / total_chars) + share = max(MIN_SECS, min(share, args.max_secs)) + cues.append((t, min(t + share, start + dur), wrap(chunk))) + t += share + clock = start + dur + + lines = [] + for i, (a, b, text) in enumerate(cues, 1): + if b <= a: + b = a + MIN_SECS + lines += [str(i), f"{ts(a)} --> {ts(b)}", text, ""] + args.out.write_text("\n".join(lines)) + print(f"{len(cues)} cues, ends at {ts(cues[-1][1])} -> {args.out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proof/scripts/narrate b/proof/scripts/narrate new file mode 100755 index 0000000..5f99e21 --- /dev/null +++ b/proof/scripts/narrate @@ -0,0 +1,284 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["pyyaml", "piper-tts"] +# /// +"""Render one narration clip per scene, and prove it says what you wrote. + +Engine selection is automatic: a cloud voice when a key is available, a +local neural voice (Piper) when there isn't one. The local path needs no +key, no network after the first voice download, and runs on macOS and +Linux alike - so a container with no secrets in it can still narrate. + +Input is a scenes file: a YAML list of scenes, each with `id` and +`narration`. Output is OUTDIR/.wav plus OUTDIR/manifest.json carrying +the exact text and measured duration of each clip, which is what +make-subtitles and the assembly step both read. + +Usage: + narrate SCENES.yaml OUTDIR [--engine auto|openai|openai-chat|piper] + [--voice NAME] [--force] +""" + +import argparse +import base64 +import difflib +import json +import os +import re +import subprocess +import sys +import urllib.request +import wave +from pathlib import Path + +import yaml + +OPENAI_TTS_MODEL = "gpt-4o-mini-tts" # deterministic: reads what you send +OPENAI_CHAT_MODEL = "gpt-audio-1.5" # better prosody, will ad-lib; gated +PIPER_VOICE = "en_US-lessac-medium" + + +def die(msg): + print(f"narrate: {msg}", file=sys.stderr) + sys.exit(1) + + +def openai_key(): + key = os.environ.get("OPENAI_API_KEY") + if key: + return key.strip() + try: + out = subprocess.run(["llm", "keys", "get", "openai"], + capture_output=True, text=True, timeout=15) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + except Exception: # noqa: BLE001 - llm not installed is a normal outcome + pass + return None + + +def norm(s): + return re.sub(r"[^a-z0-9 ]+", "", s.lower()).split() + + +ASR_SNIPPET = """ +import sys +from faster_whisper import WhisperModel +m = WhisperModel(sys.argv[2], device="cpu", compute_type="int8") +segs, _ = m.transcribe(sys.argv[1]) +print(" ".join(s.text.strip() for s in segs)) +""" + + +def transcribe_local(wav, model="base.en"): + """Transcribe with a local ASR, in its own uv env so narrate stays light. + Returns None when faster-whisper isn't available.""" + try: + out = subprocess.run( + ["uv", "run", "--quiet", "--with", "faster-whisper", "python3", + "-c", ASR_SNIPPET, str(wav), model], + capture_output=True, text=True, timeout=900) + except Exception: # noqa: BLE001 - no uv, no network: gate simply unavailable + return None + return out.stdout.strip() if out.returncode == 0 and out.stdout.strip() else None + + +def structural_drift(text, heard): + """How far a transcript diverges from the script, ignoring the noise an + ASR always makes. + + Exact word-matching is the wrong tool here: a small model mangles + unusual names ("smevals" -> "Mevil"), and - worse - a *dropped* word + scores as more similar than two mispronounced ones. What is detectable, + and what actually matters, is missing or invented CONTENT: a sentence + the voice skipped, or a preamble it invented. Returns + (length_delta_fraction, longest_run_of_missing_or_changed_words). + """ + want, got = norm(text), norm(heard) + delta = abs(len(got) - len(want)) / max(1, len(want)) + ops = difflib.SequenceMatcher(a=want, b=got).get_opcodes() + worst = max((i2 - i1 for tag, i1, i2, _, _ in ops if tag in ("delete", "replace")), + default=0) + return delta, worst + + +def post(url, key, body, want_json=True): + req = urllib.request.Request( + url, data=json.dumps(body).encode(), + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=180) as r: + return json.load(r) if want_json else r.read() + + +def say_openai(key, text, out_wav, voice): + data = post("https://api.openai.com/v1/audio/speech", key, + {"model": OPENAI_TTS_MODEL, "voice": voice or "nova", + "input": text, "response_format": "wav"}, want_json=False) + out_wav.write_bytes(data) + return None # deterministic engine: nothing to gate + + +def say_openai_chat(key, text, out_wav, voice): + doc = post("https://api.openai.com/v1/chat/completions", key, { + "model": OPENAI_CHAT_MODEL, + "modalities": ["text", "audio"], + "audio": {"voice": voice or "nova", "format": "wav"}, + "messages": [{"role": "user", "content": + "Read this narration aloud, warm and clear, verbatim, " + "and say nothing else:\n\n" + text}], + }) + audio = doc["choices"][0]["message"]["audio"] + out_wav.write_bytes(base64.b64decode(audio["data"])) + return audio.get("transcript", "") + + +def say_piper(text, out_wav, voice): + from piper import PiperVoice + from piper.download_voices import download_voice + home = Path(os.environ.get("PIPER_VOICE_DIR", + Path.home() / ".cache" / "piper-voices")) + home.mkdir(parents=True, exist_ok=True) + name = voice or PIPER_VOICE + onnx = home / f"{name}.onnx" + if not onnx.exists(): + print(f" downloading local voice {name} (one time)…") + download_voice(name, home) + v = PiperVoice.load(str(onnx)) + with wave.open(str(out_wav), "wb") as w: + v.synthesize_wav(text, w) + return None + + +def duration(path): + out = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", str(path)], capture_output=True, text=True) + return round(float(out.stdout.strip()), 3) + + +def main(): + if len(sys.argv) == 4 and sys.argv[1] == "--drift-check": + script = Path(sys.argv[2]).read_text() + heard = Path(sys.argv[3]).read_text() + delta, worst = structural_drift(script, heard) + bad = delta > 0.15 or worst >= 4 + print(f"length change {delta:.0%}, worst run {worst} -> " + f"{'MISMATCH' if bad else 'ok'}") + return 1 if bad else 0 + + ap = argparse.ArgumentParser() + ap.add_argument("scenes", type=Path) + ap.add_argument("outdir", type=Path) + ap.add_argument("--engine", default="auto", + choices=["auto", "openai", "openai-chat", "piper"]) + ap.add_argument("--voice", default=None) + ap.add_argument("--force", action="store_true") + ap.add_argument("--verify", default="auto", choices=["auto", "on", "off"], + help="listen back to each clip with a local ASR and flag " + "missing or invented content (default: on when the " + "engine can't tell you what it said)") + ap.add_argument("--asr-model", default="base.en") + args = ap.parse_args() + + doc = yaml.safe_load(args.scenes.read_text()) + scenes = [s for s in doc.get("scenes", []) if (s.get("narration") or "").strip()] + if not scenes: + die("no scenes with narration") + + key = openai_key() + engine = args.engine + if engine == "auto": + engine = "openai" if key else "piper" + if engine.startswith("openai") and not key: + die("no OPENAI_API_KEY (and `llm keys get openai` found nothing). " + "Use --engine piper for a local voice.") + print(f"engine: {engine}" + ("" if key or engine == "piper" else "")) + + # a deterministic cloud endpoint reads exactly what you send it, so the + # ear-check is optional there; anything else gets listened to by default + verify = args.verify == "on" or (args.verify == "auto" and engine != "openai") + + args.outdir.mkdir(parents=True, exist_ok=True) + # what the cached clips were rendered FROM: editing a line and keeping + # its old audio is a silent lie, and the movie will contradict itself + prior = {} + prior_path = args.outdir / "manifest.json" + if prior_path.exists(): + try: + prior = {e["id"]: e.get("text", "") for e in + json.loads(prior_path.read_text())} + except Exception: # noqa: BLE001 - a corrupt manifest just means no cache + prior = {} + manifest, failures = [], [] + + for sc in scenes: + sid = sc["id"] + text = " ".join((sc["narration"] or "").split()) + wav = args.outdir / f"{sid}.wav" + if wav.exists() and not args.force and prior.get(sid) == text: + print(f"{sid}: cached") + elif wav.exists() and not args.force and sid in prior: + print(f"{sid}: text changed since this clip was rendered - redoing") + args.force = True + else: + for attempt in (1, 2): + if engine == "openai": + claimed = say_openai(key, text, wav, args.voice) + elif engine == "openai-chat": + claimed = say_openai_chat(key, text, wav, args.voice) + else: + claimed = say_piper(text, wav, args.voice) + + # a chat model reports what it said: hold it to that exactly, + # because "Sure, here it is:" is the failure it introduces + if claimed is not None: + want, got = norm(text), norm(claimed) + drift = abs(len(want) - len(got)) + sum( + 1 for a, b in zip(want, got) if a != b) + if drift > max(2, len(want) // 25): + print(f"{sid}: engine ad-libbed (attempt {attempt}, " + f"drift {drift})") + continue + + # every engine: listen back. An ASR mangles unusual names, so + # only missing or invented CONTENT counts as a failure here. + if verify: + heard = transcribe_local(wav, args.asr_model) + if heard is None: + print(f"{sid}: ok (no local ASR available - gate skipped)") + break + delta, worst = structural_drift(text, heard) + if delta > 0.15 or worst >= 4: + print(f"{sid}: what came out does not match the script " + f"(attempt {attempt}: {delta:.0%} length change, " + f"{worst} words in a row wrong)") + print(f" heard: {heard[:120]}") + continue + print(f"{sid}: ok (verified by ear: {delta:.0%} length " + f"change, worst run {worst})") + break + print(f"{sid}: ok") + break + else: + failures.append(sid) + manifest.append({"id": sid, "text": text, "wav": wav.name, + "duration": duration(wav)}) + + (args.outdir / "manifest.json").write_text(json.dumps(manifest, indent=2)) + total = sum(m["duration"] for m in manifest) + print(f"\n{len(manifest)} clips, {total:.1f}s total -> {args.outdir}/manifest.json") + if engine == "piper": + print("local voice: it mispronounces unusual names rather than dropping " + "them - listen to one clip before you commit to a voice.") + if verify: + print("the ear-check catches missing or invented sentences, not " + "pronunciation: an ASR mangles jargon too.") + if failures: + print(f"FAILED verbatim delivery: {failures}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proof/show.py b/proof/show.py new file mode 100755 index 0000000..ccf9bfe --- /dev/null +++ b/proof/show.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Filmed CLI for the Hermes function-calling toolkit. + +Each subcommand exercises real repository code — tool schema conversion, + tool-call parsing, pydantic/jsonschema validation, and live tool +execution. Nothing here is staged or replayed from a fixture. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +logging.getLogger("function-calling-inference").setLevel(logging.CRITICAL) +logging.getLogger("yfinance").setLevel(logging.CRITICAL) + + +def banner(title: str) -> None: + line = "=" * 72 + print(line) + print(f" Hermes Function Calling · {title}") + print(line) + print() + + +def cmd_tools(_args: argparse.Namespace) -> int: + from functions import get_openai_tools + + tools = get_openai_tools() + banner("OpenAI tool schemas from functions.py") + print(f"{len(tools)} tools registered:\n") + for i, tool in enumerate(tools, 1): + fn = tool["function"] + print(f" {i:2}. {fn['name']}") + fund = next(t for t in tools if t["function"]["name"] == "get_stock_fundamentals") + print("\nConverted schema for get_stock_fundamentals:\n") + print(json.dumps(fund["function"]["parameters"], indent=2)) + return 0 + + +def cmd_parse(_args: argparse.Namespace) -> int: + from utils import validate_and_extract_tool_calls + + banner("Parse a Hermes from model output") + raw = ( + "\n" + '{"name": "code_interpreter",' + ' "arguments": {"code_markdown": "```python\\nprint(6 * 7)\\n```"}}\n' + "" + ) + print("Raw assistant message:\n") + print(raw) + print() + ok, calls, err = validate_and_extract_tool_calls(raw) + print(f"extracted_ok = {ok}") + if err: + print(f"error = {err}") + print("tool_calls =") + print(json.dumps(calls, indent=2)) + return 0 if ok else 1 + + +def cmd_validate(_args: argparse.Namespace) -> int: + from functions import get_openai_tools + from validator import validate_function_call_schema + + banner("Validate calls against the live tool schemas") + tools = get_openai_tools() + cases = [ + ( + "valid call", + { + "name": "get_stock_fundamentals", + "arguments": {"symbol": "TSLA"}, + }, + ), + ( + "missing required argument", + {"name": "get_stock_fundamentals", "arguments": {}}, + ), + ( + "unknown function", + {"name": "launch_the_missiles", "arguments": {"symbol": "TSLA"}}, + ), + ] + for label, call in cases: + ok, err = validate_function_call_schema(call, tools) + verdict = "ACCEPT" if ok else "REJECT" + print(f"{verdict:6} {label}") + print(f" call = {json.dumps(call)}") + if err: + print(f" why = {err}") + print() + return 0 + + +def cmd_execute(_args: argparse.Namespace) -> int: + from functions import code_interpreter + + banner("Execute a real tool: code_interpreter") + code_markdown = ( + "```python\n" + "squares = [n * n for n in range(1, 8)]\n" + "total = sum(squares)\n" + "print('squares', squares)\n" + "print('total', total)\n" + "```" + ) + print("Invoking functions.code_interpreter with this Python:\n") + print(code_markdown) + print("Live result (exec namespace, not a fixture):\n") + result = code_interpreter.invoke({"code_markdown": code_markdown}) + print(json.dumps(result, indent=2)) + expected = {"squares": [1, 4, 9, 16, 25, 36, 49], "total": 140} + if result != expected: + print(f"\nUNEXPECTED: {result!r} != {expected!r}", file=sys.stderr) + return 1 + print("\nOK squares 1..7 sum to 140") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("tools", help="list converted OpenAI tool schemas") + sub.add_parser("parse", help="extract JSON from a block") + sub.add_parser("validate", help="accept a good call, reject bad ones") + sub.add_parser("execute", help="run code_interpreter for real") + args = parser.parse_args() + return { + "tools": cmd_tools, + "parse": cmd_parse, + "validate": cmd_validate, + "execute": cmd_execute, + }[args.cmd](args) + + +if __name__ == "__main__": + raise SystemExit(main()) From e209e976a82b2a9797ce6261dafe365139022257 Mon Sep 17 00:00:00 2001 From: Seanmcd15 <168387522+Seanmcd15@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:38:27 -0400 Subject: [PATCH 2/3] Add Cloud Agent dev environment (CPU-only) for Hermes-Function-Calling (#2) - .cursor/environment.json: default image + idempotent install script - .cursor/install.sh: create .venv, install CPU torch + runtime deps, verify imports - .cursor/requirements-cpu.txt: Python 3.12 / CPU-compatible dependency set (torch installed from CPU index; flash-attn & bitsandbytes omitted as they are CUDA/GPU-only; adds beautifulsoup4/requests/PyYAML missing from root reqs) - .gitignore: ignore .venv, __pycache__, inference_logs Co-authored-by: Cursor Agent Co-authored-by: Seanmcd15 --- .cursor/environment.json | 5 ++++ .cursor/install.sh | 46 ++++++++++++++++++++++++++++++++++++ .cursor/requirements-cpu.txt | 25 ++++++++++++++++++++ .gitignore | 17 ++++++++++--- 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 .cursor/environment.json create mode 100755 .cursor/install.sh create mode 100644 .cursor/requirements-cpu.txt diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 0000000..fbf46e8 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,5 @@ +{ + "name": "Hermes-Function-Calling (CPU dev)", + "user": "ubuntu", + "install": "bash .cursor/install.sh" +} diff --git a/.cursor/install.sh b/.cursor/install.sh new file mode 100755 index 0000000..0467200 --- /dev/null +++ b/.cursor/install.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# Idempotent install script for the Hermes-Function-Calling Cloud Agent dev +# environment. Creates a project virtualenv at .venv and installs a CPU-only +# dependency set (see .cursor/requirements-cpu.txt for why this differs from the +# GPU-oriented root requirements.txt). +set -euo pipefail + +cd "$(dirname "$0")/.." + +PYTHON="${PYTHON:-python3}" +VENV_DIR=".venv" + +# Ensure the venv module is available (Debian splits it into python3-venv). +if ! "$PYTHON" -c "import ensurepip" >/dev/null 2>&1; then + echo "==> Installing python3-venv" + sudo apt-get update -qq + sudo apt-get install -y "$("$PYTHON" -c 'import sys; print(f"python{sys.version_info.major}.{sys.version_info.minor}-venv")')" python3-pip +fi + +if [ ! -d "$VENV_DIR" ]; then + echo "==> Creating virtualenv at $VENV_DIR" + "$PYTHON" -m venv "$VENV_DIR" +fi + +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +echo "==> Upgrading pip tooling" +python -m pip install --upgrade pip wheel setuptools + +echo "==> Installing CPU-only PyTorch" +pip install --index-url https://download.pytorch.org/whl/cpu torch + +echo "==> Installing project dependencies (CPU set)" +pip install -r .cursor/requirements-cpu.txt + +echo "==> Verifying imports" +python - <<'PY' +import functions, prompter, validator, utils, schema +from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig +tools = functions.get_openai_tools() +print(f"OK: imported core modules; {len(tools)} tools available") +PY + +echo "==> Install complete. Activate with: source .venv/bin/activate" diff --git a/.cursor/requirements-cpu.txt b/.cursor/requirements-cpu.txt new file mode 100644 index 0000000..9c1a178 --- /dev/null +++ b/.cursor/requirements-cpu.txt @@ -0,0 +1,25 @@ +# CPU-only dependency set for the Cloud Agent dev environment. +# +# This mirrors the runtime dependencies in the repo-root requirements.txt but is +# adapted for a CPU-only machine running Python 3.12: +# * torch is installed separately in .cursor/install.sh from the PyTorch CPU +# index (the pinned torch==2.1.2 has no Python 3.12 wheels). +# * flash-attn and bitsandbytes are intentionally omitted — both require a +# CUDA GPU, which is not available here. The 8B model generation step in +# functioncall.py / jsonmode.py therefore needs a GPU host; every other part +# of the pipeline runs on CPU. +# * beautifulsoup4, requests and PyYAML are added explicitly: functions.py and +# prompter.py import them but they are missing from the root requirements.txt. +transformers>=4.38.1 +accelerate==0.27.2 +langchain==0.1.9 +pydantic==2.6.2 +jsonschema==4.21.1 +yfinance==0.2.36 +pandas==2.2.0 +protobuf +sentencepiece +art +beautifulsoup4 +requests +PyYAML diff --git a/.gitignore b/.gitignore index ab1ce79..fd9e45b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,20 @@ +# Python virtualenv (created by .cursor/install.sh) .venv/ +venv/ + +# Python caches __pycache__/ -*.pyc +*.py[cod] +*.egg-info/ +.python-version + +# Runtime logs generated by utils.py inference_logs/ + +# Local model / HF caches +.cache/ + +# Proof movie build artifacts (proof/) proof/frames/ proof/narration/ proof/segments/ @@ -10,5 +23,3 @@ proof/chrome-profile/ proof/*.mp4 proof/*.srt proof/*-check/ -*.egg-info/ -.python-version From 52c53e5b39d627f8a7544e94a922407e9d8ffd26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 22:12:42 +0000 Subject: [PATCH 3/3] Add Hugging Face MCP server config without embedding the token. Point Cursor at https://huggingface.co/mcp and read the Bearer token from HF_TOKEN so the secret is not committed. Co-authored-by: Seanmcd15 --- .cursor/mcp.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .cursor/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..8a6fbbb --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "huggingface": { + "url": "https://huggingface.co/mcp", + "headers": { + "Authorization": "Bearer ${env:HF_TOKEN}" + } + } + } +}