SPIKE — CGO-free ffmpeg/libav binding for in-memory render¶
Status: DONE (task #68; 2026-06-26). Resolves spec 0015 D1's long-term question. Recommendation: keep render local-only for Phase 2; do NOT adopt a binding now; use a materialise↔readback bridge if/when in-memory render is needed; watch-list go-ffmpreg (wazero/WASM) for the future.
The question¶
Spec 0015 locked render to local (on-disk) projects because keryx shells out to
the ffmpeg binary, which needs real files — so an in-memory project's RAM
worktree can't be rendered (contention C1/C3). This spike asks: is there a Go path
to render against in-memory buffers (no host temp dir), ideally CGO-free
(keryx's posture: CGO_ENABLED=0 static binaries via goreleaser, cross-compiled)?
Reframe (important)¶
keryx already depends on the ffmpeg binary being installed (it shells out). So "remove the ffmpeg dependency" was never the ask. The two real questions are: (a) can render operate in memory (for in-memory projects), and (b) without adding CGO (a C toolchain at build + per-target ffmpeg libs, which breaks the clean static cross-compile). A libav binding (vs the binary) is what enables in-memory I/O — via libavformat custom AVIO callbacks reading/writing buffers.
Options evaluated¶
| Option | CGO-free? | In-memory I/O? | xfade + libx264 + AAC? | Host ffmpeg needed? | Maturity | Verdict |
|---|---|---|---|---|---|---|
| ffgo (purego + dlopen) | ✅ build | partial — read-side only; write-to-buffer unproven | filters claimed, no xfade example | yes (dlopen libav*.so at runtime) | ❌ 9★, solo, no releases | No — immature; the in-memory output we need isn't proven; still needs host libs |
| go-astiav (CGO libav) | ❌ needs CGO | ✅ full custom AVIO (in + out) | ✅ filter graphs + mux | yes (dev libs at build) | ✅ 723★, active, prod-used | No for now — the only mature in-memory path, but CGO breaks keryx's posture (no static cross-compile; per-target ffmpeg toolchain; goreleaser pain) |
| go-ffmpreg (wazero + embedded ffmpeg.wasm) | ✅ | via WASI virtual fs (memfs mounts) — needs /tmp access (can be memfs) |
❌ stock build lacks libavfilter/xfade + AAC; needs a custom wasm build | no (embeds ffmpeg) | ⚠️ 14★, modest; pinned to ffmpeg n5.1.10 (no wasm-threads → single-threaded/slow) | No for now — most posture-aligned long-term, but stock build can't do our render, single-threaded encode is slow, and a custom wasm build is a real maintenance burden |
| materialise↔readback bridge (no new dep) | ✅ (unchanged) | ✅ effectively — copy worktree → real temp dir → native ffmpeg → read mp4 back | ✅ (native ffmpeg, as today) | yes (as today) | n/a — it's just I/O around the existing path | The escape hatch — keeps the current posture, native-ffmpeg speed, no new dependency; the right tool if in-memory render is ever needed |
Findings¶
- CGO-free in-memory render is not a sound Phase-2 bet. The only mature
in-memory binding (
go-astiav) requires CGO, which trades away keryx's CGO-free static-binary + cross-compile posture for an edge case (in-memory projects are the rare path). The CGO-free options are either too immature (ffgo) or can't do our render without a custom build + perf hit (go-ffmpreg). - The win was never "drop ffmpeg" — it was "in-memory + CGO-free." Since keryx keeps the ffmpeg-binary dependency regardless, the materialise↔readback bridge delivers the in-memory capability (for the edge case) with zero new dependency and native ffmpeg speed, preserving the posture. It is strictly simpler and lower-risk than any binding.
- No POC needed. The research is decisive enough that prototyping an immature binding would be wasted effort; the bridge (if ever built) is trivial I/O around the existing render core.
Recommendation¶
- Phase 2: render stays LOCAL-ONLY (spec 0015 D1 lock-out confirmed). 2B builds against local projects + native ffmpeg, no new dependency. In-memory projects get the clear "switch to a local checkout to render" message.
- If in-memory render is later required: build the materialise↔readback
bridge (copy the in-memory worktree's render inputs —
cover.png,cards/NN.png,vo/*.mp3,music.mp3— to anos.MkdirTemp, run the existing render, read the mp4 back into the worktree). No new dependency; keeps CGO-free + native speed. - Do NOT adopt ffgo / go-astiav / go-ffmpreg now.
Watch-list (re-evaluate, don't adopt)¶
- go-ffmpreg / wazero / WASM ffmpeg — the most posture-aligned long-term path to true CGO-free, host-ffmpeg-free, in-memory render. Re-evaluate when: (a) a maintained ffmpeg.wasm build includes libavfilter (xfade) + AAC, (b) wazero gains wasm-threads so encode isn't single-threaded (perf), and © maturity grows. The ~7.5 MB (gzip) embedded blob is acceptable if it removes the host-ffmpeg install requirement.
- ffgo — re-evaluate if it matures (releases, adoption) and proves write-side in-memory output.
- Trigger to revisit either: the host-ffmpeg-binary install becoming a real deployment pain, or in-memory render becoming a common (not edge) need.
Sources¶
- ffgo (purego): https://github.com/obinnaokechukwu/ffgo
- go-astiav (CGO libav): https://github.com/asticode/go-astiav
- go-ffmpreg (wazero/WASM): https://codeberg.org/gruf/go-ffmpreg
- wazero (CGO-free WASM runtime): https://wazero.io/