Providers (pluggable backends)¶
Every external backend keryx uses — image, video, voice and music generation, and rendering — sits behind a narrow Go interface, with the concrete implementation chosen from user config at construction. Swapping a backend, or adding a new one, is a config change plus an additive adapter package — never a call-site change.
The seams¶
| Capability | Interface | Config key | Adapters registered today |
|---|---|---|---|
| Image generation | ImageProvider.Generate |
providers.image |
gemini |
| Video generation | VideoProvider.Generate |
providers.video |
none |
| Voice / TTS | VoiceProvider.Synthesize |
providers.voice |
elevenlabs |
| Music | MusicProvider.Compose |
providers.music |
elevenlabs |
| Rendering | Renderer.Render |
providers.render |
afmpeg (default), ffmpeg |
The seam is genuine, but it is not yet a choice. Four of the five capabilities have
exactly one implementation, and providers.video has none — its declared default of
gemini names a constructor nobody registered, so resolving the video capability
always fails. Video generation is a deferred feature, not a configuration you are
missing.
Interfaces and the provider-neutral request/response types live in
pkg/provider;
mocks are generated into mocks/pkg/provider for tests.
Render backends¶
Two Renderer adapters ship, selected with providers.render:
providers.render |
How it renders |
|---|---|
afmpeg (default) |
FFmpeg compiled to WebAssembly (the ffmpeg-wasi engine), driven via the afmpeg library — the whole reel renders in memory, no system ffmpeg |
ffmpeg |
shells out to a system ffmpeg binary — a deliberate choice for a tuned local build, and the only backend with silencedetect (see below) |
The afmpeg backend needs an ffmpeg-wasi module. By default it needs no
configuration — it downloads a pinned certified release (the gpl variant),
caches it under the OS user-cache dir, and reuses it thereafter. So a fresh install
renders with nothing but keryx present.
One capability is missing from the default. silencedetect is not in the lean
published module, so VO take screening (voice takes --screen, voice pick --best)
ranks takes on duration alone rather than on internal pauses. keryx says so on
stderr when it happens. If that screening matters to you, set providers.render:
ffmpeg.
"Certified" is the load-bearing word: that path fetches the module together with its checksums, a detached signature and its provenance, and verifies all three against signing keys embedded in the keryx binary before the module is compiled. A wasm module is executable code arriving over the network, so the default refuses to run one it cannot prove the origin of — and on this path there is no way to skip the check.
Two engines: the portable one and the fast one¶
afmpeg can drive ffmpeg-wasi two ways, and the difference is not marginal. The
wasm module runs anywhere keryx does. The native driver is the same engine
compiled for the host.
On a three-card, 5.7-second reel the wasm engine took around eight and a half minutes against seconds natively — measured on an 8-core linux/amd64 machine, at keryx's default H.264 settings. That cost is per render; it is not a first-run download that amortises.
Expect that gap to change, and know what changes it. Two things move it, and neither is a property of the software:
- What the work is. Encoding is where the wasm engine suffers most: it runs on a single thread and without SIMD, both of which encoders lean on heavily. Decode-heavy work is affected far less. Assembling a reel is encode-dominated — stills composed into an H.264 video — which is why the difference is as stark as it is here. A job that merely reads frames would show a far smaller one.
- How many cores you have. The native engine uses all of them; the wasm engine uses one. A 2-core runner narrows the gap; a 32-core workstation widens it.
So treat the figure above as the shape of the difference for this kind of work on this class of machine, not as a constant. If the number matters to a decision, time your own reel — a borrowed multiplier describes somebody else's workload.
By default (render.driver: auto) keryx takes the native driver where the
pinned release publishes one for the host, and the wasm engine where it does not.
Drivers are published for a subset of platforms — linux/amd64 while the project is
pre-release, with cross-compilation intended — so keryx asks the release what it has
rather than assuming, and picks up new platforms as they ship without a code change.
| Value | Behaviour |
|---|---|
auto (default) |
native where available, wasm otherwise |
off |
always wasm; never touches the driver |
on |
native or fail — for CI that wants the fast path or a loud error, not a silent eight-minute one |
| a path | use that driver binary, taken at its word |
The driver is fetched and verified through the same signed chain as the module. One case deliberately does not fall back: a driver that fails verification is an error, not a reason to quietly use the slow engine. "No driver for this platform" and "this driver's signature does not verify" are both reasons native is unavailable, but only the first is a reason to carry on.
WithBackend replaces the engine rather than adding to it, so this is one choice made
at startup — not something that retries mid-render.
Overriding the wasm module, and why config cannot do it¶
Three keys exist to point afmpeg somewhere else — a locally-built .wasm, a different
release, or an air-gapped mirror:
| Key | Value |
|---|---|
providers.render.module |
a host path to a .wasm, or an https:// URL (a .gz URL is decompressed on download) |
providers.render.module_sha256 |
hex SHA-256 to verify a URL module against (the decompressed .wasm) — strongly recommended, it is executable code |
providers.render.cache_dir |
override where a downloaded module is cached (the pinned release honours this too) |
Pointing module somewhere is the bring-your-own path, and it leaves the certified
release path behind: your build is verified against the SHA-256 you supply, not
against the project's signing keys. That is the right trade for a local build — it was
never issued under that provenance — but it is a weaker check, which is why
module_sha256 is worth setting rather than skipping.
None of them can be combined with providers.render: afmpeg. providers.render is
a scalar naming the adapter and providers.render.module is a key inside a map; YAML
rejects a key that is both, and splitting them across config layers does not help
either — the higher-precedence layer supplies the shape and the other form reads as
empty. Select afmpeg and the module keys resolve to nothing; set the module keys and
no adapter is selected, so you get the default afmpeg.
The working route for an air-gapped or custom module is the KERYX_FFMPEG_WASI
environment variable, which the renderer reads directly as the fallback for
providers.render.module. It takes a host path or an https:// URL.
lgpl vs gpl. ffmpeg-wasi ships two variants: gpl carries libx264 and can
encode H.264 (what reels need — keryx pins the gpl module by default); lgpl
decodes H.264 but cannot encode it. Choosing the gpl module accepts GPL terms for that
artefact.
The reel logic (still-loop, xfade-concat, audio mix) stays in keryx; afmpeg/ffmpeg-wasi
remain generic tools.
Render works over any filesystem. The render core reads inputs from and
writes the mp4 into an afero.Fs — so a project that lives in memory (the studio's
in-memory/RAM-worktree remotes) renders too, not just an on-disk checkout. afmpeg
renders any fs natively; the shell-out ffmpeg (its binary needs real files)
transparently materialises a non-OS fs to a temp dir and copies the result back. The
studio's former "render is local-only" gate is gone.
Provider-neutral requests¶
Requests carry keryx's intent, not vendor payloads — a prompt + aspect for an
image, the narration text + clone settings for voice, a Timeline of segments +
an audio mix for the renderer. Each adapter maps that intent to its own API and
back, so call sites never mention a vendor. Provider-specific identifiers (an
ElevenLabs voice id, a Gemini model name) live in the theme / provider config the
active adapter understands.
Config-driven construction¶
A per-capability Factory[T] resolves providers.<capability> to a registered
constructor and builds it from that provider's config block (endpoint, model,
credentials via keychain/env — never committed). A blank or unset value falls
back to the default adapter; an unknown value is an error listing what's
available.
// call site — never names a vendor
voice, err := provider.VoiceFactory.Resolve(cfg)
audio, err := voice.Synthesize(ctx, provider.VoiceRequest{Text: line, VoiceID: id})
Adding an adapter is purely additive — implement the interface and register a
constructor from the adapter package's init():
…then providers.voice: openai selects it. No other code changes.
Provider config blocks also carry adapter settings. For Gemini, providers.gemini.model
forces a specific image model id (else the adapter tries its built-in
Imagen→Gemini fallback chain); a per-run --model on cover/portrait overrides
it. The model is routed by id prefix — imagen* uses the Imagen :predict
endpoint, others use :generateContent.
Where API keys come from¶
Generation API keys are read from the environment, never from config, so they are never committed:
| Provider | Env var |
|---|---|
| Gemini (image / cards / chat draft) | GEMINI_API_KEY |
| ElevenLabs (voice + music) | ELEVENLABS_API_TOKEN |
| Anthropic (chat) | ANTHROPIC_API_KEY |
| OpenAI (chat) | OPENAI_API_KEY |
When providers.chat.provider is unset, keryx infers the chat provider from exactly
one of the three chat keys being present. Two or more, and it refuses rather than
guessing — which is what you hit the moment you want images from one vendor and chat
from another. Set providers.chat.provider explicitly and the combination works;
image and chat selection are independent keys.
Environment variables do not override config keys¶
There is no environment layer over keryx's configuration. It sets no env prefix,
and its config store is built from files and changed flags only. PROVIDERS_IMAGE,
STUDIO_TAKE_COUNT, KERYX_PROVIDERS_CHAT_PROVIDER and every other name derived from a
config key are ignored.
The environment variables keryx does read are a fixed list that a specific piece of code
looks up by name — the API keys above, the platform secrets, KERYX_FFMPEG_WASI, and a
few CI variables. They are all on the
environment variables page.
Where the config file is read from¶
Non-secret config (themes, provider choice, defaults) is read from files and deep-merged, project over user:
| Layer | File | Scope |
|---|---|---|
| User | ~/.keryx/config.yaml |
per-user, written by keryx init |
| User | ~/.keryx/themes.yaml |
the style library, written by keryx theme add |
| User | ~/.keryx/accounts.yaml |
credentials, written by keryx auth |
| Project | .keryx.yaml at the repo root |
committed with the owning project; overrides the user files |
The project .keryx.yaml is discovered by walking up from the working directory, so
keryx run from anywhere inside the repo picks it up. This is what lets config live in
the owning project — the blog brings its own themes and provider choices. Full
precedence, highest first: changed flags → project .keryx.yaml → accounts.yaml →
themes.yaml → config.yaml → built-in defaults.
Testability¶
Because every backend is an interface, the deterministic core is unit-tested with no network, no ffmpeg, and no API keys — fakes (or the generated mocks) stand in. This is what keeps the timing maths, wrapping, theme/provider resolution, and the posting ledger testable in isolation.
Status: the seams, the registry, and the Gemini image / ElevenLabs voice+music / ffmpeg-render adapters are implemented and drive the working pipeline. Video generation is deferred to a later phase.