Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions skill/infographic-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
---

Expand All @@ -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.
</context>
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions skill/infographic-agent/bin/infographic-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down
2 changes: 1 addition & 1 deletion skill/infographic-agent/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
83 changes: 63 additions & 20 deletions skill/infographic-agent/portable_infographic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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"),
Expand All @@ -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=(
"<current_image>The attached image is the current infographic.</current_image>\n"
Expand All @@ -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:
Expand All @@ -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}")
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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. 🎉")

Expand Down
7 changes: 4 additions & 3 deletions skill/infographic-agent/requirements.txt
Original file line number Diff line number Diff line change
@@ -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)
Loading