Skip to content

feat(recording): record through Strom's Live Recorder - #489

Draft
wagenet wants to merge 19 commits into
Eyevinn:mainfrom
wagenet:wagenet/use-live-recorder
Draft

wagenet wants to merge 19 commits into
Eyevinn:mainfrom
wagenet:wagenet/use-live-recorder

Conversation

@wagenet

@wagenet wagenet commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Opened by Claude (AI) on behalf of @wagenet.

Stacked on #411. Review only the last two commits (1efe50b, 1c71eb5).

Records through Strom's Live Recorder (builtin.liverecorder, Eyevinn/strom#1034) instead of the Recorder, for the program recording and for each recorded input track. In Live Recorder a track that stops does not hold up the other tracks: it continues in the same file, with a gap where it was missing. The maintainers plan to deprecate the Recorder.

Properties, pads, file names and the RecorderFileChanged event are the same, so only the block type changes. Files are fragmented MP4.

Strom refuses to start a flow that names a block it lacks. On a Strom without Live Recorder, the program recording is left out with a new recording-unavailable activation warning, as input recordings already are, so the production still goes on air. There is no fallback to the Recorder.

Deactivate no longer splits each recorder before it stops the flow (1c71eb5). Live Recorder finishes its open file when the flow stops, so the split only added a trailing file of a few milliseconds per recorder and a wait of up to 10 s. It now stops the flow, then uploads.

startMs in recordings.json needs Eyevinn/strom#1057, which adds the start time to Live Recorder's RecorderFileChanged. Without it, files carry openedAtMs only, as before Eyevinn/strom#944.

Testing

  • pnpm typecheck; pnpm test: 1111 passed. New test: no Live Recorder gives no recorder blocks and a recording-unavailable warning.
  • Local rig, on 1efe50b: Strom upstream plus #1057, one WHIP guest recorded, and the program recorded. The guest's video was stopped for 8 s while its audio carried on:
    • guest video: one file, 79.9 s, an 8.2 s gap where the video stopped, resumes on a keyframe;
    • guest audio: 82.0 s, no gap;
    • program: 110.0 s, h264 + aac, no gap;
    • the flow stopped in 120 ms, and the files were complete;
    • every file has a startMs.

The rig run was before 1c71eb5, so the stop-only deactivate has not been run on the rig yet.

🤖 Generated with Claude Code

wagenet and others added 18 commits October 7, 2026 06:19
Strom's builtin.recorder numbers its inputs from zero (video_in_0,
audio_in_0). The generator linked PGM to `video_in`, which the recorder
does not have. Strom drops that link as stale when the flow is created,
so the recorder was built with its video track unconnected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recorder's property is `filename_prefix`. Strom ignores the unknown
`prefix`, so segments were named recording_00001.mp4 instead of
<production id>_00001.mp4. Output dir and upload key already carry the
production id, so nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recorder refuses raw audio ("Recorder needs AAC, MP3, AC-3, DTS or
Opus audio, but its audio input is raw"), and the audio mixer's main_out
is raw, so the recording had no audio track. Route main_out through a
builtin.audioenc (AAC) into the recorder's audio_in_0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Strom writes {filename_prefix}_{timestamp}_%05d.{ext}. Update the three
comments that describe the segment filename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
builtin.audioenc first shipped in Strom 0.6.9. An older Strom refuses to
start any flow that references it, so a production with a recording
output could not go on air. Activation now checks Strom's block list
and leaves the encoder out when the block is missing. The recording then
has picture but no sound, and activation saves a warning on the
production (activationWarnings) and logs it. If the block list can't be
read, activation keeps the encoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Studio shows WS ERROR frames as a toast, so activation warnings (such as
a recording with no sound) now go out that way: broadcast to connected
controllers when the production activates, and sent again to each
controller that connects while the production is live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A production that never went on air was left inactive with the warning
from the failed attempt still set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The warning toast is broadcast when the production goes active, and
not at all if activation was aborted. The connect-time frame needs
status 'active' as well as a stromFlowId, so an activating production
or one left with a stale flow id sends nothing. controller-websocket.md
documents the ERROR frames that carry activation warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recorder's output_dir was recordings/<production id> for every
activation, and deactivate uploads everything in it. A file left there
after its upload (its delete from Strom failed, or was skipped because
the production changed mid-upload) was uploaded again at the next
deactivate and registered as a new RecordingDoc.

Give each activation its own subdirectory, store it on the production at
activate, and read it at deactivate (falling back to the shared directory
for productions activated before this change). Derive RecordingDoc ids
from the object key so a retried deactivate conflicts instead of
registering the same object twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A per-activation directory is forgotten once its deactivate clears
recorderOutputDir, so a session whose upload or listing failed was never
uploaded. Deactivate now lists every activation directory under
recordings/<production id> and uploads each file whose RecordingDoc does
not exist yet. The directory name carries the activation start, which
becomes the RecordingDoc's startedAt; endedAt is the file's last write.

The idle watchdog, startup reconcile and activation-failure reset now
clear recorderBlockId and recorderOutputDir, and activation always writes
both. Before, a later activation without a recorder kept the stale block
id, and its deactivate split and uploaded the earlier session.

The sweep keeps delete-after-upload: once the isStillRecording guard
passes, it deletes each file that is in object storage, including files
skipped as already registered, and removes each directory that leaves
empty.

Strom's media listing marks directories with is_directory, not is_dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixture repeated makeActivationResult's field list. A branch that adds
a field to makeActivationResult (as Eyevinn#386 does) then merged into whichever
copy git matched, and left the other without it. Spreading the base keeps
one list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once uploadFiles sets abortedOnAuthError, uploadProductionRecordings skips
the remaining activation directories and the legacy shared directory: the
store would reject them the same way, each after a full download from Strom.
Files already in object storage are still deleted from Strom, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A source assignment's `record` ('off' | 'transcode' | 'passthrough',
default off) records that input on its own, beside the program
recording. Off unless set: a feed may already be recorded upstream, and
in a delayed production the mixer inputs are bridged out of the store.

- POST /productions/:id/sources takes `record`. 'passthrough' is not
  built yet and is answered with 400; activation skips, with a warning,
  an assignment that still carries it, and one whose input is not an
  arriving feed (WHIP, SRT, EFP).
- Recorders tap the input's decoded pads before the time_offset blocks,
  so they hold the feed as it arrived. The offset is a pad offset on
  running time; a live trim would make the recording's timestamps jump.
- One recorder per track, so picture and sound are separate files. An
  input may carry only one (an audio-only guest, a camera with no sound),
  and a two-track recorder waits for both and writes nothing; on Strom
  26ffa2fa that stalled recorder also stopped another input's recorder.
- Transcode: leaky queue → builtin.videoenc (H.264, 60-frame GOP) and
  leaky queue → builtin.audioenc (AAC). The queues keep encoding out of the
  input's streaming thread and drop frames rather than stall the tee that
  feeds the mixers.
- Files go to recordings/<production>/<activation>/<mixerInput>/ as
  <production>_<mixerInput>_{video,audio}_…; the program recording keeps
  the activation directory itself.
- Without builtin.audioenc or builtin.videoenc inputs record the other
  track; without the recorder or both encoders they are not recorded. Each
  case adds an `input-recording-incomplete` activation warning.
- ProductionDoc.inputRecorderBlockIds (per input, per track) is set on
  activate and cleared on every teardown path. Input files follow the
  program recording: with object storage, deactivate splits every recorder
  and the sweep uploads `video_in_N/` subdirectories, registering each file
  with its mixerInput and track (no outputId); without it they stay on Strom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Joining recordings to tally and tags needs each file's wall-clock start
and, for each input, its source and guest. Open Live now writes
recordings.json into the activation's recording directory on Strom
(POST /api/media/upload):

- per recorder (the program's, and each input track's): block id, output
  dir, and every file it opened, with `startMs`, the file's t=0 on Strom's
  pipeline clock in UTC ms, when RecorderFileChanged carries
  `start_utc_us`, and `openedAtMs`, when Open Live received the event.
  `startMs` is what lines up an input's separate video and audio files.
  Without it `openedAtMs` stands in: in a 200 s test it put the tracks
  50 ms early to 15 ms late (WHIP) and 50-80 ms late (SRT) against one
  two-track file, varying per file.
- per input: sourceId, source name, stream type, record mode, and the
  guest sessions on it during the activation with their invite labels.

The index subscribes to Strom's event stream before the flow is created
and binds to the flow once it exists, so a recorder that opens at once is
not missed. It rewrites the sidecar after each event and once more when
deactivate starts; it closes itself on FlowStopped/FlowDeleted, and a
failed or aborted activation closes its own handle. Each write and
each reconnect takes a current Strom token, since an OSC SAT expires
during a long show. With object storage
the sweep copies the sidecar to <prefix><production>/<activation>/
recordings.json without registering it as a recording.

docs/recording-inputs.md says what a separate reader, such as a store
writer, can rely on: one directory per activation with the sidecar at
its top, one track per file, inputs keyed by mixerInput, and a version
that changes only when a field is renamed or removed.

StromClient gains media.upload, an onOpen callback on connectWebSocket,
and the RecorderFileChanged/FlowStopped/FlowDeleted event shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…activate

Deactivate closed the recording index, split every recorder, then uploaded
while the flow kept running. The recorders wrote into the files the split
opened until teardown: minutes of media after deactivate, in files that
recordings.json did not list (on the meeting rig, ~5 min and 200 MB per
track while an unreachable object store timed out).

Deactivate now splits, waits until each recorder has opened its next file,
stops the flow, closes the index and then uploads. The wait matters: a split
lands on the next keyframe after splitNow returns, and Strom's stop does not
finalise an MP4, so stopping earlier leaves the pre-split file with its last
periodic moov, up to ~2 s short. The wait is bounded at 10 s; a recorder
that has not opened a file is not waited for.

Each recorder's last file now holds the few milliseconds between split and
stop and is listed with its startMs. It may hold one frame or no media.

Deactivate closes the index by handle, so a reactivation during the upload
keeps its own index open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the object store rejects the credentials, every upload fails the
same way. The sweep now leaves the activation's remaining input
subdirectories, program files and recordings.json on Strom instead of
downloading each one to fail again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidecar is uploaded outside uploadFiles, so a credential rejection on
it was recorded as a failed file without setting abortedOnAuthError, and
the sweep went on to the production's next activation directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emit builtin.liverecorder instead of builtin.recorder for the program
recording and for each input track. Properties, pads and file names are
the same. A track that stops and comes back continues in the same file
with a gap, instead of holding up the other tracks.

A Strom without Live Recorder refuses a flow that names it, so the
program recording is left out with a recording-unavailable activation
warning, as input recordings already are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@birme

birme commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

This PR looks like part of a multi-PR dependency stack together with #358 → #359 → #411 → #489 (dependency order; this PR is stacked on #411, which is stacked on #359, which includes #358). Holding off on automated review/merge here — this needs a human (or the relevant dev agent) to assess the whole stack together, not a per-PR pass. Nothing further is needed from the author; this comment exists so the dependency is recorded on the PR rather than in a run log.

Live Recorder finishes each file when the flow stops (its sink sends EOS
into the muxer and waits up to 3 s), so the split-then-wait sequence only
added a few-millisecond trailing file per recorder and a wait of up to
10 s. Stop the flow, then upload.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@birme

birme commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

This PR is part of wagenet's multi-PR recording feature line on open-live. Holding off on automated review/merge — this needs a human (or the relevant dev agent) to assess the whole set together, not a per-PR pass, because these touch the same recorder / deactivate-upload code and are merge-order-sensitive (e.g. #358 and #359 conflict in one test hunk).

Members, in dependency order:

Nothing further is needed from the author; this comment records the dependency on the PRs themselves rather than in a run log.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants