Watch job progress¶
Long jobs (a full-file transcode, a large remux) run for seconds or minutes. afmpeg.WithProgress
lets you receive live progress while the job runs: a completion fraction, byte counters, and
(on a v9+ engine) the frame count, media time, and encode speed, so you can drive a progress bar.
It works on any Run, RunJob, or Frames call, needs no special module, and adds nothing to a
job that doesn't ask for it.
How it works¶
afmpeg gathers progress two ways and merges both onto the one channel:
- Phase A, observed filesystem (any module, either backend). afmpeg implements the filesystem
the engine reads and writes through, so it watches bytes flow (input consumed, output produced),
without the engine emitting anything. The completion
Fractionis the input read position (bytes_read / input_size), which tracks a linear demuxer closely. Spec 0031 phase A. - Phase B, the engine side-channel (a v9+ ffmpeg-wasi engine, WASM backend). When the module
supports it, setting
progress:truemakes the engine emit NDJSON records to a/dev/afmpeg-progressdevice afmpeg serves, fillingFrame,OutTime, and a host-derivedSpeed. When the engine also reports the media duration (n8.1.2-10+),Fractionis derived fromout_time / duration, accurate even for a generative input with no file to measure. Spec 0032.
Both feed the same Progress value: afmpeg turns on the engine channel automatically when you
attach WithProgress, phase-B records refine the phase-A samples as soon as the first one
arrives, and it falls back to byte progress on an older engine.
When both sources are live the engine's is authoritative, because it measures the work that actually
remains, where the byte source only measures input already read. Source on each sample tells you
which one produced the number. Spec
0034.
Attach a channel with WithProgress¶
Progress is per-invocation (a Runtime is shared and serialises its calls), so you attach the
channel to the call's context, not to New. afmpeg sends on the channel while the job runs and
stops when the call returns; it never closes your channel.
ch := make(chan afmpeg.Progress, 64) // buffered: see back-pressure below
// Drain in a goroutine while the (blocking) Run executes. Switch on Source:
// it tells you what the number is worth, so you don't have to infer it.
go func() {
for p := range ch {
switch p.Source {
case afmpeg.SourceEngine: // the engine's own clock — trust this one
fmt.Printf("\r%.0f%% frame=%d t=%s %.1f×realtime",
p.Fraction*100, p.Frame, p.OutTime.Round(time.Second), p.Speed)
case afmpeg.SourceBytes: // input consumed; good when the inputs dominate
fmt.Printf("\r%.0f%% out=%d bytes", p.Fraction*100, p.OutputBytes)
default: // SourceUnknown — Fraction is -1; show an indeterminate spinner
fmt.Printf("\rworking… out=%d bytes %s",
p.OutputBytes, p.Elapsed.Round(time.Second))
}
}
}()
ctx := afmpeg.WithProgress(context.Background(), ch)
res, err := rt.Run(ctx, fs,
`{"op":"process","inputs":[{"path":"in.mov"}],"outputs":[{"path":"out.mp4","video_codec":"libx264","audio_codec":"aac"}]}`)
close(ch) // safe once Run has returned — afmpeg has stopped sending
if err != nil {
return err
}
The same ctx works with the typed builder and the frames op:
res, err := rt.RunJob(afmpeg.WithProgress(ctx, ch), fs, cmd)
out, err := rt.Frames(afmpeg.WithProgress(ctx, ch), fs, job)
The Progress value¶
type Progress struct {
Fraction float64 // completion in [0,1], or -1 when unknown
Source FractionSource // how Fraction was derived: unknown / bytes / engine
Elapsed time.Duration // since the invocation began
InputBytes int64 // bytes read from inputs so far
InputTotal int64 // total input size, 0 if unknown
OutputBytes int64 // bytes written to outputs so far
// Populated once a phase-B engine record has arrived (a v9+ engine on the WASM
// backend); zero before then, and on an engine that emits nothing:
Frame int64 // frames processed
OutTime time.Duration // media timestamp reached
Speed float64 // ×realtime encode speed (host-derived: OutTime/Elapsed)
}
Fraction is clamped to [0,1] and never decreases across a run, even when a demuxer seeks
backwards. Show a determinate bar when Fraction >= 0; fall back to an indeterminate spinner (and
use OutputBytes / Elapsed) when it is -1.
Source says where the number came from, so you can decide how far to trust it:
Source |
Meaning |
|---|---|
SourceEngine |
Engine-derived from out_time / duration. Accurate regardless of input size. Preferred. |
SourceBytes |
Byte-observed at the filesystem bridge (bytes_read / input_size). Good when the inputs dominate the work, poor when the encode does. |
SourceUnknown |
Fraction is -1. |
Fraction == 1 is not a completion signal
Use the invocation's return to know a job finished. afmpeg deliberately reports -1 rather
than 1.0 when the inputs are exhausted but the job is still encoding: a render whose inputs
are small next to its output (a few cards and a music bed becoming a 30 s reel) consumes every
input byte in the first moments. Treating 1.0 as "done" would show a full bar for almost the
whole render.
Back-pressure, and why you cannot slow the job down¶
Delivery is best-effort: afmpeg sends with a non-blocking send, so a slow or non-draining consumer simply misses intermediate samples. The job is never blocked or altered. Give the channel a modest buffer and drain it promptly for the smoothest bar. A job run with a channel nobody reads produces the identical result as one with no channel at all.
Limits¶
Fraction is a completion fraction, not a wall-clock ETA. Its quality depends on what the engine
tells afmpeg:
- Byte-only fallback. Without a phase-B duration (a pre-v9 engine, or the native backend below)
Fractionis byte progress (bytes_read / input_size), reported asSourceBytes. Seek-heavy inputs (e.g. an MP4 whose index sits at the end) make it lumpy, and it is clamped so it never goes backwards, but it can jump. The denominator is the total size of the inputs the job spec declares, fixed before the run starts, so a concat job measures against the whole set rather than the parts opened so far. - Encode-bound work. When the output is long relative to the inputs, the byte source runs out of
signal: every input byte is read while most of the encode remains.
Fractionreports-1for that tail rather than a saturated1.0. On an engine that reports the media duration you get a real number throughout instead. This is the case whereSourceEnginematters most. - A brief
-1at startup. When engine progress is expected,Fractionis-1until the engine's first record arrives rather than showing a byte ratio the engine would immediately contradict. - Generative inputs (a filter source with no input file) report
Fraction == -1on the byte-only path; on a v9 engine at n8.1.2-10+ the engine reports the media duration, soFractionis accurate even there (theout_time / durationderivation). - The native backend (use the native backend) reports phase-A byte
progress (
Fraction,InputBytes,OutputBytes; its IPC I/O crosses the same filesystem boundary), but not the phase-B engine record: itsFrame/OutTime/Speedstay zero, because the/dev/afmpeg-progressdevice is served by the WASM backend only. OutputBytesis bytes written, not file size. A muxer that seeks back to patch its header writes those bytes twice, so the final counter sits slightly above the output's length.
Every field of Progress, and the exact list of cases where Fraction is -1, is in the
results reference.