diff --git a/.claude/agents/README.md b/.claude/agents/README.md index 8fe8444d7..0f10e4d57 100644 --- a/.claude/agents/README.md +++ b/.claude/agents/README.md @@ -113,7 +113,7 @@ Synthesize findings. ### Araminta (Visual Designer) - Joins discussions only when visual consistency decisions are needed -- Drives image-gen/sprite-gen/glb-gen/audio-gen via Bash — no Skill/MCP tool grant, so she invokes the connector scripts directly, e.g. `python3 tooling/db/image_connector.py generate ...` +- Drives image-gen/sprite-gen/glb-gen/audio-gen via Bash — no Skill/MCP tool grant, so she invokes the connectors directly, e.g. `reach assets image generate ...` - **image-gen calls the paid Gemini API (`GEMINI_API_KEY`) — always ask Team Leader for permission before generating. glb-gen/audio-gen run against self-hosted tower-of-joy infrastructure and sprite-gen renders locally, so they don't carry the same per-call cost, but confirm intent before large batch jobs.** ### SI (Refinement Manager) diff --git a/.claude/agents/araminta.md b/.claude/agents/araminta.md index e811249ec..12474bc43 100644 --- a/.claude/agents/araminta.md +++ b/.claude/agents/araminta.md @@ -48,7 +48,7 @@ PBR assets from these sources go through our `toon_masked` shader and come out m You have `Bash` but no `Skill`/MCP tool grant, so asset generation runs through the project's connector scripts directly, not a slash-skill invocation: -- **image-gen** (`.claude/skills/image-gen/`) — concept art, icons, UI mockups, reference images via the Gemini API: `python3 tooling/db/image_connector.py generate "prompt" --output .tmp/image-gen/[category]/[name].png --aspect 1:1`. Requires `GEMINI_API_KEY` (set in `.claude/settings.local.json`) — this is the one that costs real money per call. +- **image-gen** (`.claude/skills/image-gen/`) — concept art, icons, UI mockups, reference images via the Gemini API: `reach assets image generate "prompt" --output .tmp/image-gen/[category]/[name].png --aspect 1:1`. Requires `GEMINI_API_KEY` (set in `.claude/settings.local.json`) — this is the one that costs real money per call. - **sprite-gen** (`.claude/skills/sprite-gen/`) — flat 2D artwork (paintings, flags, billboards, signage) rendered as PNG textures/decals, via `scripts/render.sh`. - **glb-gen** (`.claude/skills/glb-gen/`) — converts a concept PNG to a game-ready `.glb` via Trellis (self-hosted on tower-of-joy) plus Blender post-processing. - **audio-gen** (`.claude/skills/audio-gen/`) — ambient loops, SFX, and UI sounds via the self-hosted Stable Audio Open Gradio app (tower-of-joy). diff --git a/.claude/rules/local-services.md b/.claude/rules/local-services.md index d3c5bf563..ada731a1f 100644 --- a/.claude/rules/local-services.md +++ b/.claude/rules/local-services.md @@ -1,6 +1,6 @@ # Local Services -Endpoints are also preconfigured in `tooling/db/config.json`. +Endpoints are also preconfigured in `tooling/domains/assets/config.json` (`reach assets …`). - **Gitea:** `https://git.schweitz.net` (login: `schweitz`; LAN-hairpinned via AdGuard — resolves to tower-of-joy directly on the home network). The old diff --git a/.claude/rules/project-structure.md b/.claude/rules/project-structure.md index aae4daf4e..70117a60c 100644 --- a/.claude/rules/project-structure.md +++ b/.claude/rules/project-structure.md @@ -59,12 +59,20 @@ docs/ workshops/ # Workshop briefs and outputs db/ schema.sql # Database schema -tooling/ - db/ # Asset/connector scripts (audio, image, trellis, wiki) - config.json # Endpoint configuration - common.py # Shared venv/config helpers - audio_connector.py # Stable Audio Open connector +tooling/ # ONE package behind the `reach` CLI (D-263) — `reach --help` + main.py # routing only; the domain registry + core/ # config, console (the single output path), errors, + # process (the single guarded exec), jobs, command + domains// # router.py (transport) + service/helper modules (logic) + atlas/ planet/ # the spatial ladder; `atlas planet` is its rung 3 + ledger/ # economics import — the sole systems.db generator + wiki/ # wiki fill rates + GTTR hook + assets/ # Stable Audio / Gemini / Trellis connectors + config.json # endpoint URLs (never keys — tracked file) + … # check, validate, godot, visual, generate, blender, pr, jobs, dev + scripts/blender/ # Blender payloads — run by Blender's Python, never imported archive/ # Provenance only, never run: pql-migrate/, wiki-bootstrap/ + test_*.py # gate tests, run by `make test-tooling` .claude/ agents/ # Agent personality files skills/ # Skill definitions diff --git a/.claude/settings.json b/.claude/settings.json index 7653998d1..771052e02 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -24,10 +24,6 @@ "Bash(git rm *)", "Bash(git ls-tree *)", "Bash(git rev-parse --show-toplevel)", - "Bash(tooling/db/audio-generate *)", - "Bash(tooling/db/audio-health)", - "Bash(tooling/db/audio-post *)", - "Bash(tooling/db/audio-batch *)", "Bash(make *)", "Bash(make)", "Bash(pql)", diff --git a/.claude/skills/audio-gen/SKILL.md b/.claude/skills/audio-gen/SKILL.md index 8d80072c7..b3dc0054a 100644 --- a/.claude/skills/audio-gen/SKILL.md +++ b/.claude/skills/audio-gen/SKILL.md @@ -13,39 +13,44 @@ description: > # Audio Generation — The Settled Reach Generate sonically consistent audio assets using the Stable Audio Open API via -wrapper scripts at `tooling/db/audio-*`. +`reach assets audio …` (`tooling/domains/assets/`, T-1290). Asset descriptions, filenames, bus routing, and design intent are documented in `docs/assets/audio/`. This skill provides the prompt system, generation workflow, and quality validation. -`audio-health`, `audio-generate`, and `audio-batch` re-exec into the project -`.venv` on startup (`audio-post` doesn't need to — it only shells out to -ffmpeg). On a fresh clone with no `.venv` yet, they fail fast with `error: -.venv not found — run make setup-venv first.` — run that once before using -this skill. +**Stable Audio is kept switched OFF** on tower-of-joy — VRAM is scarce, so +the service is turned on only when needed, and restarting it blindly takes VRAM +from whatever else is running. `reach assets audio health` tells you which: +OFF exits 1 with a remedy that says so. Get it turned on before a session. + +**Read the exit status, not an `ok` field.** Each verb prints its result as +JSON on stdout (`file`, `size_bytes`, `ogg_file`, … — the same keys as before) +and fails with a non-zero exit and a `Fix:` line. There is no `{"ok": false}` +on stdout any more, and a batch with any failed asset now exits 1. ## API Access -**Never call the API directly.** Use the wrapper scripts: +**Never call the API directly.** Use the reach verbs: ```bash -# Check API health -tooling/db/audio-health +# Check API health (OFF is the normal resting state) +reach assets audio health # Generate a single asset (WAV only) -tooling/db/audio-generate "prompt text" \ +reach assets audio generate "prompt text" \ --duration 10 --steps 100 --cfg 7 \ --output path/to/output.wav # Generate + post-process in one command (WAV → trim → normalize → OGG) -tooling/db/audio-generate "prompt text" \ +reach assets audio generate "prompt text" \ --duration 10 --steps 100 --cfg 7 \ --output path/to/gen/intermediate.wav \ --output-ogg client/assets/audio/final.ogg -# Batch-generate from a manifest (preferred for multiple assets) -tooling/db/audio-batch docs/assets/audio/.json +# Batch-generate from a manifest (preferred for multiple assets) — long: +# add --detach and follow it with `reach jobs log ` +reach assets audio batch docs/assets/audio/.json ``` ### Parameters @@ -142,9 +147,9 @@ Asset `id` values must match IDs in `docs/assets/audio/{category}.md` (e.g., AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory. **`lufs`/`quality` defaults apply to `synth` assets only.** `audio_batch.py`'s -SAO path (`run_sao_generate`) only forwards `steps`/`cfg`/`timeout` to the -connector — `--lufs`/`--quality` aren't even exposed as CLI flags on -`audio_connector.py generate`, so a manifest's `defaults.lufs`/`defaults.quality` +SAO path (`run_sao_generate`) only forwards `steps`/`cfg`/`timeout` to +`audio.generate` — `--lufs`/`--quality` aren't exposed on +`reach assets audio generate` either, so a manifest's `defaults.lufs`/`defaults.quality` are silently ignored for `method: "sao"` assets. SAO post-processing is fixed at -16 LUFS / quality 6 regardless of what the manifest says. @@ -152,16 +157,16 @@ at -16 LUFS / quality 6 regardless of what the manifest says. ```bash # Full run -tooling/db/audio-batch docs/assets/audio/.json +reach assets audio batch docs/assets/audio/.json # Dry run — preview what would be generated -tooling/db/audio-batch docs/assets/audio/.json --dry-run +reach assets audio batch docs/assets/audio/.json --dry-run # Generate only specific assets -tooling/db/audio-batch docs/assets/audio/.json --only AMB-001,AMB-002 +reach assets audio batch docs/assets/audio/.json --only AMB-001,AMB-002 # Skip assets that already have OGG files -tooling/db/audio-batch docs/assets/audio/.json --skip-existing +reach assets audio batch docs/assets/audio/.json --skip-existing ``` ### 3. Update asset docs with prompts @@ -204,8 +209,8 @@ For one-off generation or iteration on a specific asset: 2. Read `references/sonic-palette.md` for the sonic family prefix. 3. Read `references/category-templates.md` for the matching template. 4. Assemble the full prompt. -5. Run `tooling/db/audio-health` to verify the API is up. -6. Run `tooling/db/audio-generate` with `--post` or `--output-ogg` to +5. Run `reach assets audio health` to verify the API is up. +6. Run `reach assets audio generate` with `--post` or `--output-ogg` to generate and post-process in one step. 7. Verify the output (file size, duration). 8. Update the asset status and prompt in `docs/assets/audio/{category}.md`. @@ -232,23 +237,23 @@ If you need to post-process separately (e.g., re-normalizing an existing file): ```bash # Full pipeline: trim → normalize → convert -tooling/db/audio-post pipeline input.wav --output output.ogg +reach assets audio post pipeline input.wav --output output.ogg # Individual steps -tooling/db/audio-post trim input.wav -tooling/db/audio-post normalize input.wav --lufs -16 -tooling/db/audio-post convert input.wav --output output.ogg +reach assets audio post trim input.wav +reach assets audio post normalize input.wav --lufs -16 +reach assets audio post convert input.wav --output output.ogg ``` ## Manual Synthesis (Insert-Tech Sounds) For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot -produce meaningful output. Use manual synthesis via `tooling/synth_ui_sounds.py` +produce meaningful output. Use manual synthesis via `reach assets synth-ui` or the batch manifest's `method: "synth"` with harmonic parameters. For complex synthesis beyond the `harmonic` type (FM, filtered noise, bandpass -impulse), write a custom script in `tooling/` following the pattern in -`tooling/synth_ui_sounds.py`. +impulse), add a function beside the four in +`tooling/domains/assets/synth_ui.py` and call it from its `run()`. ## Quality Checklist diff --git a/.claude/skills/glb-gen/SKILL.md b/.claude/skills/glb-gen/SKILL.md index a36de3a95..aeb84a8e1 100644 --- a/.claude/skills/glb-gen/SKILL.md +++ b/.claude/skills/glb-gen/SKILL.md @@ -17,15 +17,19 @@ Convert approved concept images to game-ready .glb models. | Service | Check | |---------|-------| -| Trellis | `tooling/db/trellis_connector.py health` | +| Trellis | `reach assets trellis health` | | Blender | `reach blender which` | -Trellis runs on tower-of-joy and may be switched off. Check before batching. +Trellis runs on tower-of-joy and is kept **switched off** to save VRAM — that is +its normal state, and `health` exits 1 saying so. Get it turned on before a +session; do not restart it blindly (that takes VRAM from whatever is running). +Each verb prints its result JSON on stdout and fails with a non-zero exit — +check the exit status, not an `ok` field. ## Usage ```bash -python3 tooling/db/trellis_connector.py generate input.png \ +reach assets trellis generate input.png \ --output .tmp/glb-gen/[name].glb \ --simplify 0.95 \ --texture-size 1024 @@ -41,26 +45,27 @@ python3 tooling/db/trellis_connector.py generate input.png \ ## Batch Usage -`tooling/trellis-batch.sh` exists but is **hardcoded to one category**: it -takes no arguments and always processes the 16 character body types from -`.tmp/image-gen/characters/bodies` into `.tmp/glb-gen/characters/bodies`. -Running it for any other asset category (furniture, props, etc.) does -nothing useful — it will just re-run (or skip, if outputs already exist) the -same 16 character bodies regardless of what you intended. - -For any other category, loop `trellis_connector.py` calls yourself with a -cooldown between jobs: +`reach assets trellis batch` runs a whole directory, one job at a time, with a +cooldown between jobs and retries on failure. (It replaced `trellis-batch.sh`, +which was hardcoded to the 16 character bodies — T-1290.) It skips outputs that +already exist and exits 1 if any item failed. It is long; add `--detach`. ```bash -for f in .tmp/image-gen/furniture/tables/*.png; do - name=$(basename "$f" .png) - python3 tooling/db/trellis_connector.py generate "$f" \ - --output ".tmp/glb-gen/furniture/tables/${name}.glb" \ - --simplify 0.95 --texture-size 1024 - sleep 15 -done +# every .png in a directory +reach --detach assets trellis batch \ + --input-dir .tmp/image-gen/furniture/tables \ + --output-dir .tmp/glb-gen/furniture/tables + +# only some of them +reach assets trellis batch --input-dir --output-dir --names table_a,table_b + +# no --input-dir: the character bodies, as the bash script did +reach assets trellis batch ``` +`--cooldown` (15 s), `--retries` (3) and `--retry-delay` (60 s) keep the +original script's pacing. + **Never run Trellis jobs in parallel** — it uses the full GPU and concurrent jobs will OOM and corrupt the CUDA state. @@ -143,7 +148,7 @@ For best Trellis results: - **Do NOT force isometric angle** — Trellis reconstructs full 3D, the game camera handles the view - To maintain consistency across a batch, generate the concept images with `/image-gen` using its `--input` style anchor flag — `--input` is an - /image-gen flag, not a Trellis one; `trellis_connector.py` has no `--input` + /image-gen flag, not a Trellis one; `reach assets trellis generate` has no `--input` argument and exits on unrecognized flags. These match `/image-gen` output with the Settled Reach style guide. diff --git a/.claude/skills/glb-gen/references/trellis-api.md b/.claude/skills/glb-gen/references/trellis-api.md index 85830de50..19da2b14d 100644 --- a/.claude/skills/glb-gen/references/trellis-api.md +++ b/.claude/skills/glb-gen/references/trellis-api.md @@ -1,6 +1,6 @@ # Trellis API Reference -The connector (`tooling/db/trellis_connector.py`) talks to a Gradio API. The +The connector (`tooling/domains/assets/trellis.py`, `reach assets trellis`) talks to a Gradio API. The parameter layout is fragile — document changes here when the container is updated. This is the canonical copy; the connector's module docstring carries a duplicate for at-a-glance reference when reading the script directly — keep diff --git a/.claude/skills/image-gen/SKILL.md b/.claude/skills/image-gen/SKILL.md index 5e61e6217..7363aaedd 100644 --- a/.claude/skills/image-gen/SKILL.md +++ b/.claude/skills/image-gen/SKILL.md @@ -19,18 +19,21 @@ not something this skill triggers itself. Output is a PNG file. Requires `GEMINI_API_KEY` in environment (set in `.claude/settings.local.json`). -Uses the canonical connector at `tooling/db/image_connector.py` (see -`.claude/rules/project-structure.md` — `tooling/db/` is the documented home -for asset/connector scripts; this skill has no local fork of it). +Uses the canonical connector behind `reach assets image` +(`tooling/domains/assets/image.py`, T-1290; this skill has no local fork of it). +`health` only lists models and is free; **every `generate` costs money**. +A verb prints its result JSON on stdout and fails with a non-zero exit — check +the exit status, not an `ok` field. An invalid `--aspect` is rejected up front +with the accepted list. ```bash -python3 tooling/db/image_connector.py health +reach assets image health ``` ## Usage ```bash -python3 tooling/db/image_connector.py generate \ +reach assets image generate \ "prompt text" \ --output path/to/output.png \ --aspect 1:1 diff --git a/Makefile b/Makefile index 60e557f8c..26325907f 100644 --- a/Makefile +++ b/Makefile @@ -296,6 +296,9 @@ test-tooling: @echo " [test-tooling] reach atlas planet options vs module parsers (T-1288)..." @$(VENV_PY) tooling/test_planet_router.py 2> .cache/test-tooling-planet-router.log || \ { echo " FAIL: planet router drift — log follows:"; cat .cache/test-tooling-planet-router.log; exit 1; } + @echo " [test-tooling] reach assets connectors against a fake Gradio (T-1290)..." + @$(VENV_PY) tooling/test_assets.py 2> .cache/test-tooling-assets.log || \ + { echo " FAIL: assets connectors — log follows:"; cat .cache/test-tooling-assets.log; exit 1; } @echo " [test-tooling] canvas-generation version gate units (T-1242)..." @mkdir -p .cache @python3 tooling/test_canvas_version_check.py 2> .cache/test-tooling-canvas-version.log || \ diff --git a/docs/assets/audio/ui.md b/docs/assets/audio/ui.md index a7e37cb27..199ac9b40 100644 --- a/docs/assets/audio/ui.md +++ b/docs/assets/audio/ui.md @@ -11,7 +11,7 @@ Interface sounds triggered by player interaction, insert systems, and cognitive ## Generation Approach -- **All UI sounds:** Generated via Stable Audio Open with sonic family prefix prompts, then trimmed/normalized/converted via `audio-post pipeline`. +- **All UI sounds:** Generated via Stable Audio Open with sonic family prefix prompts, then trimmed/normalized/converted via `reach assets audio post pipeline`. - **Monologue chimes:** Replaced in Sprint 10 (#327) with manual synthesis. Insert-tech aesthetic: pure sine harmonics, mathematical envelope, no SAO. Previous S9 SAO versions were acknowledged placeholders per D-038 amendment. ## Assets diff --git a/spikes/3dpipeline/README.md b/spikes/3dpipeline/README.md index 0bd7eb4ec..0c4ee56fa 100644 --- a/spikes/3dpipeline/README.md +++ b/spikes/3dpipeline/README.md @@ -110,5 +110,5 @@ models/props/*.glb + *_mask.png # Post-processed Trellis props + masks ## Dependencies - Godot 4.6+ (gl_compatibility renderer) -- Models generated by: `tooling/db/trellis_connector.py` + `.claude/skills/glb-gen/` +- Models generated by: `reach assets trellis` (formerly `tooling/db/trellis_connector.py`) + `.claude/skills/glb-gen/` - Concept images generated by: `.claude/skills/image-gen/` diff --git a/tooling/DOMAINS.md b/tooling/DOMAINS.md index 254d5b74f..6e11e82a5 100644 --- a/tooling/DOMAINS.md +++ b/tooling/DOMAINS.md @@ -91,7 +91,7 @@ invented. | ~~`planet`~~ | **folded into `atlas planet`** — the third rung of the same ladder | — | | `ledger` | the economics pipeline, named for the UI component that will aggregate it | ✅ ported (T-1289). `economy-db/` → `domains/ledger/` (`economy_import/` kept by name; the entrypoint became `service.py`), `schema_version.py` with it. `reach ledger import`; `make regen-db` survives as a one-line delegate, `make economy-db` retired. `generated_brands.toml` byte-identical across the move | | `wiki` | wiki sync and content maintenance | ✅ ported (T-1290): `reach wiki stats`, `reach wiki gttr-hook` (both output-identical to the originals). `wiki_sync.py` moved whole, but its renderer and importer are NOT verbs — re-rendering deletes ~10,700 lines of committed pages (T-1292). The seven one-shots (`assign-astro-ids`, `migrate-s-to-gj`, `patch-core-sector`, `fill-missing-globes`, `generate-stubs`/`find-stubs`, `backfill_cultural_corridor`) and the destructive `process-wiki-system-changes` went to `archive/wiki-bootstrap/` | -| `assets` | connectors to the tower-of-joy generators | `db/audio_*.py`, `db/audio-*`, `db/image_connector.py`, `db/trellis_connector.py`, `db/common.py`, `trellis-batch.sh`, `synth_ui_sounds.py` | +| `assets` | connectors to the tower-of-joy generators | ✅ ported (T-1290): `reach assets {audio,image,trellis} …` + `synth-ui`. The four audio bash wrappers and `trellis-batch.sh` retired; connectors call each other instead of spawning each other; `tooling/db/` is gone. Synth WAVs byte-identical, ffmpeg pipeline decode-identical; network paths pinned by `test_assets.py` against a fake Gradio. Unreachable reads as OFF (VRAM, D-17), never "restart it" | | `character` | bodies, garments, GLB handling | `garment-fit/make_logo.py`, `garment-qa/analyze_captures.py`, `convert_outfit.py`, `glb_strip_utility_nodes.py`, `inspect_glb.py`, `check_hair_symmetry.py`, `check_icosphere.py`, `render_quaternius_test.py`, `setup_clothing_metadata.py` — **note this is far smaller than `garment-fit/`'s file count suggests; 22 of its 23 files are Blender payloads and belong to the carve-out** | | `visual` | screenshot and render comparison | `visual-diff`, `visual-thumbnail`, `visual-blank-check` | | `godot` | Godot parse and cold-start checks | ✅ ported (T-1283); the two bash originals were left beside the port and retired in T-1290 | diff --git a/tooling/db/audio-batch b/tooling/db/audio-batch deleted file mode 100755 index ba8468a0c..000000000 --- a/tooling/db/audio-batch +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Batch audio generation from a manifest file. Whitelistable command. -# Usage: audio-batch manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing] -exec python3 "$(dirname "$0")/audio_batch.py" "$@" diff --git a/tooling/db/audio-generate b/tooling/db/audio-generate deleted file mode 100755 index 9e85b8b7c..000000000 --- a/tooling/db/audio-generate +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Generate audio via Stable Audio Open. Whitelistable command. -# Usage: audio-generate "prompt text" [--duration N] [--steps N] [--cfg N] [--output file.wav] [--timeout N] -exec python3 "$(dirname "$0")/audio_connector.py" generate "$@" diff --git a/tooling/db/audio-health b/tooling/db/audio-health deleted file mode 100755 index 112affff7..000000000 --- a/tooling/db/audio-health +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Check if the Stable Audio Open API is reachable. Whitelistable command. -# Usage: audio-health -exec python3 "$(dirname "$0")/audio_connector.py" health diff --git a/tooling/db/audio-post b/tooling/db/audio-post deleted file mode 100755 index 3131fcfd5..000000000 --- a/tooling/db/audio-post +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Audio post-processing (ffmpeg wrapper). Whitelistable command. -# Usage: audio-post convert input.wav [--output output.ogg] -# audio-post normalize input.wav [--lufs -16] -# audio-post trim input.wav [--threshold -50] -# audio-post pipeline input.wav [--output output.ogg] -exec python3 "$(dirname "$0")/audio_post.py" "$@" diff --git a/tooling/db/audio_batch.py b/tooling/db/audio_batch.py deleted file mode 100644 index 93af17721..000000000 --- a/tooling/db/audio_batch.py +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env python3 -"""Batch audio generation from a manifest file. - -Processes multiple assets sequentially: SAO generation or harmonic synthesis, -followed by post-processing (trim, normalize, convert to OGG). - -Usage: - python3 audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing] -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from common import ensure_venv # noqa: E402 - -ensure_venv() - -import json -import os -import subprocess -import wave - -import numpy as np - - -def load_manifest(path): - with open(path) as f: - return json.load(f) - - -def resolve_paths(manifest, manifest_dir): - """Resolve output_dir and gen_dir relative to the git root.""" - # Find git root by walking up from manifest_dir - # Check for .git as file (worktree) or directory (regular repo) - git_root = manifest_dir - while git_root != "/": - if os.path.exists(os.path.join(git_root, ".git")): - break - git_root = os.path.dirname(git_root) - else: - git_root = manifest_dir - - output_dir = os.path.join(git_root, manifest.get("output_dir", "client/assets/audio")) - gen_dir = os.path.join(git_root, manifest.get("gen_dir", "client/assets/audio/gen")) - os.makedirs(output_dir, exist_ok=True) - os.makedirs(gen_dir, exist_ok=True) - return output_dir, gen_dir, git_root - - -def get_default(manifest, asset, key): - """Get a value from the asset, falling back to manifest defaults.""" - defaults = manifest.get("defaults", {}) - return asset.get(key, defaults.get(key)) - - -def run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir): - """Generate audio via Stable Audio Open + post-processing.""" - filename = asset["filename"] - base_name = os.path.splitext(filename)[0] - wav_path = os.path.join(gen_dir, base_name + ".wav") - ogg_path = os.path.join(output_dir, filename) - - prompt = asset["prompt"] - duration = asset.get("duration", 10) - steps = get_default(manifest, asset, "steps") or 100 - cfg = get_default(manifest, asset, "cfg") or 7 - timeout = get_default(manifest, asset, "timeout") or 600 - - # Run audio-generate with --post - cmd = [ - sys.executable, os.path.join(script_dir, "audio_connector.py"), - "generate", prompt, - "--duration", str(duration), - "--steps", str(steps), - "--cfg", str(cfg), - "--output", wav_path, - "--output-ogg", ogg_path, - "--timeout", str(timeout), - ] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60) - if result.returncode != 0: - stderr = result.stderr.strip() - try: - err = json.loads(result.stdout) - return {"ok": False, "error": err.get("error", stderr)} - except (json.JSONDecodeError, ValueError): - return {"ok": False, "error": stderr or "generation failed"} - - try: - return json.loads(result.stdout) - except json.JSONDecodeError: - return {"ok": False, "error": f"Unexpected output: {result.stdout[:200]}"} - - -def synthesize_harmonic(params, wav_path): - """Synthesize audio from harmonic parameters.""" - sr = 44100 - duration = params["duration"] - fundamental = params["fundamental"] - harmonics = params.get("harmonics", []) - attack_ms = params.get("attack_ms", 10) - sustain_ratio = params.get("sustain_ratio", 0.2) - decay = params.get("decay", "exponential") - - n = int(sr * duration) - t = np.linspace(0, duration, n, endpoint=False) - - # Fundamental - signal = np.sin(2 * np.pi * fundamental * t) - - # Add harmonics - for h in harmonics: - freq = h["freq"] - db = h["db"] - amplitude = 10 ** (db / 20) - signal = signal + amplitude * np.sin(2 * np.pi * freq * t) - - # Envelope: attack + sustain + decay - attack_s = attack_ms / 1000 - attack_env = np.minimum(t / attack_s, 1.0) if attack_s > 0 else np.ones(n) - - sustain_end = duration * sustain_ratio - if decay == "exponential": - # Decay rate: reach -60dB by end of duration - decay_rate = 6.9 / (duration - sustain_end) if duration > sustain_end else 10 - decay_env = np.where(t < sustain_end, 1.0, np.exp(-decay_rate * (t - sustain_end))) - else: - # Linear decay - decay_env = np.where(t < sustain_end, 1.0, - 1.0 - (t - sustain_end) / (duration - sustain_end)) - - envelope = attack_env * decay_env - signal = signal * envelope - - # Normalize to peak - peak = np.max(np.abs(signal)) - if peak > 0: - signal = signal / peak * 0.9 - - # Write WAV - int_samples = np.clip(signal * 32767, -32767, 32767).astype(np.int16) - with wave.open(wav_path, "w") as f: - f.setnchannels(1) - f.setsampwidth(2) - f.setframerate(sr) - f.writeframes(int_samples.tobytes()) - - return wav_path - - -def run_synth(asset, manifest, gen_dir, output_dir, script_dir): - """Synthesize audio from harmonic parameters + post-process.""" - filename = asset["filename"] - base_name = os.path.splitext(filename)[0] - wav_path = os.path.join(gen_dir, base_name + "_synth.wav") - ogg_path = os.path.join(output_dir, filename) - - synth_params = asset.get("synth") - if not synth_params: - return {"ok": False, "error": "No synth parameters provided"} - - synth_type = synth_params.get("type", "harmonic") - if synth_type != "harmonic": - return {"ok": False, "error": f"Unknown synth type: {synth_type}"} - - try: - synthesize_harmonic(synth_params, wav_path) - except Exception as e: - return {"ok": False, "error": f"Synthesis failed: {e}"} - - # Post-process: normalize + convert (skip trim for synth — no silence to trim) - post_script = os.path.join(script_dir, "audio_post.py") - lufs = get_default(manifest, asset, "lufs") or -16 - quality = get_default(manifest, asset, "quality") or 6 - - # Normalize - norm_path = os.path.join(gen_dir, base_name + "_norm.wav") - cmd = [sys.executable, post_script, "normalize", wav_path, "--output", norm_path, - "--lufs", str(lufs)] - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - return {"ok": False, "error": f"Normalize failed: {result.stderr.strip()}"} - - # Convert to OGG - cmd = [sys.executable, post_script, "convert", norm_path, "--output", ogg_path, - "--quality", str(quality)] - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - return {"ok": False, "error": f"Convert failed: {result.stderr.strip()}"} - - # Clean up intermediate - try: - os.remove(norm_path) - except OSError: - pass - - ogg_size = os.path.getsize(ogg_path) - return { - "ok": True, - "file": wav_path, - "ogg_file": ogg_path, - "ogg_size_bytes": ogg_size, - "synth_params": synth_params, - "post_processed": True, - } - - -def main(): - if len(sys.argv) < 2: - print("Usage: audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]", - file=sys.stderr) - sys.exit(1) - - manifest_path = sys.argv[1] - dry_run = "--dry-run" in sys.argv - skip_existing = "--skip-existing" in sys.argv - - only_ids = None - for i, arg in enumerate(sys.argv): - if arg == "--only" and i + 1 < len(sys.argv): - only_ids = set(sys.argv[i + 1].split(",")) - - manifest = load_manifest(manifest_path) - manifest_dir = os.path.dirname(os.path.abspath(manifest_path)) - output_dir, gen_dir, git_root = resolve_paths(manifest, manifest_dir) - script_dir = os.path.dirname(os.path.abspath(__file__)) - - assets = manifest.get("assets", []) - if only_ids: - assets = [a for a in assets if a["id"] in only_ids] - - # Health check if any SAO assets - sao_assets = [a for a in assets if a.get("method") == "sao"] - if sao_assets and not dry_run: - print("Checking SAO API health...", file=sys.stderr) - health_cmd = [sys.executable, os.path.join(script_dir, "audio_connector.py"), "health"] - result = subprocess.run(health_cmd, capture_output=True, text=True) - if result.returncode != 0: - print(json.dumps({"ok": False, "error": "SAO API health check failed", - "details": result.stdout.strip()})) - sys.exit(1) - print(" SAO API is up.", file=sys.stderr) - - total = len(assets) - results = [] - success = 0 - failed = 0 - skipped = 0 - - print(f"Processing {total} assets from {os.path.basename(manifest_path)}...", file=sys.stderr) - if dry_run: - print(" (dry run — no generation will occur)", file=sys.stderr) - - for i, asset in enumerate(assets, 1): - asset_id = asset["id"] - filename = asset["filename"] - method = asset.get("method", "sao") - - print(f"\n[{i}/{total}] {asset_id}: {filename} ({method})", file=sys.stderr) - - if skip_existing: - ogg_path = os.path.join(output_dir, filename) - if os.path.exists(ogg_path): - print(" Skipping — already exists", file=sys.stderr) - results.append({"id": asset_id, "status": "skipped", "reason": "exists"}) - skipped += 1 - continue - - if dry_run: - print(f" Would generate: {filename}", file=sys.stderr) - if method == "sao": - print(f" Prompt: {asset.get('prompt', '(none)')[:80]}...", file=sys.stderr) - elif method == "synth": - synth = asset.get("synth", {}) - print(f" Synth: {synth.get('fundamental')}Hz, {synth.get('duration')}s", - file=sys.stderr) - results.append({"id": asset_id, "status": "dry_run"}) - continue - - if method == "sao": - result = run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir) - elif method == "synth": - result = run_synth(asset, manifest, gen_dir, output_dir, script_dir) - else: - result = {"ok": False, "error": f"Unknown method: {method}"} - - result["id"] = asset_id - if result.get("ok"): - success += 1 - result["status"] = "success" - print(f" OK → {result.get('ogg_file', filename)}", file=sys.stderr) - else: - failed += 1 - result["status"] = "failed" - print(f" FAILED: {result.get('error', 'unknown')}", file=sys.stderr) - - results.append(result) - - # Summary - summary = { - "ok": failed == 0, - "total": total, - "success": success, - "failed": failed, - "skipped": skipped, - "results": results, - } - print(json.dumps(summary, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/tooling/db/audio_connector.py b/tooling/db/audio_connector.py deleted file mode 100644 index 017e3b987..000000000 --- a/tooling/db/audio_connector.py +++ /dev/null @@ -1,356 +0,0 @@ -#!/usr/bin/env python3 -""" -Stable Audio Open connector — Gradio API wrapper. - -Talks to the Stable Audio Open Gradio app at tower-of-joy:11500. -Uses the async Gradio API pattern: POST to submit, SSE stream for results. - -Usage: - python3 audio_connector.py generate "prompt text" [--duration 10] [--steps 100] [--cfg 7] [--output file.wav] [--post] - python3 audio_connector.py health -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from common import ensure_venv, get_base_url as _get_base_url # noqa: E402 - -ensure_venv() - -import json -import os -import shutil -import subprocess -import time -import urllib.error -import urllib.request - - -def get_base_url() -> str: - return _get_base_url("stable_audio_url", "http://tower-of-joy:11500") - -def health(): - """Check if the Stable Audio API is reachable.""" - base = get_base_url() - try: - req = urllib.request.Request(f"{base}/config", method="GET") - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read()) - version = data.get("version", "unknown") - # Extract component info for the generate endpoint - api_names = [] - for dep in data.get("dependencies", []): - name = dep.get("api_name", "") - if name and not name.startswith("js_"): - api_names.append(name) - print(json.dumps({ - "ok": True, - "url": base, - "gradio_version": version, - "api_endpoints": api_names - }, indent=2)) - except Exception as e: - print(json.dumps({ - "ok": False, - "url": base, - "error": str(e) - }, indent=2)) - sys.exit(1) - -def post_process(wav_path, ogg_path=None, lufs=-16, quality=6, threshold=-50): - """Run trim + normalize + convert on a WAV file via audio-post pipeline.""" - script_dir = os.path.dirname(os.path.abspath(__file__)) - post_script = os.path.join(script_dir, "audio_post.py") - if ogg_path is None: - ogg_path = os.path.splitext(wav_path)[0] + ".ogg" - cmd = [ - sys.executable, post_script, "pipeline", wav_path, - "--output", ogg_path, - "--lufs", str(lufs), - "--quality", str(quality), - "--threshold", str(threshold), - ] - print(f" Post-processing → {os.path.basename(ogg_path)}...", file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - return {"ok": False, "error": f"Post-processing failed: {result.stderr.strip()}"} - try: - return json.loads(result.stdout) - except json.JSONDecodeError: - return {"ok": True, "output": ogg_path} - - -def _check_available(base): - """Quick check if Stable Audio is reachable. Fail fast with a clear message.""" - try: - req = urllib.request.Request(f"{base}/config", method="GET") - urllib.request.urlopen(req, timeout=5) - except Exception: - print(json.dumps({ - "ok": False, - "error": f"Stable Audio is not available at {base}. The service may be switched off to save system resources. Start it before generating audio." - }, indent=2)) - sys.exit(1) - - -def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600, - post=False, output_ogg=None): - """ - Generate audio from a text prompt. - - Args: - prompt: Text description of the audio to generate - duration: Duration in seconds (0-47, default 10) - steps: Number of diffusion steps (default 100, lower = faster but lower quality) - cfg: Classifier-free guidance scale (default 7) - output: Output file path (default: auto-named in current directory) - timeout: Maximum wait time in seconds (default 600 = 10 minutes) - post: If True, run trim+normalize+convert after generation - output_ogg: OGG output path when post=True (default: same basename .ogg) - """ - base = get_base_url() - _check_available(base) - api_url = f"{base}/gradio_api/call/generate_audio" - - if output is None: - # Auto-name: sanitize prompt to a filename - safe = "".join(c if c.isalnum() or c in "-_ " else "" for c in prompt[:40]) - safe = safe.strip().replace(" ", "_").lower() - output = f"{safe}_{int(duration)}s.wav" - - # Step 1: Submit the generation request - payload = json.dumps({"data": [prompt, duration, steps, cfg]}) - req = urllib.request.Request( - api_url, - data=payload.encode(), - headers={"Content-Type": "application/json"}, - method="POST" - ) - - print("Submitting generation request...", file=sys.stderr) - print(f" Prompt: {prompt}", file=sys.stderr) - print(f" Duration: {duration}s, Steps: {steps}, CFG: {cfg}", file=sys.stderr) - - try: - with urllib.request.urlopen(req, timeout=30) as resp: - result = json.loads(resp.read()) - event_id = result.get("event_id") - if not event_id: - print(json.dumps({"ok": False, "error": "No event_id returned", "response": result}, indent=2)) - sys.exit(1) - except Exception as e: - print(json.dumps({"ok": False, "error": f"Submit failed: {e}"}, indent=2)) - sys.exit(1) - - print(f" Event ID: {event_id}", file=sys.stderr) - print(f" Waiting for generation (timeout: {timeout}s)...", file=sys.stderr) - - # Step 2: Poll the SSE stream for results - stream_url = f"{api_url}/{event_id}" - start_time = time.time() - result_data = None - last_status = None - - while time.time() - start_time < timeout: - try: - req = urllib.request.Request(stream_url, method="GET") - with urllib.request.urlopen(req, timeout=timeout) as resp: - # Read SSE events - current_event = None - for line_bytes in resp: - line = line_bytes.decode("utf-8").strip() - - if line.startswith("event: "): - current_event = line[7:] - elif line.startswith("data: ") and current_event: - data_str = line[6:] - - if current_event == "heartbeat": - elapsed = int(time.time() - start_time) - if elapsed % 30 == 0 and elapsed > 0: - print(f" Still generating... ({elapsed}s elapsed)", file=sys.stderr) - continue - - if current_event == "error": - error_msg = data_str - try: - error_msg = json.loads(data_str) - except (json.JSONDecodeError, TypeError): - pass - print(json.dumps({"ok": False, "error": "Generation failed", "details": error_msg}, indent=2)) - sys.exit(1) - - if current_event == "complete": - try: - result_data = json.loads(data_str) - except json.JSONDecodeError: - result_data = data_str - break - - if current_event == "progress": - try: - progress = json.loads(data_str) - # Gradio progress events vary; log what we get - status = str(progress)[:80] - if status != last_status: - print(f" Progress: {status}", file=sys.stderr) - last_status = status - except (json.JSONDecodeError, TypeError): - pass - continue - - if result_data is not None: - break - - except urllib.error.URLError as e: - # Connection dropped — retry after brief pause - time.sleep(2) - continue - except Exception as e: - print(json.dumps({"ok": False, "error": f"Stream error: {e}"}, indent=2)) - sys.exit(1) - - if result_data is None: - print(json.dumps({"ok": False, "error": f"Generation timed out after {timeout}s"}, indent=2)) - sys.exit(1) - - # Step 3: Download the audio file - # Gradio returns file info in the data array - elapsed = round(time.time() - start_time, 1) - print(f" Generation complete ({elapsed}s)", file=sys.stderr) - - try: - # result_data is typically [{"path": "...", "url": "...", ...}] or similar - if isinstance(result_data, list) and len(result_data) > 0: - audio_info = result_data[0] - elif isinstance(result_data, dict) and "data" in result_data: - audio_info = result_data["data"][0] if result_data["data"] else None - else: - audio_info = result_data - - # Extract the file URL - file_url = None - if isinstance(audio_info, dict): - file_url = audio_info.get("url") or audio_info.get("path") - elif isinstance(audio_info, str): - file_url = audio_info - - if not file_url: - print(json.dumps({ - "ok": False, - "error": "Could not extract audio URL from response", - "response": result_data - }, indent=2)) - sys.exit(1) - - # Handle relative URLs - if file_url.startswith("/"): - file_url = f"{base}{file_url}" - elif not file_url.startswith("http"): - file_url = f"{base}/file={file_url}" - - # Download the file - print(f" Downloading to {output}...", file=sys.stderr) - req = urllib.request.Request(file_url, method="GET") - with urllib.request.urlopen(req, timeout=60) as resp: - with open(output, "wb") as f: - shutil.copyfileobj(resp, f) - - file_size = os.path.getsize(output) - result_json = { - "ok": True, - "file": output, - "size_bytes": file_size, - "duration_requested": duration, - "steps": steps, - "cfg": cfg, - "prompt": prompt, - "generation_time_s": elapsed - } - - if post: - post_result = post_process(output, ogg_path=output_ogg) - if not post_result.get("ok"): - result_json["post_processed"] = False - result_json["post_error"] = post_result.get("error", "unknown") - else: - result_json["post_processed"] = True - result_json["ogg_file"] = post_result.get("output", output_ogg) - ogg_size = os.path.getsize(result_json["ogg_file"]) - result_json["ogg_size_bytes"] = ogg_size - - print(json.dumps(result_json, indent=2)) - - except Exception as e: - print(json.dumps({ - "ok": False, - "error": f"Download failed: {e}", - "response": result_data - }, indent=2)) - sys.exit(1) - -def main(): - if len(sys.argv) < 2: - print("Usage:") - print(" audio_connector.py health") - print(" audio_connector.py generate 'prompt' [--duration N] [--steps N] [--cfg N] [--output file.wav] [--timeout N]") - sys.exit(1) - - cmd = sys.argv[1] - - if cmd == "health": - health() - elif cmd == "generate": - if len(sys.argv) < 3: - print("Error: prompt required", file=sys.stderr) - print("Usage: audio_connector.py generate 'prompt' [--duration N] [--steps N] [--cfg N] [--output file.wav]", file=sys.stderr) - sys.exit(1) - - prompt = sys.argv[2] - duration = 10.0 - steps = 100 - cfg = 7.0 - output = None - timeout = 600 - post = False - output_ogg = None - - # Parse optional args - i = 3 - while i < len(sys.argv): - if sys.argv[i] == "--duration" and i + 1 < len(sys.argv): - duration = float(sys.argv[i + 1]) - i += 2 - elif sys.argv[i] == "--steps" and i + 1 < len(sys.argv): - steps = int(sys.argv[i + 1]) - i += 2 - elif sys.argv[i] == "--cfg" and i + 1 < len(sys.argv): - cfg = float(sys.argv[i + 1]) - i += 2 - elif sys.argv[i] == "--output" and i + 1 < len(sys.argv): - output = sys.argv[i + 1] - i += 2 - elif sys.argv[i] == "--timeout" and i + 1 < len(sys.argv): - timeout = int(sys.argv[i + 1]) - i += 2 - elif sys.argv[i] == "--post": - post = True - i += 1 - elif sys.argv[i] == "--output-ogg" and i + 1 < len(sys.argv): - output_ogg = sys.argv[i + 1] - post = True # --output-ogg implies --post - i += 2 - else: - print(f"Unknown argument: {sys.argv[i]}", file=sys.stderr) - sys.exit(1) - - generate(prompt, duration=duration, steps=steps, cfg=cfg, output=output, - timeout=timeout, post=post, output_ogg=output_ogg) - else: - print(f"Unknown command: {cmd}", file=sys.stderr) - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/tooling/db/audio_post.py b/tooling/db/audio_post.py deleted file mode 100644 index 57546dcfc..000000000 --- a/tooling/db/audio_post.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -"""Audio post-processing wrapper around ffmpeg. - -Subcommands: - convert — WAV to OGG (libvorbis, quality 6) - normalize — LUFS normalize to -16 LUFS (broadcast standard) - trim — Remove leading/trailing silence - pipeline — trim + normalize + convert (full post-processing chain) - -All operations write to a new file (never overwrites input). -""" - -import argparse -import os -import subprocess -import sys -import shutil -import json - - -def check_ffmpeg(): - if not shutil.which("ffmpeg"): - print(json.dumps({"ok": False, "error": "ffmpeg not found in PATH"})) - sys.exit(1) - - -def run_ffmpeg(args, description): - """Run ffmpeg, capture output, return success.""" - cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args - print(f" {description}", file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - print(json.dumps({ - "ok": False, - "error": f"ffmpeg failed: {result.stderr.strip()}", - "command": " ".join(cmd) - })) - sys.exit(1) - return True - - -def cmd_convert(args): - """Convert WAV to OGG (libvorbis).""" - output = args.output or args.input.rsplit(".", 1)[0] + ".ogg" - run_ffmpeg( - ["-i", args.input, "-c:a", "libvorbis", "-q:a", str(args.quality), output], - f"converting {os.path.basename(args.input)} → {os.path.basename(output)}" - ) - size = os.path.getsize(output) - print(json.dumps({"ok": True, "output": output, "size_bytes": size})) - - -def cmd_normalize(args): - """LUFS normalize audio file.""" - output = args.output or _suffixed(args.input, "_norm") - run_ffmpeg( - ["-i", args.input, "-af", - f"loudnorm=I={args.lufs}:LRA=11:TP=-1", - output], - f"normalizing to {args.lufs} LUFS" - ) - print(json.dumps({"ok": True, "output": output})) - - -def cmd_trim(args): - """Trim leading/trailing silence.""" - output = args.output or _suffixed(args.input, "_trimmed") - af = ( - "silenceremove=start_periods=1:start_silence=0.05" - f":start_threshold={args.threshold}dB," - "areverse," - "silenceremove=start_periods=1:start_silence=0.05" - f":start_threshold={args.threshold}dB," - "areverse" - ) - run_ffmpeg( - ["-i", args.input, "-af", af, output], - f"trimming silence (threshold: {args.threshold}dB)" - ) - print(json.dumps({"ok": True, "output": output})) - - -def cmd_pipeline(args): - """Full post-processing: trim → normalize → convert to OGG.""" - base = args.input.rsplit(".", 1)[0] - trimmed = base + "_trimmed.wav" - normalized = base + "_norm.wav" - output = args.output or base + ".ogg" - - # Step 1: trim - af_trim = ( - "silenceremove=start_periods=1:start_silence=0.05" - f":start_threshold={args.threshold}dB," - "areverse," - "silenceremove=start_periods=1:start_silence=0.05" - f":start_threshold={args.threshold}dB," - "areverse" - ) - run_ffmpeg( - ["-i", args.input, "-af", af_trim, trimmed], - "step 1/3: trimming silence" - ) - - # Step 2: normalize - run_ffmpeg( - ["-i", trimmed, "-af", - f"loudnorm=I={args.lufs}:LRA=11:TP=-1", - normalized], - f"step 2/3: normalizing to {args.lufs} LUFS" - ) - - # Step 3: convert - run_ffmpeg( - ["-i", normalized, "-c:a", "libvorbis", "-q:a", str(args.quality), output], - "step 3/3: converting to OGG" - ) - - # Clean up intermediates - os.remove(trimmed) - os.remove(normalized) - - size = os.path.getsize(output) - print(json.dumps({"ok": True, "output": output, "size_bytes": size})) - - -def _suffixed(path, suffix): - base, ext = os.path.splitext(path) - return base + suffix + ext - - -def main(): - parser = argparse.ArgumentParser(description="Audio post-processing (ffmpeg wrapper)") - sub = parser.add_subparsers(dest="command", required=True) - - # convert - p = sub.add_parser("convert", help="WAV → OGG") - p.add_argument("input", help="Input WAV file") - p.add_argument("--output", "-o", help="Output file (default: same name .ogg)") - p.add_argument("--quality", "-q", type=int, default=6, help="Vorbis quality 0-10 (default: 6)") - p.set_defaults(func=cmd_convert) - - # normalize - p = sub.add_parser("normalize", help="LUFS normalize") - p.add_argument("input", help="Input audio file") - p.add_argument("--output", "-o", help="Output file") - p.add_argument("--lufs", type=float, default=-16, help="Target LUFS (default: -16)") - p.set_defaults(func=cmd_normalize) - - # trim - p = sub.add_parser("trim", help="Trim silence") - p.add_argument("input", help="Input audio file") - p.add_argument("--output", "-o", help="Output file") - p.add_argument("--threshold", type=int, default=-50, help="Silence threshold in dB (default: -50)") - p.set_defaults(func=cmd_trim) - - # pipeline - p = sub.add_parser("pipeline", help="Full post-processing: trim + normalize + convert") - p.add_argument("input", help="Input WAV file") - p.add_argument("--output", "-o", help="Output OGG file") - p.add_argument("--quality", "-q", type=int, default=6, help="Vorbis quality 0-10 (default: 6)") - p.add_argument("--lufs", type=float, default=-16, help="Target LUFS (default: -16)") - p.add_argument("--threshold", type=int, default=-50, help="Silence threshold in dB (default: -50)") - p.set_defaults(func=cmd_pipeline) - - args = parser.parse_args() - check_ffmpeg() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/tooling/db/common.py b/tooling/db/common.py deleted file mode 100644 index 87bcfb463..000000000 --- a/tooling/db/common.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -"""Shared utilities for Settled Reach asset/connector scripts. - -Provides `ensure_venv`, used by the asset connectors (audio_connector, -audio_batch, image_connector, trellis_connector) to re-exec into the project -.venv before their third-party imports, plus the connector config helpers -(`load_config`, `get_base_url`, `get_api_key`) that were formerly copy-pasted -across the connectors (T-1067 rider, S-25). The former settledreach.db -connection helpers were removed when the ticket/decision tooling was retired -(pql migration Phase 6); planning now lives in pql (`.pql/`). -""" - -import json -import os -import sys -from pathlib import Path - -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- - -SCRIPT_DIR = Path(__file__).resolve().parent -WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() -CONFIG_PATH = SCRIPT_DIR / "config.json" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def ensure_venv() -> None: - """Re-exec into the project .venv Python if not already running there. - - Call this at the top of any script that uses third-party packages, - before those imports. - - Usage:: - - from common import ensure_venv - ensure_venv() - import numpy as np # third-party import follows - """ - venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python" - - # Already running inside the venv — nothing to do. - if Path(sys.executable).resolve() == venv_python.resolve(): - return - - # .venv not set up yet — fail with a helpful message. - if not venv_python.exists(): - print( - "error: .venv not found — run `make setup-venv` first.", - file=sys.stderr, - ) - sys.exit(1) - - # Re-exec into the venv Python, preserving all arguments. - os.execv(str(venv_python), [str(venv_python)] + sys.argv) - - -def load_config() -> dict: - """Load the shared endpoint configuration from tooling/db/config.json.""" - with open(CONFIG_PATH) as f: - return json.load(f) - - -def get_base_url(key: str, default: str) -> str: - """Resolve a service base URL from config.json, with a fallback default. - - Usage:: - - base = get_base_url("trellis_url", "http://tower-of-joy:11510") - """ - return load_config().get(key, default) - - -def get_api_key(env_var: str, config_key: str = "") -> str: - """Get an API key from the environment — environment-only, by design. - - ``tooling/db/config.json`` is a *tracked* file and holds endpoints only; - it must never carry secrets, so there is deliberately no config.json - fallback here (the old one steered users toward committing paid API keys). - ``config_key`` is retained in the signature for caller compatibility but - is ignored. Prints a JSON error and exits 1 if the variable is unset — - connector scripts emit machine-readable JSON on all paths. - """ - key = os.environ.get(env_var) - if key: - return key - print(json.dumps({ - "ok": False, - "error": ( - f"{env_var} not set. Export it in your shell or add it to the " - "machine-local .claude/settings.local.json env block (untracked). " - "Never put keys in tooling/db/config.json — it is tracked." - ) - }, indent=2)) - sys.exit(1) diff --git a/tooling/db/image_connector.py b/tooling/db/image_connector.py deleted file mode 100755 index 83a58b41a..000000000 --- a/tooling/db/image_connector.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python3 -""" -Gemini image generator connector — direct API wrapper. - -Generates images via Google's gemini-2.5-flash-image generation API. -API key from GEMINI_API_KEY env var or config.json. - -Usage: - python3 image_connector.py health - python3 image_connector.py generate "prompt" [--output file.png] [--aspect 1:1] [--size 1K] [--input image.png] -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from common import ensure_venv, get_api_key as _get_api_key # noqa: E402 - -ensure_venv() - -import base64 -import json -import os -import urllib.error -import urllib.request - -DEFAULT_OUTPUT_DIR = os.path.expanduser("~/Pictures/mcp-images") - - -def get_api_key() -> str: - """Get Gemini API key from env or config.""" - return _get_api_key("GEMINI_API_KEY", "gemini_api_key") - - -def health(): - """Check if the Gemini API is reachable with the configured key.""" - key = get_api_key() - url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}" - try: - req = urllib.request.Request(url, method="GET") - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read()) - models = [m.get("name", "") for m in data.get("models", []) - if "imagen" in m.get("name", "").lower() - or "flash" in m.get("name", "").lower()] - print(json.dumps({ - "ok": True, - "api": "gemini", - "image_capable_models": models[:5], - }, indent=2)) - except Exception as e: - print(json.dumps({ - "ok": False, - "error": str(e) - }, indent=2)) - sys.exit(1) - - -def generate(prompt, output=None, aspect_ratio="1:1", image_size=None, - input_image=None): - """ - Generate an image from a text prompt using Gemini. - - Args: - prompt: Text description of the image to generate - output: Output file path (default: auto-named in ~/Pictures/mcp-images/) - aspect_ratio: Aspect ratio (1:1, 16:9, 3:2, etc.) - image_size: Resolution hint (1K, 2K, 4K) - may not be honored - input_image: Optional input image path for image-to-image generation - """ - key = get_api_key() - - # Gemini image generation model - model = "gemini-2.5-flash-image" - url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}" - - if output is None: - safe = "".join(c if c.isalnum() or c in "-_ " else "" for c in prompt[:40]) - safe = safe.strip().replace(" ", "_").lower() - os.makedirs(DEFAULT_OUTPUT_DIR, exist_ok=True) - output = os.path.join(DEFAULT_OUTPUT_DIR, f"{safe}.png") - - # Build the request - parts = [] - - # Add input image if provided (image-to-image) - if input_image: - if not os.path.isfile(input_image): - print(json.dumps({"ok": False, "error": f"Input image not found: {input_image}"}, indent=2)) - sys.exit(1) - with open(input_image, "rb") as f: - image_data = base64.b64encode(f.read()).decode("utf-8") - # Detect mime type - ext = os.path.splitext(input_image)[1].lower() - mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".webp": "image/webp"}.get(ext, "image/png") - parts.append({ - "inlineData": { - "mimeType": mime, - "data": image_data - } - }) - - # Build enhanced prompt with a size hint. Unlike aspect ratio below, Gemini - # has no dedicated resolution parameter for this model — this is a - # best-effort prompt hint only and may not be honored. - enhanced_prompt = prompt - if image_size: - enhanced_prompt += f" Resolution: {image_size}." - - parts.append({"text": enhanced_prompt}) - - generation_config = {"responseModalities": ["TEXT", "IMAGE"]} - if aspect_ratio: - # Real API parameter (not a prompt hint). Valid values: 1:1, 3:2, - # 2:3, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, 21:9. - # https://ai.google.dev/gemini-api/docs/image-generation - generation_config["imageConfig"] = {"aspectRatio": aspect_ratio} - - payload = json.dumps({ - "contents": [{"parts": parts}], - "generationConfig": generation_config - }) - - req = urllib.request.Request( - url, - data=payload.encode(), - headers={"Content-Type": "application/json"}, - method="POST" - ) - - print("Generating image...", file=sys.stderr) - print(f" Prompt: {prompt}", file=sys.stderr) - if input_image: - print(f" Input image: {input_image}", file=sys.stderr) - - try: - with urllib.request.urlopen(req, timeout=120) as resp: - result = json.loads(resp.read()) - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace") - print(json.dumps({ - "ok": False, - "error": f"API error {e.code}: {e.reason}", - "details": body[:500] - }, indent=2)) - sys.exit(1) - except Exception as e: - print(json.dumps({"ok": False, "error": str(e)}, indent=2)) - sys.exit(1) - - # Extract image data from response - candidates = result.get("candidates", []) - if not candidates: - print(json.dumps({ - "ok": False, - "error": "No candidates in response", - "response": json.dumps(result)[:500] - }, indent=2)) - sys.exit(1) - - image_saved = False - text_response = "" - - for candidate in candidates: - content = candidate.get("content", {}) - for part in content.get("parts", []): - if "inlineData" in part: - # Image data - image_b64 = part["inlineData"]["data"] - image_bytes = base64.b64decode(image_b64) - os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True) - with open(output, "wb") as f: - f.write(image_bytes) - image_saved = True - elif "text" in part: - text_response += part["text"] - - if not image_saved: - print(json.dumps({ - "ok": False, - "error": "No image data in response", - "text_response": text_response[:500], - "response": json.dumps(result)[:500] - }, indent=2)) - sys.exit(1) - - file_size = os.path.getsize(output) - print(json.dumps({ - "ok": True, - "file": output, - "size_bytes": file_size, - "prompt": prompt, - "aspect_ratio": aspect_ratio, - }, indent=2)) - - -def main(): - if len(sys.argv) < 2: - print("Usage:") - print(" image_connector.py health") - print(" image_connector.py generate 'prompt' [--output file.png] [--aspect 1:1] [--size 1K] [--input image.png]") - sys.exit(1) - - cmd = sys.argv[1] - - if cmd == "health": - health() - elif cmd == "generate": - if len(sys.argv) < 3: - print("Error: prompt required", file=sys.stderr) - sys.exit(1) - - prompt = sys.argv[2] - output = None - aspect_ratio = "1:1" - image_size = None - input_image = None - - i = 3 - while i < len(sys.argv): - if sys.argv[i] == "--output" and i + 1 < len(sys.argv): - output = sys.argv[i + 1] - i += 2 - elif sys.argv[i] == "--aspect" and i + 1 < len(sys.argv): - aspect_ratio = sys.argv[i + 1] - i += 2 - elif sys.argv[i] == "--size" and i + 1 < len(sys.argv): - image_size = sys.argv[i + 1] - i += 2 - elif sys.argv[i] == "--input" and i + 1 < len(sys.argv): - input_image = sys.argv[i + 1] - i += 2 - else: - print(f"Unknown argument: {sys.argv[i]}", file=sys.stderr) - sys.exit(1) - - generate(prompt, output=output, aspect_ratio=aspect_ratio, - image_size=image_size, input_image=input_image) - else: - print(f"Unknown command: {cmd}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/tooling/db/trellis_connector.py b/tooling/db/trellis_connector.py deleted file mode 100755 index e957ff7cd..000000000 --- a/tooling/db/trellis_connector.py +++ /dev/null @@ -1,360 +0,0 @@ -#!/usr/bin/env python3 -""" -Trellis 3D model generator connector — Gradio API wrapper. - -Talks to the Trellis Gradio app at tower-of-joy:11510. -Pipeline: upload image → start session → image_to_3d → extract_glb → download .glb - -Usage: - python3 trellis_connector.py health - python3 trellis_connector.py generate image.png [--output model.glb] [--simplify 0.95] [--texture-size 1024] [--seed 42] [--timeout 600] - -Gradio API Parameter Reference (TRELLIS v1, microsoft/TRELLIS): - - /image_to_3d — 9 inputs: - 0: image (Image) preprocessed image from /preprocess_image_1 - 1: multiimages (Gallery) [] for single-image mode - 2: is_multiimage (State) False for single-image, True for multi-image - 3: seed (Slider) int, 0-2147483647 - 4: ss_guidance (Slider) float, sparse structure guidance strength (default 7.5) - 5: ss_steps (Slider) int, sparse structure sampling steps (default 12) - 6: slat_guidance (Slider) float, structured latent guidance strength (default 3.0) - 7: slat_steps (Slider) int, structured latent sampling steps (default 12) - 8: multiimage_algo (Radio) "stochastic" or "multidiffusion" - - /extract_glb — 3 inputs: - 0: output_buf (State) None — server uses internal state from image_to_3d - 1: simplify (Slider) float, mesh simplification ratio (default 0.95) - 2: texture_size (Slider) int, texture resolution (default 1024) - - Common failure modes: - - "needed 9, got 8": missing is_multiimage (position 2) — must pass False - - "needed 3, got 2": missing output_buf (position 0) — must pass None - - "'float' cannot be interpreted as int": numpy version issue on server, - Gradio Sliders send all values as float. Fix: patch flow_euler.py on - the server to cast steps to int, or pin numpy < 2.0 - - CUDA device mismatch after crash: restart the container to clear GPU state -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from common import ensure_venv, get_base_url as _get_base_url # noqa: E402 - -ensure_venv() - -import json -import os -import shutil -import time -import urllib.error -import urllib.parse -import urllib.request - - -def get_base_url() -> str: - return _get_base_url("trellis_url", "http://tower-of-joy:11510") - - -def health(): - """Check if the Trellis API is reachable.""" - base = get_base_url() - try: - req = urllib.request.Request(f"{base}/info", method="GET") - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read()) - endpoints = list(data.get("named_endpoints", {}).keys()) - print(json.dumps({ - "ok": True, - "url": base, - "endpoints": endpoints - }, indent=2)) - except Exception as e: - print(json.dumps({ - "ok": False, - "url": base, - "error": str(e) - }, indent=2)) - sys.exit(1) - - -def _call_api(base, endpoint, data, timeout=600, session_hash=None): - """Call a Gradio API endpoint with optional session tracking. - - Gradio gr.State components are stored per session_hash on the server. - All calls in a pipeline (image_to_3d → extract_glb) must share the same - session_hash so the server can pass state between them. - """ - api_url = f"{base}/api{endpoint}" - - body_dict = {"data": data} - if session_hash: - body_dict["session_hash"] = session_hash - - payload = json.dumps(body_dict) - req = urllib.request.Request( - api_url, - data=payload.encode(), - headers={"Content-Type": "application/json"}, - method="POST" - ) - - print(f" Calling {endpoint}...", file=sys.stderr) - - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - result = json.loads(resp.read()) - if isinstance(result, dict) and "data" in result: - return result["data"] - return result - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace") - raise RuntimeError(f"{endpoint} failed ({e.code}): {body[:300]}") - - -def _upload_image(base, image_path): - """Upload an image file to the Gradio server and return the file reference.""" - upload_url = f"{base}/upload" - - with open(image_path, "rb") as f: - image_data = f.read() - - filename = os.path.basename(image_path) - - # Gradio upload expects multipart/form-data with a 'files' field - boundary = "----TrellisConnectorBoundary" - body = ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="files"; filename="{filename}"\r\n' - f"Content-Type: image/png\r\n" - f"\r\n" - ).encode() + image_data + f"\r\n--{boundary}--\r\n".encode() - - req = urllib.request.Request( - upload_url, - data=body, - headers={ - "Content-Type": f"multipart/form-data; boundary={boundary}", - }, - method="POST" - ) - - print(f" Uploading {filename}...", file=sys.stderr) - with urllib.request.urlopen(req, timeout=30) as resp: - result = json.loads(resp.read()) - # Gradio returns a list of uploaded file paths - if isinstance(result, list) and len(result) > 0: - return result[0] - raise RuntimeError(f"Upload failed: {result}") - - -def _download_file(url, output_path, base): - """Download a file from the Gradio server.""" - if url.startswith("/"): - url = f"{base}{url}" - elif not url.startswith("http"): - url = f"{base}/file={url}" - - print(f" Downloading to {output_path}...", file=sys.stderr) - req = urllib.request.Request(url, method="GET") - with urllib.request.urlopen(req, timeout=120) as resp: - with open(output_path, "wb") as f: - shutil.copyfileobj(resp, f) - - return os.path.getsize(output_path) - - -def _check_available(base): - """Quick check if Trellis is reachable. Fail fast with a clear message.""" - try: - req = urllib.request.Request(f"{base}/info", method="GET") - urllib.request.urlopen(req, timeout=5) - except Exception: - print(json.dumps({ - "ok": False, - "error": f"Trellis is not available at {base}. The service may be switched off to save system resources. Start it before generating 3D models." - }, indent=2)) - sys.exit(1) - - -def generate(image_path, output=None, simplify=0.95, texture_size=1024, - seed=0, timeout=600): - """ - Generate a 3D model from an image. - - Pipeline: - 1. Start session - 2. Upload and preprocess image - 3. Generate 3D from image - 4. Extract GLB - 5. Download GLB file - - Args: - image_path: Path to the input image (PNG recommended) - output: Output .glb file path (default: auto-named) - simplify: Mesh simplification factor (0.9-0.98, default 0.95) - texture_size: Texture resolution (512-2048, default 1024) - seed: Random seed (default 0) - timeout: Max wait time per step in seconds - """ - base = get_base_url() - _check_available(base) - start_time = time.time() - - if not os.path.isfile(image_path): - print(json.dumps({"ok": False, "error": f"Image not found: {image_path}"}), indent=2) - sys.exit(1) - - if output is None: - name = os.path.splitext(os.path.basename(image_path))[0] - output = f"{name}.glb" - - # Generate a session hash — Gradio uses this to maintain gr.State between - # separate API calls. Without it, image_to_3d's output state is lost before - # extract_glb can read it. - import random - import string - session = ''.join(random.choices(string.ascii_lowercase + string.digits, k=12)) - print(f" Session: {session}", file=sys.stderr) - - # Step 1: Start session - print("Step 1/5: Starting session...", file=sys.stderr) - session_result = _call_api(base, "/start_session", [], timeout=30, session_hash=session) - - # Step 2: Upload and preprocess image - print("Step 2/5: Uploading and preprocessing image...", file=sys.stderr) - uploaded_path = _upload_image(base, image_path) - file_ref = { - "path": uploaded_path, - "meta": {"_type": "gradio.FileData"} - } - preprocess_result = _call_api(base, "/preprocess_image_1", [file_ref], timeout=60, session_hash=session) - - # _call_api returns the "data" array directly - if isinstance(preprocess_result, list) and len(preprocess_result) > 0: - preprocessed_ref = preprocess_result[0] - else: - preprocessed_ref = preprocess_result - - # Step 3: Get seed - print("Step 3/5: Generating 3D model...", file=sys.stderr) - seed_result = _call_api(base, "/get_seed", [True, seed], timeout=10, session_hash=session) - if isinstance(seed_result, list) and seed_result: - actual_seed = seed_result[0] - else: - actual_seed = seed - - # Step 4: Image to 3D - # Gradio app has 9 inputs: Image, Gallery, State, Slider×5, Radio - # State is a hidden session component at position 3 — pass None. - # Slider order (from /info): seed, ss_guidance, ss_steps, slat_guidance, slat_steps - # Note: server-side numpy bug requires patching flow_euler.py to cast steps to int. - gen_result = _call_api(base, "/image_to_3d", [ - preprocessed_ref, # image - [], # multiimages (empty) - False, # is_multiimage (boolean flag, not session state) - actual_seed, # seed (Slider, 0-2147483647) - 7.5, # ss_guidance_strength - 12, # ss_sampling_steps - 3.0, # slat_guidance_strength - 12, # slat_sampling_steps - "stochastic", # multiimage_algo - ], timeout=timeout, session_hash=session) - - # Step 5: Extract GLB - # State (output_buf) is maintained server-side via session_hash. - # extract_glb reads it automatically. Pass None as placeholder for the State component. - print("Step 4/5: Extracting GLB...", file=sys.stderr) - glb_result = _call_api(base, "/extract_glb", [None, simplify, texture_size], timeout=120, session_hash=session) - - # _call_api returns the "data" array: [model_viewer_data, download_button_data] - glb_url = None - if isinstance(glb_result, list): - for item in glb_result: - if isinstance(item, dict): - url = item.get("url") or item.get("path") - if url: - glb_url = url - break - - if not glb_url: - print(json.dumps({ - "ok": False, - "error": "Could not extract GLB URL from response", - "response": glb_result - }, indent=2)) - sys.exit(1) - - # Step 6: Download - print("Step 5/5: Downloading GLB...", file=sys.stderr) - os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True) - file_size = _download_file(glb_url, output, base) - - elapsed = round(time.time() - start_time, 1) - print(json.dumps({ - "ok": True, - "file": output, - "size_bytes": file_size, - "simplify": simplify, - "texture_size": texture_size, - "seed": actual_seed, - "generation_time_s": elapsed, - "source_image": image_path - }, indent=2)) - - -def main(): - if len(sys.argv) < 2: - print("Usage:") - print(" trellis_connector.py health") - print(" trellis_connector.py generate image.png [--output model.glb] [--simplify 0.95] [--texture-size 1024] [--seed N] [--timeout N]") - sys.exit(1) - - cmd = sys.argv[1] - - if cmd == "health": - health() - elif cmd == "generate": - if len(sys.argv) < 3: - print("Error: image path required", file=sys.stderr) - sys.exit(1) - - image_path = sys.argv[2] - output = None - simplify = 0.95 - texture_size = 1024 - seed = 0 - timeout = 600 - - i = 3 - while i < len(sys.argv): - if sys.argv[i] == "--output" and i + 1 < len(sys.argv): - output = sys.argv[i + 1] - i += 2 - elif sys.argv[i] == "--simplify" and i + 1 < len(sys.argv): - simplify = float(sys.argv[i + 1]) - i += 2 - elif sys.argv[i] == "--texture-size" and i + 1 < len(sys.argv): - texture_size = int(sys.argv[i + 1]) - i += 2 - elif sys.argv[i] == "--seed" and i + 1 < len(sys.argv): - seed = int(sys.argv[i + 1]) - i += 2 - elif sys.argv[i] == "--timeout" and i + 1 < len(sys.argv): - timeout = int(sys.argv[i + 1]) - i += 2 - else: - print(f"Unknown argument: {sys.argv[i]}", file=sys.stderr) - sys.exit(1) - - generate(image_path, output=output, simplify=simplify, - texture_size=texture_size, seed=seed, timeout=timeout) - else: - print(f"Unknown command: {cmd}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/tooling/domains/assets/__init__.py b/tooling/domains/assets/__init__.py new file mode 100644 index 000000000..a31569fbb --- /dev/null +++ b/tooling/domains/assets/__init__.py @@ -0,0 +1,13 @@ +"""`assets` — connectors to the generators that make game assets (D-263). + +Stable Audio Open and Trellis run on tower-of-joy and are kept switched OFF to +save VRAM; Gemini is a paid cloud API. So in this domain network failure is +the normal case, and every connector reports it through endpoints.py, which +says whether the service is off or rejected the request. + +Formerly tooling/db/ (a misnamed directory: it held connectors, not database +work) plus tooling/trellis-batch.sh and tooling/synth_ui_sounds.py (T-1290). +Output contract: a verb's result goes to stdout as JSON — the same keys the +old scripts printed, so the skills' readers keep working — and a failure is a +non-zero exit with a remedy, not `{"ok": false}` on stdout. +""" diff --git a/tooling/domains/assets/audio.py b/tooling/domains/assets/audio.py new file mode 100644 index 000000000..1edfcdb63 --- /dev/null +++ b/tooling/domains/assets/audio.py @@ -0,0 +1,247 @@ +"""Stable Audio Open connector — Gradio API wrapper. + +Talks to the Stable Audio Open Gradio app on tower-of-joy :11500 (URL from +config.json). Uses the async Gradio pattern: POST to submit, then read the SSE +stream for the result, then download the file. + +Formerly tooling/db/audio_connector.py, fronted by the audio-generate and +audio-health bash wrappers (T-1290). Behaviour is unchanged except where a +failure used to print `{"ok": false}` on stdout and exit 1: it now raises a +ReachError that says whether the service is OFF or rejected the request, and +post-processing is a function call rather than a second Python process. +""" + +from __future__ import annotations + +import json +import os +import shutil +import time +import urllib.error +import urllib.request + +from tooling.core import console +from tooling.core.errors import ReachError +from tooling.domains.assets import audio_post, endpoints + +SERVICE = "audio" + + +def get_base_url() -> str: + return endpoints.base_url(SERVICE) + + +def health() -> dict: + """Is the Stable Audio API reachable, and what does it expose?""" + base = get_base_url() + data = endpoints.call_json( + urllib.request.Request(f"{base}/config", method="GET"), + service=SERVICE, + what="the health check", + timeout=10, + ) + api_names = [ + dep.get("api_name", "") + for dep in data.get("dependencies", []) + if dep.get("api_name", "") and not dep.get("api_name", "").startswith("js_") + ] + return { + "ok": True, + "url": base, + "gradio_version": data.get("version", "unknown"), + "api_endpoints": api_names, + } + + +def post_process(wav_path: str, ogg_path: str | None = None, lufs=-16, quality=6, threshold=-50) -> dict: + """trim + normalize + convert a generated WAV (the `--post` step).""" + if ogg_path is None: + ogg_path = os.path.splitext(wav_path)[0] + ".ogg" + console.event(f"Post-processing → {os.path.basename(ogg_path)}...") + try: + return audio_post.pipeline(wav_path, ogg_path, quality=quality, lufs=lufs, threshold=threshold) + except ReachError as exc: + # Not fatal to the generation: the WAV exists and is reported, and the + # failure is carried in the result exactly as before. + return {"ok": False, "error": f"Post-processing failed: {exc.message}"} + + +def default_output(prompt: str, duration: float) -> str: + """Auto-name: the first 40 prompt characters, sanitised, plus the duration.""" + safe = "".join(c if c.isalnum() or c in "-_ " else "" for c in prompt[:40]) + safe = safe.strip().replace(" ", "_").lower() + return f"{safe}_{int(duration)}s.wav" + + +def extract_file_url(result_data, base: str) -> str | None: + """Find the audio file URL in Gradio's `complete` payload, made absolute.""" + if isinstance(result_data, list) and len(result_data) > 0: + audio_info = result_data[0] + elif isinstance(result_data, dict) and "data" in result_data: + audio_info = result_data["data"][0] if result_data["data"] else None + else: + audio_info = result_data + + file_url = None + if isinstance(audio_info, dict): + file_url = audio_info.get("url") or audio_info.get("path") + elif isinstance(audio_info, str): + file_url = audio_info + if not file_url: + return None + + if file_url.startswith("/"): + return f"{base}{file_url}" + if not file_url.startswith("http"): + return f"{base}/file={file_url}" + return file_url + + +def generate( + prompt: str, + duration: float = 10.0, + steps: int = 100, + cfg: float = 7.0, + output: str | None = None, + timeout: int = 600, + post: bool = False, + output_ogg: str | None = None, +) -> dict: + """Generate audio from a text prompt; returns the result dict. + + duration is 0-47 s. Fewer steps is faster and worse. `post` (implied by + `output_ogg`) runs trim + normalize + convert on the result. + """ + base = get_base_url() + # Fail fast, and say OFF rather than "submit failed", before building anything. + endpoints.call( + urllib.request.Request(f"{base}/config", method="GET"), + service=SERVICE, + what="the availability check", + timeout=5, + ) + api_url = f"{base}/gradio_api/call/generate_audio" + output = output or default_output(prompt, duration) + + # 1. Submit. + console.event(f"Submitting: {prompt!r} ({duration}s, {steps} steps, cfg {cfg})") + submitted = endpoints.call_json( + urllib.request.Request( + api_url, + data=json.dumps({"data": [prompt, duration, steps, cfg]}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ), + service=SERVICE, + what="the generation request", + timeout=30, + ) + event_id = submitted.get("event_id") if isinstance(submitted, dict) else None + if not event_id: + raise ReachError( + f"Stable Audio accepted the request but returned no event_id: {submitted}", + fix="the Gradio app's API may have changed — check `reach assets audio health` endpoints", + ) + console.event(f"Event {event_id} — waiting for generation (timeout {timeout}s)...") + + # 2. Read the SSE stream. A dropped connection is retried until the + # deadline, exactly as before; only an `error` event is fatal. + started = time.time() + result_data = _await_result(f"{api_url}/{event_id}", timeout, started) + + elapsed = round(time.time() - started, 1) + console.event(f"Generation complete ({elapsed}s)") + + # 3. Download. + file_url = extract_file_url(result_data, base) + if not file_url: + raise ReachError( + f"could not find the audio URL in the response: {str(result_data)[:300]}", + fix="the Gradio app's response shape may have changed — see audio.extract_file_url", + ) + console.event(f"Downloading to {output}...") + try: + with urllib.request.urlopen(urllib.request.Request(file_url, method="GET"), timeout=60) as resp: + with open(output, "wb") as f: + shutil.copyfileobj(resp, f) + except urllib.error.URLError as exc: + raise ReachError( + f"download failed from {file_url}: {exc}", + fix="the file may have expired on the server — re-run the generation", + ) from exc + + result = { + "ok": True, + "file": output, + "size_bytes": os.path.getsize(output), + "duration_requested": duration, + "steps": steps, + "cfg": cfg, + "prompt": prompt, + "generation_time_s": elapsed, + } + + if post or output_ogg: + post_result = post_process(output, ogg_path=output_ogg) + if not post_result.get("ok"): + result["post_processed"] = False + result["post_error"] = post_result.get("error", "unknown") + else: + result["post_processed"] = True + result["ogg_file"] = post_result.get("output", output_ogg) + result["ogg_size_bytes"] = os.path.getsize(result["ogg_file"]) + + return result + + +def _await_result(stream_url: str, timeout: int, start: float): + """Poll the Gradio SSE stream until `complete`, an `error`, or the deadline.""" + last_status = None + + while time.time() - start < timeout: + try: + with urllib.request.urlopen(urllib.request.Request(stream_url, method="GET"), timeout=timeout) as resp: + current_event = None + for line_bytes in resp: + line = line_bytes.decode("utf-8").strip() + if line.startswith("event: "): + current_event = line[7:] + continue + if not (line.startswith("data: ") and current_event): + continue + data_str = line[6:] + + if current_event == "heartbeat": + elapsed = int(time.time() - start) + if elapsed % 30 == 0 and elapsed > 0: + console.event(f"Still generating... ({elapsed}s elapsed)") + elif current_event == "error": + try: + detail = json.loads(data_str) + except (json.JSONDecodeError, TypeError): + detail = data_str + raise ReachError( + f"Stable Audio reported a generation error: {detail}", + fix="the service is up — check the prompt and duration (0-47 s), then re-run", + ) + elif current_event == "complete": + try: + return json.loads(data_str) + except json.JSONDecodeError: + return data_str + elif current_event == "progress": + try: + status = str(json.loads(data_str))[:80] + except (json.JSONDecodeError, TypeError): + continue + if status != last_status: + console.event(f"Progress: {status}") + last_status = status + except urllib.error.URLError: + time.sleep(2) # connection dropped mid-stream — retry until the deadline + + raise ReachError( + f"generation timed out after {timeout}s", + fix="raise --timeout, or lower --steps / --duration", + ) + diff --git a/tooling/domains/assets/audio_batch.py b/tooling/domains/assets/audio_batch.py new file mode 100644 index 000000000..53208c493 --- /dev/null +++ b/tooling/domains/assets/audio_batch.py @@ -0,0 +1,232 @@ +"""Batch audio generation from a manifest file. + +Processes assets in order: Stable Audio generation (`method: sao`) or harmonic +synthesis (`method: synth`), each followed by post-processing to OGG. + +Formerly tooling/db/audio_batch.py behind the audio-batch wrapper (T-1290). It +used to re-launch audio_connector.py and audio_post.py as subprocesses and +parse their stdout; it now calls them. The synthesis itself is unchanged — +same parameters give byte-identical WAVs. One behaviour change: a batch with +failures used to print `"ok": false` and exit 0; the router now fails it. + +Manifest schema (docs/assets/audio/, see the audio-gen skill): + {"output_dir": ..., "gen_dir": ..., "defaults": {steps, cfg, timeout, lufs, quality}, + "assets": [{"id", "filename", "method": "sao"|"synth", "prompt"?, "duration"?, + "synth"?: {"type": "harmonic", "duration", "fundamental", + "harmonics": [{"freq", "db"}], "attack_ms", + "sustain_ratio", "decay": "exponential"|"linear"}}]} +""" + +from __future__ import annotations + +import json +import os +import wave + +import numpy as np + +from tooling.core import config, console +from tooling.core.errors import ReachError +from tooling.domains.assets import audio, audio_post + +SAMPLE_RATE = 44100 + + +def load_manifest(path: str) -> dict: + try: + with open(path) as f: + return json.load(f) + except FileNotFoundError as exc: + raise ReachError(f"manifest not found: {path}", fix="pass a manifest .json path") from exc + except json.JSONDecodeError as exc: + raise ReachError(f"manifest is not valid JSON: {path}: {exc}", fix="fix the JSON, then re-run") from exc + + +def resolve_paths(manifest: dict) -> tuple[str, str]: + """output_dir and gen_dir, relative to the repo root (created if missing). + + The old script found the root by walking up from the manifest looking for + .git; config.repo_root() asks git, which gives the same answer in a + worktree and does not silently fall back to the manifest's own directory. + """ + root = str(config.repo_root()) + output_dir = os.path.join(root, manifest.get("output_dir", "client/assets/audio")) + gen_dir = os.path.join(root, manifest.get("gen_dir", "client/assets/audio/gen")) + os.makedirs(output_dir, exist_ok=True) + os.makedirs(gen_dir, exist_ok=True) + return output_dir, gen_dir + + +def get_default(manifest: dict, asset: dict, key: str): + """An asset value, falling back to the manifest's defaults.""" + return asset.get(key, manifest.get("defaults", {}).get(key)) + + +def run_sao_generate(asset: dict, manifest: dict, gen_dir: str, output_dir: str) -> dict: + """Generate via Stable Audio, post-processing straight to the final OGG.""" + filename = asset["filename"] + base_name = os.path.splitext(filename)[0] + return audio.generate( + asset["prompt"], + duration=asset.get("duration", 10), + steps=get_default(manifest, asset, "steps") or 100, + cfg=get_default(manifest, asset, "cfg") or 7, + output=os.path.join(gen_dir, base_name + ".wav"), + timeout=get_default(manifest, asset, "timeout") or 600, + output_ogg=os.path.join(output_dir, filename), + ) + + +def synthesize_harmonic(params: dict, wav_path: str) -> str: + """Synthesize a tone from harmonic parameters and write a 16-bit mono WAV.""" + sr = SAMPLE_RATE + duration = params["duration"] + fundamental = params["fundamental"] + harmonics = params.get("harmonics", []) + attack_ms = params.get("attack_ms", 10) + sustain_ratio = params.get("sustain_ratio", 0.2) + decay = params.get("decay", "exponential") + + n = int(sr * duration) + t = np.linspace(0, duration, n, endpoint=False) + + signal = np.sin(2 * np.pi * fundamental * t) + for h in harmonics: + amplitude = 10 ** (h["db"] / 20) + signal = signal + amplitude * np.sin(2 * np.pi * h["freq"] * t) + + # Envelope: attack + sustain + decay. + attack_s = attack_ms / 1000 + attack_env = np.minimum(t / attack_s, 1.0) if attack_s > 0 else np.ones(n) + + sustain_end = duration * sustain_ratio + if decay == "exponential": + # Reach -60 dB by the end of the duration. + decay_rate = 6.9 / (duration - sustain_end) if duration > sustain_end else 10 + decay_env = np.where(t < sustain_end, 1.0, np.exp(-decay_rate * (t - sustain_end))) + else: + decay_env = np.where(t < sustain_end, 1.0, 1.0 - (t - sustain_end) / (duration - sustain_end)) + + signal = signal * attack_env * decay_env + + peak = np.max(np.abs(signal)) + if peak > 0: + signal = signal / peak * 0.9 + + int_samples = np.clip(signal * 32767, -32767, 32767).astype(np.int16) + with wave.open(wav_path, "w") as f: + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(sr) + f.writeframes(int_samples.tobytes()) + return wav_path + + +def run_synth(asset: dict, manifest: dict, gen_dir: str, output_dir: str) -> dict: + """Synthesize, then normalize + convert (no trim: synth has no silence to trim).""" + filename = asset["filename"] + base_name = os.path.splitext(filename)[0] + wav_path = os.path.join(gen_dir, base_name + "_synth.wav") + norm_path = os.path.join(gen_dir, base_name + "_norm.wav") + ogg_path = os.path.join(output_dir, filename) + + synth_params = asset.get("synth") + if not synth_params: + return {"ok": False, "error": "No synth parameters provided"} + if synth_params.get("type", "harmonic") != "harmonic": + return {"ok": False, "error": f"Unknown synth type: {synth_params.get('type')}"} + + try: + synthesize_harmonic(synth_params, wav_path) + audio_post.normalize(wav_path, norm_path, lufs=get_default(manifest, asset, "lufs") or -16) + audio_post.convert(norm_path, ogg_path, quality=get_default(manifest, asset, "quality") or 6) + except ReachError as exc: + return {"ok": False, "error": exc.message} + finally: + if os.path.exists(norm_path): + os.remove(norm_path) + + return { + "ok": True, + "file": wav_path, + "ogg_file": ogg_path, + "ogg_size_bytes": os.path.getsize(ogg_path), + "synth_params": synth_params, + "post_processed": True, + } + + +def run( + manifest_path: str, + *, + dry_run: bool = False, + only: set[str] | None = None, + skip_existing: bool = False, +) -> dict: + """Process a manifest; returns the summary. The router fails it on any failure.""" + manifest = load_manifest(manifest_path) + output_dir, gen_dir = resolve_paths(manifest) + + assets = manifest.get("assets", []) + if only: + assets = [a for a in assets if a["id"] in only] + + # One health check up front if anything needs Stable Audio — so an OFF + # service fails once, with the right remedy, not once per asset. + if not dry_run and any(a.get("method") == "sao" for a in assets): + console.event("Checking Stable Audio health...") + audio.health() + + total = len(assets) + results = [] + counts = {"success": 0, "failed": 0, "skipped": 0} + console.event( + f"Processing {total} assets from {os.path.basename(manifest_path)}" + + (" — dry run, nothing generated" if dry_run else "") + ) + + for i, asset in enumerate(assets, 1): + asset_id, filename = asset["id"], asset["filename"] + method = asset.get("method", "sao") + phase = f"{i}/{total}" + console.event(f"{asset_id}: {filename} ({method})", phase=phase, progress=i / total if total else None) + + if skip_existing and os.path.exists(os.path.join(output_dir, filename)): + console.event("skipping — already exists", phase=phase) + results.append({"id": asset_id, "status": "skipped", "reason": "exists"}) + counts["skipped"] += 1 + continue + + if dry_run: + if method == "sao": + console.event(f"would generate — prompt: {asset.get('prompt', '(none)')[:80]}...", phase=phase) + elif method == "synth": + synth = asset.get("synth", {}) + console.event( + f"would synthesize — {synth.get('fundamental')}Hz, {synth.get('duration')}s", phase=phase + ) + results.append({"id": asset_id, "status": "dry_run"}) + continue + + if method == "sao": + try: + result = run_sao_generate(asset, manifest, gen_dir, output_dir) + except ReachError as exc: + result = {"ok": False, "error": exc.message} + elif method == "synth": + result = run_synth(asset, manifest, gen_dir, output_dir) + else: + result = {"ok": False, "error": f"Unknown method: {method}"} + + result["id"] = asset_id + if result.get("ok"): + counts["success"] += 1 + result["status"] = "success" + console.event(f"OK → {result.get('ogg_file', filename)}", phase=phase) + else: + counts["failed"] += 1 + result["status"] = "failed" + console.event(f"FAILED: {result.get('error', 'unknown')}", phase=phase, level="warn") + results.append(result) + + return {"ok": counts["failed"] == 0, "total": total, **counts, "results": results} diff --git a/tooling/domains/assets/audio_post.py b/tooling/domains/assets/audio_post.py new file mode 100644 index 000000000..57b046b1b --- /dev/null +++ b/tooling/domains/assets/audio_post.py @@ -0,0 +1,113 @@ +"""Audio post-processing around ffmpeg. + + convert — WAV to OGG (libvorbis, quality 6) + normalize — LUFS normalize to -16 LUFS (broadcast standard) + trim — remove leading/trailing silence + pipeline — trim + normalize + convert (the full post-processing chain) + +Every operation writes a new file and never overwrites its input. Each returns +a result dict — the same keys the old script printed as JSON — so callers read +data rather than parse output. ffmpeg runs through `core/process.run`, the one +guarded exec (D-263): a non-zero exit becomes a ReachError naming the command, +and a missing ffmpeg says what to install. + +The ffmpeg argument lists are unchanged from tooling/db/audio_post.py (T-1290); +the decoded audio of a pipeline run is identical before and after the port. +""" + +from __future__ import annotations + +import os + +from tooling.core import console, process + +FFMPEG_MISSING = "install ffmpeg (brew install ffmpeg), or check PATH in a non-interactive shell" + + +def trim_filter(threshold: int) -> str: + """The silence-removal filter chain: trim the start, reverse, trim, reverse.""" + return ( + "silenceremove=start_periods=1:start_silence=0.05" + f":start_threshold={threshold}dB," + "areverse," + "silenceremove=start_periods=1:start_silence=0.05" + f":start_threshold={threshold}dB," + "areverse" + ) + + +def ffmpeg_argv(args: list[str]) -> list[str]: + """The full argv for one ffmpeg call — pure, so it can be tested unrun.""" + return ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *args] + + +def run_ffmpeg(args: list[str], description: str) -> None: + console.event(description) + process.run(ffmpeg_argv(args), missing_fix=FFMPEG_MISSING) + + +def convert(input_path: str, output: str | None = None, quality: int = 6) -> dict: + """WAV → OGG (libvorbis).""" + output = output or input_path.rsplit(".", 1)[0] + ".ogg" + run_ffmpeg( + ["-i", input_path, "-c:a", "libvorbis", "-q:a", str(quality), output], + f"converting {os.path.basename(input_path)} → {os.path.basename(output)}", + ) + return {"ok": True, "output": output, "size_bytes": os.path.getsize(output)} + + +def normalize(input_path: str, output: str | None = None, lufs: float = -16) -> dict: + """LUFS-normalize an audio file.""" + output = output or _suffixed(input_path, "_norm") + run_ffmpeg( + ["-i", input_path, "-af", f"loudnorm=I={lufs}:LRA=11:TP=-1", output], + f"normalizing to {lufs} LUFS", + ) + return {"ok": True, "output": output} + + +def trim(input_path: str, output: str | None = None, threshold: int = -50) -> dict: + """Trim leading and trailing silence.""" + output = output or _suffixed(input_path, "_trimmed") + run_ffmpeg( + ["-i", input_path, "-af", trim_filter(threshold), output], + f"trimming silence (threshold: {threshold}dB)", + ) + return {"ok": True, "output": output} + + +def pipeline( + input_path: str, + output: str | None = None, + quality: int = 6, + lufs: float = -16, + threshold: int = -50, +) -> dict: + """Full post-processing: trim → normalize → convert to OGG.""" + base = input_path.rsplit(".", 1)[0] + trimmed = base + "_trimmed.wav" + normalized = base + "_norm.wav" + output = output or base + ".ogg" + + try: + run_ffmpeg(["-i", input_path, "-af", trim_filter(threshold), trimmed], "step 1/3: trimming silence") + run_ffmpeg( + ["-i", trimmed, "-af", f"loudnorm=I={lufs}:LRA=11:TP=-1", normalized], + f"step 2/3: normalizing to {lufs} LUFS", + ) + run_ffmpeg( + ["-i", normalized, "-c:a", "libvorbis", "-q:a", str(quality), output], + "step 3/3: converting to OGG", + ) + finally: + # The old script left the intermediates behind when a step failed. + for leftover in (trimmed, normalized): + if os.path.exists(leftover): + os.remove(leftover) + + return {"ok": True, "output": output, "size_bytes": os.path.getsize(output)} + + +def _suffixed(path: str, suffix: str) -> str: + base, ext = os.path.splitext(path) + return base + suffix + ext diff --git a/tooling/db/config.json b/tooling/domains/assets/config.json similarity index 100% rename from tooling/db/config.json rename to tooling/domains/assets/config.json diff --git a/tooling/domains/assets/endpoints.py b/tooling/domains/assets/endpoints.py new file mode 100644 index 000000000..76ed27113 --- /dev/null +++ b/tooling/domains/assets/endpoints.py @@ -0,0 +1,151 @@ +"""Where the generators live, and how a call to one fails. + +Two jobs, both shared by every connector in the domain: + +1. **Endpoints and keys.** Base URLs come from `config.json` beside this file + (moved deliberately from tooling/db/config.json in T-1290). Keys come from the + environment only: config.json is tracked, so a config fallback for a paid + API key is how one gets committed. + +2. **Failure classification.** Network failure is the NORMAL case here, not an + exception. Stable Audio and Trellis are kept stopped because VRAM on + tower-of-joy is scarce (system-admin-danoontje docs/topology.md, D-17), so + "connection refused" means *switched off*, and the remedy is to get it + turned on — never to restart it blindly, which takes VRAM from whatever is + running. That is a different answer from "the service rejected what you + sent", and a caller that cannot tell the two apart wastes a round trip + either way. `call()` maps every urllib failure to one of the two. + +`ensure_venv()` (formerly in tooling/db/common.py) is gone: it re-exec'd the +script under .venv/bin/python via os.execv. Under reach the dependencies are +declared by the package itself, and an execv carrying reach's argv into another +interpreter would relaunch something that is not the command at all. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from tooling.core.errors import ReachError + +CONFIG_PATH = Path(__file__).resolve().parent / "config.json" + +TOPOLOGY = "/var/mnt/data/projects/system-admin-danoontje/docs/topology.md" + +# service key -> (display name, config key, fallback URL) +SERVICES: dict[str, tuple[str, str, str]] = { + "audio": ("Stable Audio Open", "stable_audio_url", "http://tower-of-joy:11500"), + "trellis": ("Trellis", "trellis_url", "http://tower-of-joy:11510"), +} + + +def load_config() -> dict: + """The tracked endpoint configuration (URLs only, never secrets).""" + with open(CONFIG_PATH) as f: + return json.load(f) + + +def get_base_url(key: str, default: str) -> str: + """Resolve a service base URL from config.json, with a fallback default.""" + return load_config().get(key, default) + + +def base_url(service: str) -> str: + """The configured base URL for one of SERVICES.""" + _name, config_key, default = SERVICES[service] + return get_base_url(config_key, default) + + +def get_api_key(env_var: str) -> str: + """An API key from the environment — environment-only, by design.""" + key = os.environ.get(env_var) + if key: + return key + raise ReachError( + f"{env_var} is not set", + fix=( + f"export {env_var}, or add it to the machine-local " + ".claude/settings.local.json env block (untracked). Never put keys in " + "tooling/domains/assets/config.json — it is tracked." + ), + ) + + +def unreachable(service: str, url: str, detail: str) -> ReachError: + """The service did not answer at all. + + For the tower-of-joy generators that is their normal resting state. For + anything else (the Gemini API) it means the network, not the service. + """ + if service not in SERVICES: + return ReachError( + f"{service} is not reachable at {url} ({detail})", + fix="check this machine's network connection, then re-run", + ) + name = SERVICES[service][0] + return ReachError( + f"{name} is not reachable at {url} ({detail}). It is kept switched off to " + "save VRAM on tower-of-joy, so this usually means OFF, not broken.", + fix=( + f"ask for {name} to be turned on (something else may need to stop first " + f"to free VRAM — {TOPOLOGY}); do not restart it blindly. " + f"Then: reach assets {service} health" + ), + ) + + +def rejected(name: str, what: str, code: int, body: str) -> ReachError: + """The service answered, and said no — the request is what needs changing.""" + snippet = body.strip()[:500] + if code in (401, 403): + fix = "the service is up but refused the credentials — check the API key" + elif code == 429: + fix = "rate-limited or out of quota — wait, or check the account's quota" + else: + fix = "the service is up — check the arguments and inputs, then re-run" + return ReachError( + f"{name} rejected {what} (HTTP {code})" + (f": {snippet}" if snippet else ""), + fix=fix, + ) + + +def call( + request: urllib.request.Request, + *, + service: str, + what: str, + timeout: float, +) -> bytes: + """Perform one HTTP request, classifying any failure. Returns the body.""" + name = SERVICES[service][0] if service in SERVICES else service + try: + with urllib.request.urlopen(request, timeout=timeout) as resp: + return resp.read() + except urllib.error.HTTPError as exc: + raise rejected(name, what, exc.code, exc.read().decode("utf-8", errors="replace")) from exc + except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: + reason = getattr(exc, "reason", exc) + raise unreachable(service, _origin(request), str(reason)) from exc + + +def call_json(request: urllib.request.Request, *, service: str, what: str, timeout: float) -> Any: + """`call()`, then parse JSON — an unparseable body is the service's fault, named as such.""" + body = call(request, service=service, what=what, timeout=timeout) + try: + return json.loads(body) + except json.JSONDecodeError as exc: + raise ReachError( + f"{what}: the service answered with something that is not JSON: {body[:200]!r}", + fix="the service may be starting up or misconfigured — check its logs on tower-of-joy", + ) from exc + + +def _origin(request: urllib.request.Request) -> str: + """scheme://host:port of a request — the part that identifies the box.""" + parts = request.full_url.split("/") + return "/".join(parts[:3]) diff --git a/tooling/domains/assets/image.py b/tooling/domains/assets/image.py new file mode 100755 index 000000000..8efa02c0b --- /dev/null +++ b/tooling/domains/assets/image.py @@ -0,0 +1,149 @@ +"""Gemini image-generation connector — direct API wrapper. + +Generates images through Google's gemini-2.5-flash-image model. The key comes +from GEMINI_API_KEY in the environment only (endpoints.get_api_key). **Every +generate call costs real money**; `health` only lists models and is free. + +Formerly tooling/db/image_connector.py (T-1290). Unchanged except that +failures raise a ReachError instead of printing `{"ok": false}` and exiting 1, +and a network failure is reported as the network rather than as the API. +The key travels in the URL, so no error message ever includes the URL's query. +""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.request + +from tooling.core import console +from tooling.core.errors import ReachError +from tooling.domains.assets import endpoints + +SERVICE = "Gemini API" +MODEL = "gemini-2.5-flash-image" +API = "https://generativelanguage.googleapis.com/v1beta" +DEFAULT_OUTPUT_DIR = os.path.expanduser("~/Pictures/mcp-images") + +# Real API values for imageConfig.aspectRatio (not a prompt hint). +# https://ai.google.dev/gemini-api/docs/image-generation +ASPECT_RATIOS = ("1:1", "3:2", "2:3", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9") + +MIME_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"} + + +def get_api_key() -> str: + return endpoints.get_api_key("GEMINI_API_KEY") + + +def health() -> dict: + """Is the Gemini API reachable with the configured key? (Free — lists models.)""" + key = get_api_key() + data = endpoints.call_json( + urllib.request.Request(f"{API}/models?key={key}", method="GET"), + service=SERVICE, + what="the model listing", + timeout=10, + ) + models = [ + m.get("name", "") + for m in data.get("models", []) + if "imagen" in m.get("name", "").lower() or "flash" in m.get("name", "").lower() + ] + return {"ok": True, "api": "gemini", "image_capable_models": models[:5]} + + +def default_output(prompt: str) -> str: + safe = "".join(c if c.isalnum() or c in "-_ " else "" for c in prompt[:40]) + safe = safe.strip().replace(" ", "_").lower() + return os.path.join(DEFAULT_OUTPUT_DIR, f"{safe}.png") + + +def build_request_body( + prompt: str, + aspect_ratio: str | None = "1:1", + image_size: str | None = None, + input_image: str | None = None, +) -> dict: + """The generateContent payload — pure, so it can be tested without a call.""" + parts = [] + if input_image: + if not os.path.isfile(input_image): + raise ReachError( + f"input image not found: {input_image}", + fix="pass --input with an existing .png/.jpg/.webp", + ) + with open(input_image, "rb") as f: + data = base64.b64encode(f.read()).decode("utf-8") + mime = MIME_TYPES.get(os.path.splitext(input_image)[1].lower(), "image/png") + parts.append({"inlineData": {"mimeType": mime, "data": data}}) + + # The size is a best-effort prompt hint only: this model has no resolution + # parameter, unlike the aspect ratio below. + parts.append({"text": prompt + (f" Resolution: {image_size}." if image_size else "")}) + + generation_config: dict = {"responseModalities": ["TEXT", "IMAGE"]} + if aspect_ratio: + generation_config["imageConfig"] = {"aspectRatio": aspect_ratio} + + return {"contents": [{"parts": parts}], "generationConfig": generation_config} + + +def generate( + prompt: str, + output: str | None = None, + aspect_ratio: str = "1:1", + image_size: str | None = None, + input_image: str | None = None, +) -> dict: + """Generate one image. COSTS MONEY. Returns the result dict.""" + key = get_api_key() + body = build_request_body(prompt, aspect_ratio, image_size, input_image) + output = output or default_output(prompt) + + console.event(f"Generating image: {prompt!r}" + (f" (from {input_image})" if input_image else "")) + result = endpoints.call_json( + urllib.request.Request( + f"{API}/models/{MODEL}:generateContent?key={key}", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ), + service=SERVICE, + what="the generation request", + timeout=120, + ) + + candidates = result.get("candidates", []) + if not candidates: + raise ReachError( + f"Gemini returned no candidates: {json.dumps(result)[:500]}", + fix="the prompt may have been blocked by safety filters — rephrase it and re-run", + ) + + image_saved = False + text_response = "" + for candidate in candidates: + for part in candidate.get("content", {}).get("parts", []): + if "inlineData" in part: + os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True) + with open(output, "wb") as f: + f.write(base64.b64decode(part["inlineData"]["data"])) + image_saved = True + elif "text" in part: + text_response += part["text"] + + if not image_saved: + raise ReachError( + f"Gemini answered with text but no image: {text_response[:500]!r}", + fix="make the prompt ask for an image explicitly, then re-run", + ) + + return { + "ok": True, + "file": output, + "size_bytes": os.path.getsize(output), + "prompt": prompt, + "aspect_ratio": aspect_ratio, + } diff --git a/tooling/domains/assets/router.py b/tooling/domains/assets/router.py new file mode 100644 index 000000000..adfc86c57 --- /dev/null +++ b/tooling/domains/assets/router.py @@ -0,0 +1,306 @@ +"""Transport for the `assets` domain — args in, delegate, format out. + +A verb's result is printed to stdout as JSON with the same keys the old +connector scripts printed, so a skill that reads `file`, `ogg_file` or +`size_bytes` keeps working. What changed is failure: it is a non-zero exit +with a remedy on the event stream, never `{"ok": false}` on stdout — so a +caller checks the exit status, not a field. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import typer + +from tooling.core import cli, console +from tooling.core.command import command +from tooling.core.errors import ReachError, unknown_choice + +app = cli.domain("assets", "Asset generators — Stable Audio, Gemini images, Trellis 3D.") +audio_app = cli.domain("audio", "Stable Audio Open on tower-of-joy :11500 (kept OFF — VRAM).") +post_app = cli.domain("post", "ffmpeg post-processing: trim, normalize, convert.") +image_app = cli.domain("image", "Gemini image generation — every generate COSTS MONEY.") +trellis_app = cli.domain("trellis", "Trellis image-to-3D on tower-of-joy :11510 (kept OFF — VRAM).") + + +@app.callback() +def _domain() -> None: + """Keeps `assets` a group (Typer collapses a single-command app).""" + + +@audio_app.callback() +def _audio() -> None: + """Keeps `audio` a group.""" + + +@post_app.callback() +def _post() -> None: + """Keeps `post` a group.""" + + +@image_app.callback() +def _image() -> None: + """Keeps `image` a group.""" + + +@trellis_app.callback() +def _trellis() -> None: + """Keeps `trellis` a group.""" + + +audio_app.add_typer(post_app, name="post") +app.add_typer(audio_app, name="audio") +app.add_typer(image_app, name="image") +app.add_typer(trellis_app, name="trellis") + + +def _emit(result: dict) -> None: + console.out(json.dumps(result, indent=2)) + + +def _emit_summary(summary: dict, what: str) -> None: + """Print a batch summary, then fail the command if any item failed.""" + _emit(summary) + if summary.get("failed"): + raise ReachError( + f"{what}: {summary['failed']} of {summary['total']} failed (details in the summary above)", + fix="fix the failing items, then re-run with --skip-existing to leave the rest alone", + ) + console.verdict(f"{what}: {summary.get('success', 0)} done, {summary.get('skipped', 0)} skipped") + + +# --- audio ---------------------------------------------------------------- + + +@audio_app.command("health") +@command +def audio_health() -> None: + """Is Stable Audio up? OFF is its normal resting state — this says which.""" + from tooling.domains.assets import audio + + _emit(audio.health()) + + +@audio_app.command("generate") +@command +def audio_generate( + prompt: str = typer.Argument(..., help="What the audio should sound like."), + duration: float = typer.Option(10.0, "--duration", help="Seconds, 0-47."), + steps: int = typer.Option(100, "--steps", help="Diffusion steps; fewer is faster and worse."), + cfg: float = typer.Option(7.0, "--cfg", help="Classifier-free guidance scale."), + output: Path = typer.Option(None, "--output", help="WAV path (default: named from the prompt)."), + timeout: int = typer.Option(600, "--timeout", help="Seconds to wait for the result."), + post: bool = typer.Option(False, "--post", help="Also trim + normalize + convert to OGG."), + output_ogg: Path = typer.Option(None, "--output-ogg", help="OGG path; implies --post."), +) -> None: + """Generate audio from a prompt; prints the result JSON.""" + from tooling.domains.assets import audio + + _emit( + audio.generate( + prompt, + duration=duration, + steps=steps, + cfg=cfg, + output=str(output) if output else None, + timeout=timeout, + post=post, + output_ogg=str(output_ogg) if output_ogg else None, + ) + ) + + +@audio_app.command("batch") +@command +def audio_batch( + manifest: Path = typer.Argument(..., help="Manifest .json (see the audio-gen skill)."), + dry_run: bool = typer.Option(False, "--dry-run", help="List what would be generated."), + only: str = typer.Option(None, "--only", help="Comma-separated asset ids."), + skip_existing: bool = typer.Option(False, "--skip-existing", help="Leave existing OGGs alone."), +) -> None: + """Generate every asset in a manifest; fails if any asset failed.""" + from tooling.domains.assets import audio_batch as service + + summary = service.run( + str(manifest), + dry_run=dry_run, + only=set(only.split(",")) if only else None, + skip_existing=skip_existing, + ) + _emit_summary(summary, "audio batch") + + +@post_app.command("convert") +@command +def post_convert( + input: Path = typer.Argument(..., help="Input WAV."), + output: Path = typer.Option(None, "--output", "-o", help="Output (default: same name .ogg)."), + quality: int = typer.Option(6, "--quality", "-q", help="Vorbis quality 0-10."), +) -> None: + """WAV → OGG (libvorbis).""" + from tooling.domains.assets import audio_post + + _emit(audio_post.convert(str(input), str(output) if output else None, quality)) + + +@post_app.command("normalize") +@command +def post_normalize( + input: Path = typer.Argument(..., help="Input audio file."), + output: Path = typer.Option(None, "--output", "-o", help="Output (default: _norm)."), + lufs: float = typer.Option(-16, "--lufs", help="Target loudness, LUFS."), +) -> None: + """LUFS-normalize an audio file.""" + from tooling.domains.assets import audio_post + + _emit(audio_post.normalize(str(input), str(output) if output else None, lufs)) + + +@post_app.command("trim") +@command +def post_trim( + input: Path = typer.Argument(..., help="Input audio file."), + output: Path = typer.Option(None, "--output", "-o", help="Output (default: _trimmed)."), + threshold: int = typer.Option(-50, "--threshold", help="Silence threshold, dB."), +) -> None: + """Trim leading and trailing silence.""" + from tooling.domains.assets import audio_post + + _emit(audio_post.trim(str(input), str(output) if output else None, threshold)) + + +@post_app.command("pipeline") +@command +def post_pipeline( + input: Path = typer.Argument(..., help="Input WAV."), + output: Path = typer.Option(None, "--output", "-o", help="Output OGG (default: same name .ogg)."), + quality: int = typer.Option(6, "--quality", "-q", help="Vorbis quality 0-10."), + lufs: float = typer.Option(-16, "--lufs", help="Target loudness, LUFS."), + threshold: int = typer.Option(-50, "--threshold", help="Silence threshold, dB."), +) -> None: + """trim → normalize → convert to OGG, the full chain.""" + from tooling.domains.assets import audio_post + + _emit(audio_post.pipeline(str(input), str(output) if output else None, quality, lufs, threshold)) + + +# --- image ---------------------------------------------------------------- + + +@image_app.command("health") +@command +def image_health() -> None: + """Is the Gemini API reachable with GEMINI_API_KEY? Free — lists models.""" + from tooling.domains.assets import image + + _emit(image.health()) + + +@image_app.command("generate") +@command +def image_generate( + prompt: str = typer.Argument(..., help="What to draw."), + output: Path = typer.Option(None, "--output", help="PNG path (default: ~/Pictures/mcp-images/)."), + aspect: str = typer.Option("1:1", "--aspect", help="Aspect ratio, e.g. 1:1, 16:9, 3:4."), + size: str = typer.Option(None, "--size", help="Resolution hint (1K/2K/4K) — may be ignored."), + input: Path = typer.Option(None, "--input", help="Source image, for image-to-image."), +) -> None: + """Generate one image. COSTS MONEY per call.""" + from tooling.domains.assets import image + + if aspect not in image.ASPECT_RATIOS: + raise unknown_choice("aspect ratio", aspect, image.ASPECT_RATIOS) + _emit( + image.generate( + prompt, + output=str(output) if output else None, + aspect_ratio=aspect, + image_size=size, + input_image=str(input) if input else None, + ) + ) + + +# --- trellis -------------------------------------------------------------- + + +@trellis_app.command("health") +@command +def trellis_health() -> None: + """Is Trellis up? OFF is its normal resting state — this says which.""" + from tooling.domains.assets import trellis + + _emit(trellis.health()) + + +@trellis_app.command("generate") +@command +def trellis_generate( + image: Path = typer.Argument(..., help="Input image (PNG recommended)."), + output: Path = typer.Option(None, "--output", help=".glb path (default: named from the image)."), + simplify: float = typer.Option(0.95, "--simplify", help="Mesh simplification, 0.9-0.98."), + texture_size: int = typer.Option(1024, "--texture-size", help="Texture resolution, 512-2048."), + seed: int = typer.Option(0, "--seed", help="Random seed."), + timeout: int = typer.Option(600, "--timeout", help="Seconds for the 3D step."), +) -> None: + """Image → .glb; prints the result JSON.""" + from tooling.domains.assets import trellis + + _emit( + trellis.generate( + str(image), + output=str(output) if output else None, + simplify=simplify, + texture_size=texture_size, + seed=seed, + timeout=timeout, + ) + ) + + +@trellis_app.command("batch") +@command +def trellis_batch( + input_dir: Path = typer.Option(None, "--input-dir", help="Directory of .png inputs (default: the character bodies)."), + output_dir: Path = typer.Option(None, "--output-dir", help="Where the .glb files go."), + names: str = typer.Option(None, "--names", help="Comma-separated stems; default: every .png in --input-dir."), + simplify: float = typer.Option(0.95, "--simplify", help="Mesh simplification."), + texture_size: int = typer.Option(1024, "--texture-size", help="Texture resolution."), + cooldown: float = typer.Option(15, "--cooldown", help="Seconds between successful jobs."), + retries: int = typer.Option(3, "--retries", help="Attempts per item."), + retry_delay: float = typer.Option(60, "--retry-delay", help="Seconds before a retry."), +) -> None: + """One .glb per image, gently — long; consider --detach. Fails if any item failed.""" + from tooling.domains.assets import trellis + + summary = trellis.batch( + str(input_dir) if input_dir else trellis.BODIES_INPUT, + str(output_dir) if output_dir else trellis.BODIES_OUTPUT, + names=names.split(",") if names else None, + simplify=simplify, + texture_size=texture_size, + cooldown=cooldown, + max_retries=retries, + retry_delay=retry_delay, + ) + _emit_summary(summary, "trellis batch") + + +# --- local synthesis ------------------------------------------------------ + + +@app.command("synth-ui") +@command +def synth_ui( + output_dir: Path = typer.Option(None, "--output-dir", help="Where the WAVs go (default: client/assets/audio)."), +) -> None: + """Synthesize the four insert-tech UI sounds locally — no service needed.""" + from tooling.domains.assets import synth_ui as service + + paths = service.run(str(output_dir) if output_dir else service.OUTPUT_DIR) + console.out("\n".join(paths)) + console.verdict( + f"synth-ui: wrote {len(paths)} WAVs — convert with `reach assets audio post convert `" + ) diff --git a/tooling/synth_ui_sounds.py b/tooling/domains/assets/synth_ui.py similarity index 83% rename from tooling/synth_ui_sounds.py rename to tooling/domains/assets/synth_ui.py index 80f0d1beb..02b6ce944 100644 --- a/tooling/synth_ui_sounds.py +++ b/tooling/domains/assets/synth_ui.py @@ -6,19 +6,27 @@ Each sound uses a DIFFERENT synthesis technique to ensure distinct character: - weapon_aim: filtered noise + sub thump (mechanical) - monologue_chime: FM synthesis (crystalline bell) - monologue_chime_urgent: FM synthesis + beating/dissonance (tense bell) + +Formerly tooling/synth_ui_sounds.py (T-1290). The synthesis is untouched — +the four WAVs are byte-identical before and after the port. The output +directory became a parameter; the default is still client/assets/audio. """ -import numpy as np -import wave import os +import wave + +import numpy as np + +from tooling.core import config, console SR = 44100 -OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "..", "client", "assets", "audio") +OUTPUT_DIR = str(config.repo_root() / "client" / "assets" / "audio") +_output_dir = OUTPUT_DIR def write_wav(filename, samples, channels=1): """Write float samples [-1, 1] to 16-bit WAV.""" - path = os.path.join(OUTPUT_DIR, filename) + path = os.path.join(_output_dir, filename) # Normalize to peak if it exceeds 1.0 peak = np.max(np.abs(samples)) if peak > 1.0: @@ -30,7 +38,7 @@ def write_wav(filename, samples, channels=1): f.setframerate(SR) f.writeframes(int_samples.tobytes()) dur_ms = len(int_samples) / SR * 1000 - print(f" wrote {path} ({dur_ms:.0f}ms, {channels}ch)") + console.event(f"wrote {path} ({dur_ms:.0f}ms, {channels}ch)") return path @@ -185,22 +193,17 @@ def monologue_chime_urgent(): return write_wav("sfx_monologue_chime_urgent.wav", signal * envelope * 0.25) -if __name__ == "__main__": - os.makedirs(OUTPUT_DIR, exist_ok=True) - print("Synthesizing UI sounds for Sprint 7 #440...") - print() - - print("[UI-001] cursor_hover — bandpass noise impulse (digital click)") - cursor_hover() - - print("[UI-004] weapon_aim — filtered noise + sub thump (mechanical latch)") - weapon_aim() - - print("[UI-005] sfx_monologue_chime — FM synthesis bell (PLACEHOLDER)") - monologue_chime() - - print("[UI-006] sfx_monologue_chime_urgent — FM + beating (PLACEHOLDER)") - monologue_chime_urgent() - - print() - print("Done. Convert with: tooling/db/audio-post convert ") +def run(output_dir: str = OUTPUT_DIR) -> list[str]: + """Write all four UI sounds into output_dir; returns their paths.""" + global _output_dir + _output_dir = output_dir + os.makedirs(output_dir, exist_ok=True) + console.event("[UI-001] cursor_hover — bandpass noise impulse (digital click)") + paths = [cursor_hover()] + console.event("[UI-004] weapon_aim — filtered noise + sub thump (mechanical latch)") + paths.append(weapon_aim()) + console.event("[UI-005] sfx_monologue_chime — FM synthesis bell (PLACEHOLDER)") + paths.append(monologue_chime()) + console.event("[UI-006] sfx_monologue_chime_urgent — FM + beating (PLACEHOLDER)") + paths.append(monologue_chime_urgent()) + return paths diff --git a/tooling/domains/assets/trellis.py b/tooling/domains/assets/trellis.py new file mode 100755 index 000000000..c91c39518 --- /dev/null +++ b/tooling/domains/assets/trellis.py @@ -0,0 +1,315 @@ +"""Trellis 3D model generator connector — Gradio API wrapper. + +Talks to the Trellis Gradio app on tower-of-joy :11510 (URL from config.json). +Pipeline: start session → upload + preprocess → seed → image_to_3d → +extract_glb → download .glb. + +Formerly tooling/db/trellis_connector.py plus the tooling/trellis-batch.sh loop +(T-1290). `batch` is that loop in Python, and no longer hardcoded to the 16 +character bodies — it takes a directory, or explicit names. + +Gradio API parameter reference (TRELLIS v1, microsoft/TRELLIS): + + /image_to_3d — 9 inputs: + 0: image (Image) preprocessed image from /preprocess_image_1 + 1: multiimages (Gallery) [] for single-image mode + 2: is_multiimage (State) False for single-image, True for multi-image + 3: seed (Slider) int, 0-2147483647 + 4: ss_guidance (Slider) float, sparse structure guidance strength (default 7.5) + 5: ss_steps (Slider) int, sparse structure sampling steps (default 12) + 6: slat_guidance (Slider) float, structured latent guidance strength (default 3.0) + 7: slat_steps (Slider) int, structured latent sampling steps (default 12) + 8: multiimage_algo (Radio) "stochastic" or "multidiffusion" + + /extract_glb — 3 inputs: + 0: output_buf (State) None — server uses internal state from image_to_3d + 1: simplify (Slider) float, mesh simplification ratio (default 0.95) + 2: texture_size (Slider) int, texture resolution (default 1024) + + Common failure modes: + - "needed 9, got 8": missing is_multiimage (position 2) — must pass False + - "needed 3, got 2": missing output_buf (position 0) — must pass None + - "'float' cannot be interpreted as int": numpy version issue on server, + Gradio Sliders send all values as float. Fix: patch flow_euler.py on + the server to cast steps to int, or pin numpy < 2.0 + - CUDA device mismatch after crash: restart the container to clear GPU state +""" + +from __future__ import annotations + +import json +import os +import random +import string +import time +import urllib.request +from pathlib import Path + +from tooling.core import config, console +from tooling.core.errors import ReachError +from tooling.domains.assets import endpoints + +SERVICE = "trellis" + +# The batch defaults are the character-body run the bash script was written for. +BODIES_INPUT = ".tmp/image-gen/characters/bodies" +BODIES_OUTPUT = ".tmp/glb-gen/characters/bodies" + + +def get_base_url() -> str: + return endpoints.base_url(SERVICE) + + +def health() -> dict: + """Is the Trellis API reachable, and what endpoints does it name?""" + base = get_base_url() + data = endpoints.call_json( + urllib.request.Request(f"{base}/info", method="GET"), + service=SERVICE, + what="the health check", + timeout=10, + ) + return {"ok": True, "url": base, "endpoints": list(data.get("named_endpoints", {}).keys())} + + +def _call_api(base: str, endpoint: str, data: list, timeout: float = 600, session_hash: str | None = None): + """POST one Gradio endpoint, sharing a session so gr.State survives between calls. + + image_to_3d stores its output in server-side State keyed by session_hash; + extract_glb reads it back. Without a shared session the state is lost. + """ + body: dict = {"data": data} + if session_hash: + body["session_hash"] = session_hash + console.event(f"Calling {endpoint}...") + result = endpoints.call_json( + urllib.request.Request( + f"{base}/api{endpoint}", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ), + service=SERVICE, + what=endpoint, + timeout=timeout, + ) + if isinstance(result, dict) and "data" in result: + return result["data"] + return result + + +def multipart_body(filename: str, payload: bytes, boundary: str = "----TrellisConnectorBoundary") -> bytes: + """The multipart/form-data body Gradio's /upload expects — a 'files' field.""" + head = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="files"; filename="{filename}"\r\n' + "Content-Type: image/png\r\n" + "\r\n" + ).encode() + return head + payload + f"\r\n--{boundary}--\r\n".encode() + + +def _upload_image(base: str, image_path: str): + filename = os.path.basename(image_path) + with open(image_path, "rb") as f: + body = multipart_body(filename, f.read()) + console.event(f"Uploading {filename}...") + result = endpoints.call_json( + urllib.request.Request( + f"{base}/upload", + data=body, + headers={"Content-Type": "multipart/form-data; boundary=----TrellisConnectorBoundary"}, + method="POST", + ), + service=SERVICE, + what="the image upload", + timeout=30, + ) + if isinstance(result, list) and result: + return result[0] + raise ReachError( + f"Trellis accepted the upload but returned no file reference: {result}", + fix="the Gradio app's upload API may have changed — check `reach assets trellis health`", + ) + + +def _download_file(url: str, output_path: str, base: str) -> int: + if url.startswith("/"): + url = f"{base}{url}" + elif not url.startswith("http"): + url = f"{base}/file={url}" + console.event(f"Downloading to {output_path}...") + body = endpoints.call( + urllib.request.Request(url, method="GET"), service=SERVICE, what="the GLB download", timeout=120 + ) + with open(output_path, "wb") as f: + f.write(body) + return os.path.getsize(output_path) + + +def glb_url_from(glb_result) -> str | None: + """extract_glb returns [model_viewer_data, download_button_data]; take the first URL.""" + if isinstance(glb_result, list): + for item in glb_result: + if isinstance(item, dict): + url = item.get("url") or item.get("path") + if url: + return url + return None + + +def generate( + image_path: str, + output: str | None = None, + simplify: float = 0.95, + texture_size: int = 1024, + seed: int = 0, + timeout: int = 600, +) -> dict: + """Image → .glb. Returns the result dict.""" + # The input is checked before the service, so a typo does not read as an + # outage. (The old check also crashed: it passed indent= to print().) + if not os.path.isfile(image_path): + raise ReachError(f"image not found: {image_path}", fix="pass an existing .png") + + base = get_base_url() + endpoints.call( + urllib.request.Request(f"{base}/info", method="GET"), + service=SERVICE, + what="the availability check", + timeout=5, + ) + start_time = time.time() + output = output or f"{os.path.splitext(os.path.basename(image_path))[0]}.glb" + + session = "".join(random.choices(string.ascii_lowercase + string.digits, k=12)) + console.event(f"Session: {session}") + + console.event("Starting session...", phase="1/5") + _call_api(base, "/start_session", [], timeout=30, session_hash=session) + + console.event("Uploading and preprocessing image...", phase="2/5") + uploaded = _upload_image(base, image_path) + file_ref = {"path": uploaded, "meta": {"_type": "gradio.FileData"}} + preprocessed = _call_api(base, "/preprocess_image_1", [file_ref], timeout=60, session_hash=session) + preprocessed_ref = preprocessed[0] if isinstance(preprocessed, list) and preprocessed else preprocessed + + console.event("Generating 3D model...", phase="3/5") + seed_result = _call_api(base, "/get_seed", [True, seed], timeout=10, session_hash=session) + actual_seed = seed_result[0] if isinstance(seed_result, list) and seed_result else seed + + # Nine inputs, positions per the reference above. The server needs + # flow_euler.py patched to cast steps to int (numpy >= 2). + _call_api( + base, + "/image_to_3d", + [preprocessed_ref, [], False, actual_seed, 7.5, 12, 3.0, 12, "stochastic"], + timeout=timeout, + session_hash=session, + ) + + console.event("Extracting GLB...", phase="4/5") + glb_result = _call_api(base, "/extract_glb", [None, simplify, texture_size], timeout=120, session_hash=session) + glb_url = glb_url_from(glb_result) + if not glb_url: + raise ReachError( + f"could not find the GLB URL in the extract_glb response: {str(glb_result)[:300]}", + fix="the Gradio app's response shape may have changed — see trellis.glb_url_from", + ) + + console.event("Downloading GLB...", phase="5/5") + os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True) + size = _download_file(glb_url, output, base) + + return { + "ok": True, + "file": output, + "size_bytes": size, + "simplify": simplify, + "texture_size": texture_size, + "seed": actual_seed, + "generation_time_s": round(time.time() - start_time, 1), + "source_image": image_path, + } + + +def batch_plan(input_dir: Path, output_dir: Path, names: list[str] | None) -> list[tuple[str, Path, Path]]: + """What a batch would do: (name, input png, output glb) per item — pure.""" + if names: + chosen = names + else: + chosen = sorted(p.stem for p in input_dir.glob("*.png")) + return [(n, input_dir / f"{n}.png", output_dir / f"{n}.glb") for n in chosen] + + +def batch( + input_dir: str = BODIES_INPUT, + output_dir: str = BODIES_OUTPUT, + names: list[str] | None = None, + simplify: float = 0.95, + texture_size: int = 1024, + cooldown: float = 15, + max_retries: int = 3, + retry_delay: float = 60, +) -> dict: + """Generate one .glb per input image, one at a time, gently. + + Skips outputs that already exist. Retries each failure, and cools the GPU + down between successful jobs — the Trellis box is shared (VRAM is scarce). + Returns the summary; the router prints it and then fails the command if + anything failed. The bash original exited 0 regardless. + """ + root = config.repo_root() + in_dir = (root / input_dir) if not Path(input_dir).is_absolute() else Path(input_dir) + out_dir = (root / output_dir) if not Path(output_dir).is_absolute() else Path(output_dir) + plan = batch_plan(in_dir, out_dir, names) + if not plan: + raise ReachError(f"no .png inputs in {in_dir}", fix="pass --input-dir with images, or --names") + + out_dir.mkdir(parents=True, exist_ok=True) + total = len(plan) + results = [] + console.event(f"Trellis batch: {total} item(s), cooldown {cooldown}s, {max_retries} tries each") + + for i, (name, src, dst) in enumerate(plan, 1): + phase = f"{i}/{total}" + if dst.exists(): + console.event(f"{name} — already exists, skipping", phase=phase) + results.append({"name": name, "status": "skipped"}) + continue + if not src.exists(): + console.event(f"{name} — input not found: {src}", phase=phase, level="warn") + results.append({"name": name, "status": "failed", "error": f"input not found: {src}"}) + continue + + error = None + for attempt in range(1, max_retries + 1): + console.event(f"{name} (attempt {attempt}/{max_retries})...", phase=phase, progress=i / total) + try: + generate(str(src), output=str(dst), simplify=simplify, texture_size=texture_size) + error = None + break + except ReachError as exc: + error = exc.message + console.event(f"{name} failed: {exc.message}", phase=phase, level="warn") + if attempt < max_retries: + console.event(f"waiting {retry_delay}s before retry...", phase=phase) + time.sleep(retry_delay) + + if error is None: + results.append({"name": name, "status": "success", "file": str(dst)}) + if i < total: + console.event(f"cooling down {cooldown}s...", phase=phase) + time.sleep(cooldown) + else: + results.append({"name": name, "status": "failed", "error": error}) + + summary = { + "ok": not any(r["status"] == "failed" for r in results), + "total": total, + "success": sum(r["status"] == "success" for r in results), + "skipped": sum(r["status"] == "skipped" for r in results), + "failed": sum(r["status"] == "failed" for r in results), + "results": results, + } + return summary diff --git a/tooling/main.py b/tooling/main.py index f0da35e65..1dbbfa232 100644 --- a/tooling/main.py +++ b/tooling/main.py @@ -67,6 +67,10 @@ DOMAINS: dict[str, tuple[str, str]] = { "tooling.domains.wiki.router:app", "The wiki seed — fill rates and the GTTR hook", ), + "assets": ( + "tooling.domains.assets.router:app", + "Asset generators — Stable Audio, Gemini images, Trellis 3D", + ), "godot": ( "tooling.domains.godot.router:app", "Does the client parse, and does it parse cold", diff --git a/tooling/test_assets.py b/tooling/test_assets.py new file mode 100644 index 000000000..f95501641 --- /dev/null +++ b/tooling/test_assets.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""`reach assets` connectors against a fake Gradio server (T-1290). + +The real services cannot be used in a gate: Stable Audio and Trellis are kept +switched OFF (VRAM on tower-of-joy), and every Gemini call costs money. So the +decisions are exercised without performing them (D-263): a local HTTP server +plays the Gradio apps, and the tests assert on what each connector SENDS and +on how it classifies each kind of failure. + +What is pinned: +- audio: submit payload [prompt, duration, steps, cfg], SSE `complete` → file + download, SSE `error` → fails with the error, relative file URL resolution. +- trellis: the 5-call session sequence with one shared session_hash, the + 9-input image_to_3d payload, the 3-input extract_glb payload, the download. +- image: the generateContent body (aspect ratio as imageConfig, size as a + prompt hint, inlineData for image-to-image) — built, never sent. +- endpoints: refused connection → "OFF" remedy; HTTP 500 → "rejected"; + 401 → credentials; an unreachable non-tower service → network remedy. + +Run: .venv/bin/python tooling/test_assets.py +""" + +from __future__ import annotations + +import contextlib +import io +import json +import socket +import sys +import tempfile +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from tooling.core.errors import ReachError # noqa: E402 +from tooling.domains.assets import audio, endpoints, image, trellis # noqa: E402 + +AUDIO_BYTES = b"RIFF-fake-wav" +GLB_BYTES = b"glTF-fake-glb" + + +class FakeGradio(BaseHTTPRequestHandler): + """Just enough of both Gradio apps. Records every request it sees.""" + + seen: list[tuple[str, str, object]] = [] + sse_event = "complete" + + def log_message(self, *args): # silence the default stderr access log + pass + + def _send(self, code: int, body: bytes, ctype: str = "application/json") -> None: + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _json(self, obj, code: int = 200) -> None: + self._send(code, json.dumps(obj).encode()) + + def do_GET(self): + FakeGradio.seen.append(("GET", self.path, None)) + if self.path == "/config": + return self._json({"version": "4.44", "dependencies": [{"api_name": "generate_audio"}, {"api_name": "js_x"}]}) + if self.path == "/info": + return self._json({"named_endpoints": {"/image_to_3d": {}, "/extract_glb": {}}}) + if self.path.startswith("/gradio_api/call/generate_audio/"): + if FakeGradio.sse_event == "error": + body = b"event: error\ndata: \"CUDA out of memory\"\n\n" + else: + body = b"event: heartbeat\ndata: null\n\nevent: complete\ndata: [{\"url\": \"/file=out.wav\"}]\n\n" + return self._send(200, body, "text/event-stream") + if self.path == "/file=out.wav": + return self._send(200, AUDIO_BYTES, "audio/wav") + if self.path == "/file=model.glb": + return self._send(200, GLB_BYTES, "model/gltf-binary") + if self.path == "/boom": + return self._json({"error": "kaboom"}, 500) + if self.path == "/denied": + return self._json({"error": "bad key"}, 401) + return self._json({"error": "not found"}, 404) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + try: + body = json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError): + body = raw + FakeGradio.seen.append(("POST", self.path, body)) + if self.path == "/gradio_api/call/generate_audio": + return self._json({"event_id": "ev123"}) + if self.path == "/upload": + return self._json(["/tmp/gradio/uploaded.png"]) + if self.path == "/api/preprocess_image_1": + return self._json({"data": [{"path": "/tmp/pre.png"}]}) + if self.path == "/api/get_seed": + return self._json({"data": [4242]}) + if self.path == "/api/extract_glb": + return self._json({"data": [{"url": "/file=model.glb"}, {"url": "/file=model.glb"}]}) + if self.path.startswith("/api/"): + return self._json({"data": []}) + return self._json({"error": "not found"}, 404) + + +@contextlib.contextmanager +def fake_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), FakeGradio) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + real = endpoints.load_config + endpoints.load_config = lambda: {"stable_audio_url": base, "trellis_url": base} + FakeGradio.seen = [] + FakeGradio.sse_event = "complete" + try: + yield base + finally: + endpoints.load_config = real + server.shutdown() + + +def _closed_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def quiet(fn, *args, **kwargs): + with contextlib.redirect_stderr(io.StringIO()): + return fn(*args, **kwargs) + + +def check(failures: list[str], cond: bool, message: str) -> None: + if not cond: + failures.append(message) + + +def test_audio(failures: list[str]) -> None: + with fake_server(), tempfile.TemporaryDirectory() as tmp: + out = str(Path(tmp) / "x.wav") + result = quiet(audio.generate, "wind over dunes", duration=5, steps=40, cfg=6.5, output=out, timeout=20) + posts = [s for s in FakeGradio.seen if s[0] == "POST"] + check(failures, posts == [("POST", "/gradio_api/call/generate_audio", {"data": ["wind over dunes", 5, 40, 6.5]})], + f"audio: submit payload changed: {posts}") + check(failures, Path(out).read_bytes() == AUDIO_BYTES, "audio: downloaded bytes differ") + check(failures, result["size_bytes"] == len(AUDIO_BYTES) and result["file"] == out, + f"audio: result keys wrong: {result}") + + FakeGradio.sse_event = "error" + try: + quiet(audio.generate, "x", output=out, timeout=20) + failures.append("audio: an SSE error event did not fail the generation") + except ReachError as exc: + check(failures, "CUDA out of memory" in exc.message, f"audio: error detail lost: {exc.message}") + + health = quiet(audio.health) + check(failures, health["api_endpoints"] == ["generate_audio"], f"audio: js_ endpoints not filtered: {health}") + + +def test_audio_urls(failures: list[str]) -> None: + base = "http://h:1" + cases = { + "abs": ([{"url": "http://other/f.wav"}], "http://other/f.wav"), + "rooted": ([{"path": "/file=a.wav"}], "http://h:1/file=a.wav"), + "bare": (["tmp/a.wav"], "http://h:1/file=tmp/a.wav"), + "wrapped": ({"data": [{"url": "/x.wav"}]}, "http://h:1/x.wav"), + "empty": ([], None), + } + for name, (payload, want) in cases.items(): + got = audio.extract_file_url(payload, base) + check(failures, got == want, f"audio url {name}: {got!r} != {want!r}") + + +def test_trellis(failures: list[str]) -> None: + with fake_server(), tempfile.TemporaryDirectory() as tmp: + png = Path(tmp) / "crate.png" + png.write_bytes(b"\x89PNG-fake") + out = str(Path(tmp) / "crate.glb") + result = quiet(trellis.generate, str(png), output=out, simplify=0.9, texture_size=512, seed=7) + + posts = [(p, b) for m, p, b in FakeGradio.seen if m == "POST"] + paths = [p for p, _ in posts] + want = ["/api/start_session", "/upload", "/api/preprocess_image_1", "/api/get_seed", + "/api/image_to_3d", "/api/extract_glb"] + check(failures, paths == want, f"trellis: call sequence changed: {paths}") + sessions = {b.get("session_hash") for p, b in posts if p != "/upload" and isinstance(b, dict)} + check(failures, len(sessions) == 1 and None not in sessions, f"trellis: session not shared: {sessions}") + by_path = dict(posts) + check(failures, by_path["/api/get_seed"]["data"] == [True, 7], "trellis: get_seed payload changed") + check(failures, by_path["/api/image_to_3d"]["data"] == + [{"path": "/tmp/pre.png"}, [], False, 4242, 7.5, 12, 3.0, 12, "stochastic"], + f"trellis: image_to_3d payload changed: {by_path['/api/image_to_3d']['data']}") + check(failures, by_path["/api/extract_glb"]["data"] == [None, 0.9, 512], + "trellis: extract_glb payload changed") + check(failures, Path(out).read_bytes() == GLB_BYTES, "trellis: downloaded bytes differ") + check(failures, result["seed"] == 4242, "trellis: server seed not reported") + + # batch: one present, one missing input -> summary counts, no sleeping + plan = trellis.batch_plan(Path(tmp), Path(tmp) / "out", ["crate", "ghost"]) + check(failures, [p[0] for p in plan] == ["crate", "ghost"], "trellis: batch plan ignores --names") + summary = quiet(trellis.batch, tmp, str(Path(tmp) / "out"), names=["crate", "ghost"], + cooldown=0, max_retries=1, retry_delay=0) + check(failures, (summary["success"], summary["failed"]) == (1, 1), + f"trellis: batch counts wrong: {summary}") + + +def test_image_body(failures: list[str]) -> None: + body = image.build_request_body("a lighthouse", aspect_ratio="16:9", image_size="2K") + check(failures, body["contents"][0]["parts"] == [{"text": "a lighthouse Resolution: 2K."}], + f"image: prompt/size hint changed: {body}") + check(failures, body["generationConfig"] == {"responseModalities": ["TEXT", "IMAGE"], + "imageConfig": {"aspectRatio": "16:9"}}, + f"image: generationConfig changed: {body['generationConfig']}") + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "ref.jpg" + src.write_bytes(b"jpegdata") + parts = image.build_request_body("x", input_image=str(src))["contents"][0]["parts"] + check(failures, parts[0]["inlineData"]["mimeType"] == "image/jpeg" and len(parts) == 2, + f"image: image-to-image part wrong: {parts}") + try: + image.build_request_body("x", input_image="/nope/missing.png") + failures.append("image: a missing input image did not fail") + except ReachError: + pass + + +def test_classification(failures: list[str]) -> None: + port = _closed_port() + real = endpoints.load_config + endpoints.load_config = lambda: {"stable_audio_url": f"http://127.0.0.1:{port}"} + try: + quiet(audio.health) + failures.append("endpoints: a refused connection did not fail") + except ReachError as exc: + check(failures, "OFF" in exc.message and "do not restart" in (exc.fix or ""), + f"endpoints: refused connection not reported as OFF: {exc.message} / {exc.fix}") + finally: + endpoints.load_config = real + + try: + endpoints.call(urllib.request.Request(f"http://127.0.0.1:{port}/x"), service="Gemini API", + what="t", timeout=2) + failures.append("endpoints: unreachable cloud API did not fail") + except ReachError as exc: + check(failures, "network" in (exc.fix or ""), f"endpoints: cloud outage blamed on VRAM: {exc.fix}") + + with fake_server() as base: + for path, needle in (("/boom", "check the arguments"), ("/denied", "credentials")): + try: + endpoints.call(urllib.request.Request(base + path), service="audio", what="t", timeout=5) + failures.append(f"endpoints: HTTP error at {path} did not fail") + except ReachError as exc: + check(failures, needle in (exc.fix or "") and "rejected" in exc.message, + f"endpoints: {path} misclassified: {exc.message} / {exc.fix}") + + try: + endpoints.get_api_key("SR_TEST_SURELY_UNSET_KEY") + failures.append("endpoints: a missing API key did not fail") + except ReachError as exc: + check(failures, "tracked" in (exc.fix or ""), "endpoints: missing-key remedy lost the never-commit warning") + + +def main() -> int: + failures: list[str] = [] + for test in (test_audio, test_audio_urls, test_trellis, test_image_body, test_classification): + test(failures) + if failures: + print("test_assets: FAIL", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + print("test_assets: OK — audio, trellis and image payloads unchanged; OFF, rejected, " + "credentials and network failures each named correctly; nothing sent to a real service") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tooling/trellis-batch.sh b/tooling/trellis-batch.sh deleted file mode 100755 index 56278767e..000000000 --- a/tooling/trellis-batch.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -# Batch Trellis generation — one at a time, gently. -# Usage: ./tooling/trellis-batch.sh -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$PROJECT_ROOT" - -INPUT_DIR=".tmp/image-gen/characters/bodies" -OUTPUT_DIR=".tmp/glb-gen/characters/bodies" -COOLDOWN=15 # seconds between jobs -MAX_RETRIES=3 -RETRY_DELAY=60 # seconds before retry - -mkdir -p "$OUTPUT_DIR" - -TYPES=( - slim_m slim_f - average_m average_f - stocky_m stocky_f - tall_lean_m tall_lean_f - short_stout_m short_stout_f - athletic_m athletic_f - heavyset_m heavyset_f - petite_m petite_f -) - -TOTAL=${#TYPES[@]} -SUCCESS=0 -FAILED=0 - -echo "=== Trellis Batch: $TOTAL bodies ===" -echo " Cooldown: ${COOLDOWN}s between jobs" -echo " Retries: $MAX_RETRIES with ${RETRY_DELAY}s delay" -echo "" - -for i in "${!TYPES[@]}"; do - TYPE="${TYPES[$i]}" - NUM=$((i + 1)) - INPUT="$INPUT_DIR/${TYPE}.png" - OUTPUT="$OUTPUT_DIR/${TYPE}.glb" - - # Skip if already generated - if [ -f "$OUTPUT" ]; then - echo "[$NUM/$TOTAL] $TYPE — already exists, skipping" - SUCCESS=$((SUCCESS + 1)) - continue - fi - - if [ ! -f "$INPUT" ]; then - echo "[$NUM/$TOTAL] $TYPE — input not found: $INPUT" - FAILED=$((FAILED + 1)) - continue - fi - - ATTEMPT=0 - DONE=false - while [ "$ATTEMPT" -lt "$MAX_RETRIES" ] && [ "$DONE" = "false" ]; do - ATTEMPT=$((ATTEMPT + 1)) - echo "[$NUM/$TOTAL] $TYPE (attempt $ATTEMPT/$MAX_RETRIES)..." - - if python3 tooling/db/trellis_connector.py generate \ - "$INPUT" \ - --output "$OUTPUT" \ - --simplify 0.95 \ - --texture-size 1024 \ - 2>&1 | tee /dev/stderr | grep -q '"ok": true'; then - echo " OK" - SUCCESS=$((SUCCESS + 1)) - DONE=true - else - echo " FAILED" - if [ "$ATTEMPT" -lt "$MAX_RETRIES" ]; then - echo " Waiting ${RETRY_DELAY}s before retry..." - sleep "$RETRY_DELAY" - else - echo " Giving up on $TYPE after $MAX_RETRIES attempts" - FAILED=$((FAILED + 1)) - fi - fi - done - - # Cooldown between successful jobs - if [ "$DONE" = "true" ] && [ "$NUM" -lt "$TOTAL" ]; then - echo " Cooling down ${COOLDOWN}s..." - sleep "$COOLDOWN" - fi -done - -echo "" -echo "=== Results: $SUCCESS/$TOTAL succeeded, $FAILED failed ===" -ls -la "$OUTPUT_DIR"/*.glb 2>/dev/null || echo "No GLB files generated"