View on GitHub

raspberry_ninja

Publish or capture VDO.Ninja streams with Python (Raspberry Pi, Linux, Mac, Windows WSL)

Recording guide

--record and --record-room pull remote VDO.Ninja streams to disk; they do not publish the local camera. To save a copy while publishing your own camera or test source, use --save instead.

Save the outgoing stream

Add --save to the publishing command to write a timestamped .mkv file in the current directory. AV1 recording branches after av1parse, before RTP packetization, for AOM, rav1e, and Quick Sync encoders. The installed Matroska muxer and playback decoder must support the selected codec. Validate the file by decoding it; file size alone does not establish that recording succeeded.

If automatic H.264 playback fails in v4l2h264dec, try a software decoder before concluding the file is damaged. For the local Matroska video track:

gst-launch-1.0 filesrc location=RECORDING.mkv ! matroskademux ! h264parse ! avdec_h264 ! fakesink

This checks video decoding only. Validate audio separately when present. Local --save recording starts even before a viewer connects and continues when viewers leave. Stop with Ctrl+C to finalize the Matroska file before using it.

Single-stream recording

To publish an existing file without re-encoding video, use --filesrc2 clip.mp4 --noaudio for H.264 in MP4, or --filesrc2 clip.webm --vp8 --noaudio (or --vp9) for WebM/Matroska. The codec flag must match the file. VP9 passthrough requires rtpvp9pay, but does not require vp9enc. This path does not forward the file’s audio. Use --filesrc when video decoding and re-encoding are needed.

H.264 MP4 passthrough also supports --rtmp URL. Add --save to write a local Matroska copy of the video while publishing, with either RTMP or WebRTC output. RTMP playback exits with status 0 at normal end-of-file, 1 on a pipeline error, and 130 on Ctrl+C. VP8 and VP9 file passthrough also support --save when publishing over WebRTC; the recording keeps the original video codec without re-encoding.

python3 -u publish.py \
  --record STREAM_ID \
  --password false

Audio is recorded by default. Add --noaudio for video only.

The file container follows the negotiated codec:

Incoming media Normal recording output Processing
H.264 video .ts MPEG-TS Depayload and remux
VP8 or VP9 video .webm WebM WebM-compatible path
Opus audio separate _audio.webm WebM Depayload and remux

Filenames include stream identifiers and timestamps. Read the startup log for the exact paths; do not assume that a user-supplied .webm suffix survives when the selected video container is MPEG-TS.

Stream identifiers keep their original spelling for signaling. Characters that cannot safely appear in portable filenames are encoded in output names (for example, / becomes ~2F); long identifiers receive a short hash suffix. This also prevents a remote stream name from creating unintended directory paths. The subprocess recorder gives audio and video the same basename, even when one track arrives later, so the combine tool can find the pair. Audio is written beside the video when an explicit output path is provided.

On a graceful Raspberry Ninja publisher shutdown, a single-stream recorder finalizes that session and renews its subscription to the original stream ID. When the publisher starts again, recording continues in a new pair of files.

Stop with Ctrl+C and let GStreamer finalize the files. A non-empty file is not proof of a valid recording; validate it as described below.

Room recording

Record every participant in a room:

python3 -u publish.py \
  --room ROOM_NAME \
  --record-room \
  --password false

Limit recording to selected stream IDs:

python3 -u publish.py \
  --room ROOM_NAME \
  --record-room \
  --record-streams "camera-one,camera-two" \
  --password false

Each participant is handled separately. Expect codec-appropriate video files and separate audio files rather than one mixed room file.

HLS recording

The validated compatibility path is the splitmux backend:

python3 -u publish.py \
  --record STREAM_ID \
  --hls --hls-splitmux \
  --password false

This produces an .m3u8 playlist and numbered .ts MPEG-TS segments. H.264 is used for video and AAC for audio; non-H.264 input is transcoded and can be expensive on small boards.

Serve the current recording directory with the built-in server:

python3 -u publish.py \
  --record STREAM_ID \
  --hls --hls-splitmux \
  --webserver 8080 \
  --password false

Open the playlist named in the log through port 8080. For browser playback, open the local tools/play_hls.html file on your viewing computer, paste the full playlist URL, and choose Play. Use the recording device’s hostname or address, not localhost, when viewing from another computer. The player supports switching URLs and stopping playback. Browsers without native HLS support need internet access to load its pinned HLS.js library.

For recordings that are already on disk, run python3 tools/serve_hls.py --directory /path/to/recordings --port 8089, then enter http://DEVICE:8089/PLAYLIST.m3u8 in the local player. This helper serves media files only; open the player HTML locally. The built-in dashboard uses http://DEVICE:8080/hls/PLAYLIST.m3u8 instead.

--hls without --hls-splitmux keeps the older manual backend for platform compatibility. It produced empty segments in testing on GStreamer 1.18, so it is not the recommended general-purpose path. Do not remove it without testing the platforms that still depend on it.

Validate recordings

Identify the real container:

gst-typefind-1.0 recording.ts
ffprobe -hide_banner recording.ts
ffprobe -hide_banner recording_audio.webm

Decode without requiring a display or speaker:

gst-launch-1.0 -q filesrc location=recording.ts ! decodebin ! fakesink
gst-launch-1.0 -q filesrc location=recording_audio.webm ! decodebin ! fakesink

For HLS, confirm all three layers:

test -s recording.m3u8
grep -v '^#' recording.m3u8
gst-typefind-1.0 recording_00000.ts
gst-launch-1.0 -q filesrc location=recording_00000.ts ! decodebin ! fakesink

The segment must type-find as MPEG-TS and the playlist must reference existing, non-empty segments.

Combine separate audio and video

Install FFmpeg, then combine a known pair:

python3 tools/combine_recordings.py video_file audio_file combined.mp4

With no arguments, the tool scans the current directory for timestamp-matched pairs. It recognizes current H.264 .ts, VP8/VP9 .webm/.mkv, and _audio.webm outputs, plus legacy _audio.wav and _audio.ts files:

python3 tools/combine_recordings.py

The tool re-encodes video to H.264 and audio to AAC. When both inputs share a timestamp origin, it pads the later track with black video or silence and ends at the shorter track. Failed merges return a nonzero exit status and preserve existing output files; a successful explicit merge replaces the named output. Automatic discovery skips existing outputs.

MPEG-TS and WebM/WAV can use different timestamp origins, so their timestamps alone do not establish synchronization. For these mixed pairs, specify the audio offset explicitly. Start with zero to align the first decoded samples, inspect a visible/audible event, then adjust as needed:

python3 tools/combine_recordings.py --audio-offset 0 video.ts audio.webm combined.mp4

Positive offsets delay audio; negative offsets delay video. For example, --audio-offset 0.25 adds 250 ms of silence before the audio. This overrides container timestamps. Keep the originals until the combined file has been inspected and decoded successfully.

Resource planning

Direct H.264/Opus remuxing is much lighter than decoding and transcoding. HLS conversion, VP8/VP9 re-encoding, several room participants, or simultaneous publishing can exceed a Pi Zero 2 W’s practical memory and CPU budget. Test one stream first and monitor:

watch -n 2 'free -h; ps -o pid,rss,%cpu,%mem,etime,cmd -C python3'

Also monitor disk space and write rate. An 8 GB card is suitable for the runtime and short tests, not unattended long-term recording.