0004 — the runtime & public API (Run / Probe)¶
Status: DRAFT (component spec; not started. Implements spec 0001 §3.3, §4 and the D-D / D-E resolutions. Review before building.) Date: 2026-06-26 Parent: 0001-afmpeg.md §3.3, §4, §10 (D-D, D-E) Owns: R-AF-1, R-AF-4, R-AF-5, R-AF-8; resolves D-D, scopes D-E
1. Purpose¶
The public pkg/afmpeg surface: compile the WASM module once into a reusable Runtime, then
Run an ffmpeg invocation with its filesystem bridged (via 0003) to a caller-supplied
afero.Fs, returning the exit code + captured stderr; plus Probe (ffprobe-equivalent
duration). Per D-E this is v1 — raw Run(ctx, fs, args…) over a vfs; the general
command builder is 0005.
2. Scope¶
In scope (package pkg/afmpeg):
- New(ctx, opts…) (*Runtime, error) — compiles the module (the expensive step), held for
reuse; Close(ctx).
- Run(ctx, fs, args…) (Result, error) — mounts fs via 0003, runs ffmpeg, captures
exit/stderr.
- Probe(ctx, fs, path) (Probe, error) — duration over the same bridge (R-AF-5).
- Module loading wiring that honours D-C: the GPL wasm is not //go:embed-ed; it is
supplied as an external artifact (option below).
- Context cancellation that aborts a running invocation (R-AF-8).
- Concurrency model per D-D.
Out of scope: - The vfs adapter internals (0003) — consumed here. - The general command builder (0005). - The wasm build (0002).
3. API (from 0001 §4, made concrete)¶
package afmpeg
type Runtime struct { /* compiled wazero module + runtime */ }
type Option func(*config)
// WASM source (D-C: not embedded). Exactly one is required.
func WithModuleFile(path string) Option // load ffmpeg.wasm from disk (afero or os)
func WithModuleBytes(b []byte) Option // caller supplies the module bytes
func WithModuleFS(fs afero.Fs, path string) Option // load from an afero fs
func New(ctx context.Context, opts ...Option) (*Runtime, error) // compiles once, reuse
func (r *Runtime) Close(ctx context.Context) error
// Run executes one ffmpeg invocation; paths in args resolve against fs.
func (r *Runtime) Run(ctx context.Context, fs afero.Fs, args ...string) (Result, error)
type Result struct {
ExitCode int
Stderr string // captured guest stderr (the error tail on failure)
}
// Probe mirrors `ffprobe -show_entries format=duration` over the fs bridge.
func (r *Runtime) Probe(ctx context.Context, fs afero.Fs, path string) (Probe, error)
type Probe struct {
DurationSec float64
}
Notes:
- Run does not error on a non-zero ffmpeg exit by itself — it returns Result{ExitCode,
Stderr} and a nil error for "ffmpeg ran and exited non-zero"; it returns a non-nil error
for host-side failures (module instantiation, bridge errors, ctx-cancel). The caller
inspects ExitCode. (0005's Renderer maps a non-zero exit to a wrapped error, matching
keyrx's current errors.Wrapf(err, "ffmpeg: %s", tail(out, 1500)).) — confirm in review.
- Probe is implemented either by running the embedded ffprobe entrypoint if the module
provides one, or by an ffmpeg -i stderr parse — decision D-0004-A below.
4. Decisions (local)¶
- D-0004-A — Probe mechanism. RESOLVED 2026-06-27: parse
ffmpeg -i <path>stderr. The first implementation used ffprobe-shaped args (-show_entries format=duration) withargv[0]="ffmpeg", which a real ffmpeg rejects (Unrecognized option 'show_entries'— those are ffprobe options) — caught against the interim real module.ffmpeg -i <path>prints the input'sDuration: HH:MM:SS.ssto stderr (then exits non-zero, no output requested, which Probe ignores), so afmpeg parses that. This is module-agnostic — it needs only ffmpeg, not a separate ffprobe entrypoint — and is validated against the real module (integration_test.goprobes a rendered file). - D-0004-B — concurrency (resolves 0001 D-D). Proposed: one invocation at a time per
Runtime(a guarding mutex; compilation shared, instantiation per-Run). ARuntimePool(N module instances for parallel renders) is a documented follow-up (0006), not v1. Rationale: wazero module instances are cheap to instantiate from a sharedCompiledModulebut ffmpeg's single-threaded encode means parallelism is bounded by CPU anyway; keryx renders one reel at a time. - D-0004-C — module acquisition. Since the GPL wasm is not embedded (D-C),
Newrequires one of theWithModule*options; there is no implicit default fetch in v1 (avoids a surprise network call). A separate helper (or 0006) may add download-and-cache later.
5. Requirements¶
R-0004-1(R-AF-1) Builds withCGO_ENABLED=0and cross-compiles (linux/macOS, amd64/arm64) — a hard CI gate.R-0004-2(R-AF-4)Runreturns the exit code + stderr; a non-zero exit surfaces ffmpeg's error tail — no silent failures.R-0004-3(R-AF-5)Probereturns input duration over the fs bridge (keyrx VO timing).R-0004-4(R-AF-8) A cancelledctxaborts a running invocation promptly (wazeroCloseWithExitCode/context close), returning a context error.R-0004-5Newcompiles the module once;Runreuses theCompiledModule(compilation is the expensive step — 0001 §4).R-0004-6The module is loaded via aWithModule*option, never//go:embed(D-C); the package builds and tests without the GPL wasm present (using a stand-in/stub for unit tests).R-0004-7OneRunat a time perRuntime(D-0004-B); concurrentRuncalls serialise safely (no data race —go test -race).R-0004-8The R-AF-3 end-to-end: with a real ffmpeg module,Runexecutes a real ffmpeg command against aMemMapFsand produces a valid mp4 read back in memory — no host fs access (composes 0003's R-AF-2).R-0004-9(Added 2026-06-27, via the spec-0002 de-risk.) The runtime can load a real ffmpeg.wasm. A real FFmpeg build needs more than plain WASI: it imports anenvhost module providing__wasm_setjmp/__wasm_longjmp(FFmpeg uses C setjmp/longjmp, which WebAssembly lacks; the build lowers them to host calls), and it requires the WebAssembly core feature set SIMD + bulk-memory + non-trapping-float + mutable-global + reference-types + sign-extension + extended-const + tail-call.Newenables that feature set and registers theenvmodule (setjmp.go), implementing setjmp/longjmp with wazero's experimental Snapshotter — original code, no GPL imported.
Resolved via de-risk (2026-06-27)¶
R-0004-9 was discovered by wiring an interim real module (go-ffmpreg's stock ffmpreg.wasm,
GPL — used only as a runtime test fixture, never a dependency) and finding a plain-WASI
runtime cannot even instantiate it. With the env module + features added, afmpeg's own
New/Run transcodes testsrc → an H.264 (libx264) mp4 entirely in a MemMapFs, no host fs
(ffmpeg n5.1.9). This closes the project's biggest integration risk before 0002 builds the
real artifact. The validation lives as a gated integration test (AFMPEG_TEST_FFMPEG_WASM).
6. Test strategy (TDD)¶
- Unit (no real wasm): a stub/stand-in module exercises
New/Run/Closewiring, option handling, stderr capture, exit-code surfacing, ctx-cancel, and the serialise-on-Runguard (-race). The vfs bridge is 0003 (already tested); here it's composed. - Integration (env-gated via
AFMPEG_TEST_FFMPEG_WASM=/path/to/ffmpeg.wasm,integration_test.go): load a real ffmpeg.wasm and run a transcode overMemMapFs, assert a valid mp4 and no host-fs access. Skips when the env var is unset, so the default unit run stays fast and module-free. Implemented and passing against go-ffmpreg's stock build. - Coverage ≥90% on new
pkg/afmpegcode (excluding the gated integration path).
7. Definition of done¶
pkg/afmpegNew/Run/Probe/Closeimplemented to the §3 signatures.- Unit tests green under
-race; the gated integration test transcodes end-to-end in memory. CGO_ENABLED=0 go build+ cross-compile verified in CI (R-0004-1).- Package doc + a runnable example (the 0001 §4 usage snippet) compile (
Exampletest). - D-0004-A/B/C confirmed in review and recorded here.
8. Sequencing¶
Depends on 0003 (the sys.FS interface) for the bridge and 0002 (the real module)
for the gated end-to-end test — but the unit layer can be built against 0003 + a stub module
before 0002 lands. Blocks 0005.