From 25cba571da752ef45bb759546053fcd6be5a5642 Mon Sep 17 00:00:00 2001 From: Bernard Gawor Date: Thu, 28 Aug 2025 13:07:33 +0200 Subject: [PATCH 1/5] add codec info to manifest --- lib/ex_webrtc_recorder.ex | 30 ++++++++++++++++++++++++++---- lib/ex_webrtc_recorder/manifest.ex | 5 ++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/ex_webrtc_recorder.ex b/lib/ex_webrtc_recorder.ex index 057535f..3ab69df 100644 --- a/lib/ex_webrtc_recorder.ex +++ b/lib/ex_webrtc_recorder.ex @@ -7,6 +7,10 @@ defmodule ExWebRTC.Recorder do Can optionally upload the saved files to S3-compatible storage. See `ExWebRTC.Recorder.S3` and `t:options/0` for more info. """ + require Protocol + Protocol.derive(Jason.Encoder, ExWebRTC.RTPCodecParameters) + Protocol.derive(Jason.Encoder, ExSDP.Attribute.FMTP) + Protocol.derive(Jason.Encoder, ExSDP.Attribute.RTCPFeedback) alias ExWebRTC.MediaStreamTrack alias __MODULE__.S3 @@ -121,11 +125,12 @@ defmodule ExWebRTC.Recorder do recorder(), MediaStreamTrack.id(), MediaStreamTrack.rid() | nil, + ExWebRTC.RTPCodecParameters.t() | nil, ExRTP.Packet.t() ) :: :ok - def record(recorder, track_id, rid, %ExRTP.Packet{} = packet) do + def record(recorder, track_id, rid, codec, %ExRTP.Packet{} = packet) do recv_time = System.monotonic_time(:millisecond) - GenServer.cast(recorder, {:record, track_id, rid, recv_time, packet}) + GenServer.cast(recorder, {:record, track_id, rid, codec, recv_time, packet}) end @doc """ @@ -220,10 +225,12 @@ defmodule ExWebRTC.Recorder do end @impl true - def handle_cast({:record, track_id, rid, recv_time, packet}, state) + def handle_cast({:record, track_id, rid, codec, recv_time, packet}, state) when is_map_key(state.track_data, track_id) do %{file: file, rid_map: rid_map} = state.track_data[track_id] + state = if codec, do: update_codec(state, track_id, codec), else: state + with {:ok, rid_idx} <- Map.fetch(rid_map, rid), false <- is_nil(file) do :ok = IO.binwrite(file, serialize_packet(packet, rid_idx, recv_time)) @@ -243,7 +250,7 @@ defmodule ExWebRTC.Recorder do end @impl true - def handle_cast({:record, track_id, _rid, _recv_time, _packet}, state) do + def handle_cast({:record, track_id, _rid, _codec, _recv_time, _packet}, state) do Logger.warning(""" Tried to save packet for unknown track id. Ignoring. Track id: #{inspect(track_id)}.\ """) @@ -290,6 +297,7 @@ defmodule ExWebRTC.Recorder do start_time: start_time, kind: track.kind, streams: track.streams, + codec: nil, rid_map: (track.rids || [nil]) |> Enum.with_index() |> Map.new(), location: file_path, file: File.open!(file_path, [:write]) @@ -342,6 +350,20 @@ defmodule ExWebRTC.Recorder do end) end + defp update_codec(state, track_id, codec) do + case get_in(state, [:track_data, track_id, :codec]) do + nil -> + updated_track_data = Map.update!(state.track_data, track_id, &Map.put(&1, :codec, codec)) + state = %{state | track_data: updated_track_data} + :ok = File.write!(state.manifest_path, state.track_data |> to_manifest |> Jason.encode!()) + Logger.info("Updated manifest with codec info for track #{track_id}") + state + + _ -> + state + end + end + defp serialize_packet(packet, rid_idx, recv_time) do packet = ExRTP.Packet.encode(packet) packet_size = byte_size(packet) diff --git a/lib/ex_webrtc_recorder/manifest.ex b/lib/ex_webrtc_recorder/manifest.ex index 4776ec3..99ac2f4 100644 --- a/lib/ex_webrtc_recorder/manifest.ex +++ b/lib/ex_webrtc_recorder/manifest.ex @@ -20,6 +20,7 @@ defmodule ExWebRTC.Recorder.Manifest do kind: :video | :audio, streams: [MediaStreamTrack.stream_id()], rid_map: %{MediaStreamTrack.rid() => integer()}, + codec: ExWebRTC.RTPCodecParameters.t() | nil, location: location() } @@ -38,6 +39,7 @@ defmodule ExWebRTC.Recorder.Manifest do "kind" => kind, "streams" => streams, "rid_map" => rid_map, + "codec" => codec, "location" => location }) do %{ @@ -45,7 +47,8 @@ defmodule ExWebRTC.Recorder.Manifest do location: location, start_time: parse_start_time(start_time), rid_map: parse_rid_map(rid_map), - kind: parse_kind(kind) + kind: parse_kind(kind), + codec: codec } end From 2d0df2bf8b6b2b865574d864d8bfc9d04d9ef350 Mon Sep 17 00:00:00 2001 From: Bernard Gawor Date: Tue, 2 Sep 2025 11:20:59 +0200 Subject: [PATCH 2/5] serialize, deserialize --- lib/ex_webrtc_recorder.ex | 12 +--- lib/ex_webrtc_recorder/manifest.ex | 89 +++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/lib/ex_webrtc_recorder.ex b/lib/ex_webrtc_recorder.ex index 3ab69df..b576bd5 100644 --- a/lib/ex_webrtc_recorder.ex +++ b/lib/ex_webrtc_recorder.ex @@ -7,11 +7,6 @@ defmodule ExWebRTC.Recorder do Can optionally upload the saved files to S3-compatible storage. See `ExWebRTC.Recorder.S3` and `t:options/0` for more info. """ - require Protocol - Protocol.derive(Jason.Encoder, ExWebRTC.RTPCodecParameters) - Protocol.derive(Jason.Encoder, ExSDP.Attribute.FMTP) - Protocol.derive(Jason.Encoder, ExSDP.Attribute.RTCPFeedback) - alias ExWebRTC.MediaStreamTrack alias __MODULE__.S3 @@ -310,7 +305,7 @@ defmodule ExWebRTC.Recorder do state = %{state | track_data: Map.merge(state.track_data, new_track_data)} - :ok = File.write!(state.manifest_path, state.track_data |> to_manifest() |> Jason.encode!()) + :ok = File.write!(state.manifest_path, state.track_data |> to_manifest() |> ExWebRTC.Recorder.Manifest.to_map() |> Jason.encode!()) {manifest_diff, state} end @@ -352,10 +347,9 @@ defmodule ExWebRTC.Recorder do defp update_codec(state, track_id, codec) do case get_in(state, [:track_data, track_id, :codec]) do - nil -> - updated_track_data = Map.update!(state.track_data, track_id, &Map.put(&1, :codec, codec)) + updated_track_data = put_in(state.track_data , [track_id, :codec], codec) state = %{state | track_data: updated_track_data} - :ok = File.write!(state.manifest_path, state.track_data |> to_manifest |> Jason.encode!()) + :ok = File.write!(state.manifest_path, state.track_data |> to_manifest |> ExWebRTC.Recorder.Manifest.to_map() |> Jason.encode!()) Logger.info("Updated manifest with codec info for track #{track_id}") state diff --git a/lib/ex_webrtc_recorder/manifest.ex b/lib/ex_webrtc_recorder/manifest.ex index 99ac2f4..d3f4cc7 100644 --- a/lib/ex_webrtc_recorder/manifest.ex +++ b/lib/ex_webrtc_recorder/manifest.ex @@ -26,6 +26,57 @@ defmodule ExWebRTC.Recorder.Manifest do @type t :: %{MediaStreamTrack.id() => track_manifest()} + @doc false + @spec to_map(t()) :: map() + def to_map(manifest) do + Map.new(manifest, fn {id, entry} -> + { + id, + %{ + "start_time" => DateTime.to_iso8601(entry.start_time), + "kind" => Atom.to_string(entry.kind), + "streams" => entry.streams, + "rid_map" => encode_rid_map(entry.rid_map), + "codec" => encode_codec(entry.codec), + "location" => entry.location + } + } + end) + end + + defp encode_rid_map(rid_map) do + Map.new(rid_map, fn + {nil, v} -> {"nil", v} + {layer, v} -> {layer, v} + end) + end + + defp encode_codec(nil), do: nil + + defp encode_codec(%ExWebRTC.RTPCodecParameters{} = codec) do + %{ + "payload_type" => codec.payload_type, + "mime_type" => codec.mime_type, + "clock_rate" => codec.clock_rate, + "channels" => codec.channels, + "sdp_fmtp_line" => fmtp_to_string(codec.sdp_fmtp_line), + "rtcp_fbs" => rtcp_fbs_to_strings(codec.rtcp_fbs) + } + end + + defp fmtp_to_string([]), do: nil + defp fmtp_to_string(nil), do: nil + defp fmtp_to_string(fmtp), do: fmtp |> to_string() |> String.replace_prefix("fmtp:", "") + + defp rtcp_fbs_to_strings(nil), do: nil + defp rtcp_fbs_to_strings([]), do: nil + + defp rtcp_fbs_to_strings(list) when is_list(list) do + list + |> Enum.map(&to_string/1) + |> Enum.map(&String.replace_prefix(&1, "rtcp-fb:", "")) + end + @doc false @spec from_json!(map()) :: t() def from_json!(json_manifest) do @@ -48,7 +99,7 @@ defmodule ExWebRTC.Recorder.Manifest do start_time: parse_start_time(start_time), rid_map: parse_rid_map(rid_map), kind: parse_kind(kind), - codec: codec + codec: parse_codec(codec) } end @@ -66,4 +117,40 @@ defmodule ExWebRTC.Recorder.Manifest do defp parse_kind("video"), do: :video defp parse_kind("audio"), do: :audio + + defp parse_codec(%{ + "payload_type" => payload_type, + "mime_type" => mime_type, + "clock_rate" => clock_rate, + "channels" => channels, + "sdp_fmtp_line" => sdp_fmtp_line, + "rtcp_fbs" => rtcp_fbs + }) do + %ExWebRTC.RTPCodecParameters{ + payload_type: payload_type, + mime_type: mime_type, + clock_rate: clock_rate, + channels: channels, + sdp_fmtp_line: parse_sdp_fmtp_line(sdp_fmtp_line), + rtcp_fbs: parse_rtcp_fbs(rtcp_fbs) + } + end + + defp parse_codec(nil), do: nil + + defp parse_sdp_fmtp_line(sdp_fmtp_line) when is_binary(sdp_fmtp_line) do + {:ok, fmtp} = ExSDP.Attribute.FMTP.parse(sdp_fmtp_line) + fmtp + end + + defp parse_sdp_fmtp_line(nil), do: [] + + defp parse_rtcp_fbs(rtcp_fbs) when is_list(rtcp_fbs) do + Enum.map(rtcp_fbs, fn fb -> + {:ok, rtcp_fb} = ExSDP.Attribute.RTCPFeedback.parse(fb) + rtcp_fb + end) + end + + defp parse_rtcp_fbs(nil), do: [] end From 6db2edae5669a28ce9cf5ad5a348558ae376d42c Mon Sep 17 00:00:00 2001 From: Bernard Gawor Date: Tue, 2 Sep 2025 11:22:49 +0200 Subject: [PATCH 3/5] format --- lib/ex_webrtc_recorder.ex | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/ex_webrtc_recorder.ex b/lib/ex_webrtc_recorder.ex index b576bd5..2b5fd38 100644 --- a/lib/ex_webrtc_recorder.ex +++ b/lib/ex_webrtc_recorder.ex @@ -305,7 +305,14 @@ defmodule ExWebRTC.Recorder do state = %{state | track_data: Map.merge(state.track_data, new_track_data)} - :ok = File.write!(state.manifest_path, state.track_data |> to_manifest() |> ExWebRTC.Recorder.Manifest.to_map() |> Jason.encode!()) + :ok = + File.write!( + state.manifest_path, + state.track_data + |> to_manifest() + |> ExWebRTC.Recorder.Manifest.to_map() + |> Jason.encode!() + ) {manifest_diff, state} end @@ -347,9 +354,19 @@ defmodule ExWebRTC.Recorder do defp update_codec(state, track_id, codec) do case get_in(state, [:track_data, track_id, :codec]) do - updated_track_data = put_in(state.track_data , [track_id, :codec], codec) + nil -> + updated_track_data = put_in(state.track_data, [track_id, :codec], codec) state = %{state | track_data: updated_track_data} - :ok = File.write!(state.manifest_path, state.track_data |> to_manifest |> ExWebRTC.Recorder.Manifest.to_map() |> Jason.encode!()) + + :ok = + File.write!( + state.manifest_path, + state.track_data + |> to_manifest + |> ExWebRTC.Recorder.Manifest.to_map() + |> Jason.encode!() + ) + Logger.info("Updated manifest with codec info for track #{track_id}") state From 5b6fc96affc261750f032cc2bbef2a0f01e017e8 Mon Sep 17 00:00:00 2001 From: Bernard Gawor Date: Tue, 2 Sep 2025 16:59:52 +0200 Subject: [PATCH 4/5] add tests, apply suggestions --- lib/ex_webrtc_recorder.ex | 32 ++-- lib/ex_webrtc_recorder/manifest.ex | 4 +- test/ex_webrtc_recorder/manifest_test.exs | 193 ++++++++++++++++++++++ 3 files changed, 208 insertions(+), 21 deletions(-) create mode 100644 test/ex_webrtc_recorder/manifest_test.exs diff --git a/lib/ex_webrtc_recorder.ex b/lib/ex_webrtc_recorder.ex index 2b5fd38..05993b6 100644 --- a/lib/ex_webrtc_recorder.ex +++ b/lib/ex_webrtc_recorder.ex @@ -305,14 +305,7 @@ defmodule ExWebRTC.Recorder do state = %{state | track_data: Map.merge(state.track_data, new_track_data)} - :ok = - File.write!( - state.manifest_path, - state.track_data - |> to_manifest() - |> ExWebRTC.Recorder.Manifest.to_map() - |> Jason.encode!() - ) + :ok = write_manifest(state) {manifest_diff, state} end @@ -355,18 +348,9 @@ defmodule ExWebRTC.Recorder do defp update_codec(state, track_id, codec) do case get_in(state, [:track_data, track_id, :codec]) do nil -> - updated_track_data = put_in(state.track_data, [track_id, :codec], codec) - state = %{state | track_data: updated_track_data} - - :ok = - File.write!( - state.manifest_path, - state.track_data - |> to_manifest - |> ExWebRTC.Recorder.Manifest.to_map() - |> Jason.encode!() - ) + state = put_in(state, [:track_data, track_id, :codec], codec) + :ok = write_manifest(state) Logger.info("Updated manifest with codec info for track #{track_id}") state @@ -388,4 +372,14 @@ defmodule ExWebRTC.Recorder do :io_lib.format("~4..0w~2..0w~2..0w-~2..0w~2..0w~2..0w", [y, mo, d, h, m, s]) |> to_string() end + + defp write_manifest(state) do + File.write!( + state.manifest_path, + state.track_data + |> to_manifest() + |> ExWebRTC.Recorder.Manifest.to_json!() + |> Jason.encode!() + ) + end end diff --git a/lib/ex_webrtc_recorder/manifest.ex b/lib/ex_webrtc_recorder/manifest.ex index d3f4cc7..79df8c8 100644 --- a/lib/ex_webrtc_recorder/manifest.ex +++ b/lib/ex_webrtc_recorder/manifest.ex @@ -27,8 +27,8 @@ defmodule ExWebRTC.Recorder.Manifest do @type t :: %{MediaStreamTrack.id() => track_manifest()} @doc false - @spec to_map(t()) :: map() - def to_map(manifest) do + @spec to_json!(t()) :: map() + def to_json!(manifest) do Map.new(manifest, fn {id, entry} -> { id, diff --git a/test/ex_webrtc_recorder/manifest_test.exs b/test/ex_webrtc_recorder/manifest_test.exs new file mode 100644 index 0000000..592a75c --- /dev/null +++ b/test/ex_webrtc_recorder/manifest_test.exs @@ -0,0 +1,193 @@ +defmodule ExWebRTC.Recorder.Manifest.Test do + use ExUnit.Case, async: true + + alias ExWebRTC.Recorder.Manifest + alias ExWebRTC.RTPCodecParameters + alias ExSDP.Attribute.{FMTP, RTCPFeedback} + + describe "to_json!/1" do + test "empty manifest" do + manifest = %{} + json = Manifest.to_json!(manifest) + assert json == %{} + end + + test "two entries(audio,video) manifest" do + audio_id = 6_264_340_764_417_145_606_381_315_372 + video_id = 34_899_663_449_195_684_468_354_913_891 + base_dir = "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515" + audio_path = Path.join(base_dir, "6264340764417145606381315372.rtpx") + video_path = Path.join(base_dir, "34899663449195684468354913891.rtpx") + streams = ["{13d54720-6d00-45a9-b234-11f0e969f4b7}"] + start_time = ~U[2025-09-02 14:35:15.336022Z] + + manifest = %{ + audio_id => %{ + location: audio_path, + kind: :audio, + streams: streams, + start_time: start_time, + codec: audio_opus_codec(), + rid_map: %{nil: 0} + }, + video_id => %{ + location: video_path, + kind: :video, + streams: streams, + start_time: start_time, + codec: video_vp8_codec(), + rid_map: %{nil: 0} + } + } + + json = Manifest.to_json!(manifest) + + assert json == + %{ + audio_id => %{ + "codec" => %{ + "channels" => 2, + "clock_rate" => 48000, + "mime_type" => "audio/opus", + "payload_type" => 109, + "rtcp_fbs" => nil, + "sdp_fmtp_line" => "109 maxplaybackrate=48000;stereo=1;useinbandfec=1" + }, + "kind" => "audio", + "location" => audio_path, + "rid_map" => %{"nil" => 0}, + "start_time" => "2025-09-02T14:35:15.336022Z", + "streams" => streams + }, + video_id => %{ + "codec" => %{ + "channels" => nil, + "clock_rate" => 90000, + "mime_type" => "video/VP8", + "payload_type" => 120, + "rtcp_fbs" => ["120 nack", "120 nack pli", "120 ccm fir", "120 transport-cc"], + "sdp_fmtp_line" => "120 max-fs=12288;max-fr=60" + }, + "kind" => "video", + "location" => video_path, + "rid_map" => %{"nil" => 0}, + "start_time" => "2025-09-02T14:35:15.336022Z", + "streams" => streams + } + } + end + end + + describe "from_json!/1" do + test "empty manifest" do + json = %{} + manifest = Manifest.from_json!(json) + assert manifest == %{} + end + + test "two entries(audio,video) manifest" do + json = + Jason.decode!( + """ + { + "6264340764417145606381315372": { + "codec": { + "channels": 2, + "clock_rate": 48000, + "mime_type": "audio/opus", + "payload_type": 109, + "rtcp_fbs": null, + "sdp_fmtp_line": "109 maxplaybackrate=48000;stereo=1;useinbandfec=1" + }, + "kind": "audio", + "location": "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515/6264340764417145606381315372.rtpx", + "rid_map": { + "nil": 0 + }, + "start_time": "2025-09-02T14:35:15.336022Z", + "streams": [ + "{13d54720-6d00-45a9-b234-11f0e969f4b7}" + ] + }, + "34899663449195684468354913891": { + "codec": { + "channels": null, + "clock_rate": 90000, + "mime_type": "video/VP8", + "payload_type": 120, + "rtcp_fbs": [ + "120 nack", + "120 nack pli", + "120 ccm fir", + "120 transport-cc" + ], + "sdp_fmtp_line": "120 max-fs=12288;max-fr=60" + }, + "kind": "video", + "location": "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515/34899663449195684468354913891.rtpx", + "rid_map": { + "nil": 0 + }, + "start_time": "2025-09-02T14:35:15.336022Z", + "streams": [ + "{13d54720-6d00-45a9-b234-11f0e969f4b7}" + ] + } + } + """ + ) + + manifest = Manifest.from_json!(json) + + assert manifest == + %{ + "34899663449195684468354913891" => %{ + location: + "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515/34899663449195684468354913891.rtpx", + kind: :video, + streams: ["{13d54720-6d00-45a9-b234-11f0e969f4b7}"], + start_time: ~U[2025-09-02 14:35:15.336022Z], + codec: video_vp8_codec(), + rid_map: %{nil: 0} + }, + "6264340764417145606381315372" => %{ + location: + "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515/6264340764417145606381315372.rtpx", + kind: :audio, + streams: ["{13d54720-6d00-45a9-b234-11f0e969f4b7}"], + start_time: ~U[2025-09-02 14:35:15.336022Z], + codec: audio_opus_codec(), + rid_map: %{nil: 0} + } + } + end + end + + # Helpers + defp audio_opus_codec do + %RTPCodecParameters{ + payload_type: 109, + mime_type: "audio/opus", + clock_rate: 48_000, + channels: 2, + sdp_fmtp_line: %FMTP{pt: 109, maxplaybackrate: 48_000, stereo: true, useinbandfec: true}, + rtcp_fbs: [] + } + end + + defp video_vp8_codec do + %RTPCodecParameters{ + payload_type: 120, + mime_type: "video/VP8", + clock_rate: 90_000, + channels: nil, + sdp_fmtp_line: %FMTP{pt: 120, max_fs: 12_288, max_fr: 60}, + rtcp_fbs: [ + %RTCPFeedback{pt: 120, feedback_type: :nack}, + %RTCPFeedback{pt: 120, feedback_type: :pli}, + %RTCPFeedback{pt: 120, feedback_type: :fir}, + %RTCPFeedback{pt: 120, feedback_type: :twcc} + ] + } + end +end From 615591b86afd0b02c7fb01da5bd6756741b97e2e Mon Sep 17 00:00:00 2001 From: Bernard Gawor Date: Tue, 2 Sep 2025 17:02:08 +0200 Subject: [PATCH 5/5] credo, format --- test/ex_webrtc_recorder/manifest_test.exs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/ex_webrtc_recorder/manifest_test.exs b/test/ex_webrtc_recorder/manifest_test.exs index 592a75c..547e559 100644 --- a/test/ex_webrtc_recorder/manifest_test.exs +++ b/test/ex_webrtc_recorder/manifest_test.exs @@ -47,7 +47,7 @@ defmodule ExWebRTC.Recorder.Manifest.Test do audio_id => %{ "codec" => %{ "channels" => 2, - "clock_rate" => 48000, + "clock_rate" => 48_000, "mime_type" => "audio/opus", "payload_type" => 109, "rtcp_fbs" => nil, @@ -62,7 +62,7 @@ defmodule ExWebRTC.Recorder.Manifest.Test do video_id => %{ "codec" => %{ "channels" => nil, - "clock_rate" => 90000, + "clock_rate" => 90_000, "mime_type" => "video/VP8", "payload_type" => 120, "rtcp_fbs" => ["120 nack", "120 nack pli", "120 ccm fir", "120 transport-cc"], @@ -87,8 +87,7 @@ defmodule ExWebRTC.Recorder.Manifest.Test do test "two entries(audio,video) manifest" do json = - Jason.decode!( - """ + Jason.decode!(""" { "6264340764417145606381315372": { "codec": { @@ -134,8 +133,7 @@ defmodule ExWebRTC.Recorder.Manifest.Test do ] } } - """ - ) + """) manifest = Manifest.from_json!(json)