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>
283 lines
13 KiB
Python
283 lines
13 KiB
Python
#!/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())
|