diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f7e0a..693f32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Skill output is now true lossless PNG (`infographic-agent` v3.0.1)**: `gemini-3.1-flash-lite-image` returns JPEG on the Gemini Developer API (which does not allow forcing the output format), so the CLI was saving JPEG bytes under a `.png` name — visibly degrading text. The skill now transcodes the model's output to real PNG via Pillow, threads the actual mime type through the refinement loop, and writes the correct file extension. `pip install` / `--install` now include `pillow` (google-genai remains the only hard requirement — without Pillow the script degrades gracefully to the model's native format). + ### Changed - **Portable skill rewritten to match the web pipeline (`infographic-agent` v3.0.0)**: the skill now generates infographics **directly with Gemini** using the same two agents as the web demo — a research orchestrator (`gemini-3.5-flash`) grounds the topic with Google Search and engineers a text-accurate prompt, then `gemini-3.1-flash-lite-image` renders the PNG. Replaces the previous HTML/CSS + headless-Chromium screenshot approach. -- **No browser dependencies**: removed Playwright/Chromium from the skill entirely. The only runtime dependency is Google's GenAI SDK — `npx infographic-agent --install` now just runs `pip install google-genai` (seconds, not a browser download). +- **No browser dependencies**: removed Playwright/Chromium from the skill entirely. `npx infographic-agent --install` now just runs `pip install google-genai pillow` (seconds, not a browser download). ### Added diff --git a/README.md b/README.md index 3446d89..e5d9138 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ See [`docs/architecture.md`](docs/architecture.md) for the full technical deep-d Prefer working from a coding agent instead of the browser? [`skill/infographic-agent/`](skill/infographic-agent/) packages the **same two-agent pipeline** as a standalone, agent-agnostic **skill** — a `SKILL.md` plus a `portable_infographic.py` script that any AI coding agent with skill/tool support can invoke to generate an infographic PNG directly from the command line, no web app required. It uses `gemini-3.5-flash` to research and engineer the prompt, then `gemini-3.1-flash-lite-image` to render it. -**The only dependency is Google's GenAI SDK** — no browser, Playwright, or Chromium download. Install is a single `pip install google-genai`. +**No browser, Playwright, or Chromium download** — install is a single `pip install google-genai pillow` (Google's GenAI SDK runs the pipeline; Pillow transcodes the output to lossless PNG for crisp text). The skill is also published on npm and works with the [Vercel agent skills ecosystem](https://github.com/vercel-labs/skills), so you can run it anywhere with a single command: @@ -75,7 +75,7 @@ npx skills add ryanbaumann/infographic-agent **Or run directly without installing:** ```bash -# First-time setup (just: pip install google-genai) +# First-time setup (just: pip install google-genai pillow) npx infographic-agent --install # Generate an infographic — no key set? The CLI walks you through getting a diff --git a/skill/infographic-agent/SKILL.md b/skill/infographic-agent/SKILL.md index e90e90f..95525e7 100644 --- a/skill/infographic-agent/SKILL.md +++ b/skill/infographic-agent/SKILL.md @@ -5,9 +5,9 @@ description: > A research agent (gemini-3.5-flash) grounds the topic with Google Search and engineers a precise prompt, then gemini-3.1-flash-lite-image renders it into a PNG. No browser, Playwright, or Chromium dependencies — the only requirement is Google's GenAI SDK. Fully portable to any agent CLI environment. -compatibility: "Requires Python 3.8+ and the google-genai SDK (one pip install, no browser)." +compatibility: "Requires Python 3.8+ and the google-genai + pillow packages (one pip install, no browser)." metadata: - version: "3.0.0" + version: "3.0.1" author: "Infographic Agent contributors" --- @@ -25,7 +25,7 @@ This skill mirrors the repo's web demo as a two-agent pipeline, both powered by After the first draft, an interactive refine loop lets the user iterate ("make the header bolder", "use teal accents") — each turn re-invokes the image model with the previous image plus the edit, saving a new revision in seconds. -The entire workflow lives in `portable_infographic.py`. There are **no browser dependencies** — install is a single `pip install google-genai`. +The entire workflow lives in `portable_infographic.py`. There are **no browser dependencies** — install is a single `pip install google-genai pillow` (google-genai runs the pipeline; pillow transcodes the output to lossless PNG for crisp text). **Security posture:** the Gemini API key is user-provided. If not set via `GEMINI_API_KEY`, the CLI walks the user through getting a free key from Google AI Studio and stores it locally at `~/.config/infographic-agent/config.json` with `0600` permissions. Errors are scrubbed of anything that looks like a credential before printing. If this skill is invoked autonomously, treat `--output` as trusted input — the path is resolved but will write wherever the invoker points it. @@ -38,7 +38,7 @@ The entire workflow lives in `portable_infographic.py`. There are **no browser d npx skills add ryanbaumann/infographic-agent # Or run directly without installing via npm: - npx infographic-agent --install # first-time: pip install google-genai + npx infographic-agent --install # first-time: pip install google-genai pillow ``` 3. **Set up the API key (free, ~20 seconds):** Either export it, or let the CLI onboard you: ```bash diff --git a/skill/infographic-agent/bin/infographic-agent.js b/skill/infographic-agent/bin/infographic-agent.js index 84bb014..c7d9b7e 100644 --- a/skill/infographic-agent/bin/infographic-agent.js +++ b/skill/infographic-agent/bin/infographic-agent.js @@ -131,8 +131,8 @@ if (!python) { // ─── --install: first-time dependency setup (just the GenAI SDK) ───────────── if (doInstall) { - print("Installing the Google GenAI SDK (google-genai)..."); - const result = run(python, ["-m", "pip", "install", "--upgrade", "google-genai"]); + print("Installing Python dependencies (google-genai, pillow)..."); + const result = run(python, ["-m", "pip", "install", "--upgrade", "google-genai", "pillow"]); if (result.status !== 0) process.exit(result.status); print(` diff --git a/skill/infographic-agent/package.json b/skill/infographic-agent/package.json index a3a8d7a..71d8569 100644 --- a/skill/infographic-agent/package.json +++ b/skill/infographic-agent/package.json @@ -1,6 +1,6 @@ { "name": "infographic-agent", - "version": "3.0.0", + "version": "3.0.1", "description": "Generate professional infographic PNGs from text with Gemini — a research agent (gemini-3.5-flash) grounds your topic, then gemini-3.1-flash-lite-image renders it. No browser/Playwright deps.", "license": "MIT", "author": "Ryan Baumann", diff --git a/skill/infographic-agent/portable_infographic.py b/skill/infographic-agent/portable_infographic.py index c084cb8..f7bb826 100755 --- a/skill/infographic-agent/portable_infographic.py +++ b/skill/infographic-agent/portable_infographic.py @@ -10,10 +10,13 @@ 2. Image generator (gemini-3.1-flash-lite-image) — renders the prompt directly into a PNG. -There are NO browser or Playwright dependencies — the only requirement is -Google's GenAI SDK: +There are NO browser or Playwright dependencies. Install with: - pip install google-genai + pip install google-genai pillow + +(google-genai is required; pillow is used to transcode the model's output to +lossless PNG for crisp text — the script still runs without it, saving the +model's native format instead.) Quick start: @@ -380,16 +383,47 @@ def _parse_json(text: str) -> dict: # Agent 2 — image generator (gemini-3.1-flash-lite-image) # --------------------------------------------------------------------------- # -def _extract_image(response) -> bytes: +def _extract_image(response): + """Return (image_bytes, mime_type) from a Gemini image response.""" parts = (response.candidates[0].content.parts if response.candidates else None) or [] for part in parts: inline = getattr(part, "inline_data", None) if inline and inline.data: - return inline.data + return inline.data, (inline.mime_type or "image/png") raise RuntimeError("The model did not return an image. Try rephrasing your topic and generate again.") -def generate_image(client, prompt: str, aspect: str) -> bytes: +def _normalize_to_png(data: bytes, mime: str): + """Convert the model's output to lossless PNG so text stays crisp. + + The Gemini Developer API returns JPEG for gemini-3.1-flash-lite-image and + does not allow forcing the output format, so we transcode here. Pillow keeps + the install tiny; if it is somehow missing we fall back to the native bytes + (with an honest extension) rather than hard-failing. + """ + m = (mime or "").lower() + if m in ("image/png", ""): + return data, "image/png" + try: + import io + from PIL import Image + + img = Image.open(io.BytesIO(data)) + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue(), "image/png" + except ImportError: + warn("Pillow not installed — saving the model's native format. " + "Run 'pip install pillow' for guaranteed lossless PNG output.") + return data, m + except Exception as e: # noqa: BLE001 — never let transcoding lose the image + warn(f"Could not transcode to PNG ({type(e).__name__}); saving native format.") + return data, m + + +def generate_image(client, prompt: str, aspect: str): config = types.GenerateContentConfig( response_modalities=["TEXT", "IMAGE"], image_config=types.ImageConfig(aspect_ratio=aspect, image_size="1K"), @@ -403,14 +437,14 @@ def generate_image(client, prompt: str, aspect: str) -> bytes: ) -def refine_image(client, image_bytes: bytes, instruction: str, aspect: str) -> bytes: +def refine_image(client, image_bytes: bytes, mime: str, instruction: str, aspect: str): config = types.GenerateContentConfig( response_modalities=["TEXT", "IMAGE"], image_config=types.ImageConfig(aspect_ratio=aspect, image_size="1K"), http_options=types.HttpOptions(timeout=180_000), ) contents = [ - types.Part.from_bytes(data=image_bytes, mime_type="image/png"), + types.Part.from_bytes(data=image_bytes, mime_type=mime or "image/png"), types.Part.from_text( text=( "The attached image is the current infographic.\n" @@ -421,14 +455,16 @@ def refine_image(client, image_bytes: bytes, instruction: str, aspect: str) -> b return _call_image_model(client, contents=contents, config=config) -def _call_image_model(client, contents, config) -> bytes: +def _call_image_model(client, contents, config): + """Call the image model with retries; return (png_bytes, mime_type).""" last_error = None for attempt in range(3): try: response = client.models.generate_content( model=IMAGE_MODEL, contents=contents, config=config ) - return _extract_image(response) + data, mime = _extract_image(response) + return _normalize_to_png(data, mime) except Exception as e: # noqa: BLE001 last_error = e if _is_transient(e) and attempt < 2: @@ -444,10 +480,17 @@ def _call_image_model(client, contents, config) -> bytes: # Output handling # --------------------------------------------------------------------------- # -def save_png(image_bytes: bytes, output_path: str) -> str: +_EXT_BY_MIME = {"image/png": ".png", "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/webp": ".webp"} +_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ""} + + +def save_image(image_bytes: bytes, mime: str, output_path: str) -> str: + """Write the image, ensuring the extension matches the actual format.""" + correct_ext = _EXT_BY_MIME.get((mime or "").lower(), ".png") path = os.path.realpath(output_path) - if not path.lower().endswith(".png"): - path += ".png" + root, ext = os.path.splitext(path) + # Swap a mismatched image extension (or none) for the true one; otherwise append. + path = (root + correct_ext) if ext.lower() in _IMAGE_EXTS else (path + correct_ext) parent = os.path.dirname(path) if parent and not os.path.isdir(parent): error(f"Output directory does not exist: {parent}") @@ -466,12 +509,13 @@ def open_output(path: str) -> None: # Interactive refine loop — fast, iterative preview # --------------------------------------------------------------------------- # -def refine_loop(client, image_bytes: bytes, output_path: str, aspect: str, auto_open: bool) -> None: +def refine_loop(client, image_bytes: bytes, mime: str, output_path: str, aspect: str, auto_open: bool) -> None: info( "\n💬 Refine it, or press Enter to finish.\n" ' e.g. "make the header bolder", "use teal accents", "add source citations"' ) revision = 1 + base, _ = os.path.splitext(output_path) while True: try: instruction = input("\nRefine › ").strip() @@ -481,13 +525,12 @@ def refine_loop(client, image_bytes: bytes, output_path: str, aspect: str, auto_ if not instruction or instruction.lower() in ("q", "quit", "exit", "done"): break try: - image_bytes = refine_image(client, image_bytes, instruction, aspect) + image_bytes, mime = refine_image(client, image_bytes, mime, instruction, aspect) except Exception as e: # noqa: BLE001 error(_scrub(str(e))) continue revision += 1 - base, ext = os.path.splitext(output_path) - rev_path = save_png(image_bytes, f"{base}-v{revision}{ext or '.png'}") + rev_path = save_image(image_bytes, mime, f"{base}-v{revision}") info(f"✓ Saved revision {revision}: {rev_path}") if auto_open: open_output(rev_path) @@ -549,12 +592,12 @@ def main() -> None: prompt = direct_prompt(content, args.mode, args.instructions) else: prompt = research_prompt(client, content, args.mode, args.aspect, args.instructions) - image_bytes = generate_image(client, prompt, args.aspect) + image_bytes, mime = generate_image(client, prompt, args.aspect) except Exception as e: # noqa: BLE001 error(_friendly_api_error(e)) sys.exit(1) - path = save_png(image_bytes, args.output) + path = save_image(image_bytes, mime, args.output) info(f"\n✓ Saved infographic: {path}") auto_open = not args.no_open @@ -563,7 +606,7 @@ def main() -> None: interactive = not args.yes and sys.stdin.isatty() if interactive: - refine_loop(client, image_bytes, path, args.aspect, auto_open) + refine_loop(client, image_bytes, mime, path, args.aspect, auto_open) info("\nDone. 🎉") diff --git a/skill/infographic-agent/requirements.txt b/skill/infographic-agent/requirements.txt index 85c9ee8..bb94f99 100644 --- a/skill/infographic-agent/requirements.txt +++ b/skill/infographic-agent/requirements.txt @@ -1,3 +1,4 @@ -# The only runtime dependency — Google's GenAI SDK. -# Install with: pip install -r requirements.txt (or: pip install google-genai) -google-genai>=1.0.0 +# Runtime dependencies. Install with: pip install -r requirements.txt +# (or: pip install google-genai pillow) +google-genai>=1.0.0 # Gemini SDK — the two-agent research + image pipeline +pillow>=10.0.0 # transcodes the model's output to lossless PNG (crisp text)