diff --git a/lib/plug/router.ex b/lib/plug/router.ex index a902cef..dee6c3c 100644 --- a/lib/plug/router.ex +++ b/lib/plug/router.ex @@ -66,6 +66,8 @@ if Code.ensure_loaded?(Plug) do end end + forward "/webrtc", to: Plug.Shinkai.Router.WebRTC + match _ do send_resp(conn, 404, "Not Found") end diff --git a/lib/plug/router/webrtc.ex b/lib/plug/router/webrtc.ex new file mode 100644 index 0000000..9f94665 --- /dev/null +++ b/lib/plug/router/webrtc.ex @@ -0,0 +1,62 @@ +if Code.ensure_loaded?(Plug) do + defmodule Plug.Shinkai.Router.WebRTC do + @moduledoc false + require Logger + + require EEx + + use Plug.Router + use Plug.ErrorHandler + + plug :match + plug :dispatch + + EEx.function_from_file(:defp, :webrtc_index, "lib/plug/templates/webrtc.html.eex", [:assigns]) + + get "/:source_id" do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, webrtc_index(source_id: source_id)) + end + + post "/:source_id/whep" do + case Shinkai.Sources.add_webrtc_peer(source_id) do + {:ok, sdp_offer, session_id} -> + conn + |> put_resp_content_type("application/sdp") + |> put_resp_header("location", "/webrtc/#{source_id}/whep/#{session_id}") + |> send_resp(416, sdp_offer) + + {:error, reason} -> + Logger.error("Failed to create WebRTC peer: #{inspect(reason)}") + send_resp(conn, 400, "Bad Request") + end + end + + patch "/:source_id/whep/:session_id" do + case get_req_header(conn, "content-type") do + ["application/sdp"] -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + case Shinkai.Sources.handle_webrtc_peer_answer(source_id, session_id, body) do + :ok -> + send_resp(conn, 204, "") + + {:error, reason} -> + Logger.error("Failed to handle WebRTC peer answer: #{inspect(reason)}") + send_resp(conn, 400, "Bad Request") + end + + send_resp(conn, 204, "") + + _ -> + send_resp(conn, 415, "Unsupported Media Type") + end + end + + delete "/:source_id/whep/:session_id" do + Shinkai.Sources.remove_webrtc_peer(source_id, session_id) + send_resp(conn, 204, "") + end + end +end diff --git a/lib/plug/templates/webrtc.html.eex b/lib/plug/templates/webrtc.html.eex new file mode 100644 index 0000000..bb0a190 --- /dev/null +++ b/lib/plug/templates/webrtc.html.eex @@ -0,0 +1,60 @@ + + + + + Shinkai WebRTC + + + + + + + diff --git a/lib/shinkai/pipeline.ex b/lib/shinkai/pipeline.ex index 0788231..fce5e49 100644 --- a/lib/shinkai/pipeline.ex +++ b/lib/shinkai/pipeline.ex @@ -15,7 +15,19 @@ defmodule Shinkai.Pipeline do @spec add_rtmp_client(String.t()) :: :ok def add_rtmp_client(source_id) do - Sink.RTMP.add_client({:via, Registry, {Source.Registry, {:rtmp_sink, source_id}}}, self()) + Sink.RTMP.add_client(rtmp_name(source_id), self()) + end + + def add_webrtc_peer(source_id) do + Sink.WebRTC.add_new_peer(webrtc_name(source_id)) + end + + def handle_webrtc_peer_answer(source_id, session_id, sdp_answer) do + Sink.WebRTC.handle_peer_answer(webrtc_name(source_id), session_id, sdp_answer) + end + + def remove_webrtc_peer(source_id, session_id) do + Sink.WebRTC.remove_peer(webrtc_name(source_id), session_id) end def stop(source_id) do @@ -29,7 +41,8 @@ defmodule Shinkai.Pipeline do children = [ - {Sink.Hls, [id: id] ++ hls_config} + {Sink.Hls, [id: id] ++ hls_config}, + {Sink.WebRTC, id: id} ] ++ rtmp_sink(rtmp_config[:enabled], id) ++ source(source) Supervisor.init(children, strategy: :one_for_all) @@ -41,4 +54,7 @@ defmodule Shinkai.Pipeline do defp rtmp_sink(false, _id), do: [] defp rtmp_sink(true, id), do: [{Sink.RTMP, [id: id]}] + + defp webrtc_name(id), do: {:via, Registry, {Source.Registry, {:webrtc_sink, id}}} + defp rtmp_name(id), do: {:via, Registry, {Source.Registry, {:rtmp_sink, id}}} end diff --git a/lib/shinkai/sink/webrtc.ex b/lib/shinkai/sink/webrtc.ex new file mode 100644 index 0000000..98670a9 --- /dev/null +++ b/lib/shinkai/sink/webrtc.ex @@ -0,0 +1,218 @@ +defmodule Shinkai.Sink.WebRTC do + @moduledoc false + + use GenServer + + require Logger + + import Shinkai.Utils + + alias __MODULE__.PeerManager + alias ExWebRTC.RTPCodecParameters + alias Phoenix.PubSub + alias RTSP.RTP.Encoder, as: RTPEncoder + + @supported_codecs [:h264, :h265, :av1, :pcma] + @video_clock_rate 90_000 + + def start_link(opts) do + id = {:via, Registry, {Source.Registry, {:webrtc_sink, opts[:id]}}} + GenServer.start_link(__MODULE__, opts, name: id) + end + + @spec add_new_peer(GenServer.server()) :: {:ok, String.t(), String.t()} | {:error, any()} + def add_new_peer(server) do + GenServer.call(server, :add_new_peer) + end + + @spec handle_peer_answer( + GenServer.server(), + session_id :: String.t(), + sdp :: String.t() + ) :: :ok | {:error, any()} + def handle_peer_answer(server, session_id, sdp) do + GenServer.call(server, {:handle_peer_answer, session_id, sdp}) + end + + @spec remove_peer(GenServer.server(), session_id :: String.t()) :: :ok + def remove_peer(server, session_id) do + GenServer.cast(server, {:remove_peer, session_id}) + end + + @impl true + def init(opts) do + source_id = opts[:id] + {:ok, peer_manager} = PeerManager.start_link(source_id: source_id) + + PubSub.subscribe(Shinkai.PubSub, tracks_topic(source_id)) + + {:ok, + %{ + peer_manager: peer_manager, + source_id: source_id, + packets_topic: packets_topic(source_id), + tracks: %{} + }} + end + + @impl true + def handle_call(:add_new_peer, _from, %{video_tracks: [], audio_tracks: []} = state) do + {:reply, {:error, :no_tracks}, state} + end + + def handle_call(:add_new_peer, from, state) do + :ok = PeerManager.add_peer(state.peer_manager, from) + {:noreply, state} + end + + def handle_call({:handle_peer_answer, session_id, sdp}, from, state) do + :ok = PeerManager.handle_peer_answer(state.peer_manager, from, session_id, sdp) + {:noreply, state} + end + + @impl true + def handle_cast({:remove_peer, session_id}, state) do + :ok = PeerManager.remove_peer(state.peer_manager, session_id) + {:noreply, state} + end + + @impl true + def handle_info({:tracks, tracks}, state) do + {tracks, unsupported_tracks} = Enum.split_with(tracks, &(&1.codec in @supported_codecs)) + + if unsupported_tracks != [] do + Logger.warning( + "Unsupported codecs received in WebRTC sink: #{join_codecs(unsupported_tracks)}" + ) + end + + video_track = Enum.find(tracks, fn t -> t.type == :video end) + audio_track = Enum.find(tracks, fn t -> t.type == :audio end) + + stream_id = ExWebRTC.MediaStreamTrack.generate_stream_id() + + state = + [video_track, audio_track] + |> Enum.reject(&is_nil/1) + |> Enum.reduce(state, fn track, state -> + media_stream = ExWebRTC.MediaStreamTrack.new(track.type, [stream_id]) + webrtc_track = webrtc_track(track) + + payloader_mod = payloader_mod(track.codec) + + track_ctx = %{ + id: media_stream.id, + timescale: track.timescale, + target_timescale: webrtc_track.clock_rate, + payloader_mod: payloader_mod, + payloader_state: payloader_mod.init([]) + } + + if track.type == :video, + do: PeerManager.add_video_track(state.peer_manager, {media_stream, webrtc_track}), + else: PeerManager.add_audio_track(state.peer_manager, {media_stream, webrtc_track}) + + %{state | tracks: Map.put(state.tracks, track.id, track_ctx)} + end) + + :ok = PubSub.subscribe(Shinkai.PubSub, state.packets_topic) + + {:noreply, state} + end + + @impl true + def handle_info({:packet, packets}, state) when is_list(packets) do + track_id = hd(packets).track_id + + case(Map.fetch(state, track_id)) do + :error -> + {:noreply, state} + + {:ok, track_ctx} -> + track_ctx = + Enum.reduce(packets, track_ctx, fn packet, track_ctx -> + do_handle_packet(packet, state.source_id, track_ctx) + end) + + {:noreply, %{state | tracks: Map.put(state.tracks, track_id, track_ctx)}} + end + end + + def handle_info({:packet, packet}, state) do + case Map.fetch(state.tracks, packet.track_id) do + :error -> + {:noreply, state} + + {:ok, track_ctx} -> + track_ctx = do_handle_packet(packet, state.source_id, track_ctx) + {:noreply, %{state | tracks: Map.put(state.tracks, packet.track_id, track_ctx)}} + end + end + + def handle_info(_msg, state) do + {:noreply, state} + end + + defp do_handle_packet(packet, source_id, track_ctx) do + rtp_timestamp = + ExMP4.Helper.timescalify(packet.pts, track_ctx.timescale, track_ctx.target_timescale) + + {packets, payloader_state} = + track_ctx.payloader_mod.handle_sample( + packet.data, + rtp_timestamp, + track_ctx.payloader_state + ) + + track_id = track_ctx.id + + Registry.dispatch(Sink.Registry, {:webrtc, source_id}, fn peers -> + for {_pid, {pc, _session_id}} <- peers do + Enum.each(packets, fn rtp_packet -> + :ok = ExWebRTC.PeerConnection.send_rtp(pc, track_id, rtp_packet) + end) + end + end) + + %{track_ctx | payloader_state: payloader_state} + end + + defp webrtc_track(track) do + pt = payload_type(track.codec) + + %RTPCodecParameters{ + payload_type: pt, + mime_type: mime_type(track.codec), + clock_rate: clock_rate(track), + channels: if(track.type == :audio, do: 1, else: nil), + sdp_fmtp_line: sdp_fmtp_line(track.codec, pt) + } + end + + defp clock_rate(%{type: :video}), do: @video_clock_rate + defp clock_rate(%{timescale: timescale}), do: timescale + + defp payload_type(:pcma), do: 8 + defp payload_type(_codec), do: 96 + + defp mime_type(:h264), do: "video/H264" + defp mime_type(:h265), do: "video/H265" + defp mime_type(:av1), do: "video/AV1" + defp mime_type(:pcma), do: "audio/PCMA" + + defp sdp_fmtp_line(:h264, pt) do + %ExSDP.Attribute.FMTP{ + pt: pt, + level_asymmetry_allowed: true, + packetization_mode: 1, + profile_level_id: 0x42E01F + } + end + + defp sdp_fmtp_line(_codec, _pt), do: nil + + defp payloader_mod(:h264), do: RTPEncoder.H264 + defp payloader_mod(:h265), do: RTPEncoder.H265 + defp payloader_mod(:av1), do: RTPEncoder.AV1 + defp payloader_mod(:pcma), do: RTPEncoder.G711 +end diff --git a/lib/shinkai/sink/webrtc/peer_manager.ex b/lib/shinkai/sink/webrtc/peer_manager.ex new file mode 100644 index 0000000..72a1eaf --- /dev/null +++ b/lib/shinkai/sink/webrtc/peer_manager.ex @@ -0,0 +1,183 @@ +defmodule Shinkai.Sink.WebRTC.PeerManager do + @moduledoc false + + use GenServer + + require Logger + + alias ExWebRTC.PeerConnection + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: opts[:name]) + end + + @spec add_video_track(server :: GenServer.name() | pid(), tuple()) :: :ok + def add_video_track(manager, track) do + GenServer.call(manager, {:add_video_track, track}) + end + + @spec add_audio_track(server :: GenServer.name() | pid(), tuple()) :: :ok + def add_audio_track(manager, track) do + GenServer.call(manager, {:add_audio_track, track}) + end + + @spec add_peer(server :: GenServer.name() | pid(), from :: GenServer.from()) :: :ok + def add_peer(manager, from) do + GenServer.cast(manager, {:add_peer, from}) + end + + @spec handle_peer_answer( + server :: pid() | atom(), + from :: GenServer.from(), + session_id :: String.t(), + sdp_answer :: String.t() + ) :: :ok + def handle_peer_answer(manager, from, session_id, sdp_answer) do + GenServer.cast(manager, {:handle_peer_answer, from, session_id, sdp_answer}) + end + + @spec remove_peer(server :: pid() | atom(), session_id :: String.t()) :: :ok + def remove_peer(manager, session_id) do + GenServer.call(manager, {:remove_peer, session_id}) + end + + @impl true + def init(opts) do + {:ok, + %{ + source_id: opts[:source_id], + sessions: %{}, + peers: %{}, + video_track: nil, + audio_track: nil + }} + end + + @impl true + def handle_call({:add_video_track, track}, _from, state) do + {:reply, :ok, %{state | video_track: track}} + end + + def handle_call({:add_audio_track, track}, _from, state) do + {:reply, :ok, %{state | audio_track: track}} + end + + def handle_call({:remove_peer, session_id}, _from, state) do + Logger.info("Removing WebRTC peer with session ID: #{session_id}") + + case Registry.match(Sink.Registry, {:webrtc, state.source_id}, {:_, session_id}) do + [{_pid, {pc, _session_id}}] -> unregister(state.source_id, pc) + [] -> :ok + end + + {pc, sessions} = Map.pop(state.sessions, session_id) + if pc, do: PeerConnection.stop(pc) + + {:reply, :ok, %{state | sessions: sessions}} + end + + @impl true + def handle_cast({:add_peer, from}, state) do + video_tracks = if state.video_track, do: [elem(state.video_track, 1)], else: [] + audio_tracks = if state.audio_track, do: [elem(state.audio_track, 1)], else: [] + + tracks = + Enum.reject([state.video_track, state.audio_track], &is_nil/1) |> Enum.map(&elem(&1, 0)) + + with {:ok, pc} <- + PeerConnection.start(video_codecs: video_tracks, audio_codecs: audio_tracks), + :ok <- add_tracks(pc, tracks), + {:ok, offer} <- PeerConnection.create_offer(pc), + :ok <- PeerConnection.set_local_description(pc, offer) do + {:noreply, %{state | peers: Map.put(state.peers, pc, from)}} + else + {:error, reason} -> + GenServer.reply(from, {:error, reason}) + {:noreply, state} + end + end + + def handle_cast({:handle_peer_answer, from, session_id, sdp}, state) do + case Map.fetch(state.sessions, session_id) do + {:ok, pid} -> + desc = %ExWebRTC.SessionDescription{ + type: :answer, + sdp: sdp + } + + :ok = PeerConnection.set_remote_description(pid, desc) + GenServer.reply(from, :ok) + {:noreply, state} + + :error -> + GenServer.reply(from, {:error, :invalid_session_id}) + {:noreply, state} + end + end + + @impl true + def handle_info({:ex_webrtc, pid, {:ice_gathering_state_change, :complete}}, state) do + state = + case Map.pop(state.peers, pid) do + {nil, peers} -> + %{state | peers: peers} + + {from, peers} -> + session_id = UUID.uuid4() + offer = PeerConnection.get_local_description(pid) + GenServer.reply(from, {:ok, offer.sdp, session_id}) + + %{ + state + | peers: peers, + sessions: Map.put(state.sessions, session_id, pid) + } + end + + {:noreply, state} + end + + def handle_info({:ex_webrtc, pid, {:connection_state_change, :connected}}, state) do + Logger.info("New WebRTC peer connected") + {session_id, pc} = Enum.find(state.sessions, fn {_session_id, p} -> p == pid end) + Registry.register(Sink.Registry, {:webrtc, state.source_id}, {pc, session_id}) + {:noreply, %{state | sessions: Map.delete(state.sessions, session_id)}} + end + + def handle_info({:ex_webrtc, pid, {:connection_state_change, connection_state}}, state) + when connection_state in [:failed, :closed] do + Logger.debug( + "WebRTC PeerConnection #{inspect(pid)} connection state changed to: #{connection_state}" + ) + + unregister(state.source_id, pid) + {:noreply, state} + end + + def handle_info({:ex_webrtc, _pid, {:rtcp, _}}, state) do + {:noreply, state} + end + + def handle_info({:ex_webrtc, _pid, _msg}, state) do + # Logger.info("Unhandled ExWebRTC message: #{inspect(msg)}") + {:noreply, state} + end + + def handle_info(_msg, state) do + {:noreply, state} + end + + defp add_tracks(pc, tracks) do + Enum.reduce_while(tracks, :ok, fn track, :ok -> + case PeerConnection.add_track(pc, track) do + {:ok, _} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp unregister(source_id, pc) do + PeerConnection.stop(pc) + Registry.unregister_match(Sink.Registry, {:webrtc, source_id}, {pc, :_}) + end +end diff --git a/lib/shinkai/sources.ex b/lib/shinkai/sources.ex index 6f0aae6..c2f919e 100644 --- a/lib/shinkai/sources.ex +++ b/lib/shinkai/sources.ex @@ -91,6 +91,10 @@ defmodule Shinkai.Sources do end end + defdelegate add_webrtc_peer(source_id), to: Shinkai.Pipeline + defdelegate handle_webrtc_peer_answer(source_id, session_id, sdp_answer), to: Shinkai.Pipeline + defdelegate remove_webrtc_peer(source_id, session_id), to: Shinkai.Pipeline + defp storage_impl do Application.get_env(:shinkai, :storage_impl, Shinkai.Sources.Storage.File) end diff --git a/lib/shinkai/utils.ex b/lib/shinkai/utils.ex index e73222c..cdae759 100644 --- a/lib/shinkai/utils.ex +++ b/lib/shinkai/utils.ex @@ -12,4 +12,7 @@ defmodule Shinkai.Utils do @spec sink_topic(String.t()) :: String.t() def sink_topic(id), do: "source:sink:#{id}" + + @spec join_codecs([Shinkai.Track.t()]) :: String.t() + def join_codecs(tracks), do: Enum.map_join(tracks, ", ", & &1.codec) end diff --git a/mix.exs b/mix.exs index 8ebb00f..7a077a5 100644 --- a/mix.exs +++ b/mix.exs @@ -35,6 +35,7 @@ defmodule Shinkai.MixProject do {:rtsp, "~> 0.8.0"}, {:hlx, "~> 0.5.0"}, {:ex_rtmp, "~> 0.4.1"}, + {:ex_webrtc, "~> 0.15.0"}, {:yaml_elixir, "~> 2.12"}, {:plug, "~> 1.19", optional: true}, {:bandit, "~> 1.8", optional: true}, diff --git a/mix.lock b/mix.lock index 3e1d075..30bdb61 100644 --- a/mix.lock +++ b/mix.lock @@ -1,20 +1,31 @@ %{ "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "bunch": {:hex, :bunch, "1.6.1", "5393d827a64d5f846092703441ea50e65bc09f37fd8e320878f13e63d410aec7", [:mix], [], "hexpm", "286cc3add551628b30605efbe2fca4e38cc1bea89bcd0a1a7226920b3364fe4a"}, + "bunch_native": {:hex, :bunch_native, "0.5.0", "8ac1536789a597599c10b652e0b526d8833348c19e4739a0759a2bedfd924e63", [:mix], [{:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "24190c760e32b23b36edeb2dc4852515c7c5b3b8675b1a864e0715bdd1c8f80d"}, + "bundlex": {:hex, :bundlex, "1.5.4", "3726acd463f4d31894a59bbc177c17f3b574634a524212f13469f41c4834a1d9", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:zarex, "~> 1.0", [hex: :zarex, repo: "hexpm", optional: false]}], "hexpm", "e745726606a560275182a8ac1c8ebd5e11a659bb7460d8abf30f397e59b4c5d2"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "coerce": {:hex, :coerce, "1.0.2", "5ef791040c92baaa5dd344887563faaeac6e6742573a167493294f8af3672bbe", [:mix], [], "hexpm", "0b3451c729571234fdac478636c298e71d1f2ce1243abed5fa43fa3181b980eb"}, "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, + "crc": {:hex, :crc, "0.10.6", "a52243715da06265399ade929b12e6807a82ddbd04231d8bd3069480aa890f01", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9e832833d48a5fff03cb7488f8aa5c08adda0a5fa8188bbe124cb17c4e39a00d"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, "ex_doc": {:hex, :ex_doc, "0.40.0", "2635974389b80fd3ca61b0f993d459dad05b4a8f9b069dcfbbc5f6a8a6aef60e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "c040735250e2752b6e1102eeb4aa3f1dca74c316db873ae09f955d42136e7e5b"}, "ex_flv": {:hex, :ex_flv, "0.4.0", "9e43c833b5cbe3c6e21bb2651ae7650f3ec939eac8079f34efed8f813bf9133d", [:mix], [], "hexpm", "484f6990791e0c8862a88e4150f004deb067c7342e40b779c82d5c9a9c057969"}, + "ex_dtls": {:hex, :ex_dtls, "0.18.0", "0815e3384bb0c1e6c06559012479cf9a94a501ddf46c3df54dc2d1b169e29d5c", [:mix], [{:bundlex, "~> 1.5.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "562eda1815eeaed8360b2b5c34d4db5b453794bc096404a4c64f193fa7b18bf2"}, + "ex_ice": {:hex, :ex_ice, "0.13.0", "13a6ae106b26bb5f2957a586bf20d4031299e5b968533828e637bb4ac7645d31", [:mix], [{:elixir_uuid, "~> 1.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}, {:ex_turn, "~> 0.2.0", [hex: :ex_turn, repo: "hexpm", optional: false]}], "hexpm", "0d65afa15e36b5610d0f51e72e4c25b22346caa9a6d7d2f6f1cfd8db94bd494e"}, + "ex_libsrtp": {:hex, :ex_libsrtp, "0.7.3", "f0a0dcb6c6518986c61a01ff47e99d71ff6eeef8108a207d92e3ab8a3687b435", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.2.1", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "0964a9ad35f4aa871a472fa827cfef8dcd3cbf22c912a32bc7b19a8769fbc744"}, "ex_m3u8": {:hex, :ex_m3u8, "0.15.4", "66f6ec7e4fb7372c48032db1c2d4a3e6c2bbbde2d1d9a1098986e3caa0ab7a55", [:mix], [{:nimble_parsec, "~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "ec03aa516919e0c8ec202da55f609b763bd7960195a3388900090fcad270c873"}, "ex_mp4": {:hex, :ex_mp4, "0.14.2", "c362b27c50fa8d5a16e4f5652963fcc47d5a61215eb729a0d6f8ec521575ed6d", [:mix], [{:media_codecs, "~> 0.10.0", [hex: :media_codecs, repo: "hexpm", optional: true]}, {:ratio, "~> 4.0", [hex: :ratio, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "3712c62a93ddde83419bb22e382a145c6527c8b002d8a22348202828022e1041"}, "ex_rtcp": {:hex, :ex_rtcp, "0.4.1", "e0f0c0baf329de92059e2afdc34d66d61f8b983f0801daa10f1a712360919e45", [:mix], [], "hexpm", "83ab3dffcffc6149eb404f1e1b1b62f755efd54c818e27c2c88e6ba3341c5d41"}, "ex_rtmp": {:hex, :ex_rtmp, "0.4.1", "1be8a1f75f2940d59ae07939218d1cdddac85de118370f0f001f816b8bac4576", [:mix], [{:ex_flv, "~> 0.4.0", [hex: :ex_flv, repo: "hexpm", optional: false]}], "hexpm", "58e1f993c575b6604e8256ed89887fcb7f7b80d0627e0bd71e9eea98bbe79766"}, "ex_rtp": {:hex, :ex_rtp, "0.4.0", "1f1b5c1440a904706011e3afbb41741f5da309ce251cb986690ce9fd82636658", [:mix], [], "hexpm", "0f72d80d5953a62057270040f0f1ee6f955c08eeae82ac659c038001d7d5a790"}, "ex_sdp": {:hex, :ex_sdp, "1.1.2", "7e7465cb13b557cc76ef3e854bad7626b73cc1d1f480d38b5fbcf539c7d8a45d", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "50a27c2d745924679acca32b3d5499d0b35d135a180b83422df82c289afce564"}, + "ex_stun": {:hex, :ex_stun, "0.2.0", "feb1fc7db0356406655b2a617805e6c712b93308c8ea2bf0ba1197b1f0866deb", [:mix], [], "hexpm", "1e01ba8290082ccbf37acaa5190d1f69b51edd6de2026a8d6d51368b29d115d0"}, + "ex_turn": {:hex, :ex_turn, "0.2.0", "4e1f9b089e9a5ee44928d12370cc9ea7a89b84b2f6256832de65271212eb80de", [:mix], [{:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "08e884f0af2c4a147e3f8cd4ffe33e3452a256389f0956e55a8c4d75bf0e74cd"}, + "ex_webrtc": {:hex, :ex_webrtc, "0.15.0", "c5849edcf7d035fcecf01db5be6d33a9d111999640bfc9d13a8c24e8eab7cced", [:mix], [{:crc, "~> 0.10", [hex: :crc, repo: "hexpm", optional: false]}, {:ex_dtls, "~> 0.18.0", [hex: :ex_dtls, repo: "hexpm", optional: false]}, {:ex_ice, "~> 0.13.0", [hex: :ex_ice, repo: "hexpm", optional: false]}, {:ex_libsrtp, "~> 0.7.1", [hex: :ex_libsrtp, repo: "hexpm", optional: false]}, {:ex_rtcp, "~> 0.4.0", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.0", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {:ex_sctp, "0.1.2", [hex: :ex_sctp, repo: "hexpm", optional: true]}, {:ex_sdp, "~> 1.0", [hex: :ex_sdp, repo: "hexpm", optional: false]}], "hexpm", "79c21017b45a464c513f87e64ae9a20c8085d937fb5e0d639c50a8c41018172d"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "hlx": {:hex, :hlx, "0.5.0", "22542ba77c4fd9a50d4566e546ff49b19d9b22d5110bf3b920d3d1f5f61ca9c1", [:mix], [{:ex_m3u8, "~> 0.15.0", [hex: :ex_m3u8, repo: "hexpm", optional: false]}, {:ex_mp4, "~> 0.14.0", [hex: :ex_mp4, repo: "hexpm", optional: false]}, {:media_codecs, "~> 0.10.0", [hex: :media_codecs, repo: "hexpm", optional: false]}, {:mpeg_ts, "~> 3.3.5", [hex: :mpeg_ts, repo: "hexpm", optional: false]}, {:qex, "~> 0.5.1", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "74bcb44fda7a2407b37c125385b2389b7922438aba05b13bbc6ab01f8ba42a70"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -22,12 +33,15 @@ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, "media_codecs": {:hex, :media_codecs, "0.10.0", "dcc64779c3b287202fd8083fe49bf11b37f7b6bbd8edf3a9bd756370ee4417c5", [:mix], [], "hexpm", "8ea233ae378acfae3ab95a90f6f5c99711d55f15d0c5fac244d46b42f6a9ca04"}, + "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.2.2", "0fbff1eb651619ce95abd7f9d19dd636ce460adc01bea36a440c48d1a6572a95", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "60296232d613856d22494303b64487bfa141666544f2e83a97f1d2dd28c34453"}, "membrane_rtsp": {:hex, :membrane_rtsp, "0.11.0", "887b1c0cd4f40f6ce93880bfa1a1e8c9e250aabb24810a8fe2a7556bb54c29c4", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:ex_sdp, "~> 0.17.0 or ~> 1.0", [hex: :ex_sdp, repo: "hexpm", optional: false]}, {:mockery, "~> 2.3", [hex: :mockery, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "69252d77ad3df48e6cb21fc16b0c5730607709714ad7849b7635813f9741ee2f"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mockery": {:hex, :mockery, "2.5.0", "a87acd74fd733aa3b9cb5663d6f690178b056608f2652f18e4ec423ddd5496ed", [:mix], [], "hexpm", "52492b2eba61055df1c626e894663b624b5e6fdfaaaba1d9a8596236fbf4da69"}, "mpeg_ts": {:hex, :mpeg_ts, "3.3.11", "77d69c5599fcd6eadef926b03cf6fe990dd76301ec41ce77de71bc84ad53412c", [:mix], [], "hexpm", "e1554e7b2ffe5692effca19173200fdee0959bd40d201ec920a950054c27cb76"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "numbers": {:hex, :numbers, "5.2.4", "f123d5bb7f6acc366f8f445e10a32bd403c8469bdbce8ce049e1f0972b607080", [:mix], [{:coerce, "~> 1.0", [hex: :coerce, repo: "hexpm", optional: false]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "eeccf5c61d5f4922198395bf87a465b6f980b8b862dd22d28198c5e6fab38582"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, @@ -35,10 +49,14 @@ "qex": {:hex, :qex, "0.5.1", "0d82c0f008551d24fffb99d97f8299afcb8ea9cf99582b770bd004ed5af63fd6", [:mix], [], "hexpm", "935a39fdaf2445834b95951456559e9dc2063d0a055742c558a99987b38d6bab"}, "ratio": {:hex, :ratio, "4.0.1", "3044166f2fc6890aa53d3aef0c336f84b2bebb889dc57d5f95cc540daa1912f8", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:numbers, "~> 5.2.0", [hex: :numbers, repo: "hexpm", optional: false]}], "hexpm", "c60cbb3ccdff9ffa56e7d6d1654b5c70d9f90f4d753ab3a43a6bf40855b881ce"}, "rtsp": {:hex, :rtsp, "0.8.1", "4bffebfcb0e1354283567178c040bbf40a85c4fbbde6d23addbbc7672cb3c700", [:mix], [{:ex_mp4, "~> 0.14.0", [hex: :ex_mp4, repo: "hexpm", optional: true]}, {:ex_rtcp, "~> 0.4.0", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.0", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {:ex_sdp, "~> 1.0", [hex: :ex_sdp, repo: "hexpm", optional: false]}, {:media_codecs, "~> 0.10.0", [hex: :media_codecs, repo: "hexpm", optional: false]}, {:membrane_rtsp, "~> 0.11.0", [hex: :membrane_rtsp, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "b4af3c30b8f79dd642940452c6ad6727bfd1df492e5ddbc1eb705f25df6f4053"}, + "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, + "shmex": {:hex, :shmex, "0.5.1", "81dd209093416bf6608e66882cb7e676089307448a1afd4fc906c1f7e5b94cf4", [:mix], [{:bunch_native, "~> 0.5.0", [hex: :bunch_native, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "c29f8286891252f64c4e1dac40b217d960f7d58def597c4e606ff8fbe71ceb80"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, + "unifex": {:hex, :unifex, "1.2.1", "6841c170a6e16509fac30b19e4e0a19937c33155a59088b50c15fc2c36251b6b", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:shmex, "~> 0.5.0", [hex: :shmex, repo: "hexpm", optional: false]}], "hexpm", "8c9d2e3c48df031e9995dd16865bab3df402c0295ba3a31f38274bb5314c7d37"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, + "zarex": {:hex, :zarex, "1.0.6", "f657ed1187e6e90472e24c92b1fd5bf3f846e74bd240bd77276c13f336a8d168", [:mix], [], "hexpm", "b628a9b0bc312f278af2c288078c31fd4757224b82d768e91bcf3bedbe3a50e7"}, }