tooling/db/ (a misnamed directory: connectors, not database work),
trellis-batch.sh and synth_ui_sounds.py become `reach assets`:
audio {health,generate,batch,post {convert,normalize,trim,pipeline}},
image {health,generate}, trellis {health,generate,batch}, and synth-ui.
The four audio bash wrappers are retired, and tooling/db/ is gone.
Parity, from baselines taken before anything moved:
- the four UI-sound WAVs and the harmonic-synth WAVs (exponential and linear
decay) are byte-identical
- the ffmpeg pipeline's decoded PCM is identical. Its .ogg bytes are not,
even between two runs of the OLD code: Ogg picks a random stream serial,
so the encoded file was never the right thing to compare
- the network success paths can't be run in a gate (Stable Audio and Trellis
are kept off, Gemini costs money), so tooling/test_assets.py stands up a
fake Gradio and pins every payload: the audio submit, Trellis's six-call
session sequence with its 9-input image_to_3d, and the Gemini body. It
failed when one Trellis value was mutated (7.5 → 7.0)
Failure classification, in endpoints.py, is the point of the port. The
services are OFF by design (VRAM on tower-of-joy, D-17), and the topology doc
warns against "fixing" one by restarting it. So a refused connection says OFF
and asks for the service to be turned on rather than restarted; a 4xx/5xx says
the request was rejected; 401/403 says credentials; 429 says quota; and an
unreachable Gemini blames the network, not VRAM.
Behaviour changes, each a failure that used to read as success or crash:
- audio batch and trellis batch exited 0 with failures in their summaries;
they now print the summary and exit 1
- trellis generate on a missing image crashed with a TypeError
(print(..., indent=2)); it now names the file, and checks it before the
service so a typo is not reported as an outage
- the ffmpeg pipeline left its intermediates behind when a step failed
Structure: the connectors called each other as subprocesses (batch spawned
the connector, which spawned audio_post) and parsed each other's stdout. They
are now function calls, and ffmpeg is the only exec, through core/process.
ensure_venv() is removed: it os.execv'd into .venv, which D-263's exec rule
forbids, and reach declares the dependencies itself. config.json moved into
the domain deliberately, and the local-services rule follows it.
Output contract: results are still JSON on stdout with the same keys, so skill
readers keep working. Failures are an exit status with a Fix line, never
{"ok": false}. The audio-gen, glb-gen and image-gen skills, Araminta's agent
file and the allow-list are updated to match. glb-gen's "trellis-batch.sh is
hardcoded to one category" caveat is gone: batch takes --input-dir or --names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""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: <name>_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: <name>_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 <file.wav>`"
|
|
)
|