From 4b5d529fffa469cfa884b67f8d8dd2007dad8a98 Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:29:43 +0200 Subject: [PATCH 1/5] wip1 --- lib/ex_webrtc_recorder/converter.ex | 2 +- lib/ex_webrtc_recorder/converter/ffmpeg.ex | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/ex_webrtc_recorder/converter.ex b/lib/ex_webrtc_recorder/converter.ex index 6b65cd6..8579edc 100644 --- a/lib/ex_webrtc_recorder/converter.ex +++ b/lib/ex_webrtc_recorder/converter.ex @@ -411,7 +411,7 @@ defmodule ExWebRTC.Recorder.Converter do end end - defp get_audio_track_context(%{nil: packets}) do + defp get_audio_track_context(%{0 => packets}) do {:ok, depayloader} = Depayloader.new(@audio_codec_params) start_time = get_start_time(packets, depayloader) diff --git a/lib/ex_webrtc_recorder/converter/ffmpeg.ex b/lib/ex_webrtc_recorder/converter/ffmpeg.ex index 14f6a6d..2fe53e6 100644 --- a/lib/ex_webrtc_recorder/converter/ffmpeg.ex +++ b/lib/ex_webrtc_recorder/converter/ffmpeg.ex @@ -34,6 +34,7 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do cues_to_front: cues_to_front } -> if(threads == nil, do: ~w(), else: ~w(-threads #{threads})) ++ + # -c:v vp8 -s 1920x1080 -crf 10 -b:v... ~w(-c:v vp8 -b:v #{bitrate} -g #{gop_size}) ++ if cues_to_front, do: ~w(-cues_to_front 1), else: ~w() end @@ -41,9 +42,13 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do {_io, 0} = System.cmd( "ffmpeg", - ~w(-nostdin -ss #{video_start_time} -i #{video_file} -ss #{audio_start_time} -i #{audio_file}) ++ + ~w(-nostdin -ss #{video_start_time} -i) ++ + [video_file] ++ + ~w(-ss #{audio_start_time} -i) ++ + [audio_file] ++ reencode_flags ++ - ~w(-c:a copy -shortest #{output_file}), + ~w(-c:a copy -shortest) ++ + [output_file], stderr_to_stdout: true ) @@ -57,7 +62,10 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do {_io, 0} = System.cmd( "ffmpeg", - ~w(-nostdin -i #{file} -vf thumbnail,scale=#{thumbnails_ctx.width}:#{thumbnails_ctx.height} -frames:v 1 #{thumbnail_file}), + ~w(-nostdin -i) ++ + [file] ++ + ~w(-vf thumbnail,scale=#{thumbnails_ctx.width}:#{thumbnails_ctx.height} -frames:v 1) ++ + [thumbnail_file], stderr_to_stdout: true ) @@ -69,7 +77,8 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do {duration, 0} = System.cmd( "ffprobe", - ~w(-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 #{file}) + ~w(-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1) ++ + [file] ) {duration_seconds, _rest} = Float.parse(duration) From 363e73518b1ec097356752b280e28a44f9fc69cf Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:51:42 +0200 Subject: [PATCH 2/5] use hackney instead of req --- README.md | 2 +- lib/ex_webrtc_recorder/s3.ex | 2 +- mix.exs | 2 +- mix.lock | 31 +++++++++++++++++++------------ 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5e22fb8..7dc001a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ def deps do {:ex_aws_s3, "~> 2.5"}, {:ex_aws, "~> 2.5"}, {:sweet_xml, "~> 0.7"}, - {:req, "~> 0.5"} # or any other HTTP client supported by `ex_aws` + {:hackney, "~> 1.9"} # or any other HTTP client supported by `ex_aws` ] end ``` diff --git a/lib/ex_webrtc_recorder/s3.ex b/lib/ex_webrtc_recorder/s3.ex index 39fe026..11a81f2 100644 --- a/lib/ex_webrtc_recorder/s3.ex +++ b/lib/ex_webrtc_recorder/s3.ex @@ -6,7 +6,7 @@ defmodule ExWebRTC.Recorder.S3 do * `:ex_aws_s3` * `:ex_aws` * `:sweet_xml` - * an HTTP client (e.g. `:req`) + * an HTTP client (e.g. `:hackney`) """ @typedoc """ diff --git a/mix.exs b/mix.exs index 8843b7e..bb72773 100644 --- a/mix.exs +++ b/mix.exs @@ -68,7 +68,7 @@ defmodule ExWebRTC.Recorder.MixProject do {:ex_aws_s3, "~> 2.5", optional: true}, {:ex_aws, "~> 2.5", optional: true}, {:sweet_xml, "~> 0.7", optional: true}, - {:req, "~> 0.5", optional: true}, + {:hackney, "~> 1.9", optional: true}, # dev/test {:excoveralls, "~> 0.18.0", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index cb9489a..15acbc1 100644 --- a/mix.lock +++ b/mix.lock @@ -4,20 +4,21 @@ "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"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "coerce": {:hex, :coerce, "1.0.1", "211c27386315dc2894ac11bc1f413a0e38505d808153367bd5c6e75a4003d096", [:mix], [], "hexpm", "b44a691700f7a1a15b4b7e2ff1fa30bebd669929ac8aa43cffe9e2f8bf051cf1"}, "crc": {:hex, :crc, "0.10.5", "ee12a7c056ac498ef2ea985ecdc9fa53c1bfb4e53a484d9f17ff94803707dfd8", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3e673b6495a9525c5c641585af1accba59a1eb33de697bedf341e247012c2c7f"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [: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", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, - "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, "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"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, - "ex_aws": {:hex, :ex_aws, "2.5.10", "d3f8ca8959dad6533a2a934dfdf380df1b1bef425feeb215a47a5176dee8736c", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "88fcd9cc1b2e0fcea65106bdaa8340ac56c6e29bf72f46cf7ef174027532d3da"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.7", "e571424d2f345299753382f3a01b005c422b1a460a8bc3ed47659b3d3ef91e9e", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "858e51241e50181e29aa2bc128fef548873a3a9cd580471f57eda5b64dec937f"}, - "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [: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", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, + "ex_aws": {:hex, :ex_aws, "2.6.0", "346e87e35e5df0b3c016a96fb30adf6001de102981a71648dfc3ce3ad04765af", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "30729ee9cbaacda674a4e4260d74206fa89bcd712267c4eaf42a0fc34592c0b3"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.8", "5ee7407bc8252121ad28fba936b3b293f4ecef93753962351feb95b8a66096fa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "84e512ca2e0ae6a6c497036dff06d4493ffb422cfe476acc811d7c337c16691c"}, + "ex_doc": {:hex, :ex_doc, "0.38.4", "ab48dff7a8af84226bf23baddcdda329f467255d924380a0cf0cee97bb9a9ede", [: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", "f7b62346408a83911c2580154e35613eb314e0278aeea72ed7fedef9c1f165b2"}, "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.2", "211bd89c08026943ce71f3e2c0231795b99cee748808ed3ae7b97cd8d2450b6b", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2e20645d0d739a4ecdcf8d4810a0c198120c8a2f617f2b75b2e2e704d59f492a"}, + "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_rtcp": {:hex, :ex_rtcp, "0.4.0", "f9e515462a9581798ff6413583a25174cfd2101c94a2ebee871cca7639886f0a", [:mix], [], "hexpm", "28956602cf210d692fcdaf3f60ca49681634e1deb28ace41246aee61ee22dc3b"}, "ex_rtp": {:hex, :ex_rtp, "0.4.0", "1f1b5c1440a904706011e3afbb41741f5da309ce251cb986690ce9fd82636658", [:mix], [], "hexpm", "0f72d80d5953a62057270040f0f1ee6f955c08eeae82ac659c038001d7d5a790"}, "ex_sdp": {:hex, :ex_sdp, "1.1.1", "1a7b049491e5ec02dad9251c53d960835dc5631321ae978ec331831f3e4f6d5f", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "1b13a72ac9c5c695b8824dbdffc671be8cbb4c0d1ccb4ff76a04a6826759f233"}, @@ -25,49 +26,55 @@ "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"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, - "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "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"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "heap": {:hex, :heap, "2.0.2", "d98cb178286cfeb5edbcf17785e2d20af73ca57b5a2cf4af584118afbcf917eb", [:mix], [], "hexpm", "ba9ea2fe99eb4bcbd9a8a28eaf71cbcac449ca1d8e71731596aace9028c9d429"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "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.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "membrane_common_c": {:hex, :membrane_common_c, "0.16.0", "caf3f29d2f5a1d32d8c2c122866110775866db2726e4272be58e66dfdf4bce40", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:shmex, "~> 0.5.0", [hex: :shmex, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "a3c7e91de1ce1f8b23b9823188a5d13654d317235ea0ca781c05353ed3be9b1c"}, - "membrane_core": {:hex, :membrane_core, "1.2.3", "0e23f50b2e7dfe95dd6047cc341807991f9d0349cd98455cc5cbfab41ba5233c", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:qex, "~> 0.3", [hex: :qex, repo: "hexpm", optional: false]}, {:ratio, "~> 3.0 or ~> 4.0", [hex: :ratio, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e3099dcb52d136a4aef84b6fbb20905ea55d9f0d2d6726f7b589e8d169a55cd"}, + "membrane_core": {:hex, :membrane_core, "1.2.4", "3f9fc78cef29b69acadd4f959c8ec23cbb1544c26c8e8474589b143ada9a0da2", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:qex, "~> 0.3", [hex: :qex, repo: "hexpm", optional: false]}, {:ratio, "~> 3.0 or ~> 4.0", [hex: :ratio, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ec7a77b7ab457267c0243338383365f6ef5ace2686ddc129939e502a58eba546"}, "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_funnel_plugin": {:hex, :membrane_funnel_plugin, "0.9.2", "2b2e840dbb232ce29aaff2d55bd329d9978766518dbeb6e8dba7aba7115fadcc", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "865ac9d84f86698e2cfeb7904d3b12ab74855a38ca651a880db1505965fa77cc"}, "membrane_h264_format": {:hex, :membrane_h264_format, "0.6.1", "44836cd9de0abe989b146df1e114507787efc0cf0da2368f17a10c47b4e0738c", [:mix], [], "hexpm", "4b79be56465a876d2eac2c3af99e115374bbdc03eb1dea4f696ee9a8033cd4b0"}, - "membrane_ivf_plugin": {:hex, :membrane_ivf_plugin, "0.8.0", "0495a4fd34a1b9841d288b3a078d2b09c48020294a6452ce08e8954711648ac8", [:mix], [{: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_vp8_format, "~> 0.5.0", [hex: :membrane_vp8_format, repo: "hexpm", optional: false]}, {:membrane_vp9_format, "~> 0.5.0", [hex: :membrane_vp9_format, repo: "hexpm", optional: false]}], "hexpm", "b17805a451f1066dab68c19577efad7819200c20970e3ba5f7d6fbbc751240e9"}, "membrane_matroska_format": {:hex, :membrane_matroska_format, "0.1.0", "99902bac6c46cc783776038d91d7bb035a917933b260343204fd6261d911f039", [:mix], [], "hexpm", "8bf180165ea9bb4094673818df5989fe6bd44b752a86dc071daafe611af1f3cc"}, "membrane_matroska_plugin": {:hex, :membrane_matroska_plugin, "0.6.1", "0759be00e35ae9a38b63c31eb9c6d40392a34fc8a1a532dbc9074387f0b7fd23", [:mix], [{:bimap, "~> 1.2", [hex: :bimap, 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_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_matroska_format, "~> 0.1.0", [hex: :membrane_matroska_format, repo: "hexpm", optional: false]}, {:membrane_opus_format, "~> 0.3.0", [hex: :membrane_opus_format, repo: "hexpm", optional: false]}, {:membrane_vp8_format, "~> 0.5.0", [hex: :membrane_vp8_format, repo: "hexpm", optional: false]}, {:membrane_vp9_format, "~> 0.5.0", [hex: :membrane_vp9_format, repo: "hexpm", optional: false]}, {:qex, "~> 0.5.1", [hex: :qex, repo: "hexpm", optional: false]}], "hexpm", "9db2a7ea8056c4c7ac6b0e61dc4913f556962e8489691ed971e1bfd6d2cd3cd5"}, "membrane_opus_format": {:hex, :membrane_opus_format, "0.3.0", "3804d9916058b7cfa2baa0131a644d8186198d64f52d592ae09e0942513cb4c2", [:mix], [], "hexpm", "8fc89c97be50de23ded15f2050fe603dcce732566fe6fdd15a2de01cb6b81afe"}, - "membrane_opus_plugin": {:hex, :membrane_opus_plugin, "0.20.5", "aa344bb9931c8e22b2286778cce0658e0d4aa071a503c18c55e1b161e17ab337", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.2", [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_opus_format, "~> 0.3.0", [hex: :membrane_opus_format, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:membrane_raw_audio_format, "~> 0.12.0", [hex: :membrane_raw_audio_format, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "94fd4447b6576780afc6144dbb0520b43bd399c86a10bf5df1fa878a91798cf6"}, - "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.1.2", "8af73b7dc15ba55c9f5fbfc0453d4a8edfb007ade54b56c37d626be0d1189aba", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "7fe3e07361510445a29bee95336adde667c4162b76b7f4c8af3aeb3415292023"}, + "membrane_opus_plugin": {:hex, :membrane_opus_plugin, "0.20.6", "cbdd4cb4e37ed5bf874d8bce8204576ccc897e44a9ef18fada7bc8f89a989eba", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.2", [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_opus_format, "~> 0.3.0", [hex: :membrane_opus_format, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.2.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:membrane_raw_audio_format, "~> 0.12.0", [hex: :membrane_raw_audio_format, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "d852c5a9aa40d321f8ed3080f7c166d7d5614b927799c0a0821f5ccab164cdc4"}, + "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.2.1", "d385afa61f9e30318d672960acdb951669bb911cd5ee98062d06c3b739a44a76", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "5470400b720581871efe688c9e446aef17085042ff80c7b8855f10de55d73c93"}, "membrane_raw_audio_format": {:hex, :membrane_raw_audio_format, "0.12.0", "b574cd90f69ce2a8b6201b0ccf0826ca28b0fbc8245b8078d9f11cef65f7d5d5", [:mix], [{:bimap, "~> 1.1", [hex: :bimap, repo: "hexpm", optional: false]}, {:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "6e6c98e3622a2b9df19eab50ba65d7eb45949b1ba306fa8423df6cdb12fd0b44"}, "membrane_raw_video_format": {:hex, :membrane_raw_video_format, "0.4.1", "d7344499c2d80f236a7ef962b5490c651341a501052ee43dec56cf0319fa3936", [:mix], [], "hexpm", "9920b7d445b5357608a364fec5685acdfce85334c647f745045237a0d296c442"}, "membrane_rtp_format": {:hex, :membrane_rtp_format, "0.11.0", "0091465530ab946bd4d8affe849e9a01a79e46b750f6ec420521bcb806d5be49", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "bea52c2f286d5edb90d647bf3916f4e9e0270d5c6eddb708b65880570c1e42d2"}, "membrane_rtp_opus_plugin": {:hex, :membrane_rtp_opus_plugin, "0.10.1", "7c81d3edb8dfe3c53dd35061f402ee6278d07678f8518aa9014f8c5406a475f6", [: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.11.0", [hex: :membrane_rtp_format, repo: "hexpm", optional: false]}], "hexpm", "161db3d03e9db3d5d2b1c850942ae3f37500ccf769afbfb9d38dca2eb1d09726"}, - "membrane_rtp_plugin": {:hex, :membrane_rtp_plugin, "0.31.0", "f2f3147de028f5b8f0839f22015c5d1e54f3a6f3a26886c878ed6d8c98245bc2", [: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]}, {:ex_rtcp, "~> 0.4.0", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.0", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {: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.11.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", "7dc0d1b2e6e4eea6027bb645859134949a4a6e17c403926fd427eb333407a236"}, + "membrane_rtp_plugin": {:hex, :membrane_rtp_plugin, "0.31.1", "08b1864f4979cc5c258c5dc487673d4a3ec5a21cc4b91025e0e2c98ece6fa9dc", [: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]}, {:ex_rtcp, "~> 0.4.0", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.0", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {: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.11.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", "c5652d845dbe3c26ccc9cbb38696c1e8b6131abe34ea39072a400c31eae33af1"}, "membrane_rtp_vp8_plugin": {:hex, :membrane_rtp_vp8_plugin, "0.9.5", "61072297ed4737fd0e6e0d791ebbb19c7ae920f9b2db3487a2f7d185560791d5", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_rtp_format, "~> 0.11.0", [hex: :membrane_rtp_format, repo: "hexpm", optional: false]}, {:membrane_vp8_format, "~> 0.4.0 or ~> 0.5.0", [hex: :membrane_vp8_format, repo: "hexpm", optional: false]}], "hexpm", "5c60fa6979b84d436969b134ff41d6353eb61caffdc11312101b99de56a47231"}, "membrane_telemetry_metrics": {:hex, :membrane_telemetry_metrics, "0.1.1", "57917e72012f9ebe124eab54f29ca74c9d9eb3ae2207f55c95618ee51280eb4f", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.1 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "69392966e6bd51937244758c2b3d835c5ff47d8953d25431a9d37059737afc11"}, "membrane_vp8_format": {:hex, :membrane_vp8_format, "0.5.0", "a589c20bb9d97ddc9b717684d00cefc84e2500ce63a0c33c4b9618d9b2f9b2ea", [:mix], [], "hexpm", "d29e0dae4bebc6838e82e031c181fe626d168c687e4bc617c1d0772bdeed19d5"}, "membrane_vp9_format": {:hex, :membrane_vp9_format, "0.5.0", "c6a4f2cbfc39dba5d80ad8287162c52b5cf6488676bd64435c1ac957bd16e66f", [:mix], [], "hexpm", "68752d8cbe7270ec222fc84a7d1553499f0d8ff86ef9d9e89f8955d49e20278e"}, - "membrane_vpx_plugin": {:hex, :membrane_vpx_plugin, "0.4.0", "911df2079ef20c28c945001b1f048556690ff3dd4b2d586a0c03d3ba03d83a34", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, 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.4.0", [hex: :membrane_raw_video_format, repo: "hexpm", optional: false]}, {:membrane_vp8_format, "~> 0.5.0", [hex: :membrane_vp8_format, repo: "hexpm", optional: false]}, {:membrane_vp9_format, "~> 0.5.0", [hex: :membrane_vp9_format, repo: "hexpm", optional: false]}, {:unifex, "~> 1.2", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "921f86af2ff0efe072fc1bce7873845fa9f02eebd27df5ed658c42d4d655b402"}, + "membrane_vpx_plugin": {:hex, :membrane_vpx_plugin, "0.4.2", "781944d370845c0259eb2d5c2eb2209bece17dc49ac3e85c11ef1c4161c0afd1", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.2.1", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:membrane_raw_video_format, "~> 0.4.0", [hex: :membrane_raw_video_format, repo: "hexpm", optional: false]}, {:membrane_vp8_format, "~> 0.5.0", [hex: :membrane_vp8_format, repo: "hexpm", optional: false]}, {:membrane_vp9_format, "~> 0.5.0", [hex: :membrane_vp9_format, repo: "hexpm", optional: false]}, {:unifex, "~> 1.2", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "5621a389fe4fcd1c964b14d665025ec91127d1c9d848f38110000c25f8299c9d"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "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"}, "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"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "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.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [: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", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "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.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "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"}, "zarex": {:hex, :zarex, "1.0.5", "58239e3ee5d75f343262bb4df5cf466555a1c689f920e5d3651a9333972f7c7e", [:mix], [], "hexpm", "9fb72ef0567c2b2742f5119a1ba8a24a2fabb21b8d09820aefbf3e592fa9a46a"}, } From 4130278ab2dad367c25d26982a385e0197992af3 Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:54:07 +0200 Subject: [PATCH 3/5] wip3 --- lib/ex_webrtc_recorder/converter.ex | 389 ++++++++++++------- lib/ex_webrtc_recorder/converter/ffmpeg.ex | 77 ++-- lib/ex_webrtc_recorder/converter/pipeline.ex | 53 ++- 3 files changed, 326 insertions(+), 193 deletions(-) diff --git a/lib/ex_webrtc_recorder/converter.ex b/lib/ex_webrtc_recorder/converter.ex index 8579edc..1070dc9 100644 --- a/lib/ex_webrtc_recorder/converter.ex +++ b/lib/ex_webrtc_recorder/converter.ex @@ -34,41 +34,58 @@ defmodule ExWebRTC.Recorder.Converter do channels: 2 } + # + ## DEFAULT PARAMS + @default_output_path "./converter/output" @default_download_path "./converter/download" - @default_thumbnail_width 640 - @default_thumbnail_height -1 @default_reorder_buffer_size 100 @default_reencode_bitrate "1.5M" + @default_reencode_crf 10 @default_reencode_gop_size 125 @default_reencode_cues_to_front true - @typedoc """ - Context for the thumbnail generation. + # + ## PUBLIC TYPES - * `:width` - Thumbnail width. #{@default_thumbnail_width} by default. - * `:height` - Thumbnail height. #{@default_thumbnail_height} by default. + @typedoc """ + Resolution of reencoded video and generated thumbnails. - Setting either of the values to `-1` will fit the size to the aspect ratio. + Make sure to provide at least one of the dimensions. + Setting the other dimension to `-1` will fit the image to the aspect ratio. """ - @type thumbnails_ctx :: %{ + @type resolution :: %{ optional(:width) => pos_integer() | -1, optional(:height) => pos_integer() | -1 } + @typedoc """ + Context for the thumbnail generation. + + * `:resolution` (required) - Thumbnail size. See `t:resolution/0` for more info. + """ + @type thumbnails_ctx :: %{ + :resolution => resolution() + } + @typedoc """ Context for the video reencoding. See `man ffmpeg` for more details. * `:threads` - How many threads to use. Unlimited by default. * `:bitrate` - Video bitrate the VP8 encoder shall strive for. `#{@default_reencode_bitrate}` by default. + * `:crf` - Output video quality (lower is better). `#{@default_reencode_crf}` by default. * `:gop_size` - Keyframe interval. `#{@default_reencode_gop_size}` by default. + * `:resolution` - Output video resolution. Inferred from the first video frame by default. + See `t:resolution/0` for more info. * `:cues_to_front` - Whether the muxer should put MKV Cues element at the front of the file, to aid with seeking e.g. when streaming the result file. `#{@default_reencode_cues_to_front}` by default. """ @type reencode_ctx :: %{ optional(:threads) => pos_integer(), optional(:bitrate) => String.t(), + optional(:crf) => 4..63, optional(:gop_size) => pos_integer(), + optional(:resolution) => resolution(), optional(:cues_to_front) => boolean() } @@ -109,6 +126,9 @@ defmodule ExWebRTC.Recorder.Converter do @type options :: [option()] + # + ## PUBLIC FUNCTIONS + @doc """ Converts the saved dumps of tracks in the manifest to WEBM files. @@ -180,6 +200,10 @@ defmodule ExWebRTC.Recorder.Converter do def convert!(_empty_manifest, _options), do: %{} + # + ## PRIVATE FUNCTIONS + # Option parsing + defp get_thumbnails_ctx(options) do case Keyword.get(options, :thumbnails_ctx) do nil -> @@ -187,8 +211,7 @@ defmodule ExWebRTC.Recorder.Converter do ctx -> %{ - width: ctx[:width] || @default_thumbnail_width, - height: ctx[:height] || @default_thumbnail_height + resolution: fill_resolution_dimensions(ctx.resolution) } end end @@ -202,12 +225,26 @@ defmodule ExWebRTC.Recorder.Converter do %{ threads: ctx[:threads], bitrate: ctx[:bitrate] || @default_reencode_bitrate, + crf: ctx[:crf] || @default_reencode_crf, gop_size: ctx[:gop_size] || @default_reencode_gop_size, + resolution: fill_resolution_dimensions(ctx[:resolution]), cues_to_front: ctx[:cues_to_front] || @default_reencode_cues_to_front } end end + defp fill_resolution_dimensions(nil), do: nil + + defp fill_resolution_dimensions(resolution) do + %{ + width: resolution[:width] || -1, + height: resolution[:height] || -1 + } + end + + # + # Downloading input files + defp fetch_remote_files!(manifest, dl_path, dl_config) do Map.new(manifest, fn {track_id, %{location: location} = track_data} -> scheme = URI.parse(location).scheme || "file" @@ -235,6 +272,9 @@ defmodule ExWebRTC.Recorder.Converter do end end + # + # Conversion + defp do_convert_manifest!( manifest, output_path, @@ -256,7 +296,18 @@ defmodule ExWebRTC.Recorder.Converter do rid_map: rid_map } = track - file = File.open!(path) + file = + with {:ok, %File.Stat{size: s}} <- File.stat(path), + true <- s > 0, + {:ok, file} <- File.open(path) do + file + else + false -> + raise "File #{path} is empty!" + + {:error, reason} -> + raise "Unable to open #{path}: #{inspect(reason)}" + end packets = read_packets( @@ -284,154 +335,87 @@ defmodule ExWebRTC.Recorder.Converter do |> Map.update!(stream_id, &Map.put(&1, kind, track_contexts)) end) - # FIXME: This won't work if we have audio/video only - for {stream_id, %{video: video_contexts, audio: audio_contexts}} <- stream_map, - {rid, video_ctx} <- video_contexts, - {nil, audio_ctx} <- audio_contexts, - into: %{} do - output_id = if rid == nil, do: stream_id, else: "#{stream_id}_#{rid}" - output_file = Path.join(output_path, "#{output_id}.webm") - - video_stream = make_stream(self(), :video) - audio_stream = make_stream(self(), :audio) - - # FIXME: Possible RC here: when the pipeline playback starts, the `Stream`s will start sending - # `{:demand, kind, self()}` messages to this process. - # There's no guarantee we'll start listening for these messages (in `emit_packets/3`) soon enough, - # so we may reach a deadlock. - # For now, it seems to work just fine, though. - {:ok, _sup, pid} = Pipeline.start_link(video_stream, audio_stream, output_file) - Process.monitor(pid) - - emit_packets(pid, video_ctx.packets, audio_ctx.packets) - - FFmpeg.combine_av!( - output_file <> "_video.webm", - video_ctx.start_time, - output_file <> "_audio.webm", - audio_ctx.start_time, - output_file, - reencode_ctx - ) - - File.rm!(output_file <> "_video.webm") - File.rm!(output_file <> "_audio.webm") - - stream_manifest = %{ - location: output_file, - duration_seconds: FFmpeg.get_duration_in_seconds!(output_file) - } - - stream_manifest = - if thumbnails_ctx do - thumbnail_file = FFmpeg.generate_thumbnail!(output_file, thumbnails_ctx) - Map.put(stream_manifest, :thumbnail_location, thumbnail_file) - else - stream_manifest - end - - {output_id, stream_manifest} - end - end - - defp maybe_upload_result!(output_manifest, nil) do - output_manifest - end - - defp maybe_upload_result!(output_manifest, upload_handler) do - {ref, upload_handler} = - output_manifest - |> __MODULE__.Manifest.to_upload_handler_manifest() - |> then(&S3.UploadHandler.spawn_task(upload_handler, &1)) - - # FIXME: Add descriptive errors - {:ok, upload_handler_result_manifest, _handler} = - receive do - {^ref, _res} = task_result -> - S3.UploadHandler.process_result(upload_handler, task_result) + Enum.flat_map(stream_map, fn {stream_id, %{video: v, audio: a}} -> + cond do + map_size(v) == 0 and map_size(a) == 0 -> + raise "Stream #{stream_id} contains no tracks!" + + map_size(v) == 0 -> + %{nil => audio_ctx} = a + output_file = Path.join(output_path, "#{stream_id}.webm") + output_file |> Path.dirname() |> File.mkdir_p!() + [{stream_id, process!(nil, audio_ctx, output_file, nil, nil)}] + + true -> + for {rid, video_ctx} <- v do + output_id = if rid == nil, do: stream_id, else: "#{stream_id}_#{rid}" + output_file = Path.join(output_path, "#{output_id}.webm") + output_file |> Path.dirname() |> File.mkdir_p!() + {output_id, process!(video_ctx, a[nil], output_file, thumbnails_ctx, reencode_ctx)} + end end - - # UploadHandler spawns a task that gets auto-monitored - # We don't want to propagate this message - receive do - {:DOWN, ^ref, :process, _pid, _reason} -> :ok - end - - upload_handler_result_manifest - |> __MODULE__.Manifest.from_upload_handler_manifest(output_manifest) + end) + |> Map.new() end - defp make_stream(pid, kind) do - Stream.resource( - fn -> pid end, - fn pid -> - send(pid, {:demand, kind, self()}) - - receive do - {^kind, nil} -> - {:halt, pid} + defp process!(video_ctx, audio_ctx, output_file, thumbnails_ctx, reencode_ctx) do + video_stream = if video_ctx, do: make_stream(self(), :video) + audio_stream = if audio_ctx, do: make_stream(self(), :audio) + + # FIXME: Possible RC here: when the pipeline playback starts, the `Stream`s will start sending + # `{:demand, kind, self()}` messages to this process. + # There's no guarantee we'll start listening for these messages (in `emit_packets/3`) soon enough, + # so we may reach a deadlock. + # For now, it seems to work just fine, though. + {:ok, _sup, pid} = Pipeline.start_link(video_stream, audio_stream, output_file) + Process.monitor(pid) + + emit_packets(pid, video_ctx[:packets] || [], audio_ctx[:packets] || []) + + cond do + video_ctx != nil and audio_ctx != nil -> + FFmpeg.combine_audio_video!( + output_file <> "_video.webm", + video_ctx.start_time, + output_file <> "_audio.webm", + audio_ctx.start_time, + output_file, + reencode_ctx + ) - {^kind, packet} -> - {[packet], pid} - end - end, - fn _pid -> :ok end - ) - end + File.rm!(output_file <> "_video.webm") + File.rm!(output_file <> "_audio.webm") - defp emit_packets(pipeline_pid, video_packets, audio_packets) do - receive do - {:demand, :video, pid} -> - {p, video_packets} = List.pop_at(video_packets, 0) - send(pid, {:video, p}) - emit_packets(pipeline_pid, video_packets, audio_packets) + video_ctx != nil -> + if reencode_ctx, + do: FFmpeg.reencode_video!(output_file <> "_video.webm", output_file, reencode_ctx), + else: File.cp!(output_file <> "_video.webm", output_file) - {:demand, :audio, pid} -> - {p, audio_packets} = List.pop_at(audio_packets, 0) - send(pid, {:audio, p}) - emit_packets(pipeline_pid, video_packets, audio_packets) + File.rm!(output_file <> "_video.webm") - {:DOWN, _monitor, :process, ^pipeline_pid, _reason} -> - :ok + true -> + File.cp!(output_file <> "_audio.webm", output_file) + File.rm!(output_file <> "_audio.webm") end - end - - defp get_video_track_contexts(rid_map, packets) do - for {rid, rid_idx} <- rid_map, into: %{} do - {:ok, depayloader} = Depayloader.new(@video_codec_params) - start_time = get_start_time(packets[rid_idx], depayloader) - - video_ctx = %{ - packets: packets[rid_idx], - start_time: start_time - } + stream_manifest = %{ + location: output_file, + duration_seconds: FFmpeg.get_duration_in_seconds!(output_file) + } + + stream_manifest = + if thumbnails_ctx do + thumbnail_file = FFmpeg.generate_thumbnail!(output_file, thumbnails_ctx) + Map.put(stream_manifest, :thumbnail_location, thumbnail_file) + else + stream_manifest + end - {rid, video_ctx} - end + stream_manifest end - defp get_audio_track_context(%{0 => packets}) do - {:ok, depayloader} = Depayloader.new(@audio_codec_params) - - start_time = get_start_time(packets, depayloader) - - %{nil: %{packets: packets, start_time: start_time}} - end - - # Returns the timestamp (in milliseconds) at which the first frame was received - defp get_start_time([packet | rest], depayloader) do - case Depayloader.depayload(depayloader, packet) do - {nil, depayloader} -> - get_start_time(rest, depayloader) - - {_frame, _depayloader} -> - {:ok, %ExRTP.Packet.Extension{id: 1, data: <>}} = - ExRTP.Packet.fetch_extension(packet, 1) - - recv_time - end - end + # + # Reading and reordering captured packets defp read_packets(file, state, reorder_buffer_size) do case read_packet(file) do @@ -505,6 +489,117 @@ defmodule ExWebRTC.Recorder.Converter do %{layer_state | store: store, packets_in_store: n - 1, acc: [packet | acc]} end + # + # Extracting track metadata + + defp get_video_track_contexts(rid_map, packets) do + for {rid, rid_idx} <- rid_map, into: %{} do + {:ok, depayloader} = Depayloader.new(@video_codec_params) + + start_time = get_start_time(packets[rid_idx], depayloader) + + video_ctx = %{ + packets: packets[rid_idx], + start_time: start_time + } + + {rid, video_ctx} + end + end + + defp get_audio_track_context(%{0 => packets}) do + {:ok, depayloader} = Depayloader.new(@audio_codec_params) + + start_time = get_start_time(packets, depayloader) + + %{nil: %{packets: packets, start_time: start_time}} + end + + # Returns the timestamp (in milliseconds) at which the first frame was received + defp get_start_time([packet | rest], depayloader) do + case Depayloader.depayload(depayloader, packet) do + {nil, depayloader} -> + get_start_time(rest, depayloader) + + {_frame, _depayloader} -> + {:ok, %ExRTP.Packet.Extension{id: 1, data: <>}} = + ExRTP.Packet.fetch_extension(packet, 1) + + recv_time + end + end + + # + # Passing packets to the `Pipeline` process + + defp make_stream(pid, kind) do + Stream.resource( + fn -> pid end, + fn pid -> + send(pid, {:demand, kind, self()}) + + receive do + {^kind, nil} -> + {:halt, pid} + + {^kind, packet} -> + {[packet], pid} + end + end, + fn _pid -> :ok end + ) + end + + defp emit_packets(pipeline_pid, video_packets, audio_packets) do + receive do + {:demand, :video, pid} -> + {p, video_packets} = List.pop_at(video_packets, 0) + send(pid, {:video, p}) + emit_packets(pipeline_pid, video_packets, audio_packets) + + {:demand, :audio, pid} -> + {p, audio_packets} = List.pop_at(audio_packets, 0) + send(pid, {:audio, p}) + emit_packets(pipeline_pid, video_packets, audio_packets) + + {:DOWN, _monitor, :process, ^pipeline_pid, _reason} -> + :ok + end + end + + # + # Uploading output files + + defp maybe_upload_result!(output_manifest, nil) do + output_manifest + end + + defp maybe_upload_result!(output_manifest, upload_handler) do + {ref, upload_handler} = + output_manifest + |> __MODULE__.Manifest.to_upload_handler_manifest() + |> then(&S3.UploadHandler.spawn_task(upload_handler, &1)) + + # FIXME: Add descriptive errors + {:ok, upload_handler_result_manifest, _handler} = + receive do + {^ref, _res} = task_result -> + S3.UploadHandler.process_result(upload_handler, task_result) + end + + # UploadHandler spawns a task that gets auto-monitored + # We don't want to propagate this message + receive do + {:DOWN, ^ref, :process, _pid, _reason} -> :ok + end + + upload_handler_result_manifest + |> __MODULE__.Manifest.from_upload_handler_manifest(output_manifest) + end + + # + # Helpers + defp filter_rids(rid_map, rid_allowed?) do Map.filter(rid_map, fn {rid, _rid_idx} -> rid_allowed?.(rid) end) end diff --git a/lib/ex_webrtc_recorder/converter/ffmpeg.ex b/lib/ex_webrtc_recorder/converter/ffmpeg.ex index 2fe53e6..bc7d911 100644 --- a/lib/ex_webrtc_recorder/converter/ffmpeg.ex +++ b/lib/ex_webrtc_recorder/converter/ffmpeg.ex @@ -3,7 +3,26 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do alias ExWebRTC.Recorder.Converter - @spec combine_av!( + # FIXME: When the VM exits, FFmpeg processes started by this module keep running + # See https://hexdocs.pm/elixir/1.18.4/Port.html#module-zombie-operating-system-processes + # for ideas on tackling this issue + + @spec reencode_video!(Path.t(), Path.t(), Converter.reencode_ctx()) :: Path.t() | no_return() + def reencode_video!(video_file, output_file, reencode_ctx) do + {_io, 0} = + System.cmd( + "ffmpeg", + ~w(-nostdin) ++ + input_flags(video_file) ++ + reencode_flags(reencode_ctx) ++ + [output_file], + stderr_to_stdout: true + ) + + output_file + end + + @spec combine_audio_video!( Path.t(), integer(), Path.t(), @@ -11,7 +30,7 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do Path.t(), Converter.reencode_ctx() | nil ) :: Path.t() | no_return() - def combine_av!( + def combine_audio_video!( video_file, video_start_timestamp_ms, audio_file, @@ -22,31 +41,13 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do {video_start_time, audio_start_time} = calculate_start_times(video_start_timestamp_ms, audio_start_timestamp_ms) - reencode_flags = - case reencode_ctx do - nil -> - ~w(-c:v copy) - - %{ - threads: threads, - bitrate: bitrate, - gop_size: gop_size, - cues_to_front: cues_to_front - } -> - if(threads == nil, do: ~w(), else: ~w(-threads #{threads})) ++ - # -c:v vp8 -s 1920x1080 -crf 10 -b:v... - ~w(-c:v vp8 -b:v #{bitrate} -g #{gop_size}) ++ - if cues_to_front, do: ~w(-cues_to_front 1), else: ~w() - end - {_io, 0} = System.cmd( "ffmpeg", - ~w(-nostdin -ss #{video_start_time} -i) ++ - [video_file] ++ - ~w(-ss #{audio_start_time} -i) ++ - [audio_file] ++ - reencode_flags ++ + ~w(-nostdin) ++ + input_flags(video_file, video_start_time) ++ + input_flags(audio_file, audio_start_time) ++ + reencode_flags(reencode_ctx) ++ ~w(-c:a copy -shortest) ++ [output_file], stderr_to_stdout: true @@ -62,9 +63,9 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do {_io, 0} = System.cmd( "ffmpeg", - ~w(-nostdin -i) ++ - [file] ++ - ~w(-vf thumbnail,scale=#{thumbnails_ctx.width}:#{thumbnails_ctx.height} -frames:v 1) ++ + ~w(-nostdin) ++ + input_flags(file) ++ + ~w(-vf thumbnail,#{scale_filter(thumbnails_ctx.resolution)} -frames:v 1) ++ [thumbnail_file], stderr_to_stdout: true ) @@ -85,6 +86,28 @@ defmodule ExWebRTC.Recorder.Converter.FFmpeg do round(duration_seconds) end + defp reencode_flags(nil), do: ~w(-c:v copy) + + defp reencode_flags(%{ + threads: threads, + crf: crf, + bitrate: bitrate, + gop_size: gop_size, + resolution: resolution, + cues_to_front: cues_to_front + }) do + if(threads == nil, do: ~w(), else: ~w(-threads #{threads})) ++ + ~w(-c:v vp8 -crf #{crf} -b:v #{bitrate} -g #{gop_size}) ++ + if(resolution, do: ~w(-vf #{scale_filter(resolution)}), else: ~w()) ++ + if cues_to_front, do: ~w(-cues_to_front 1), else: ~w() + end + + defp input_flags(path, start_time \\ nil) + defp input_flags(path, nil), do: ["-i", path] + defp input_flags(path, start_time), do: ["-ss", start_time, "-i", path] + + defp scale_filter(%{width: width, height: height}), do: "scale=#{width}:#{height}" + defp calculate_start_times(video_start_ms, audio_start_ms) when is_nil(video_start_ms) or is_nil(audio_start_ms) do {"00:00:00.000", "00:00:00.000"} diff --git a/lib/ex_webrtc_recorder/converter/pipeline.ex b/lib/ex_webrtc_recorder/converter/pipeline.ex index 393c4ef..217179d 100644 --- a/lib/ex_webrtc_recorder/converter/pipeline.ex +++ b/lib/ex_webrtc_recorder/converter/pipeline.ex @@ -63,25 +63,11 @@ defmodule ExWebRTC.Recorder.Converter.Pipeline do def handle_init(_ctx, opts) do # TODO: Support codecs other than VP8/Opus # TODO: Use a single muxer + sink once `Membrane.Matroska.Muxer` supports synchronizing AV - spec = [ - child(:video_source, %Source{stream: opts.video_stream}) - |> child(:video_depayloader, %Membrane.RTP.DepayloaderBin{ - clock_rate: 90_000, - depayloader: Membrane.RTP.VP8.Depayloader - }) - |> child(:video_muxer, Membrane.Matroska.Muxer) - |> child(:video_sink, %Membrane.File.Sink{location: opts.output_path <> "_video.webm"}), - child(:audio_source, %Source{stream: opts.audio_stream}) - |> child(:audio_depayloader, %Membrane.RTP.DepayloaderBin{ - clock_rate: 48_000, - depayloader: Membrane.RTP.Opus.Depayloader - }) - |> child(:opus_parser, Membrane.Opus.Parser) - |> child(:audio_muxer, Membrane.Matroska.Muxer) - |> child(:audio_sink, %Membrane.File.Sink{location: opts.output_path <> "_audio.webm"}) - ] + spec = + video_spec(opts.video_stream, opts.output_path <> "_video.webm") ++ + audio_spec(opts.audio_stream, opts.output_path <> "_audio.webm") - {[spec: spec], %{}} + {[spec: spec], %{sinks_total: length(spec)}} end @impl true @@ -89,7 +75,7 @@ defmodule ExWebRTC.Recorder.Converter.Pipeline do when sink in [:video_sink, :audio_sink] do state = Map.update(state, :sinks_done, 1, &(&1 + 1)) - if state.sinks_done == 2 do + if state.sinks_done == state.sinks_total do {[terminate: :normal], state} else {[], state} @@ -100,4 +86,33 @@ defmodule ExWebRTC.Recorder.Converter.Pipeline do def handle_element_end_of_stream(_element, _pad, _ctx, state) do {[], state} end + + defp video_spec(nil, _), do: [] + + defp video_spec(stream, location) do + [ + child(:video_source, %Source{stream: stream}) + |> child(:video_depayloader, %Membrane.RTP.DepayloaderBin{ + clock_rate: 90_000, + depayloader: Membrane.RTP.VP8.Depayloader + }) + |> child(:video_muxer, Membrane.Matroska.Muxer) + |> child(:video_sink, %Membrane.File.Sink{location: location}) + ] + end + + defp audio_spec(nil, _), do: [] + + defp audio_spec(stream, location) do + [ + child(:audio_source, %Source{stream: stream}) + |> child(:audio_depayloader, %Membrane.RTP.DepayloaderBin{ + clock_rate: 48_000, + depayloader: Membrane.RTP.Opus.Depayloader + }) + |> child(:opus_parser, Membrane.Opus.Parser) + |> child(:audio_muxer, Membrane.Matroska.Muxer) + |> child(:audio_sink, %Membrane.File.Sink{location: location}) + ] + end end From 9aa1ce939c9b9a4b5c9387e5f4bd84d65994ddd8 Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:38:36 +0200 Subject: [PATCH 4/5] wip4 --- lib/ex_webrtc_recorder/converter.ex | 192 +++++++++---------- lib/ex_webrtc_recorder/converter/pipeline.ex | 15 +- 2 files changed, 103 insertions(+), 104 deletions(-) diff --git a/lib/ex_webrtc_recorder/converter.ex b/lib/ex_webrtc_recorder/converter.ex index 1070dc9..d9634e5 100644 --- a/lib/ex_webrtc_recorder/converter.ex +++ b/lib/ex_webrtc_recorder/converter.ex @@ -188,7 +188,7 @@ defmodule ExWebRTC.Recorder.Converter do recorder_manifest |> fetch_remote_files!(download_path, download_config) - |> do_convert_manifest!( + |> convert_manifest!( output_path, thumbnails_ctx, rid_allowed?, @@ -275,7 +275,7 @@ defmodule ExWebRTC.Recorder.Converter do # # Conversion - defp do_convert_manifest!( + defp convert_manifest!( manifest, output_path, thumbnails_ctx, @@ -287,131 +287,110 @@ defmodule ExWebRTC.Recorder.Converter do # 1. Read tracks # 2. Convert tracks to WEBM files # 3. Mux WEBM files into a single file - stream_map = - Enum.reduce(manifest, %{}, fn {_id, track}, stream_map -> - %{ - location: path, - kind: kind, - streams: streams, - rid_map: rid_map - } = track - - file = - with {:ok, %File.Stat{size: s}} <- File.stat(path), - true <- s > 0, - {:ok, file} <- File.open(path) do - file - else - false -> - raise "File #{path} is empty!" + Enum.reduce(manifest, %{}, fn {_id, track}, stream_map -> + %{ + location: path, + kind: kind, + streams: streams, + rid_map: rid_map + } = track + + file = + with {:ok, %File.Stat{size: s}} <- File.stat(path), + true <- s > 0, + {:ok, file} <- File.open(path) do + file + else + false -> + raise "File #{path} is empty!" + + {:error, reason} -> + raise "Unable to open #{path}: #{inspect(reason)}" + end - {:error, reason} -> - raise "Unable to open #{path}: #{inspect(reason)}" - end + packets = + read_packets( + file, + Map.new(rid_map, fn {_rid, rid_idx} -> + {rid_idx, %{store: %PacketStore{}, acc: [], packets_in_store: 0}} + end), + reorder_buffer_size + ) - packets = - read_packets( - file, - Map.new(rid_map, fn {_rid, rid_idx} -> - {rid_idx, %{store: %PacketStore{}, acc: [], packets_in_store: 0}} - end), - reorder_buffer_size - ) - - track_contexts = - case kind do - :video -> - rid_map = filter_rids(rid_map, rid_allowed?) - get_video_track_contexts(rid_map, packets) - - :audio -> - get_audio_track_context(packets) - end + track_contexts = + case kind do + :video -> + rid_map = filter_rids(rid_map, rid_allowed?) + get_video_track_contexts(rid_map, packets) - stream_id = List.first(streams) - - stream_map - |> Map.put_new(stream_id, %{video: %{}, audio: %{}}) - |> Map.update!(stream_id, &Map.put(&1, kind, track_contexts)) - end) - - Enum.flat_map(stream_map, fn {stream_id, %{video: v, audio: a}} -> - cond do - map_size(v) == 0 and map_size(a) == 0 -> - raise "Stream #{stream_id} contains no tracks!" - - map_size(v) == 0 -> - %{nil => audio_ctx} = a - output_file = Path.join(output_path, "#{stream_id}.webm") - output_file |> Path.dirname() |> File.mkdir_p!() - [{stream_id, process!(nil, audio_ctx, output_file, nil, nil)}] - - true -> - for {rid, video_ctx} <- v do - output_id = if rid == nil, do: stream_id, else: "#{stream_id}_#{rid}" - output_file = Path.join(output_path, "#{output_id}.webm") - output_file |> Path.dirname() |> File.mkdir_p!() - {output_id, process!(video_ctx, a[nil], output_file, thumbnails_ctx, reencode_ctx)} - end - end + :audio -> + get_audio_track_context(packets) + end + + stream_id = List.first(streams) + + stream_map + |> Map.put_new(stream_id, %{video: %{}, audio: %{}}) + |> Map.update!(stream_id, &Map.put(&1, kind, track_contexts)) end) + |> Enum.flat_map(&convert_stream!(&1, output_path, thumbnails_ctx, reencode_ctx)) |> Map.new() end - defp process!(video_ctx, audio_ctx, output_file, thumbnails_ctx, reencode_ctx) do - video_stream = if video_ctx, do: make_stream(self(), :video) - audio_stream = if audio_ctx, do: make_stream(self(), :audio) + defp convert_stream!({stream_id, %{video: video_ctxs, audio: audio_ctxs}}, output_path, thumbnails_ctx, reencode_ctx) do + video_ctxs = if map_size(video_ctxs) == 0, do: %{nil: nil}, else: video_ctxs - # FIXME: Possible RC here: when the pipeline playback starts, the `Stream`s will start sending - # `{:demand, kind, self()}` messages to this process. - # There's no guarantee we'll start listening for these messages (in `emit_packets/3`) soon enough, - # so we may reach a deadlock. - # For now, it seems to work just fine, though. - {:ok, _sup, pid} = Pipeline.start_link(video_stream, audio_stream, output_file) - Process.monitor(pid) + Enum.map(video_ctxs, fn {rid, video_ctx} -> + output_id = if rid == nil, do: stream_id, else: "#{stream_id}_#{rid}" - emit_packets(pid, video_ctx[:packets] || [], audio_ctx[:packets] || []) + output_file = Path.join(output_path, "#{output_id}.webm") + output_file |> Path.dirname() |> File.mkdir_p!() + + audio_ctx = audio_ctxs[nil] + if video_ctx == nil and audio_ctx == nil, do: raise "Stream #{stream_id} contains no tracks!" + + {output_id, convert_file!(video_ctx, audio_ctx, output_file, thumbnails_ctx, reencode_ctx)} + end) + end + + defp convert_file!(video_ctx, audio_ctx, output_file, thumbnails_ctx, reencode_ctx) do + {video_file, audio_file} = run_pipeline(video_ctx, audio_ctx, output_file) cond do video_ctx != nil and audio_ctx != nil -> FFmpeg.combine_audio_video!( - output_file <> "_video.webm", + video_file, video_ctx.start_time, - output_file <> "_audio.webm", + audio_file, audio_ctx.start_time, output_file, reencode_ctx ) - File.rm!(output_file <> "_video.webm") - File.rm!(output_file <> "_audio.webm") - video_ctx != nil -> if reencode_ctx, - do: FFmpeg.reencode_video!(output_file <> "_video.webm", output_file, reencode_ctx), - else: File.cp!(output_file <> "_video.webm", output_file) - - File.rm!(output_file <> "_video.webm") + do: FFmpeg.reencode_video!(video_file, output_file, reencode_ctx), + else: File.cp!(video_file, output_file) true -> - File.cp!(output_file <> "_audio.webm", output_file) - File.rm!(output_file <> "_audio.webm") + File.cp!(audio_file, output_file) end - stream_manifest = %{ + File.rm(video_file) + File.rm(audio_file) + + %{ location: output_file, duration_seconds: FFmpeg.get_duration_in_seconds!(output_file) } + |> maybe_generate_thumbnail!(output_file, thumbnails_ctx) + end - stream_manifest = - if thumbnails_ctx do - thumbnail_file = FFmpeg.generate_thumbnail!(output_file, thumbnails_ctx) - Map.put(stream_manifest, :thumbnail_location, thumbnail_file) - else - stream_manifest - end - - stream_manifest + defp maybe_generate_thumbnail!(stream_manifest, _file, nil), do: stream_manifest + defp maybe_generate_thumbnail!(stream_manifest, file, ctx) do + file + |> FFmpeg.generate_thumbnail!(ctx) + |> then(&Map.put(stream_manifest, :thumbnail_location, &1)) end # @@ -532,6 +511,23 @@ defmodule ExWebRTC.Recorder.Converter do # # Passing packets to the `Pipeline` process + defp run_pipeline(video_ctx, audio_ctx, output_file) do + video_stream = if video_ctx, do: make_stream(self(), :video) + audio_stream = if audio_ctx, do: make_stream(self(), :audio) + + # FIXME: Possible RC here: when the pipeline playback starts, the `Stream`s will start sending + # `{:demand, kind, self()}` messages to this process. + # There's no guarantee we'll start listening for these messages (in `emit_packets/3`) soon enough, + # so we may reach a deadlock. + # For now, it seems to work just fine, though. + {:ok, _sup, pid} = Pipeline.start_link(video_stream, audio_stream, output_file) + Process.monitor(pid) + + emit_packets(pid, video_ctx[:packets] || [], audio_ctx[:packets] || []) + + {Pipeline.video_output(output_file), Pipeline.audio_output(output_file)} + end + defp make_stream(pid, kind) do Stream.resource( fn -> pid end, diff --git a/lib/ex_webrtc_recorder/converter/pipeline.ex b/lib/ex_webrtc_recorder/converter/pipeline.ex index 217179d..74bac3b 100644 --- a/lib/ex_webrtc_recorder/converter/pipeline.ex +++ b/lib/ex_webrtc_recorder/converter/pipeline.ex @@ -59,13 +59,16 @@ defmodule ExWebRTC.Recorder.Converter.Pipeline do }) end + def video_output(path), do: path <> "_video.webm" + def audio_output(path), do: path <> "_audio.webm" + @impl true def handle_init(_ctx, opts) do # TODO: Support codecs other than VP8/Opus # TODO: Use a single muxer + sink once `Membrane.Matroska.Muxer` supports synchronizing AV spec = - video_spec(opts.video_stream, opts.output_path <> "_video.webm") ++ - audio_spec(opts.audio_stream, opts.output_path <> "_audio.webm") + video_spec(opts.video_stream, opts.output_path) ++ + audio_spec(opts.audio_stream, opts.output_path) {[spec: spec], %{sinks_total: length(spec)}} end @@ -89,7 +92,7 @@ defmodule ExWebRTC.Recorder.Converter.Pipeline do defp video_spec(nil, _), do: [] - defp video_spec(stream, location) do + defp video_spec(stream, path) do [ child(:video_source, %Source{stream: stream}) |> child(:video_depayloader, %Membrane.RTP.DepayloaderBin{ @@ -97,13 +100,13 @@ defmodule ExWebRTC.Recorder.Converter.Pipeline do depayloader: Membrane.RTP.VP8.Depayloader }) |> child(:video_muxer, Membrane.Matroska.Muxer) - |> child(:video_sink, %Membrane.File.Sink{location: location}) + |> child(:video_sink, %Membrane.File.Sink{location: video_output(path)}) ] end defp audio_spec(nil, _), do: [] - defp audio_spec(stream, location) do + defp audio_spec(stream, path) do [ child(:audio_source, %Source{stream: stream}) |> child(:audio_depayloader, %Membrane.RTP.DepayloaderBin{ @@ -112,7 +115,7 @@ defmodule ExWebRTC.Recorder.Converter.Pipeline do }) |> child(:opus_parser, Membrane.Opus.Parser) |> child(:audio_muxer, Membrane.Matroska.Muxer) - |> child(:audio_sink, %Membrane.File.Sink{location: location}) + |> child(:audio_sink, %Membrane.File.Sink{location: audio_output(path)}) ] end end From 9e8a15b0110bb631c64406951867c2b10ce88aee Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:38:52 +0200 Subject: [PATCH 5/5] lint --- lib/ex_webrtc_recorder/converter.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/ex_webrtc_recorder/converter.ex b/lib/ex_webrtc_recorder/converter.ex index d9634e5..29519c7 100644 --- a/lib/ex_webrtc_recorder/converter.ex +++ b/lib/ex_webrtc_recorder/converter.ex @@ -337,7 +337,12 @@ defmodule ExWebRTC.Recorder.Converter do |> Map.new() end - defp convert_stream!({stream_id, %{video: video_ctxs, audio: audio_ctxs}}, output_path, thumbnails_ctx, reencode_ctx) do + defp convert_stream!( + {stream_id, %{video: video_ctxs, audio: audio_ctxs}}, + output_path, + thumbnails_ctx, + reencode_ctx + ) do video_ctxs = if map_size(video_ctxs) == 0, do: %{nil: nil}, else: video_ctxs Enum.map(video_ctxs, fn {rid, video_ctx} -> @@ -347,7 +352,9 @@ defmodule ExWebRTC.Recorder.Converter do output_file |> Path.dirname() |> File.mkdir_p!() audio_ctx = audio_ctxs[nil] - if video_ctx == nil and audio_ctx == nil, do: raise "Stream #{stream_id} contains no tracks!" + + if video_ctx == nil and audio_ctx == nil, + do: raise("Stream #{stream_id} contains no tracks!") {output_id, convert_file!(video_ctx, audio_ctx, output_file, thumbnails_ctx, reencode_ctx)} end) @@ -387,6 +394,7 @@ defmodule ExWebRTC.Recorder.Converter do end defp maybe_generate_thumbnail!(stream_manifest, _file, nil), do: stream_manifest + defp maybe_generate_thumbnail!(stream_manifest, file, ctx) do file |> FFmpeg.generate_thumbnail!(ctx)