Skip to content

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 uses exec.CommandContext(...).CombinedOutput() (blocking, buffered). Switching to a streamed exec.Cmd with 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 Go io.Writer (ModuleConfig.WithStderr) and calls that writer as the guest emits (per WASI fd_write), not just at the end. So streaming is possible — but afmpeg v0.4.0 hardwires a private bytes.Buffer (runtime.go invoke()) and only exposes the final Result.Stderr string. 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).

type Progress struct { Percent float64; OutTimeSec, TotalSec float64 }

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:2 in buildCommand; run RunJob in 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-updated rendering… NN% line (falls back to a spinner/elapsed when no progress stream).
  • Studio job: add a percent field to the render job, fed by the callback — augments/replaces the best-effort eta_ms with a real percentage (updates 0017 §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), optional provider.ProgressRenderer seam, native ffmpeg streaming (-progress pipe:2 via an injectable streamFunc), threaded through build.RenderInto (type-asserts the backend, falls back to Render), the CLI reel build live percent line (TTY-gated, on stderr), and the studio render job's percent field surfaced in RenderPanel. The in-memory afmpeg backend does not implement ProgressRenderer, 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 RenderProgress by 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 RenderWithProgress optional interface (lean: optional interface, matching SilenceDetector).
  • -stats_period tuning — how often ffmpeg flushes progress blocks (default ~0.5s); fine for a UI, avoid over-frequent callback churn.
  • Studio: real percent and keep eta_ms, or derive ETA from percent + elapsed?