Skip to content

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

  1. 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).
  2. 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.
  3. 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 an os.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