- filmcraft_media::cancel: a per-job cancellation flag passed through a thread-local, so the
MediaSource API is unchanged. Frame workers run each job inside it.
- FrameServer::retain_queue (called by schedule_playback every refresh) now also cancels running
jobs for frames the playhead has passed. Before, a worker blocked on a source's decoder lock
went on to decode its frame after playback had moved 50–100 frames on, found it evicted and
restarted the decoder at the keyframe (decoder trace: "seek i=32 key=0 next=142").
- GopCache gives up (CodecError/MediaError::Cancelled) before seeking or decoding for a cancelled
job and between samples; decoder state stays consistent, so the next request carries on.
- The CPU compositor checks the flag between tracks and effects; cancelled results are never
cached.
- Stop drops queued prefetch jobs and cancels running ones (FrameServer::stop_prefetch). They
used to run to completion after Stop, e.g. 14 frames of live effects rendering competing with
a Render In to Out started right after.
bench, same-session A/B (GPU path, 8 s, load avg 35–41):
after-preview Full live play (1080p + Lumetri/Sharpen/Levels/Tint, CPU-bound):
38/154 → 98/94 shown/dropped, 297 → 267 ms CPU/frame, 84 jobs cancelled
after-preview plays from previews, h264-1080, stack3, h264-2160: unchanged within noise
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Root causes (seen with the bench's decoder counters):
- A frame-threaded H.264 decoder holds 16–32 pictures in flight. GopCache only continued the
running decoder when it was at most 16 samples past the wanted sample, so a request for a frame
that had been fed but not output yet restarted decoding at the keyframe: 3–13 extra seeks per
8 s play at 4K, each re-decoding up to a GOP. It now tracks the highest pts output since the
restart and continues while the wanted frame is still inside the decoder.
- The byte budget (384 MB) held 31 4K frames, less than the decoder's lead plus the prefetch
window, so frames were evicted before they were shown and re-decoded. The cache now always has
room for 64 frames.
- A request just past the next sync sample restarted there, skipping frames playback still
wanted, which then forced a restart at the earlier keyframe. A running decoder now carries on
through up to 48 samples into the next GOP.
- Intra-only streams (ProRes render previews, MJPEG) decoded one frame at a time under the cache
lock and re-fed up to 64 samples per miss. VideoDecoder::intra_only() lets GopCache decode them
outside the lock with a pool of decoders, one sample per frame.
Output is unchanged (same decoder, same frames); unit tests cover the in-flight, continue-through
and parallel intra cases.
bench (GPU path, 8 s, same-session A/B, load avg 70–120 on 14 cores):
h264-2160 Full: 0–12 → 71–171 shown of 192; decoder seeks 7–10 → 1; 229–355 → 175 ms CPU/frame
h264-2160 Half: 0–15 → 94–159 shown; seeks 6–13 → 1; 247–524 → 173–185 ms CPU/frame
after-preview Full, 1st play after render: 86/106 → 192/0 shown/dropped, 272 → 26 ms CPU/frame,
1716 → 182 samples decoded; 2nd play: 135/57 → 192/0
h264-1080 and stack3 unchanged (1 and 3 seeks before and after)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- crates/ui-egui/examples/bench_playback.rs drives the Program monitor's own FrameServer,
prefetch (now FrameServer::schedule_playback, shared with the monitor) and PlaybackMeter at a
simulated 60 Hz display, optionally compositing on a headless wgpu device. Scenarios:
h264-1080, stack3 (3 × 1080p, scaled/rotated/opacity), h264-2160, demo, after-preview
(live play → Render Effects In to Out → 2 plays), seek-storm. Reports latency percentiles,
on-time ratio, worker/decode wall and thread-CPU time, UI-thread present time, process CPU per
frame, decoder seeks/samples (codecs::gop_stats) and wasted jobs; --json for machine output.
Fixtures are generated with ffmpeg into target/fixtures/playback.
- FrameServer records per-job timings (thread CPU via rustix clocks) while profiling is on.
- Dropped/shown were counted per UI refresh: "shown" counted every refresh that had the frame
(322 "shown" in an 8 s play of 192 frames) and "dropped" counted a frame that arrived a few ms
after its first refresh. PlaybackMeter counts timeline frames: shown when the exact picture was
on screen while due, dropped otherwise, including frames passed over without a refresh.
Baseline (GPU path, 8 s, load avg 104–168 on 14 cores; CPU columns are load-robust):
h264-1080 Full 191/1 shown/dropped, 46 ms CPU/frame; Half 188/4
stack3 Full 189/3, 102 ms CPU/frame; Half 157/35 (3 extra decoder seeks)
h264-2160 Full 8/184, 313 ms CPU/frame, 10 seeks; demo Full 114/78, Half 191/1
after-preview: live 0/192; 1st play after render 124/67, 2nd 119/73 (UI-thread present
p50 21 ms / p95 45 ms); seek-storm: 0 of 40 seeks shown within 150 ms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/codecs decodes Opus with filmcraft-opus (48 kHz output):
- Matroska/WebM A_OPUS: CodecPrivate = OpusHead. CodecDelay removes the
pre-skip exactly (header pre-skip when CodecDelay is absent); packet starts
are accumulated from TOC durations because block timestamps are only
tick-accurate. Random access decodes max(SeekPreRoll, 320 ms) first.
- MP4/MOV Opus + dOps: edit list removes pre-skip (dOps pre-skip without
one); same pre-roll on random access.
- Family-1 surround reordered from Vorbis to WAV/SMPTE channel order;
corrupt packets are concealed instead of failing.
Tests (ffmpeg/libopus fixtures, skipped when absent): WebM stereo, MKV mono
VoIP (SILK/hybrid), MKV 5.1 multistream, MP4 stereo; sample-aligned against
libopus (52-89 dB sequential, 45-89 dB after random access).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>