0026 — Render progress: true percentage streaming¶
Status: IN PROGRESS — Phase A (native ffmpeg) IMPLEMENTED 2026-07-11; Phase B
(in-memory afmpeg) still blocked on afmpeg#1
(unimplemented as of afmpeg v0.9.0 — its stderr is still buffered).
Date: 2026-07-05 (spike); 2026-07-11 (Phase A)
Source: backlog item S3; follows the "progress percentage (elapsed-only v1)"
deferral in 0017-studio-render-preview.md §5.
1. Problem¶
Render currently reports elapsed time only — the studio job surfaces
elapsed_ms + a best-effort eta_ms, and the CLI reel build shows no live
progress at all (it blocks, then prints the result). There is no true percentage
because neither surface reads ffmpeg's own progress signal.
2. Spike finding — feasible on both backends, no fundamental blocker¶
ffmpeg emits machine-readable progress via -progress pipe:2: periodic
key=value blocks (out_time_us=…, then progress=continue / progress=end),
flushed per update. The reel's total duration is known upfront (the Timeline),
so percent = out_time / total.
The two render backends differ only in how that stream is tapped:
- Native ffmpeg (
internal/render/ffmpeg) — shells out. Today it usesexec.CommandContext(...).CombinedOutput()(blocking, buffered). Switching to a streamedexec.Cmdwith a stderr pipe read in a goroutine exposes the progress blocks live. No external dependency. - afmpeg (
internal/render/afmpeg, the DEFAULT in-memory wasm backend) — runs ffmpeg-wasi via wazero. wazero wires the guest's stderr to a Goio.Writer(ModuleConfig.WithStderr) and calls that writer as the guest emits (per WASIfd_write), not just at the end. So streaming is possible — but afmpeg v0.4.0 hardwires a privatebytes.Buffer(runtime.go invoke()) and only exposes the finalResult.Stderrstring. A small afmpeg change is required to tee that stderr to a caller-provided writer.
Crucial distinction from the R1 silencedetect blocker: the afmpeg change here
is pure Go / wazero plumbing — no new wasm/C recompile. That makes it small and
low-risk, unlike R1 (which needs a rebuilt ffmpeg-wasi module).
3. Design¶
3.1 Shared progress parser + seam¶
A single out_time→percent parser (keyed on the Timeline total) serves both
backends. Surface progress as an optional capability (mirroring the
SilenceDetector pattern), e.g. a Progress func(Progress) callback carried on the
request or a RenderWithProgress method — a backend that can't stream simply never
calls it (graceful degrade to elapsed/ETA).
3.2 Backends (the goroutine/channel bridge)¶
- Native: run the streamed
exec.Cmd; a goroutine reads the stderr pipe, parses each progress block, invokes the callback (or pushes on a channel the caller selects on alongside the render-done signal). - afmpeg: pass
-progress pipe:2inbuildCommand; runRunJobin a goroutine; the tee'd stderr writer (from the afmpeg FR) parses blocks and pushes percent on a channel; the caller selects on it until RunJob returns.
3.3 Surfaces¶
- CLI
reel build: render on a goroutine; print a\r-updatedrendering… NN%line (falls back to a spinner/elapsed when no progress stream). - Studio job: add a
percentfield to the render job, fed by the callback — augments/replaces the best-efforteta_mswith a real percentage (updates0017§5's "elapsed-only" note once landed).
4. Dependency — afmpeg feature request¶
Blocked on afmpeg exposing a streaming stderr writer, e.g. a
WithStderrWriter(io.Writer) option that tees the guest stderr (keep the buffered
Result.Stderr for back-compat: io.MultiWriter(&buf, w)). Filed as
afmpeg#1; keryx will not
do that work itself. Target: afmpeg v0.5.0.
5. Sequencing¶
- Phase A — IMPLEMENTED (2026-07-11): shared parser
(
internal/render/progress), optionalprovider.ProgressRendererseam, native ffmpeg streaming (-progress pipe:2via an injectablestreamFunc), threaded throughbuild.RenderInto(type-asserts the backend, falls back toRender), the CLIreel buildlive percent line (TTY-gated, on stderr), and the studio render job'spercentfield surfaced inRenderPanel. The in-memory afmpeg backend does not implementProgressRenderer, so it renders without a percentage (elapsed-only) — a graceful degrade, no error. - Phase B (after afmpeg gains streaming stderr, afmpeg#1): the keryx afmpeg
adapter implements
RenderProgressby passing the tee writer +-progress→ the default backend lights up with no other keryx-side rework. Still blocked: afmpeg#1 is unimplemented as of v0.9.0.
6. Effort¶
~2.5–3 days total: afmpeg upstream ~½ day (+ release; not ours), native adapter ~½ day, afmpeg adapter ~½ day, seam + CLI + studio wiring ~1 day.
7. Open questions¶
- Callback-on-request vs a dedicated
RenderWithProgressoptional interface (lean: optional interface, matchingSilenceDetector). -stats_periodtuning — how often ffmpeg flushes progress blocks (default ~0.5s); fine for a UI, avoid over-frequent callback churn.- Studio: real
percentand keepeta_ms, or derive ETA from percent + elapsed?