Repository navigation
feat(live recorder): a recorder where a quiet track holds nothing up - #1034
Conversation
90a9d11 to
63db84e
Compare
|
Comment by Claude (AI), on behalf of @wagenet. An audio-only The fix is one template structure, with a test ( |
|
Comment by Claude (AI), on behalf of @wagenet. I ran the stall cases from #927 against
Holds up:
Each of these either fails or starts a new file on the Recorder. Found:
Minor: a resolution change does not fail the flow. That covers the SRT rejoin at 1280x720 on the rig, and mp4, mkv and ts in the scratch tests. The header keeps the first size, but GStreamer and ffmpeg both decode the new one. I have not tried QuickTime or an editor on those files. Not a recorder input: WHIP with Not run: Linux, GStreamer 1.24, and real network loss on WHIP. |
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment. The mechanism is sound, and the 15 new tests ran green in CI. Two things a human has to decide are open: the fate of the three Recorder PRs this replaces, and a git fork in the build. One correctness gap reported in-thread is real in the code and not yet fixed.
Requested changes
- Wait for a keyframe after a quiet video track. @wagenet's run reported this in-thread and I confirmed it in the code.
wait_keyframeis set only for a buffer that arrives behind a GAP:backend/src/blocks/builtin/live_recorder/keepalive.rs:254—if pts < track.gap_until_ns.load(Ordering::Relaxed) {. Frames lost upstream come back with new PTS. They pass that check, and delta frames reach the file. With splitting on, a file can open on them. The comment's suggestion is the smallest fix: setwait_keyframewhen the keepalive sends a video GAP, and send a force-key-unit upstream. Add a guard inlive_recorder_test.rs. - Cherry-pick 195cf41 from #1042. Audio-only
mkvfailsnot-negotiatedbecause the sink template takes onlyvideo/x-matroska. The fix and its test already exist on #1042.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| The per-buffer probe obeys the BUFFER-probe rules | CONFIRMED |
backend/src/blocks/builtin/live_recorder/keepalive.rs:243 — queue_sink.add_probe(gst::PadProbeType::BUFFER, move. Atomics and Instant only: no lock, no allocation, no formatting |
| Probes and the keepalive thread hold no strong pipeline references | CONFIRMED |
Track holds WeakRef input, queue and mux pad. The thread exits when mux.upgrade() fails, i.e. when the pipeline is dropped |
| A GAP never blocks on a parked upstream push | CONFIRMED |
backend/src/blocks/builtin/live_recorder/keepalive.rs:310 — if queue.property::<u32>("current-level-buffers") > 0 { |
live_recorder_test ran in CI |
CONFIRMED |
Check (Linux) job 112880295986: 15 passed. It also logs 47 gst_mini_object_unref CRITICALs. The same line appears on main-based jobs, so it is not attributed to this diff |
Diagnosis. This is the right layer. splitmuxsink's keep-EOS-forever semantics are the root cause behind #757, #762, #927 and #967, and a live aggregator removes them. It does not suppress them. Coverage is BOUNDED:
- tracks later than
NO_DATA_TIMEOUT(20 s) are not recorded; - a stop before that release writes no file (in-thread item 3);
- MPEG-TS loses PCR across a video stall, so
tsdemuxmisreads the duration; mkvneeds GStreamer 1.26.
The PR states all four. The queue sizes are not defaults, which CLAUDE.md allows only with a documented reason. The reason is documented at backend/src/blocks/builtin/live_recorder/mod.rs:363, and it is a real buffering requirement.
Radius. SHARED. The block is new, but the change also touches:
- the shared
recorder_split_nowpath; - the properties panel;
[patch.crates-io]on a personal fork: the rootCargo.toml(gst-plugin-isobmff = { git = "…/srperens/gst-plugin-isobmff", tag = "v0.15.4-strom1" }).
That fork now builds into every release. The maintainer has to accept it, together with the plan to drop it.
Overlaps.
- #927, #762, #967 patch the Recorder's stall and stop path. This block makes them unnecessary if it replaces the Recorder. Options: land this and close those three; land both and keep the Recorder; or wait. Only a human can choose.
- #1042 (draft) is stacked on this branch.
- #1002 and #1036 both rewrite
Cargo.lock. Regenerate the lock against whichever lands first.
Tests & CI. All Linux checks are green at 63db84e. macOS and Windows were skipped. The statically linked isofmp4mux and the fork are the platform risk, so add the ci:macos and ci:windows labels before merge.
Confidence: HIGH
…othing up A new block, builtin.liverecorder, next to the Recorder. It records to fragmented MP4 through isofmp4mux, a live aggregator, instead of splitmuxsink: - A track that stops does not stop the others or anything upstream, and continues in the same file when it returns, with a gap where it was missing. No watchdog, no ended tracks, no new file per stall. - A quiet track gets GAP events, so the muxer keeps cutting fragments while the video is gone and the audio keeps reaching the disk. - A connected track that carries nothing for 20 s is released from the muxer. Until then the other tracks wait in their queues, which answer ALLOCATION queries themselves, so an encoder is never held up. - A small BaseSink writes the fragments and starts a new file at a fragment boundary, with the init segment first. A split due at the muxer's last fragment keeps that fragment in the current file. - The sink is not async, so a late first buffer does not pull the flow out of PLAYING. Experimental: mp4 only, and a track that arrives after the 20 s release is not recorded until the flow restarts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each split file now starts at zero. The muxer numbers time through the whole recording, so the second file said it started at 38 s and players showed a 46 s file holding 8 s. The sink moves every tfdt in a file back by the file's start, the same instant for every track, in each track's own timescale. Values change in place; no box changes size. A flow stop no longer drops the fragment the muxer is still holding. When the pipeline is on its way to READY or NULL, the sink sends EOS into each track's queue at its PLAYING -> PAUSED step, while it still renders, and waits up to 3 s for the muxer to hand the rest over. A pause does not trigger it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Live Recorder now takes the Recorder's container property: - mkv: matroskamux, streamable. A file can start at a cluster that starts on a keyframe; the header comes from the caps' streamheader. The sink moves every cluster timestamp back so each file starts at zero. - mpegts: mpegtsmux with default alignment, so the keyframe's first packet is flagged and a file can start there after PAT and PMT from the streamheader. - ts_passthrough: the Recorder's own passthrough, unchanged. Both muxers are live aggregators, so a quiet track holds nothing up and comes back in the same file, as with MP4. Fragmented MP4 cannot carry MP3 or DTS; the refusal says to pick mkv or mpegts for those. The sink now buffers its writes and flushes at every cut point and at least every 500 ms, so the audio keeps reaching the disk while a stalled video provides no cut points. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A requested split was held back a fragment like a timed one, so the new file, and its name in the editor, showed up to two GOPs after the click. Only a split the policy makes can coincide with a stop and leave the muxer's tail alone in a file; one the operator asks for goes at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gst-plugin-isobmff 0.15 builds gstreamer-audio with v1_26 for a single function, gst_audio::reorder_channels_with_reorder_map(), so Strom stopped building on Ubuntu 24.04 (GStreamer 1.24.2): Linux CI and the Linux release builds, whose binaries install.sh puts on the distro's GStreamer. The fork (srperens/gst-plugin-isobmff, tag v0.15.4-strom1) is the published 0.15.4 source plus one commit that does that reordering in Rust and builds gstreamer-audio with v1_18. Nothing in the dependency tree enables v1_26 any more. Drop the patch once upstream no longer needs 1.26. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng events Linux CI (GStreamer 1.24.2) showed two things the macOS runs (1.28) could not: - matroskamux became a GstAggregator only in 1.26. Before that it is GstCollectPads-based and waits for every track, so a stalled video kept the audio out of the file, the freeze this block exists to avoid. The block now refuses mkv on older GStreamer with an error that names what is needed and to use mp4 or mpegts. The mkv tests check that refusal on older GStreamer and the behaviour on 1.26 and later. - The retired-track probe dropped events. Before GStreamer 1.24.8 a dropped event is freed twice, which logged gst_mini_object_unref CRITICALs. Events are now discarded with Handled; buffers are still dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
matroskamux labels a recording without video audio/x-matroska, which the fragment sink's template refused, so an mkv Live Recorder with no video tracks failed with not-negotiated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ataless track held back From @wagenet's run of the #927 stall cases on #1034: - Frames lost upstream of the recorder leave the first frames back as deltas against frames the file never got; with a split, a file could open on them. When the keepalive first fills a video track's gap, the track now drops until a keyframe and asks upstream for one with a force-key-unit. An encoder that went on encoding through the stall used that request up, so it is asked again when frames start arriving and are still being dropped. A Strom encoder then answers with its next frame instead of a GOP later. - A stop before a connected but dataless track was released wrote no file: the drain only ran once a file was open. It now always sends EOS, which also ends the muxer's wait for the dataless track. For that drain to get its data through, the fragment sink is now a plain element with a sink pad rather than a BaseSink. A BaseSink with no buffer by PLAYING still counts as not prerolled, and a first buffer arriving while the pipeline heads for PAUSED waited in preroll for a PLAYING that never came. The sink never synced to the clock, so nothing else changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mpegtsmux puts the PCR on the first video stream, so a video stall left a hole in the clock reference as long as the stall. The file's PTS were right, but tsdemux rebases across a PCR gap: in @wagenet's run a 10 s recording with a 3 s video stall read as 7.4 s, audio included, for any GStreamer-based reader, Strom's media player among them. prog-map PCR_1=(int)0x100 gives the program a PCR-only PID, which mpegtsmux keeps writing whatever the tracks do. Supported since before GStreamer 1.24. The mpegts stall test now checks the gaps and coverage through tsdemux like the other containers; without the PCR PID it reads a largest video gap of 0.47 s for a 4 s stall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
63db84e to
3fc29b6
Compare
|
Comment by Claude (AI), on behalf of @srperens. Thank you, this is exactly the testing it needed. All four are in, each with a test that fails when the fix is reverted:
The resolution change you noted (header keeps the first size) is left as it is for now. All 18 Live Recorder tests pass on macOS 1.28. CI runs the Linux 1.24.2 set on this push. |
Refs #927, #762, #967, #932, #938
Written by Claude (Claude Code). Experimental block, opened as a draft.
Problem
The Recorder writes every track through one
splitmuxsink.splitmuxsinkreleases a GOP only once every track has passed it, so a track that stops freezes the whole recording, and through the tee in front of the recorder it backs up the source's other branches too. The stall watchdog (#757) ends the stopped track with EOS. That EOS is permanent:splitmuxsinkkeeps it on the pad, and mp4mux takes no new pads once a file has started. One stall of the program video left a recording audio-only for three hours.Open PRs work around this inside
splitmuxsink:splitmuxsinkto NULL, requests fresh pads and replays. It adds about 1000 lines and has already turned up Recorder: a video track that changes codec fails the flow #931, Recorder: mp4 with max_size_time fails the flow on a long video stall #932 and Recorder: a video left out of a file switch keeps a break it should drop #939.Other problems sit next to it:
Change
A new block,
builtin.liverecorder(Live Recorder), next to the Recorder. It has the same pads and properties (tracks, container, output directory, prefix, split by time and size, auto-stop, split now, file events, download). If it proves more stable, the Recorder can be deprecated later.splitmuxsink:isofmp4mux(gst-plugins-rs, linked statically like the WebRTC plugins),matroskamuxormpegtsmux. A quiet track does not stop the others or anything upstream. When it comes back it continues in the same file, with a gap where it was missing. There is no watchdog, no ended track, and no new file per stall.FragmentFileSink, a small plain element with a sink pad (not aBaseSink, whose preroll held a first buffer that arrived during a stop):tfdtand Matroska cluster timestamp back so every file starts at zero.PipelineManager(compare fix(recorder): record a stalled track again once it resumes #927 and fix(recorder): finish the current file when the flow stops #967). A pause does not trigger it.prog-mapPCR_1). On the video PID, a video stall left a hole in the clock reference and tsdemux rebased across it.ts_passthroughreuses the Recorder's own code.Rejected:
splitmuxsink(fix(recorder): record a stalled track again once it resumes #927). It works againstsplitmuxsink's semantics.isomp4mux/ plainmp4muxdirectly. Measured, they freeze likesplitmuxsink.Measured before writing it (macOS, GStreamer 1.28.6, gst-plugins-rs 0.15.3), with a live H.264 + AAC source and one track's buffers dropped (no EOS):
splitmuxsink+ mp4muxEvidence
Every row is a test in
backend/tests/live_recorder_test.rs, built throughLiveRecorderBuilder, except where noted.an_audio_stall_holds_nothing_up_and_audio_returns_in_the_same_filea_video_stall_keeps_the_audio_written_to_disknothing reached the file while the video was stalled: 351901 bytes at 4s, 351901 at 8sa_connected_track_that_never_carries_data_does_not_block_the_recordingthe video source was held up while the muxer waited for audio: 0 frames in 5s; no ALLOCATION answer: hangssplit_files_each_play_on_their_owntfdtshift:..._00001.mp4 starts at 0:00:02.647301587a_stop_without_eos_still_writes_the_last_fragmentvideo/x-h264 spans 0:00:04.633333333 of a 5.505041709s recordinga_pause_does_not_end_the_filefragment_sink::a_split_due_at_the_last_fragment_keeps_it_in_the_current_filefragment_sink::split_now_starts_the_next_file_at_the_next_fragmentcontainers::mkv_split..._00001.mkv starts at 0:00:02.004000000containers::mpegts_stalls_and_stopnothing reached the file while the video was stalled: 1376348 then 1376348 bytesa_stopped_pipeline_is_freeda_video_that_comes_back_resumes_on_a_keyframe_without_waiting_a_gopthe first video frame after the stall is a delta frame, at 0:00:05.004; no request:no video after the stalla_stop_before_a_dataless_track_is_released_still_writes_the_filethe stop wrote no file: []containers::mpegts_stalls_and_stopthe video stall should show as a gap, largest 0:00:00.466666666containers::mkv_audio_only_recordsaudio/x-matroska: not-negotiatedUnit tests cover the
tfdtand cluster timestamp rewrites (mp4_boxes,mkv_cluster).Real server, by hand (macOS, debug build, headless). The flow was test sources → x264/AAC →
valve→ Live Recorder, with the valves toggled through the property API.The same flow on the Recorder:
Split files, opened by the maintainer:
Tests
Ran, on macOS 26 arm64, GStreamer 1.28.6, on the final head after rebasing onto main:
STROM_REQUIRE_GST_PLUGINS=1 cargo test --test live_recorder_test: 15 passed, run three times across rebases, every run green.cargo test --lib: 903 passed.openapi_test,pipeline_lifecycle_test,recorder_testandrecorder_stall_testall passed.cargo clippy --all-targets -- -D warningsis clean, and the pre-commit hook passed (including wasm32 clippy).Skipped: none (
STROM_REQUIRE_GST_PLUGINS=1). The new tests needisofmp4mux, which is linked in, plusqtdemux,matroskamux,matroskademux,mpegtsmuxandtsdemux, which are in the CI package list.Linux CI (
Check (Linux), GStreamer 1.24.2) ranlive_recorder_test: 15 passed, includingcontainers::mkv_*, which there check the refusal below.That run logs 47
GStreamer-CRITICAL: gst_mini_object_unref: assertion 'mini_object != NULL'lines fromlive_recorder_test. None appear on 1.28. They are not yet attributed. In an Ubuntu 24.04 container, neither answering ALLOCATION queries in a probe nor sending GAP events into an aggregator reproduces them. Other test binaries on main log the same line on 1.24.2.Not run:
For the reviewer
gst-plugin-isobmff0.15, registered statically inmain.rslike the other gst-plugins-rs crates. It is taken from a fork through[patch.crates-io]: srperens/gst-plugin-isobmff, tagv0.15.4-strom1.gstreamer-audiowithv1_26for one function,reorder_channels_with_reorder_map()(raw multichannel PCM only), so Strom stopped building on Ubuntu 24.04 (GStreamer 1.24.2). That broke Linux CI and the Linux release binaries thatinstall.shputs on the distro's GStreamer.reorder_channels(). Nothing in the tree enablesv1_26any more.change_stateto drain on the way to READY/NULL.build_ts_passthroughinrecorder.rsbecamepub(crate)so it can be reused.recorder_split_nowalso finds the Live Recorder's sink, and the properties panel shows the recorder status for both blocks. The API and WebSocket contracts are unchanged. The block emits the existingRecorderFileChangedandRecorderAutoStop.Out of scope
matroskamuxbecame a live aggregator in 1.26. Before that it waits for every track, likesplitmuxsink, so the block refusesmkvon older GStreamer and points to mp4 or mpegts. MP4 (with the fork) and MPEG-TS work on 1.24.🤖 Generated with Claude Code