colophon docs
← colophon docs

Image & audio generation

colophon can generate images from a text prompt with an AI provider, anywhere it accepts an image: a post's hero:/image: frontmatter and Markdown body embeds. A generated image is…

colophon can generate images from a text prompt with an AI provider, anywhere it accepts an image: a post's hero:/image: frontmatter and Markdown body embeds. A generated image is cached on disk (and committed with your content), so it is produced once and then ships through the normal asset pipeline like any other image — including routing to an object store.

The feature is opt-in and off until you configure a provider.

Writing a gen: reference

Anywhere you'd put an image path, write gen: followed by the prompt.

In frontmatter:

---
title: On Patience
hero: "gen:a weathered fisherman waiting by a still lake at dawn, photoreal"
hero_alt: "A fisherman waiting by a calm lake at first light"
image: "gen:a minimalist book cover, muted teal palette"
---

In the body — wrap the prompt in <…> whenever it contains spaces (plain Markdown image syntax stops at the first space):

![A fisherman at dawn](<gen:a weathered fisherman by a still lake at dawn, photoreal>)

The alt text works exactly as for any image — write it for meaning, leave it empty for purely decorative banners.

Tuning a single image

Append query-style parameters to the prompt:

![cover](<gen:a quiet mountain road in fog?aspect=16:9>)

aspect (e.g. 1:1, 16:9, 9:16) is the common one; available parameters depend on the provider. Per-reference parameters override the defaults: in your config.

Reuse

Two references with the same prompt, model and parameters resolve to the same generated file — so a hero and an in-body embed sharing one prompt are generated once. Change the prompt, the model, or a parameter and you get a new image.

Configuration

Add a generation: block to colophon.yaml:

generation:
  image:
    provider: minimax            # which service to use (see table below)
    model: ""                    # optional — defaults per provider
    defaults:                    # optional — params applied to every prompt
      aspect: "16:9"
    output_dir: ""               # optional — default: content/assets/generated
    concurrency: 5               # optional — max images generated at once
    system_prompt: ""            # optional — house style; overrides the theme's (see below)
    # api_key: "{env:MINIMAX_API_KEY}"   # optional — see "API keys" below

Providers

provider Default model API key (environment variable)
google gemini-3.1-flash-image GEMINI_API_KEY (or GOOGLE_API_KEY)
minimax image-01 MINIMAX_API_KEY
openai gpt-image-1 OPENAI_API_KEY
xai grok-imagine-image-quality XAI_API_KEY
together (set model:) TOGETHER_API_KEY
deepinfra (set model:) DEEPINFRA_API_KEY
custom (set model:) set api_key:

custom targets any OpenAI-compatible images endpoint — also set base_url: and api_path: (e.g. /images/generations).

xai is Grok Imagine: xAI's OpenAI-compatible images endpoint. Set model: grok-imagine-image for the cheaper tier, and use aspect as usual (it is sent as xAI's aspect_ratio):

generation:
  image:
    provider: xai                # Grok Imagine — reads XAI_API_KEY
    defaults:
      aspect: "16:9"

API keys

Keys are read from the environment, never required in the file:

  • If you set api_key: (typically "{env:VAR}"), that value is used.
  • Otherwise colophon reads the provider's default environment variable from the table.

Either way the secret stays in your shell / .env / CI secrets, not in colophon.yaml.

House style (theme system prompt)

So you don't repeat the same look in every prompt, a house style is applied to every generated image — describe only the subject in each post and let the style come from the theme. The style is resolved in this order (first wins):

  1. Per-reference ?systemprompt=… (below)
  2. Site configgeneration.image.system_prompt
  3. Themeimage.genai.system_prompt in the theme's theme.yaml
  4. Theme — the theme's description (a gentle fallback)

A theme sets its style in theme.yaml:

# themes/<name>/theme.yaml
description: "A clean editorial broadsheet theme."
image:
  genai:
    system_prompt: "editorial illustration, muted palette, soft depth, no text or lettering"

So hero: "gen:a lighthouse on a rocky coast" is generated in the theme's style with no extra wording in the post.

Per image, you can override or switch it off with the reserved systemprompt parameter:

  • ?systemprompt=bold woodcut print, two-tone — use this style instead for this image
  • ?systemprompt=none (or nil, or empty) — no house style for this image
![A lighthouse, in woodcut](<gen:a lighthouse on a rocky coast?systemprompt=bold woodcut, two-tone>)

The house style is part of the cache identity, so changing it (in the theme, the config, or a post) regenerates the affected images.

Audio readings (podcasts)

A post can carry an audio reading — generated by AI or a file you recorded yourself. Both become a player in the theme and a podcast <enclosure> in the feeds (RSS/Atom/JSON).

# pre-recorded — no AI, just attach a file (copied like a hero image)
audio_file: episode.mp3
# AI text-to-speech: omit to use the site default; set true/false to force per-post
audio: true
audio_voice: "English_Graceful_Lady"   # optional; else the author's/persona's voice, else the default

audio: is optional and three-state: omit it to follow the site default (read aloud when a speech provider is configured), or set true/false to force it for this post. audio_file wins if both are set. The reading voice resolves: post audio_voice → the author's (or persona's) voice: → the generation.speech default.

Configure the speech provider alongside the image one:

generation:
  speech:
    provider: minimax        # MiniMax t2a (more providers later)
    model: speech-2.6-hd
    voice: "English_Graceful_Lady"
    format: mp3              # default — the podcast-portable, now-patent-free choice

Authors/personas can carry a default voice (e.g. a cloned voice id):

# authors/sam.yaml
id: sam
name: Sam Avery
voice: "English_Graceful_Lady"

Themes get has_audio (bool) and audio (URL) to render a player or filter audio posts. The press theme shows a themed play/pause + scrubbable waveform player under the byline.

Waveform. The player derives the waveform from the audio itself, in the browser (Web Audio decodeAudioData), on first play — so generated readings cost a single TTS call, not a second one, and the computed peaks are cached in localStorage for instant redraws. For a recorded file you can still ship a precomputed episode.mp3.json of the form {"peaks":[0.1, 0.7, …]} (values 0–1) next to it — e.g. produced with ffmpeg/audiowaveform — and it renders instantly (paused, pre-play); a .wav is read directly at build. Until peaks exist, the player shows a resting shape or a live Web Audio visualiser (same-origin). When audio is routed cross-origin to an object store, the in-browser decode and any peaks fetch need CORS (a GET policy) on the bucket.

What gets read aloud

Blocks that don't translate to speech — code, math, tables, diagrams — aren't read verbatim (reading code or LaTeX aloud is gibberish). By default each is replaced with a short spoken cue ("Here, the post shows a Go code example."), the first cue adds "Visit the post to view it.", and a closing note is appended. Prose, headings, lists, blockquotes and callouts are read normally. Tune it per type:

generation:
  speech:
    transcript:
      blocks:                # cue | drop (silent) | keep (read the text)
        code: cue
        math_display: cue
        math_inline: drop
        table: cue
        diagram: cue
        inline_code: spell   # spell | keep | drop — "spell" voices symbols ("/etc" → "slash etc")
      wrap_up: true          # append the closing "visit the post" note

Acronyms in your glossary are read as their expansion so they're spoken as words, not letters — SSH → "Secure Shell", DDD → "Domain Driven Design". Only entries that look like acronym expansions qualify (an all-caps term with a short Title-Case definition whose letters spell the acronym), so an ordinary term like Rust is left alone. Turn it off with generation.speech.transcript.expand_acronyms: false.

For finer control, wrap content in the body:

  • <notts>…</notts> — shown on the page but never spoken (e.g. an aside, a pronunciation- hostile string).
  • <tts>…</tts>always read verbatim, overriding the rules above (e.g. a short code snippet you do want spoken).

Both the injected speech (cues, hint, wrap-up, inline-code symbol names) and the player UI (the "Listen" caption and Play/Pause labels) are localised to the post's language (lang: frontmatter, else the site lang). English, Spanish, French, German, Italian and Mandarin ship built in; add or override any language with a project i18n/tts/replacements.json (same shape as the built-in table). A missing language or field falls back to English.

(No LLM is involved — cues are fixed text. An abstractive summary reading would need a text-LLM provider, which colophon doesn't have yet.)

Turning generation on/off

Three nested switches, all defaulting on, all behaving the same way: when off, no new assets of that kind are generated (no provider/API calls) even with --generate-ai, while everything already generated and committed keeps being served.

generation:
  enabled: true          # master — turns ALL generation off in one line
  image:
    enabled: true        # just images
  speech:
    enabled: true        # just audio (also the per-post audio default — see below)

generation.speech.enabled does double duty: it's the audio guard and the default for a post's audio: field. With a speech provider configured and speech enabled, every post reads aloud by default; a post opts out with audio: false. Without a provider, audio is off regardless (so speech effectively self-disables). Image generation has no per-post equivalent (images are explicit gen: references), so image.enabled is purely the guard.

Cleaning up the cache

colophon doctor reports cached generated assets that no content references any more (after an edited prompt, a changed style/model/voice, or a deleted post). colophon doctor --prune deletes them (and their sidecars).

Image post-processing

Some providers bake black letterbox bars into images. colophon removes them deterministically after generation (generation.image.postprocess.trim_letterbox, on by default): it crops solid black borders and re-frames to the requested aspect. It's a no-op on clean images and needs no configuration; set it to false to disable.

Generating images

Generation is a separate, explicit step — ordinary builds never call the provider:

colophon build --generate-ai

This produces only the images that aren't already cached, writing each into output_dir alongside a .json sidecar that records the prompt, provider, model, parameters and date. Already-cached images are reused with no API call. Generations run in parallel (up to concurrency, default 5 — lower it if your provider rate-limits you).

--generate-ai is also a flag on colophon publish, so a deploy can generate any still-uncached media first: colophon publish --env production --generate-ai. (The same applies to TTS audio readings — without the flag, an uncached reading is skipped with a build --generate-ai to create it hint.)

Then commit the generated files (content/assets/generated/ by default). The cache is part of your content: it makes builds reproducible and means a plain colophon build or colophon publish — including in CI — needs no API key and incurs no cost.

If a gen: reference has no cached image and you build without --generate-ai (or the provider errors, or no provider is configured), colophon warns and skips it — the build still succeeds, the image is just absent, exactly like any other missing asset. colophon doctor will not flag gen: references as broken.

Good to know

  • File format follows the provider. colophon names each file by its actual content (e.g. MiniMax returns JPEG, so you get .jpg), so the served Content-Type is always correct.
  • Changing the model or a default reuses nothing — it's a new prompt identity, so the old image stays in the cache until you delete it. There is no automatic pruning yet.
  • Provider model names change. Pin model: if you need stability; the defaults track each provider's current recommended image model.

Generated from docs/image-generation.md — edit it there.