`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.
Parity, from baselines taken before anything moved:
- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
(the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
summary
run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.
The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.
Archived, with reasons in tooling/archive/README.md:
- setup_clothing_metadata.py wrote coverage data for five garments that no
longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db
segment_reference_distribution.md moved to docs/assets/visual/.
Behaviour changes:
- The QA analyzer exited 0 whatever it found, though its own README says
clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
rewrote an unchanged file: it left them as orphans and then found them
again. Only nodes still linked into the graph count now, and a first pass
writes the same bytes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""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,
|
|
}
|