diff --git a/.circleci/config.yml b/.circleci/config.yml index 63f16d2..c077fb4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,20 +7,16 @@ workflows: build: jobs: - elixir/build_test: - cache-version: 7 filters: &filters tags: only: /v.*/ - elixir/test: - cache-version: 7 filters: <<: *filters - elixir/lint: - cache-version: 7 filters: <<: *filters - elixir/hex_publish: - cache-version: 7 requires: - elixir/build_test - elixir/test diff --git a/examples.livemd b/examples.livemd index 8a2b3c1..8c1de99 100644 --- a/examples.livemd +++ b/examples.livemd @@ -6,7 +6,7 @@ Logger.configure(level: :info) # For ffmpeg and ffplay commands to work on Mac Livebook Desktop System.put_env("PATH", "/opt/homebrew/bin:#{System.get_env("PATH")}") -Mix.install([{:boombox, path: __DIR__}, :kino, :nx, :exla, :bumblebee]) +Mix.install([{:boombox, path: __DIR__, env: :test}, :kino, :nx, :exla, :bumblebee]) Nx.global_default_backend(EXLA.Backend) ``` @@ -87,10 +87,40 @@ Boombox.run(input: {:webrtc, "ws://localhost:8829"}, output: {:webrtc, "ws://loc To receive the stream, visit http://localhost:1234/hls/stream.html after running the cell below ```elixir -Boombox.run( - input: "#{input_dir}/bun10s.mp4", - output: {:hls, "#{__DIR__}/examples_assets/hls/hls_output"} -) +hls_out_dir = "#{__DIR__}/examples_assets/hls/hls_output" + +"#{hls_out_dir}/*" |> Path.wildcard() |> Enum.each(&File.rm!/1) + +Boombox.run(input: "#{input_dir}/bun10s.mp4", output: {:hls, hls_out_dir}) +``` + + + +## RTSP to HLS + +To receive the stream, visit http://localhost:1234/hls/stream.html after running the cell below + +```elixir +hls_out_dir = "#{__DIR__}/examples_assets/hls/hls_output" + +"#{hls_out_dir}/*" |> Path.wildcard() |> Enum.each(&File.rm!/1) + +rtp_server_port = 30_003 +rtsp_port = 8554 + +{:ok, server} = + Membrane.RTSP.Server.start_link( + handler: Membrane.Support.RTSP.Server.Handler, + handler_config: %{fixture_path: bbb_mp4}, + address: {127, 0, 0, 1}, + port: rtsp_port, + udp_rtp_port: rtp_server_port, + udp_rtcp_port: rtp_server_port + 1 + ) + +Boombox.run(input: "rtsp://localhost:#{rtsp_port}/livestream", output: "#{hls_out_dir}/index.m3u8") + +Membrane.RTSP.Server.stop(server) ``` diff --git a/lib/boombox.ex b/lib/boombox.ex index a45dad7..065d126 100644 --- a/lib/boombox.ex +++ b/lib/boombox.ex @@ -22,13 +22,14 @@ defmodule Boombox do | {:mp4, location :: String.t(), transport: :file | :http} | {:webrtc, webrtc_signaling()} | {:rtmp, (uri :: String.t()) | (client_handler :: pid)} + | {:rtsp, url :: String.t()} | {:stream, in_stream_opts()} @type output :: (path_or_uri :: String.t()) | {:mp4, location :: String.t()} | {:webrtc, webrtc_signaling()} - | {:hls, location :: String.t()} + | {:hls, location :: String.t(), transport: :file | :http} | {:stream, out_stream_opts()} @typep procs :: %{pipeline: pid(), supervisor: pid()} @@ -84,7 +85,8 @@ defmodule Boombox do {scheme, ".mp4", :input} when scheme in [nil, "http", "https"] -> {:mp4, value} {nil, ".mp4", :output} -> {:mp4, value} {scheme, _ext, :input} when scheme in ["rtmp", "rtmps"] -> {:rtmp, value} - {nil, ".m3u8", :output} -> {:hls, value} + {"rtsp", _ext, :input} -> {:rtsp, value} + {scheme, ".m3u8", :output} when scheme in [nil, "http", "https"] -> {:hls, value} _other -> raise ArgumentError, "Unsupported URI: #{value} for direction: #{direction}" end |> then(&parse_opt!(direction, &1)) @@ -113,6 +115,13 @@ defmodule Boombox do value {:hls, location} when direction == :output and is_binary(location) -> + parse_opt!(:output, {:hls, location, []}) + + {:hls, location, opts} when direction == :output and is_binary(location) -> + if Keyword.keyword?(opts), + do: {:hls, location, transport: resolve_transport(location, opts)} + + {:rtsp, location} when direction == :input and is_binary(location) -> value {:stream, opts} -> diff --git a/lib/boombox/hls.ex b/lib/boombox/hls.ex index 43065cb..b70e7ad 100644 --- a/lib/boombox/hls.ex +++ b/lib/boombox/hls.ex @@ -4,15 +4,87 @@ defmodule Boombox.HLS do import Membrane.ChildrenSpec require Membrane.Pad, as: Pad + alias Boombox.Pipeline.Ready - alias Membrane.Time + alias Membrane.{HTTPAdaptiveStream, Time, UtilitySupervisor} + + defmodule HTTPUploader do + @moduledoc false + use GenServer + + require Logger + + alias Membrane.HTTPAdaptiveStream.Storages.GenServerStorage + + @spec start_link(directory: String.t()) :: GenServer.on_start() + def start_link(config) do + GenServer.start_link(__MODULE__, config) + end + + @impl true + def init(config) do + {:ok, %{directory: config[:directory]}} + end + + @impl true + def handle_call( + {GenServerStorage, :store, %{context: %{type: :partial_segment}}}, + _from, + state + ) do + Logger.warning("LL-HLS is not supported. The partial segment is omitted.") + {:reply, :ok, state} + end + + @impl true + def handle_call({GenServerStorage, :store, params}, _from, state) do + location = Path.join(state.directory, params.name) + + reply = + :hackney.request(:post, location, [], params.contents, follow_redirect: true) + |> handle_request_result() + + {:reply, reply, state} + end + + @impl true + def handle_call({GenServerStorage, :remove, params}, _from, state) do + location = Path.join(state.directory, params.name) + + reply = + :hackney.request(:delete, location, [], <<>>, follow_redirect: true) + |> handle_request_result() + + {:reply, reply, state} + end + + @spec handle_request_result( + {:ok, pos_integer(), list(), :hackney.client_ref()} + | {:error, term()} + ) :: :ok | {:error, term()} + defp handle_request_result(result) do + case result do + {:ok, status, _headers, _ref} when status in 200..299 -> + :ok + + {:ok, status, _headers, ref} -> + {:ok, body} = :hackney.body(ref) + {:error, "Request failed with status code #{status}: #{body}"} + + error -> + error + end + end + end @spec link_output( Path.t(), Boombox.Pipeline.track_builders(), - Membrane.ChildrenSpec.t() + Membrane.ChildrenSpec.t(), + UtilitySupervisor.t(), + transport: :file | :http ) :: Ready.t() - def link_output(location, track_builders, spec_builder) do + def link_output(location, track_builders, spec_builder, utility_supervisor, opts) do {directory, manifest_name} = if Path.extname(location) == ".m3u8" do {Path.dirname(location), Path.basename(location, ".m3u8")} @@ -20,6 +92,24 @@ defmodule Boombox.HLS do {location, "index"} end + storage = + case opts[:transport] do + :file -> + %HTTPAdaptiveStream.Storages.FileStorage{directory: directory} + + :http -> + {:ok, uploader} = + UtilitySupervisor.start_link_child( + utility_supervisor, + {HTTPUploader, directory: directory} + ) + + %HTTPAdaptiveStream.Storages.GenServerStorage{destination: uploader} + end + + hls_mode = + if Map.keys(track_builders) == [:video], do: :separate_av, else: :muxed_av + spec = [ spec_builder, @@ -28,10 +118,8 @@ defmodule Boombox.HLS do %Membrane.HTTPAdaptiveStream.SinkBin{ manifest_name: manifest_name, manifest_module: Membrane.HTTPAdaptiveStream.HLS, - storage: %Membrane.HTTPAdaptiveStream.Storages.FileStorage{ - directory: directory - }, - hls_mode: :muxed_av + storage: storage, + hls_mode: hls_mode } ), Enum.map(track_builders, fn diff --git a/lib/boombox/mp4.ex b/lib/boombox/mp4.ex index 7b628b9..5b896a0 100644 --- a/lib/boombox/mp4.ex +++ b/lib/boombox/mp4.ex @@ -38,7 +38,10 @@ defmodule Boombox.MP4 do {:audio, spec} {id, %Membrane.H264{}} -> - spec = get_child(:mp4_demuxer) |> via_out(Pad.ref(:output, id)) + spec = + get_child(:mp4_demuxer) + |> via_out(Pad.ref(:output, id)) + {:video, spec} end) diff --git a/lib/boombox/pipeline.ex b/lib/boombox/pipeline.ex index ec3f64a..f48c044 100644 --- a/lib/boombox/pipeline.ex +++ b/lib/boombox/pipeline.ex @@ -61,7 +61,8 @@ defmodule Boombox.Pipeline do spec_builder: [], track_builders: nil, last_result: nil, - eos_info: nil + eos_info: nil, + rtsp_state: nil ] @typedoc """ @@ -90,6 +91,7 @@ defmodule Boombox.Pipeline do track_builders: Boombox.Pipeline.track_builders() | nil, last_result: Boombox.Pipeline.Ready.t() | Boombox.Pipeline.Wait.t() | nil, eos_info: term(), + rtsp_state: Boombox.RTSP.rtsp_state() | nil, parent: pid() } end @@ -112,6 +114,18 @@ defmodule Boombox.Pipeline do |> proceed_result(ctx, state) end + @impl true + def handle_child_notification({:set_up_tracks, tracks}, :rtsp_source, ctx, state) do + {result, state} = Boombox.RTSP.handle_set_up_tracks(tracks, state) + proceed_result(result, ctx, state) + end + + @impl true + def handle_child_notification({:new_track, ssrc, track}, :rtsp_source, ctx, state) do + {result, state} = Boombox.RTSP.handle_input_track(ssrc, track, state) + proceed_result(result, ctx, state) + end + @impl true def handle_child_notification({:new_tracks, tracks}, :webrtc_input, ctx, state) do Boombox.WebRTC.handle_input_tracks(tracks) @@ -284,6 +298,10 @@ defmodule Boombox.Pipeline do Boombox.RTMP.create_input(src, ctx.utility_supervisor) end + defp create_input({:rtsp, uri}, _ctx, _state) do + Boombox.RTSP.create_input(uri) + end + defp create_input({:stream, params}, _ctx, state) do Boombox.ElixirStream.create_input(state.parent, params) end @@ -314,8 +332,8 @@ defmodule Boombox.Pipeline do Boombox.MP4.link_output(location, track_builders, spec_builder) end - defp link_output({:hls, location}, track_builders, spec_builder, _ctx, _state) do - Boombox.HLS.link_output(location, track_builders, spec_builder) + defp link_output({:hls, location, opts}, track_builders, spec_builder, ctx, _state) do + Boombox.HLS.link_output(location, track_builders, spec_builder, ctx.utility_supervisor, opts) end defp link_output({:stream, opts}, track_builders, spec_builder, _ctx, state) do diff --git a/lib/boombox/rtmp.ex b/lib/boombox/rtmp.ex index 8adbafe..59e82cd 100644 --- a/lib/boombox/rtmp.ex +++ b/lib/boombox/rtmp.ex @@ -4,9 +4,9 @@ defmodule Boombox.RTMP do import Membrane.ChildrenSpec require Membrane.Logger alias Boombox.Pipeline.{Ready, Wait} - alias Membrane.{RTMP, RTMPServer} + alias Membrane.{RTMP, RTMPServer, UtilitySupervisor} - @spec create_input(String.t() | pid(), pid()) :: Wait.t() + @spec create_input(String.t() | pid(), UtilitySupervisor.t()) :: Wait.t() def create_input(client_ref, _utility_supervisor) when is_pid(client_ref) do handle_connection(client_ref) end @@ -43,12 +43,11 @@ defmodule Boombox.RTMP do @spec handle_connection(pid()) :: Ready.t() def handle_connection(client_ref) do - spec = [ + spec = child(:rtmp_source, %RTMP.SourceBin{client_ref: client_ref}) |> via_out(:audio) |> child(:rtmp_in_aac_parser, Membrane.AAC.Parser) |> child(:rtmp_in_aac_decoder, Membrane.AAC.FDK.Decoder) - ] track_builders = %{ audio: get_child(:rtmp_in_aac_decoder), diff --git a/lib/boombox/rtsp.ex b/lib/boombox/rtsp.ex new file mode 100644 index 0000000..0ed056e --- /dev/null +++ b/lib/boombox/rtsp.ex @@ -0,0 +1,104 @@ +defmodule Boombox.RTSP do + @moduledoc false + import Membrane.ChildrenSpec + + require Membrane.Pad + + require Membrane.Logger + alias Membrane.{RTP, RTSP} + alias Boombox.Pipeline.{Ready, State, Wait} + + @type rtsp_state :: %{ + set_up_tracks: %{ + optional(:audio) => Membrane.RTSP.Source.track(), + optional(:video) => Membrane.RTSP.Source.track() + }, + tracks_left_to_link: non_neg_integer(), + track_builders: Boombox.Pipeline.track_builders() + } + + @spec create_input(URI.t()) :: Wait.t() + def create_input(uri) do + port = Enum.random(5_000..65_000) + + spec = + child(:rtsp_source, %RTSP.Source{ + transport: {:udp, port, port + 20}, + allowed_media_types: [:video, :audio], + stream_uri: uri, + on_connection_closed: :send_eos + }) + + %Wait{actions: [spec: spec]} + end + + @spec handle_set_up_tracks([RTSP.Source.track()], State.t()) :: {Wait.t(), State.t()} + def handle_set_up_tracks(tracks, state) do + rtsp_state = %{ + set_up_tracks: Map.new(tracks, fn track -> {track.type, track} end), + tracks_left_to_link: length(tracks), + track_builders: %{} + } + + {%Wait{}, %{state | rtsp_state: rtsp_state}} + end + + @spec handle_input_track(RTP.ssrc_t(), RTSP.Source.track(), State.t()) :: + {Ready.t() | Wait.t(), State.t()} + def handle_input_track(ssrc, track, state) do + track_builders = state.rtsp_state.track_builders + + {spec, track_builders} = + case track do + %{type: type} when is_map_key(track_builders, type) -> + Membrane.Logger.warning( + "Tried to link a track of type #{inspect(type)}, but another track + of that type has already been received" + ) + + spec = + get_child(:rtsp_source) + |> via_out(Membrane.Pad.ref(:output, ssrc)) + |> child(Membrane.Debug.Sink) + + {spec, track_builders} + + %{rtpmap: %{encoding: "H264"}} -> + {spss, ppss} = + case track.fmtp.sprop_parameter_sets do + nil -> {[], []} + parameter_sets -> {parameter_sets.sps, parameter_sets.pps} + end + + video_spec = + get_child(:rtsp_source) + |> via_out(Membrane.Pad.ref(:output, ssrc)) + |> child(:rtsp_in_h264_parser, %Membrane.H264.Parser{spss: spss, ppss: ppss}) + + {[], Map.put(track_builders, :video, video_spec)} + + %{rtpmap: %{encoding: "MP4A-LATM"}} -> + audio_spec = + get_child(:rtsp_source) + |> via_out(Membrane.Pad.ref(:output, ssrc)) + |> child(:rtsp_in_aac_parser, Membrane.AAC.Parser) + |> child(:rtsp_in_aac_decoder, Membrane.AAC.FDK.Decoder) + + {[], Map.put(track_builders, :audio, audio_spec)} + + %{rtpmap: %{encoding: unsupported_encoding}} -> + raise "Received unsupported encoding with RTSP: #{inspect(unsupported_encoding)}" + end + + state = + state + |> Bunch.Struct.put_in([:rtsp_state, :track_builders], track_builders) + |> Bunch.Struct.update_in([:rtsp_state, :tracks_left_to_link], &(&1 - 1)) + + if state.rtsp_state.tracks_left_to_link == 0 do + {%Ready{actions: [spec: spec], track_builders: track_builders}, state} + else + {%Wait{actions: [spec: spec]}, state} + end + end +end diff --git a/mix.exs b/mix.exs index 508e88b..0ef02b4 100644 --- a/mix.exs +++ b/mix.exs @@ -52,14 +52,17 @@ defmodule Boombox.Mixfile do {:membrane_aac_fdk_plugin, "~> 0.18.0"}, {:membrane_h26x_plugin, "~> 0.10.0"}, {:membrane_h264_ffmpeg_plugin, "~> 0.32.0"}, - {:membrane_mp4_plugin, "~> 0.35.2", override: true}, + {:membrane_mp4_plugin, "~> 0.35.2"}, {:membrane_realtimer_plugin, "~> 0.9.0"}, - {:membrane_http_adaptive_stream_plugin, "~> 0.18.0"}, + {:membrane_http_adaptive_stream_plugin, "~> 0.18.5"}, {:membrane_rtmp_plugin, "~> 0.25.0"}, + {:membrane_rtsp_plugin, "~> 0.3.0"}, + {:membrane_rtp_plugin, "~> 0.29.0"}, {:membrane_ffmpeg_swresample_plugin, "~> 0.20.0"}, {:membrane_hackney_plugin, "~> 0.11.0"}, {:membrane_ffmpeg_swscale_plugin, "~> 0.16.0"}, {:image, "~> 0.54.0"}, + {:bandit, "~> 1.5"}, {:burrito, "~> 1.0", runtime: burrito?()}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index aff6690..b6f3576 100644 --- a/mix.lock +++ b/mix.lock @@ -5,7 +5,7 @@ "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.3", "35d01e5bc0679510dd9a327936ffb518f63f47175c26a35e708cc29eaec0890b", [: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", "debd0eac151b404f6216fc60222761dff049bf26f7d24d066c365317650cd118"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "burrito": {:hex, :burrito, "1.1.0", "4f26919234e144be9c3f5eb3fbd01e63395816376cf742b3570433167e46ede4", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "decda65f57271d38c84a34e262b40636414f9f58b2b22f243e782938bfc2a414"}, + "burrito": {:hex, :burrito, "1.1.1", "50ecc98644f2454d856fec600b607b0f353e9e2e81a00c2a63751daafc728214", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "1f3bd1165cef588f09f03f4f31e16852385a023768a780eaea77e20a327ad8fa"}, "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, @@ -50,12 +50,12 @@ "membrane_file_plugin": {:hex, :membrane_file_plugin, "0.17.2", "650e134c2345d946f930082fac8bac9f5aba785a7817d38a9a9da41ffc56fa92", [:mix], [{:logger_backends, "~> 1.0", [hex: :logger_backends, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "df50c6040004cd7b901cf057bd7e99c875bbbd6ae574efc93b2c753c96f43b9d"}, "membrane_flv_plugin": {:hex, :membrane_flv_plugin, "0.12.0", "d715ad405af86dcaf4b2f479e34088e1f6738c7280366828e1066b39d2aa493a", [:mix], [{:membrane_aac_format, "~> 0.8.0", [hex: :membrane_aac_format, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.1", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}], "hexpm", "a317872d6d394e550c7bfd8979f12a3a1cc1e89b547d75360321025b403d3279"}, "membrane_funnel_plugin": {:hex, :membrane_funnel_plugin, "0.9.0", "9cfe09e44d65751f7d9d8d3c42e14797f7be69e793ac112ea63cd224af70a7bf", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "988790aca59d453a6115109f050699f7f45a2eb6a7f8dc5c96392760cddead54"}, - "membrane_h264_ffmpeg_plugin": {:hex, :membrane_h264_ffmpeg_plugin, "0.32.1", "1e9eb5647dd5fcfc4a35b69b6c5bdaad27e51d3f8fc0a7fe17cb02b710353cbf", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_common_c, "~> 0.16.0", [hex: :membrane_common_c, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.1", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:membrane_raw_video_format, "~> 0.3.0", [hex: :membrane_raw_video_format, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "e28aafb236587c6e093d610f3e4ee5fd6801250152f4743037b096b49e6e9a53"}, + "membrane_h264_ffmpeg_plugin": {:hex, :membrane_h264_ffmpeg_plugin, "0.32.2", "e8a51e4c85f223a6c8fe2a6e350f2694cd32b703119b76835882281cb92348a8", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_common_c, "~> 0.16.0", [hex: :membrane_common_c, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.1", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:membrane_raw_video_format, "~> 0.3.0", [hex: :membrane_raw_video_format, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "abca6a95132fe4983507168238387f97c3103d09ed6fa4028acc0de8059f7569"}, "membrane_h264_format": {:hex, :membrane_h264_format, "0.6.1", "44836cd9de0abe989b146df1e114507787efc0cf0da2368f17a10c47b4e0738c", [:mix], [], "hexpm", "4b79be56465a876d2eac2c3af99e115374bbdc03eb1dea4f696ee9a8033cd4b0"}, "membrane_h265_format": {:hex, :membrane_h265_format, "0.2.0", "1903c072cf7b0980c4d0c117ab61a2cd33e88782b696290de29570a7fab34819", [:mix], [], "hexpm", "6df418bdf242c0d9f7dbf2e5aea4c2d182e34ac9ad5a8b8cef2610c290002e83"}, "membrane_h26x_plugin": {:hex, :membrane_h26x_plugin, "0.10.2", "caf2790d8c107df35f8d456b45f4e09fb9c56ce6c7669a3a03f7d59972e6ed82", [:mix], [{:bunch, "~> 1.4", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.0", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}, {:membrane_h265_format, "~> 0.2.0", [hex: :membrane_h265_format, repo: "hexpm", optional: false]}], "hexpm", "becf1ac4a589adecd850137ccd61a33058f686083a514a7e39fcd721bcf9fb2e"}, "membrane_hackney_plugin": {:hex, :membrane_hackney_plugin, "0.11.0", "54b368333a23394e7cac2f4d6b701bf8c5ee6614670a31f4ebe009b5e691a5c1", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:mockery, "~> 2.3", [hex: :mockery, repo: "hexpm", optional: false]}], "hexpm", "2b28fd1be3c889d5824d7d985598386c7673828c88f49a91221df3626af8a998"}, - "membrane_http_adaptive_stream_plugin": {:hex, :membrane_http_adaptive_stream_plugin, "0.18.4", "f938907eb0e39db2acf8c84e0b770c1976b2793bd58c2e37eca0334dc019f11d", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_aac_plugin, "~> 0.18.0", [hex: :membrane_aac_plugin, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h26x_plugin, "~> 0.10.0", [hex: :membrane_h26x_plugin, repo: "hexpm", optional: false]}, {:membrane_mp4_plugin, "~> 0.34.1", [hex: :membrane_mp4_plugin, repo: "hexpm", optional: false]}, {:membrane_tee_plugin, "~> 0.12.0", [hex: :membrane_tee_plugin, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "3f9174faf1f734f1b8507aeea0525384869797e51b88f1b2e33931bae9de32fc"}, + "membrane_http_adaptive_stream_plugin": {:hex, :membrane_http_adaptive_stream_plugin, "0.18.5", "4b9b0e8c9fe058a4076c9d0dcc35309e5a1d83c7e32d17a5419cb9a3f7dfca56", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_aac_plugin, "~> 0.18.0", [hex: :membrane_aac_plugin, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h26x_plugin, "~> 0.10.0", [hex: :membrane_h26x_plugin, repo: "hexpm", optional: false]}, {:membrane_mp4_plugin, "~> 0.35.0", [hex: :membrane_mp4_plugin, repo: "hexpm", optional: false]}, {:membrane_tee_plugin, "~> 0.12.0", [hex: :membrane_tee_plugin, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "a37facbae4b4c0c497661891c9f704784c383b54b76a67c02f5538ead14ac810"}, "membrane_mp4_format": {:hex, :membrane_mp4_format, "0.8.0", "8c6e7d68829228117d333b4fbb030e7be829aab49dd8cb047fdc664db1812e6a", [:mix], [], "hexpm", "148dea678a1f82ccfd44dbde6f936d2f21255f496cb45a22cc6eec427f025522"}, "membrane_mp4_plugin": {:hex, :membrane_mp4_plugin, "0.35.2", "cbedb5272ef1c8f7d9cd3c44f820a90306469b1dc84b8db30ff55bb6195b7cb2", [:mix], [{:bunch, "~> 1.5", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_aac_format, "~> 0.8.0", [hex: :membrane_aac_format, repo: "hexpm", optional: false]}, {:membrane_cmaf_format, "~> 0.7.0", [hex: :membrane_cmaf_format, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_file_plugin, "~> 0.17.0", [hex: :membrane_file_plugin, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.1", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}, {:membrane_h265_format, "~> 0.2.0", [hex: :membrane_h265_format, repo: "hexpm", optional: false]}, {:membrane_mp4_format, "~> 0.8.0", [hex: :membrane_mp4_format, repo: "hexpm", optional: false]}, {:membrane_opus_format, "~> 0.3.0", [hex: :membrane_opus_format, repo: "hexpm", optional: false]}, {:membrane_timestamp_queue, "~> 0.2.1", [hex: :membrane_timestamp_queue, repo: "hexpm", optional: false]}], "hexpm", "8afd4e7779a742dd56c23f1f23053933d1b0b34d397ad368a2f56f995edb2fe0"}, "membrane_opus_format": {:hex, :membrane_opus_format, "0.3.0", "3804d9916058b7cfa2baa0131a644d8186198d64f52d592ae09e0942513cb4c2", [:mix], [], "hexpm", "8fc89c97be50de23ded15f2050fe603dcce732566fe6fdd15a2de01cb6b81afe"}, @@ -67,12 +67,17 @@ "membrane_rtmp_plugin": {:hex, :membrane_rtmp_plugin, "0.25.0", "7a208ca84ccc97108296f8d60b7c9348466387de166a66637ba11a0ce9156f24", [:mix], [{:membrane_aac_plugin, "~> 0.18.1", [hex: :membrane_aac_plugin, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_file_plugin, "~> 0.17.0", [hex: :membrane_file_plugin, repo: "hexpm", optional: false]}, {:membrane_flv_plugin, "~> 0.12.0", [hex: :membrane_flv_plugin, repo: "hexpm", optional: false]}, {:membrane_funnel_plugin, "~> 0.9.0", [hex: :membrane_funnel_plugin, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.1", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}, {:membrane_h26x_plugin, "~> 0.10.0", [hex: :membrane_h26x_plugin, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:unifex, "~> 1.2.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "f508cf27a0af274a3e06ee01e91115076123b37104434817dd38006bcda55d44"}, "membrane_rtp_format": {:hex, :membrane_rtp_format, "0.8.0", "828924bbd27efcf85b2015ae781e824c4a9928f0a7dc132abc66817b2c6edfc4", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "bc75d2a649dfaef6df563212fbb9f9f62eebc871393692f9dae8d289bd4f94bb"}, "membrane_rtp_h264_plugin": {:hex, :membrane_rtp_h264_plugin, "0.19.2", "de3eeaf35052f9f709d469fa7630d9ecc8f5787019f7072516eae1fd881bc792", [:mix], [{:bunch, "~> 1.5", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.0", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}, {:membrane_rtp_format, "~> 0.8.0", [hex: :membrane_rtp_format, repo: "hexpm", optional: false]}], "hexpm", "d298e9cd471ab3601366c48ca0fec84135966707500152bbfcf3f968700647ae"}, + "membrane_rtp_h265_plugin": {:hex, :membrane_rtp_h265_plugin, "0.5.1", "1e72309e340eaae5fce04f47b7b563accd563ab10bac139596626f0f0b4c72af", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h265_format, "~> 0.2.0", [hex: :membrane_h265_format, repo: "hexpm", optional: false]}, {:membrane_rtp_format, "~> 0.8.0", [hex: :membrane_rtp_format, repo: "hexpm", optional: false]}], "hexpm", "283d4b1b0271719f300b3bad4e05bef4db1cf3190f87291785e3f973106a1476"}, "membrane_rtp_opus_plugin": {:hex, :membrane_rtp_opus_plugin, "0.9.0", "ae76421faa04697a4af76a55b6c5e675dea61b611d29d8201098783d42863af7", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_opus_format, "~> 0.3.0", [hex: :membrane_opus_format, repo: "hexpm", optional: false]}, {:membrane_rtp_format, "~> 0.8.0", [hex: :membrane_rtp_format, repo: "hexpm", optional: false]}], "hexpm", "58f095d2978daf999d87c1c016007cb7d99434208486331ab5045e77f5be9dcc"}, "membrane_rtp_plugin": {:hex, :membrane_rtp_plugin, "0.29.0", "0277310eb599b8e6de9e0b864807f23b3b245865e39a28f0cbab695d1f2c157e", [:mix], [{:bimap, "~> 1.2", [hex: :bimap, repo: "hexpm", optional: false]}, {:bunch, "~> 1.5", [hex: :bunch, repo: "hexpm", optional: false]}, {:ex_libsrtp, "~> 0.6.0 or ~> 0.7.0", [hex: :ex_libsrtp, repo: "hexpm", optional: true]}, {:heap, "~> 2.0.2", [hex: :heap, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_funnel_plugin, "~> 0.9.0", [hex: :membrane_funnel_plugin, repo: "hexpm", optional: false]}, {:membrane_rtp_format, "~> 0.8.0", [hex: :membrane_rtp_format, repo: "hexpm", optional: false]}, {:membrane_telemetry_metrics, "~> 0.1.0", [hex: :membrane_telemetry_metrics, repo: "hexpm", optional: false]}, {:qex, "~> 0.5.1", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "1b3fd808114e06332b6a4e000238998a9188d1ef625c414ca3239aee70f0775d"}, "membrane_rtp_vp8_plugin": {:hex, :membrane_rtp_vp8_plugin, "0.9.1", "9e8a74d764730a23382ba862a238963c9639b4c6963238caeb6fe2449a66add8", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_rtp_format, "~> 0.8.0", [hex: :membrane_rtp_format, repo: "hexpm", optional: false]}, {:membrane_vp8_format, "~> 0.4.0", [hex: :membrane_vp8_format, repo: "hexpm", optional: false]}], "hexpm", "704856eb2734bb6ea5cc47242c241de45debb5724a81cffb344bacda9867fe98"}, + "membrane_rtsp": {:hex, :membrane_rtsp, "0.9.0", "6232f716bdf128b9893bc8302d6d81a0374eef2f114a65bf0eafcdb8d681a98f", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:ex_sdp, "~> 0.17.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", "b1c4c91dd38b3c4c41f39c7ba025c892e6c85a23eabd1a5e2bdc6f555cbc39e9"}, + "membrane_rtsp_plugin": {:hex, :membrane_rtsp_plugin, "0.3.0", "07dabd7a34470c0f9e9c5e2359c4785fcb99794f1472cb27fef1b1da095110eb", [:mix], [{:membrane_core, "~> 1.1", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h26x_plugin, "~> 0.10.0", [hex: :membrane_h26x_plugin, repo: "hexpm", optional: false]}, {:membrane_rtp_h264_plugin, "~> 0.19.0", [hex: :membrane_rtp_h264_plugin, repo: "hexpm", optional: false]}, {:membrane_rtp_h265_plugin, "~> 0.5.1", [hex: :membrane_rtp_h265_plugin, repo: "hexpm", optional: false]}, {:membrane_rtp_plugin, "~> 0.29.0", [hex: :membrane_rtp_plugin, repo: "hexpm", optional: false]}, {:membrane_rtsp, "~> 0.9.0", [hex: :membrane_rtsp, repo: "hexpm", optional: false]}, {:membrane_tcp_plugin, "~> 0.6.0", [hex: :membrane_tcp_plugin, repo: "hexpm", optional: false]}, {:membrane_udp_plugin, "~> 0.14.0", [hex: :membrane_udp_plugin, repo: "hexpm", optional: false]}], "hexpm", "de2c23abfad57cf75db25aa0deca0bd5d3206c0d85868263a59ed29ca1a94fe1"}, + "membrane_tcp_plugin": {:hex, :membrane_tcp_plugin, "0.6.0", "1f8dba5525504fb2d49070932f24113d1b26c7e5429c700671ed80433ac83f2f", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:mockery, "~> 2.3.0", [hex: :mockery, repo: "hexpm", optional: false]}], "hexpm", "820440f5a8181a96cff461ad2d5ed426d47eacfdd7764dd9596dad68ad892d3d"}, "membrane_tee_plugin": {:hex, :membrane_tee_plugin, "0.12.0", "f94989b4080ef4b7937d74c1a14d3379577c7bd4c6d06e5a2bb41c351ad604d4", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "0d61c9ed5e68e5a75d54200e1c6df5739c0bcb52fee0974183ad72446a179887"}, "membrane_telemetry_metrics": {:hex, :membrane_telemetry_metrics, "0.1.0", "cb93d28356b436b0597736c3e4153738d82d2a14ff547f831df7e9051e54fc06", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.1", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "aba28dc8311f70ced95d984509be930fac55857d2d18bffcf768815e627be3f0"}, "membrane_timestamp_queue": {:hex, :membrane_timestamp_queue, "0.2.2", "1c831b2273d018a6548654aa9f7fa7c4b683f71d96ffe164934ef55f9d11f693", [:mix], [{:heap, "~> 2.0", [hex: :heap, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "7c830e760baaced0988421671cd2c83c7cda8d1bd2b61fd05332711675d1204f"}, + "membrane_udp_plugin": {:hex, :membrane_udp_plugin, "0.14.0", "d533ee5f6fcdd0551ad690045cdb6c1a76307a155d9255cc4a4606f85774bc37", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:mockery, "~> 2.3.0", [hex: :mockery, repo: "hexpm", optional: false]}], "hexpm", "902d1a7aa228ec377482d53a605b100e20e0b6e59196f94f94147bb62b23c47e"}, "membrane_vp8_format": {:hex, :membrane_vp8_format, "0.4.0", "6c29ec67479edfbab27b11266dc92f18f3baf4421262c5c31af348c33e5b92c7", [:mix], [], "hexpm", "8bb005ede61db8fcb3535a883f32168b251c2dfd1109197c8c3b39ce28ed08e2"}, "membrane_webrtc_plugin": {:hex, :membrane_webrtc_plugin, "0.21.0", "0d47a6ffe3eb18abf43e9f6d089a409120ecd5cff43095d065fbb9e1c038f79c", [:mix], [{:bandit, "~> 1.2", [hex: :bandit, repo: "hexpm", optional: false]}, {:ex_webrtc, "~> 0.3.0", [hex: :ex_webrtc, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_rtp_h264_plugin, "~> 0.19.0", [hex: :membrane_rtp_h264_plugin, repo: "hexpm", optional: false]}, {:membrane_rtp_opus_plugin, "~> 0.9.0", [hex: :membrane_rtp_opus_plugin, repo: "hexpm", optional: false]}, {:membrane_rtp_plugin, "~> 0.29.0", [hex: :membrane_rtp_plugin, repo: "hexpm", optional: false]}, {:membrane_rtp_vp8_plugin, "~> 0.9.1", [hex: :membrane_rtp_vp8_plugin, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.0", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "39d383eadb1b1ce10975ac8505012e901c8961e6f5a65577ff0fbf03b7bc8fc7"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, @@ -90,7 +95,7 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "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"}, - "req": {:hex, :req, "0.4.0", "1c759054dd64ef1b1a0e475c2d2543250d18f08395d3174c371b7746984579ce", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [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", "f53eadc32ebefd3e5d50390356ec3a59ed2b8513f7da8c6c3f2e14040e9fe989"}, + "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [: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", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, "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"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, diff --git a/test/boombox_test.exs b/test/boombox_test.exs index b935ea7..98dca42 100644 --- a/test/boombox_test.exs +++ b/test/boombox_test.exs @@ -209,6 +209,25 @@ defmodule BoomboxTest do end) end + @tag :file_hls_http + async_test "mp4 file -> http hls", %{tmp_dir: tmp} do + port = 9090 + Bandit.start_link(plug: {HTTPServer.Router, %{directory: tmp}}, port: port) + url = "http://localhost:#{port}/hls_output/index.m3u8" + + Boombox.run(input: @bbb_mp4, output: url) + ref_path = "test/fixtures/ref_bun10s_aac_hls" + Compare.compare(tmp, ref_path, format: :hls) + + Enum.zip( + Path.join(tmp, "*.mp4") |> Path.wildcard(), + Path.join(ref_path, "*.mp4") |> Path.wildcard() + ) + |> Enum.each(fn {output_file, ref_file} -> + assert File.read!(output_file) == File.read!(ref_file) + end) + end + @tag :rtmp_hls async_test "rtmp -> hls", %{tmp_dir: tmp} do manifest_filename = Path.join(tmp, "index.m3u8") @@ -232,6 +251,77 @@ defmodule BoomboxTest do end) end + @tag :rtsp_mp4_video + async_test "rtsp video -> mp4", %{tmp_dir: tmp} do + rtp_server_port = 30_003 + rtsp_port = 8554 + output = Path.join(tmp, "output.mp4") + + {:ok, _server} = + Membrane.RTSP.Server.start_link( + handler: Membrane.Support.RTSP.Server.Handler, + handler_config: %{fixture_path: @bbb_mp4}, + address: {127, 0, 0, 1}, + port: rtsp_port, + udp_rtp_port: rtp_server_port, + udp_rtcp_port: rtp_server_port + 1 + ) + + Boombox.run(input: "rtsp://localhost:#{rtsp_port}/livestream", output: output) + Compare.compare(output, "test/fixtures/ref_bun10s_rtsp.mp4", kinds: [:video]) + end + + @tag :rtsp_hls_video + async_test "rtsp video -> hls", %{tmp_dir: tmp} do + rtp_server_port = 30_005 + rtsp_port = 8555 + + {:ok, _server} = + Membrane.RTSP.Server.start_link( + handler: Membrane.Support.RTSP.Server.Handler, + handler_config: %{fixture_path: @bbb_mp4}, + address: {127, 0, 0, 1}, + port: rtsp_port, + udp_rtp_port: rtp_server_port, + udp_rtcp_port: rtp_server_port + 1 + ) + + manifest_filename = Path.join(tmp, "index.m3u8") + Boombox.run(input: "rtsp://localhost:#{rtsp_port}/livestream", output: manifest_filename) + ref_path = "test/fixtures/ref_bun10s_aac_hls" + Compare.compare(tmp, ref_path, kinds: [:video], format: :hls) + end + + @tag :rtsp_webrtc_mp4_video + async_test "rtsp video -> webrtc -> mp4", %{tmp_dir: tmp} do + rtp_server_port = 30_007 + rtsp_port = 8556 + output = Path.join(tmp, "output.mp4") + signaling = Membrane.WebRTC.SignalingChannel.new() + + {:ok, _server} = + Membrane.RTSP.Server.start_link( + handler: Membrane.Support.RTSP.Server.Handler, + handler_config: %{fixture_path: @bbb_mp4_v}, + address: {127, 0, 0, 1}, + port: rtsp_port, + udp_rtp_port: rtp_server_port, + udp_rtcp_port: rtp_server_port + 1 + ) + + t = + Task.async(fn -> + Boombox.run( + input: "rtsp://localhost:#{rtsp_port}/livestream", + output: {:webrtc, signaling} + ) + end) + + Boombox.run(input: {:webrtc, signaling}, output: output) + Task.await(t) + Compare.compare(output, "test/fixtures/ref_bun10s_rtsp.mp4", kinds: [:video]) + end + @tag :mp4_elixir_rotate_mp4 async_test "mp4 -> elixir rotate -> mp4", %{tmp_dir: tmp} do Boombox.run(input: @bbb_mp4, output: {:stream, video: :image, audio: :binary}) diff --git a/test/fixtures/ref_bun10s_rtsp.mp4 b/test/fixtures/ref_bun10s_rtsp.mp4 new file mode 100644 index 0000000..451671e Binary files /dev/null and b/test/fixtures/ref_bun10s_rtsp.mp4 differ diff --git a/test/support/compare.ex b/test/support/compare.ex index 336c34e..6676a3a 100644 --- a/test/support/compare.ex +++ b/test/support/compare.ex @@ -58,10 +58,10 @@ defmodule Support.Compare do Testing.Pipeline.execute_actions(p, spec: head_spec) - assert_pipeline_notified(p, :ref_demuxer, {:new_tracks, tracks}) + assert_pipeline_notified(p, :ref_demuxer, {:new_tracks, ref_tracks}) ref_spec = - Enum.map(tracks, fn + Enum.map(ref_tracks, fn {id, %Membrane.AAC{}} -> get_child(:ref_demuxer) |> via_out(Pad.ref(:output, id)) @@ -77,12 +77,12 @@ defmodule Support.Compare do |> child(:ref_video_bufs, GetBuffers) end) - assert_pipeline_notified(p, :sub_demuxer, {:new_tracks, tracks}) + assert_pipeline_notified(p, :sub_demuxer, {:new_tracks, sub_tracks}) - assert length(tracks) == length(kinds) + assert length(sub_tracks) == length(kinds) sub_spec = - Enum.map(tracks, fn + Enum.map(sub_tracks, fn {id, %Membrane.AAC{}} -> assert :audio in kinds @@ -116,7 +116,7 @@ defmodule Support.Compare do # and subsequent runs due to transcoding. # The threshold here is obtained empirically and may need # to be adjusted, or a better metric should be used. - assert samples_min_square_error(sub.payload, ref.payload, 8) < 5 + assert samples_min_square_error(sub.payload, ref.payload, 8) < 10 end) end diff --git a/test/support/hls/http_adaptive_stream_source.ex b/test/support/hls/http_adaptive_stream_source.ex new file mode 100644 index 0000000..2bdc1d1 --- /dev/null +++ b/test/support/hls/http_adaptive_stream_source.ex @@ -0,0 +1,232 @@ +defmodule Membrane.HTTPAdaptiveStream.Source do + @moduledoc false + + use Membrane.Source + + alias Boombox.MP4 + alias Membrane.{Buffer, MP4} + alias Membrane.MP4.MovieBox.TrackBox + + def_options directory: [ + spec: Path.t(), + description: "directory containing hls files" + ] + + def_output_pad :output, + accepted_format: any_of(%Membrane.H264{}, %Membrane.AAC{}), + availability: :on_request, + flow_control: :manual, + demand_unit: :buffers + + @impl true + def handle_init(_ctx, opts) do + hls_mode = + case opts.directory |> File.ls!() |> Enum.find(&String.starts_with?(&1, "muxed_header")) do + nil -> :separate_av + _muxed_header -> :muxed_av + end + + tracks_map = get_tracks(opts.directory, hls_mode) + + %{audio_buffers: audio_buffers, video_buffers: video_buffers} = + get_buffers(opts.directory, hls_mode, tracks_map) + + state = + %{ + track_data: %{ + audio: assemble_track_data(tracks_map.audio_track, audio_buffers), + video: assemble_track_data(tracks_map.video_track, video_buffers) + } + } + + notification = + state.track_data + |> Enum.reject(&match?({_media, nil}, &1)) + |> Enum.map(fn {media, %{stream_format: stream_format}} -> {media, stream_format} end) + + {[notify_parent: {:new_tracks, notification}], state} + end + + @impl true + def handle_pad_added(Pad.ref(:output, id) = pad, _ctx, state) do + {[stream_format: {pad, state.track_data[id].stream_format}], state} + end + + @impl true + def handle_demand(Pad.ref(:output, id) = pad, demand_size, :buffers, _ctx, state) do + {buffers_to_send, buffers_left} = state.track_data[id].buffers_left |> Enum.split(demand_size) + + actions = + if buffers_left == [] do + [buffer: {pad, buffers_to_send}, end_of_stream: pad] + else + [buffer: {pad, buffers_to_send}] + end + + state = put_in(state, [:track_data, id, :buffers_left], buffers_left) + + {actions, state} + end + + @spec get_prefixed_files(Path.t(), String.t()) :: [Path.t()] + defp get_prefixed_files(directory, prefix) do + File.ls!(directory) + |> Enum.filter(&String.starts_with?(&1, prefix)) + |> Enum.map(&Path.join(directory, &1)) + end + + @spec get_tracks(Path.t(), hls_mode :: :muxed_av | :separate_av) :: + %{audio_track: MP4.Track.t(), video_track: MP4.Track.t()} + defp get_tracks(directory, :muxed_av) do + {parsed_header, ""} = + get_prefixed_files(directory, "muxed_header") + |> List.first() + |> File.read!() + |> MP4.Container.parse!() + + parsed_header[:moov].children + |> Keyword.get_values(:trak) + |> Enum.map(&TrackBox.unpack/1) + |> Enum.reduce(%{audio_track: nil, video_track: nil}, fn track, tracks_map -> + case track.stream_format do + %Membrane.AAC{} -> %{tracks_map | audio_track: track} + %Membrane.H264{} -> %{tracks_map | video_track: track} + _other -> tracks_map + end + end) + end + + defp get_tracks(directory, :separate_av) do + %{ + audio_track: get_separate_track(directory, :audio), + video_track: get_separate_track(directory, :video) + } + end + + @spec get_separate_track(Path.t(), :audio | :video) :: MP4.Track.t() | nil + defp get_separate_track(directory, media) do + header_prefix = + case media do + :audio -> "audio_header" + :video -> "video_header" + end + + case get_prefixed_files(directory, header_prefix) |> List.first() do + nil -> + nil + + header -> + {parsed_header, ""} = + header + |> File.read!() + |> MP4.Container.parse!() + + TrackBox.unpack(parsed_header[:moov].children[:trak]) + end + end + + @spec get_buffers( + Path.t(), + hls_mode :: :muxed_av | :separate_av, + %{audio_track: MP4.Track.t() | nil, video_track: MP4.Track.t() | nil} + ) :: %{audio_buffers: [Buffer.t()], video_buffers: [Buffer.t()]} + defp get_buffers( + directory, + :muxed_av, + %{audio_track: %MP4.Track{id: audio_id}, video_track: %MP4.Track{id: video_id}} + ) do + segments_filenames = get_prefixed_files(directory, "muxed_segment") |> Enum.sort() + + Enum.map(segments_filenames, fn file -> + %{^audio_id => segment_audio_buffers, ^video_id => segment_video_buffers} = + get_buffers_from_muxed_segment(file) + + {segment_audio_buffers, segment_video_buffers} + end) + |> Enum.unzip() + |> then(fn {audio_buffers, video_buffers} -> + %{audio_buffers: List.flatten(audio_buffers), video_buffers: List.flatten(video_buffers)} + end) + end + + defp get_buffers(directory, :separate_av, %{audio_track: audio_track, video_track: video_track}) do + %{ + audio_buffers: audio_track && get_separate_buffers(directory, :audio), + video_buffers: video_track && get_separate_buffers(directory, :video) + } + end + + @spec get_separate_buffers(Path.t(), :audio | :video) :: [Buffer.t()] + defp get_separate_buffers(directory, media) do + segment_prefix = + case media do + :audio -> "audio_segment" + :video -> "video_segment" + end + + case get_prefixed_files(directory, segment_prefix) |> Enum.sort() do + [] -> + nil + + segment_filenames -> + Enum.flat_map(segment_filenames, fn segment_filename -> + {container, ""} = segment_filename |> File.read!() |> MP4.Container.parse!() + + sample_lengths = + container[:moof].children[:traf].children[:trun].fields.samples + |> Enum.map(& &1.sample_size) + + samples_binary = container[:mdat].content + + get_buffers_from_samples(sample_lengths, samples_binary) + end) + end + end + + @spec get_buffers_from_muxed_segment(Path.t()) :: %{(track_id :: pos_integer()) => [Buffer.t()]} + defp get_buffers_from_muxed_segment(segment_filename) do + {container, ""} = segment_filename |> File.read!() |> MP4.Container.parse!() + + Enum.zip( + Keyword.get_values(container, :moof), + Keyword.get_values(container, :mdat) + ) + |> Map.new(fn {moof_box, mdat_box} -> + traf_box_children = moof_box.children[:traf].children + + sample_sizes = + traf_box_children[:trun].fields.samples + |> Enum.map(& &1.sample_size) + + buffers = get_buffers_from_samples(sample_sizes, mdat_box.content) + + {traf_box_children[:tfhd].fields.track_id, buffers} + end) + end + + @spec get_buffers_from_samples([pos_integer()], binary()) :: [Buffer.t()] + defp get_buffers_from_samples([], <<>>) do + [] + end + + defp get_buffers_from_samples([first_sample_length | sample_lengths_rest], samples_binary) do + <> = samples_binary + + [ + %Buffer{payload: payload} + | get_buffers_from_samples(sample_lengths_rest, samples_binary_rest) + ] + end + + @spec assemble_track_data(MP4.Track.t() | nil, [Buffer.t()] | nil) :: + %{stream_format: Membrane.H264.t() | Membrane.AAC.t(), buffers_left: [Buffer.t()]} | nil + defp assemble_track_data(track, buffers) do + case track do + nil -> + nil + + %MP4.Track{stream_format: stream_format} -> + %{stream_format: stream_format, buffers_left: buffers} + end + end +end diff --git a/test/support/http_adaptive_stream_source.ex b/test/support/http_adaptive_stream_source.ex deleted file mode 100644 index e165764..0000000 --- a/test/support/http_adaptive_stream_source.ex +++ /dev/null @@ -1,140 +0,0 @@ -defmodule Membrane.HTTPAdaptiveStream.Source do - @moduledoc false - - use Membrane.Source - - alias Membrane.{Buffer, MP4} - alias Membrane.MP4.MovieBox.TrackBox - - def_options directory: [ - spec: Path.t(), - description: "directory containing hls files" - ] - - def_output_pad :output, - accepted_format: any_of(%Membrane.H264{}, %Membrane.AAC{}), - availability: :on_request, - flow_control: :manual, - demand_unit: :buffers - - @impl true - def handle_init(_ctx, opts) do - {parsed_header, ""} = - get_prefixed_files(opts.directory, "muxed_header") - |> List.first() - |> File.read!() - |> MP4.Container.parse!() - - %{ - audio_track: %MP4.Track{id: audio_id, stream_format: audio_stream_format}, - video_track: %MP4.Track{id: video_id, stream_format: video_stream_format} - } = - parsed_header[:moov].children - |> Keyword.get_values(:trak) - |> Enum.map(&TrackBox.unpack/1) - |> Enum.reduce(%{audio_track: nil, video_track: nil}, fn track, tracks_map -> - case track.stream_format do - %Membrane.AAC{} -> %{tracks_map | audio_track: track} - %Membrane.H264{} -> %{tracks_map | video_track: track} - _other -> tracks_map - end - end) - - segments_filenames = get_prefixed_files(opts.directory, "muxed_segment") |> Enum.sort() - - {audio_buffers, video_buffers} = - Enum.map(segments_filenames, fn file -> - %{^audio_id => segment_audio_buffers, ^video_id => segment_video_buffers} = - get_buffers_from_segment(file) - - {segment_audio_buffers, segment_video_buffers} - end) - |> Enum.unzip() - |> then(fn {audio_buffers, video_buffers} -> - {List.flatten(audio_buffers), List.flatten(video_buffers)} - end) - - state = - %{ - track_data: %{ - audio: %{ - stream_format: audio_stream_format, - buffers_left: audio_buffers - }, - video: %{ - stream_format: video_stream_format, - buffers_left: video_buffers - } - } - } - - {[ - notify_parent: - {:new_tracks, [{:audio, audio_stream_format}, {:video, video_stream_format}]} - ], state} - end - - @impl true - def handle_pad_added(Pad.ref(:output, id) = pad, _ctx, state) do - {[stream_format: {pad, state.track_data[id].stream_format}], state} - end - - @impl true - def handle_demand(Pad.ref(:output, id) = pad, demand_size, :buffers, _ctx, state) do - {buffers_to_send, buffers_left} = state.track_data[id].buffers_left |> Enum.split(demand_size) - - actions = - if buffers_left == [] do - [buffer: {pad, buffers_to_send}, end_of_stream: pad] - else - [buffer: {pad, buffers_to_send}] - end - - state = put_in(state, [:track_data, id, :buffers_left], buffers_left) - - {actions, state} - end - - @spec get_buffers_from_segment(Path.t()) :: %{(track_id :: pos_integer()) => [Buffer.t()]} - defp get_buffers_from_segment(segment_filename) do - {container, ""} = segment_filename |> File.read!() |> MP4.Container.parse!() - - Enum.zip( - Keyword.get_values(container, :moof), - Keyword.get_values(container, :mdat) - ) - |> Enum.map(fn {moof_box, mdat_box} -> - traf_box_children = moof_box.children[:traf].children - - sample_sizes = - traf_box_children[:trun].fields.samples - |> Enum.map(& &1.sample_size) - - buffers = get_buffers_from_samples(sample_sizes, mdat_box.content) - - {traf_box_children[:tfhd].fields.track_id, buffers} - end) - |> Enum.into(%{}) - end - - @spec get_buffers_from_samples([pos_integer()], binary()) :: [Buffer.t()] - defp get_buffers_from_samples([], <<>>) do - [] - end - - defp get_buffers_from_samples([first_sample_length | sample_lengths_rest], samples_binary) do - <> = samples_binary - - [ - %Buffer{payload: payload} - | get_buffers_from_samples(sample_lengths_rest, samples_binary_rest) - ] - end - - @spec get_prefixed_files(Path.t(), String.t()) :: [Path.t()] - defp get_prefixed_files(directory, prefix) do - File.ls!(directory) - |> Enum.filter(&String.starts_with?(&1, prefix)) - |> Enum.map(&Path.join(directory, &1)) - end -end diff --git a/test/support/http_server/router.ex b/test/support/http_server/router.ex new file mode 100644 index 0000000..c06695f --- /dev/null +++ b/test/support/http_server/router.ex @@ -0,0 +1,48 @@ +defmodule HTTPServer.Router do + use Plug.Router + + require Logger + + plug(:match) + plug(:dispatch) + + @impl true + def call(conn, opts) do + assign(conn, :directory, opts.directory) + |> super(opts) + end + + post "/hls_output/:filename" do + file_path = Path.join(conn.assigns.directory, filename) + + conn + |> write_body_to_file(file_path) + |> send_resp(200, "File successfully written") + end + + delete "/hls_output/:filename" do + case Path.join(conn.assigns.directory, filename) |> File.rm() do + :ok -> + send_resp(conn, 200, "File deleted successfully") + + {:error, error} -> + send_resp(conn, 409, "Error deleting file: #{inspect(error)}") + end + end + + match _ do + send_resp(conn, 404, "Not found") + end + + defp write_body_to_file(conn, file_path) do + case read_body(conn) do + {:ok, body, conn} -> + File.write(file_path, body) + conn + + {:more, partial_body, conn} -> + File.write(file_path, partial_body) + write_body_to_file(conn, file_path) + end + end +end diff --git a/test/support/rtsp_server/handler.ex b/test/support/rtsp_server/handler.ex new file mode 100644 index 0000000..081bcc2 --- /dev/null +++ b/test/support/rtsp_server/handler.ex @@ -0,0 +1,82 @@ +defmodule Membrane.Support.RTSP.Server.Handler do + @moduledoc false + + use Membrane.RTSP.Server.Handler + + require Membrane.Logger + + alias Membrane.RTSP.Response + @pt 96 + @clock_rate 90_000 + + @impl true + def init(config) do + config + |> Map.put(:pipeline_pid, nil) + |> Map.put(:socket, nil) + end + + @impl true + def handle_open_connection(conn, state) do + %{state | socket: conn} + end + + @impl true + def handle_describe(_req, state) do + sdp = """ + v=0 + m=video 0 RTP/AVP 96 + a=control:/control + a=rtpmap:#{@pt} H264/#{@clock_rate} + a=fmtp:#{@pt} packetization-mode=1 + """ + + response = + Response.new(200) + |> Response.with_header("Content-Type", "application/sdp") + |> Response.with_body(sdp) + + {response, state} + end + + @impl true + def handle_setup(_req, state) do + {Response.new(200), state} + end + + @impl true + def handle_play(configured_media_context, state) do + media_context = configured_media_context |> Map.values() |> List.first() + + {client_rtp_port, _client_rtcp_port} = media_context.client_port + + arg = %{ + socket: state.socket, + ssrc: media_context.ssrc, + pt: @pt, + clock_rate: @clock_rate, + client_port: client_rtp_port, + client_ip: media_context.address, + server_rtp_socket: media_context.rtp_socket, + fixture_path: state.fixture_path + } + + {:ok, _sup_pid, pipeline_pid} = + Membrane.Support.RTSP.Server.Pipeline.start_link(arg) + + {Response.new(200), %{state | pipeline_pid: pipeline_pid}} + end + + @impl true + def handle_pause(state) do + {Response.new(501), state} + end + + @impl true + def handle_teardown(state) do + {Response.new(200), state} + end + + @impl true + def handle_closed_connection(_state), do: :ok +end diff --git a/test/support/rtsp_server/pipeline.ex b/test/support/rtsp_server/pipeline.ex new file mode 100644 index 0000000..e88677c --- /dev/null +++ b/test/support/rtsp_server/pipeline.ex @@ -0,0 +1,77 @@ +defmodule Membrane.Support.RTSP.Server.Pipeline do + @moduledoc false + + use Membrane.Pipeline + + @spec start_link(map()) :: Membrane.Pipeline.on_start() + def start_link(config) do + Membrane.Pipeline.start_link(__MODULE__, config) + end + + @impl true + def handle_init(_ctx, opts) do + spec = + child(:mp4_in_file_source, %Membrane.File.Source{ + location: opts.fixture_path, + seekable?: true + }) + |> child(:mp4_demuxer, %Membrane.MP4.Demuxer.ISOM{optimize_for_non_fast_start?: true}) + + {[spec: spec], opts} + end + + @impl true + def handle_child_notification({:new_tracks, tracks}, :mp4_demuxer, _ctx, state) do + spec = + Enum.map(tracks, fn + {id, %Membrane.AAC{}} -> + get_child(:mp4_demuxer) + |> via_out(Pad.ref(:output, id)) + |> child(Membrane.Debug.Sink) + + {id, %Membrane.H264{}} -> + get_child(:mp4_demuxer) + |> via_out(Pad.ref(:output, id)) + |> child(:parser, %Membrane.H264.Parser{ + output_alignment: :nalu, + repeat_parameter_sets: true, + skip_until_keyframe: true, + output_stream_structure: :annexb + }) + |> via_in(Pad.ref(:input, state.ssrc), + options: [payloader: Membrane.RTP.H264.Payloader] + ) + |> child(:rtp, Membrane.RTP.SessionBin) + |> via_out(Pad.ref(:rtp_output, state.ssrc), + options: [ + payload_type: state.pt, + clock_rate: state.clock_rate + ] + ) + |> child(:udp_sink, %Membrane.UDP.Sink{ + destination_address: state.client_ip, + destination_port_no: state.client_port, + local_socket: state.server_rtp_socket + }) + end) + + {[spec: spec], state} + end + + @impl true + def handle_child_notification(_notification, _element, _ctx, state) do + {[], state} + end + + @impl true + def handle_element_end_of_stream(:udp_sink, :input, _ctx, state) do + Process.sleep(50) + :gen_tcp.close(state.socket) + {[terminate: :normal], state} + end + + @impl true + def handle_element_end_of_stream(_child, _pad, _ctx, state) do + {[], state} + end +end