diff --git a/lib/transcoder.ex b/lib/transcoder.ex index 3e64777..91c5559 100644 --- a/lib/transcoder.ex +++ b/lib/transcoder.ex @@ -86,6 +86,15 @@ defmodule Membrane.Transcoder do """ @type stream_format_resolver :: (stream_format() -> stream_format() | stream_format_module()) + @typedoc """ + Describes bitrate option for video transcoding. + Can be either a ConstantBitrate or VariableBitrate struct. + """ + @type bitrate_option :: + Membrane.Transcoder.Video.ConstantBitrate.t() + | Membrane.Transcoder.Video.VariableBitrate.t() + | nil + def_input_pad :input, accepted_format: format @@ -126,6 +135,13 @@ defmodule Membrane.Transcoder do description: """ Per-output native acceleration setting. Inherits from bin's `native_acceleration` option if nil. """ + ], + bitrate: [ + spec: bitrate_option(), + default: nil, + description: """ + Per-output bitrate setting for video streams. Inherits from bin's `bitrate` option if nil. + """ ] ] @@ -202,6 +218,18 @@ defmodule Membrane.Transcoder do * `:never` - Always use software-based transcoding (default) * `:if_available` - Use Vulkan acceleration when available on the system """ + ], + bitrate: [ + spec: bitrate_option(), + default: nil, + description: """ + Per-output bitrate setting for video streams. + + Can be either: + * a `Membrane.Transcoder.Video.ConstantBitrate` struct for constant bitrate encoding + * a `Membrane.Transcoder.Video.VariableBitrate` struct for variable bitrate encoding + * nil (default) - use encoder defaults + """ ] @impl true @@ -260,6 +288,7 @@ defmodule Membrane.Transcoder do output_stream_format: pad_opts.output_stream_format || state.output_stream_format, transcoding_policy: pad_opts.transcoding_policy || state.transcoding_policy, native_acceleration: pad_opts.native_acceleration || state.native_acceleration, + bitrate: pad_opts.bitrate || state.bitrate, funnel_name: funnel_name, suffix: suffix, pad_id: pad_id @@ -298,7 +327,7 @@ defmodule Membrane.Transcoder do resolved_format, transcoding_policy, use_hw?, - output_spec.suffix + output_spec ) |> get_child(output_spec.funnel_name) ] @@ -324,7 +353,7 @@ defmodule Membrane.Transcoder do resolved_format, transcoding_policy, use_hw?, - output_spec.suffix + output_spec ) |> get_child(output_spec.funnel_name) end) @@ -383,11 +412,11 @@ defmodule Membrane.Transcoder do output_format, transcoding_policy, _use_hardware_acceleration?, - suffix + output_spec ) when Audio.is_audio_format(input_format) do builder - |> Audio.plug_audio_transcoding(input_format, output_format, transcoding_policy, suffix) + |> Audio.plug_audio_transcoding(input_format, output_format, transcoding_policy, output_spec) end defp plug_transcoding( @@ -396,7 +425,7 @@ defmodule Membrane.Transcoder do output_format, transcoding_policy, use_hardware_acceleration?, - suffix + output_spec ) when Video.is_video_format(input_format) do builder @@ -405,7 +434,7 @@ defmodule Membrane.Transcoder do output_format, transcoding_policy, use_hardware_acceleration?, - suffix + output_spec ) end end diff --git a/lib/transcoder/audio.ex b/lib/transcoder/audio.ex index 66a2da4..ecc36c9 100644 --- a/lib/transcoder/audio.ex +++ b/lib/transcoder/audio.ex @@ -65,17 +65,23 @@ defmodule Membrane.Transcoder.Audio do audio_stream_format() | RemoteStream.t(), audio_stream_format(), :always | :if_needed | :never, - String.t() | nil + map() ) :: ChildrenSpec.builder() def plug_audio_transcoding( builder, input_format, output_format, transcoding_policy, - suffix \\ nil + output_spec ) when is_audio_format(input_format) and is_audio_format(output_format) do - do_plug_audio_transcoding(builder, input_format, output_format, transcoding_policy, suffix) + do_plug_audio_transcoding( + builder, + input_format, + output_format, + transcoding_policy, + output_spec.suffix + ) end defp do_plug_audio_transcoding( diff --git a/lib/transcoder/video.ex b/lib/transcoder/video.ex index 5dc1b7d..f1e1eee 100644 --- a/lib/transcoder/video.ex +++ b/lib/transcoder/video.ex @@ -5,6 +5,7 @@ defmodule Membrane.Transcoder.Video do require Membrane.Logger alias Membrane.{ChildrenSpec, H264, H265, RawVideo, RemoteStream, VP8, VP9} alias Membrane.FFmpeg.SWScale + alias Membrane.Transcoder.Video.{ConstantBitrate, VariableBitrate} @type video_stream_format :: VP8.t() | VP9.t() | H264.t() | H265.t() | RawVideo.t() @@ -35,7 +36,7 @@ defmodule Membrane.Transcoder.Video do video_stream_format(), :always | :if_needed | :never, boolean(), - String.t() | nil + map() ) :: ChildrenSpec.builder() def plug_video_transcoding( builder, @@ -43,7 +44,7 @@ defmodule Membrane.Transcoder.Video do output_format, transcoding_policy, use_hardware_acceleration?, - suffix \\ nil + output_spec ) when is_video_format(input_format) and is_video_format(output_format) do do_plug_video_transcoding( @@ -52,7 +53,7 @@ defmodule Membrane.Transcoder.Video do output_format, transcoding_policy, use_hardware_acceleration?, - suffix + output_spec ) end @@ -62,7 +63,7 @@ defmodule Membrane.Transcoder.Video do output_format, transcoding_policy, use_hardware_acceleration?, - suffix + output_spec ) when h26x in [H264, H265] do do_plug_video_transcoding( @@ -71,7 +72,7 @@ defmodule Membrane.Transcoder.Video do output_format, transcoding_policy, use_hardware_acceleration?, - suffix + output_spec ) end @@ -81,11 +82,11 @@ defmodule Membrane.Transcoder.Video do %H264{} = output_format, transcoding_policy, _use_hardware_acceleration?, - suffix + output_spec ) when transcoding_policy in [:if_needed, :never] do builder - |> child(child_name(suffix, :h264_parser), %H264.Parser{ + |> child(child_name(output_spec.suffix, :h264_parser), %H264.Parser{ output_stream_structure: stream_structure_type(output_format), output_alignment: output_format.alignment }) @@ -97,11 +98,11 @@ defmodule Membrane.Transcoder.Video do %H265{} = output_format, transcoding_policy, _use_hardware_acceleration?, - suffix + output_spec ) when transcoding_policy in [:if_needed, :never] do builder - |> child(child_name(suffix, :h265_parser), %H265.Parser{ + |> child(child_name(output_spec.suffix, :h265_parser), %H265.Parser{ output_stream_structure: stream_structure_type(output_format), output_alignment: output_format.alignment }) @@ -113,10 +114,10 @@ defmodule Membrane.Transcoder.Video do %RawVideo{} = output_format, _transcoding_policy, true, - suffix + output_spec ) do builder - |> maybe_plug_swscale_converter_vulkan(input_format, output_format, suffix) + |> maybe_plug_swscale_converter_vulkan(input_format, output_format, output_spec.suffix) end defp do_plug_video_transcoding( @@ -125,10 +126,10 @@ defmodule Membrane.Transcoder.Video do %RawVideo{} = output_format, _transcoding_policy, false, - suffix + output_spec ) do builder - |> maybe_plug_swscale_converter(input_format, output_format, suffix) + |> maybe_plug_swscale_converter(input_format, output_format, output_spec.suffix) end defp do_plug_video_transcoding( @@ -137,7 +138,7 @@ defmodule Membrane.Transcoder.Video do %format_module{}, transcoding_policy, _use_hardware_acceleration?, - _suffix + _output_spec ) when transcoding_policy in [:if_needed, :never] do Membrane.Logger.debug(""" @@ -153,7 +154,7 @@ defmodule Membrane.Transcoder.Video do output_format, :never, _use_hardware_acceleration?, - _suffix + _output_spec ), do: raise(""" @@ -167,12 +168,12 @@ defmodule Membrane.Transcoder.Video do output_format, _transcoding_policy, true, - suffix + output_spec ) do builder - |> maybe_plug_parser_and_decoder_vulkan(input_format, suffix) - |> maybe_plug_swscale_converter_vulkan(input_format, output_format, suffix) - |> maybe_plug_encoder_and_parser_vulkan(output_format, suffix) + |> maybe_plug_parser_and_decoder_vulkan(input_format, output_spec) + |> maybe_plug_swscale_converter_vulkan(input_format, output_format, output_spec.suffix) + |> maybe_plug_encoder_and_parser_vulkan(output_format, output_spec) end defp do_plug_video_transcoding( @@ -181,16 +182,18 @@ defmodule Membrane.Transcoder.Video do output_format, _transcoding_policy, false, - suffix + output_spec ) do builder - |> maybe_plug_parser_and_decoder(input_format, suffix) - |> maybe_plug_swscale_converter(input_format, output_format, suffix) - |> maybe_plug_encoder_and_parser(output_format, suffix) + |> maybe_plug_parser_and_decoder(input_format, output_spec.suffix) + |> maybe_plug_swscale_converter(input_format, output_format, output_spec.suffix) + |> maybe_plug_encoder_and_parser(output_format, output_spec) end # VK-specific decoder: child name :vk_h264_decoder distinguishes it from FFmpeg's :h264_decoder - defp maybe_plug_parser_and_decoder_vulkan(builder, %H264{}, suffix) do + defp maybe_plug_parser_and_decoder_vulkan(builder, %H264{}, output_spec) do + suffix = output_spec.suffix + builder |> child(child_name(suffix, :h264_input_parser), %H264.Parser{ output_stream_structure: :annexb, @@ -199,8 +202,8 @@ defmodule Membrane.Transcoder.Video do |> child(child_name(suffix, :vk_h264_decoder), Membrane.VKVideo.Decoder) end - defp maybe_plug_parser_and_decoder_vulkan(builder, format, suffix), - do: maybe_plug_parser_and_decoder(builder, format, suffix) + defp maybe_plug_parser_and_decoder_vulkan(builder, format, output_spec), + do: maybe_plug_parser_and_decoder(builder, format, output_spec.suffix) defp maybe_plug_parser_and_decoder(builder, %H264{}, suffix) do builder @@ -320,53 +323,91 @@ defmodule Membrane.Transcoder.Video do defp maybe_plug_swscale_converter(builder, _input_format, _output_format, _suffix), do: builder - defp maybe_plug_encoder_and_parser_vulkan(builder, %H264{} = h264, suffix) do + defp maybe_plug_encoder_and_parser_vulkan(builder, %H264{} = h264, output_spec) do + suffix = output_spec.suffix + bitrate = output_spec.bitrate + rate_control = get_vkvideo_rate_control(bitrate) + builder - |> child(child_name(suffix, :vk_h264_encoder), Membrane.VKVideo.Encoder) + |> child(child_name(suffix, :vk_h264_encoder), %Membrane.VKVideo.Encoder{ + rate_control: rate_control + }) |> child(child_name(suffix, :h264_output_parser), %H264.Parser{ output_stream_structure: stream_structure_type(h264), output_alignment: h264.alignment }) end - defp maybe_plug_encoder_and_parser_vulkan(builder, format, suffix), - do: maybe_plug_encoder_and_parser(builder, format, suffix) + defp maybe_plug_encoder_and_parser_vulkan(builder, format, output_spec), + do: maybe_plug_encoder_and_parser(builder, format, output_spec) + + defp maybe_plug_encoder_and_parser(builder, %H264{} = h264, output_spec) do + suffix = output_spec.suffix + bitrate = output_spec.bitrate + ffmpeg_params = get_h264_ffmpeg_params(bitrate) + + encoder_params = %H264.FFmpeg.Encoder{ + preset: :ultrafast, + ffmpeg_params: ffmpeg_params + } + + # default CRF overrides bitrate param, setting it to -1 disables it + encoder_params = + if is_nil(bitrate), + do: encoder_params, + else: %{encoder_params | crf: -1} - defp maybe_plug_encoder_and_parser(builder, %H264{} = h264, suffix) do builder - |> child(child_name(suffix, :h264_encoder), %H264.FFmpeg.Encoder{preset: :ultrafast}) + |> child(child_name(suffix, :h264_encoder), encoder_params) |> child(child_name(suffix, :h264_output_parser), %H264.Parser{ output_stream_structure: stream_structure_type(h264), output_alignment: h264.alignment }) end - defp maybe_plug_encoder_and_parser(builder, %H265{} = h265, suffix) do + defp maybe_plug_encoder_and_parser(builder, %H265{} = h265, output_spec) do + suffix = output_spec.suffix + bitrate = output_spec.bitrate + x265_params = get_h265_x265_params(bitrate) + builder - |> child(child_name(suffix, :h265_encoder), %H265.FFmpeg.Encoder{preset: :ultrafast}) + |> child(child_name(suffix, :h265_encoder), %H265.FFmpeg.Encoder{ + preset: :ultrafast, + x265_params: x265_params + }) |> child(child_name(suffix, :h265_output_parser), %H265.Parser{ output_stream_structure: stream_structure_type(h265), output_alignment: h265.alignment }) end - defp maybe_plug_encoder_and_parser(builder, %VP8{}, suffix) do + defp maybe_plug_encoder_and_parser(builder, %VP8{}, output_spec) do + suffix = output_spec.suffix + bitrate = output_spec.bitrate + target_bitrate = get_vpx_target_bitrate(bitrate) + builder |> child(child_name(suffix, :vp8_encoder), %VP8.Encoder{ g_threads: cpu_count(), - cpu_used: 15 + cpu_used: 15, + rc_target_bitrate: target_bitrate }) end - defp maybe_plug_encoder_and_parser(builder, %VP9{}, suffix) do + defp maybe_plug_encoder_and_parser(builder, %VP9{}, output_spec) do + suffix = output_spec.suffix + bitrate = output_spec.bitrate + target_bitrate = get_vpx_target_bitrate(bitrate) + builder |> child(child_name(suffix, :vp9_encoder), %VP9.Encoder{ g_threads: cpu_count(), - cpu_used: 15 + cpu_used: 15, + rc_target_bitrate: target_bitrate }) end - defp maybe_plug_encoder_and_parser(builder, %RawVideo{}, _suffix), do: builder + defp maybe_plug_encoder_and_parser(builder, %RawVideo{}, _output_spec), do: builder defp stream_structure_type(%h26x{stream_structure: stream_structure}) when h26x in [H264, H265] do @@ -376,6 +417,73 @@ defmodule Membrane.Transcoder.Video do end end + defp get_vkvideo_rate_control(nil), do: :encoder_default + + defp get_vkvideo_rate_control(%ConstantBitrate{ + bitrate: bitrate, + virtual_buffer_size_ms: virtual_buffer_size_ms + }) do + {:constant_bitrate, + %Membrane.VKVideo.Encoder.ConstantBitrate{ + bitrate: bitrate, + virtual_buffer_size_ms: virtual_buffer_size_ms + }} + end + + defp get_vkvideo_rate_control(%VariableBitrate{ + average_bitrate: avg, + max_bitrate: max, + virtual_buffer_size_ms: virtual_buffer_size_ms + }) do + {:variable_bitrate, + %Membrane.VKVideo.Encoder.VariableBitrate{ + average_bitrate: avg, + max_bitrate: max, + virtual_buffer_size_ms: virtual_buffer_size_ms + }} + end + + defp get_h264_ffmpeg_params(nil), do: %{} + + defp get_h264_ffmpeg_params(%ConstantBitrate{bitrate: bitrate, virtual_buffer_size_ms: vbr_ms}) do + %{ + "b" => Integer.to_string(bitrate), + "bufsize" => Integer.to_string(trunc(bitrate * vbr_ms / 1000)) + } + end + + defp get_h264_ffmpeg_params(%VariableBitrate{ + average_bitrate: avg, + max_bitrate: max, + virtual_buffer_size_ms: vbr_ms + }) do + %{ + "b" => Integer.to_string(avg), + "maxrate" => Integer.to_string(max), + "bufsize" => Integer.to_string(trunc(max * vbr_ms / 1000)) + } + end + + defp get_h265_x265_params(nil), do: "" + + defp get_h265_x265_params(%ConstantBitrate{bitrate: bitrate, virtual_buffer_size_ms: vbr_ms}) do + "bitrate=#{bitrate}:vbv-bufsize=#{trunc(bitrate * vbr_ms / 1000.0 / 8)}:vbv-maxrate=#{bitrate}" + end + + defp get_h265_x265_params(%VariableBitrate{ + average_bitrate: avg, + max_bitrate: max, + virtual_buffer_size_ms: vbr_ms + }) do + "bitrate=#{avg}:vbv-bufsize=#{trunc(avg * vbr_ms / 1000.0 / 8)}:vbv-maxrate=#{max}" + end + + defp get_vpx_target_bitrate(nil), do: :auto + + defp get_vpx_target_bitrate(%ConstantBitrate{bitrate: bitrate}), do: trunc(bitrate / 1000) + + defp get_vpx_target_bitrate(%VariableBitrate{average_bitrate: avg}), do: trunc(avg / 1000) + defp cpu_count() do cpu_quota = :erlang.system_info(:cpu_quota) diff --git a/lib/transcoder/video/constant_bitrate.ex b/lib/transcoder/video/constant_bitrate.ex new file mode 100644 index 0000000..cffcff0 --- /dev/null +++ b/lib/transcoder/video/constant_bitrate.ex @@ -0,0 +1,15 @@ +defmodule Membrane.Transcoder.Video.ConstantBitrate do + @moduledoc """ + Defines encoder setting for constant bitrate rate control algorithm. + + The following fields need to be specified: + * bitrate - desired bitrate of the stream; expressed in bits per second. + * virtual_buffer_size_ms - virtual buffer duration for rate control smoothing; + larger values increase bitrate stability, smaller values improve responsiveness + to scene changes; expressed in milliseconds, defaults to 2 seconds. + """ + + @type t :: %__MODULE__{bitrate: non_neg_integer(), virtual_buffer_size_ms: non_neg_integer()} + @enforce_keys [:bitrate] + defstruct @enforce_keys ++ [virtual_buffer_size_ms: 2000] +end diff --git a/lib/transcoder/video/variable_bitrate.ex b/lib/transcoder/video/variable_bitrate.ex new file mode 100644 index 0000000..de41e63 --- /dev/null +++ b/lib/transcoder/video/variable_bitrate.ex @@ -0,0 +1,22 @@ +defmodule Membrane.Transcoder.Video.VariableBitrate do + @moduledoc """ + Defines encoder setting for variable bitrate rate control algorithm. + + The following fields need to be specified: + * average_bitrate - Target average bitrate for VBR encoding; the encoder will try to meet this + average over the sequence; expressed in bits per second. + * max_bitrate - Maximum allowed bitrate in VBR encoding; caps peak bitrate to prevent excessive + spikes while maintaining average bitrate constraints; expressed in bits per second. + * virtual_buffer_size_ms - virtual buffer duration for rate control smoothing; larger values + increase bitrate stability, smaller values improve responsiveness to scene changes; + expressed in milliseconds, defaults to 2 seconds. + """ + + @type t :: %__MODULE__{ + average_bitrate: non_neg_integer(), + max_bitrate: non_neg_integer(), + virtual_buffer_size_ms: non_neg_integer() + } + @enforce_keys [:average_bitrate, :max_bitrate] + defstruct @enforce_keys ++ [virtual_buffer_size_ms: 2000] +end diff --git a/test/integration_test.exs b/test/integration_test.exs index 473e872..5c31fa0 100644 --- a/test/integration_test.exs +++ b/test/integration_test.exs @@ -1,13 +1,14 @@ defmodule Membrane.Transcoder.IntegrationTest do use ExUnit.Case - import Membrane.Testing.Assertions import Membrane.ChildrenSpec + import Membrane.Testing.Assertions require Membrane.Pad alias Membrane.{AAC, H264, H265, MPEGAudio, Opus, RawAudio, RawVideo, VP8, VP9} alias Membrane.Testing alias Membrane.Transcoder.Support.Preprocessors + alias Membrane.Transcoder.Video.{ConstantBitrate, VariableBitrate} @video_inputs [ %{input_format: H264, input_file: "video.h264", preprocess: &Preprocessors.parse_h264/1}, @@ -151,6 +152,38 @@ defmodule Membrane.Transcoder.IntegrationTest do bytes end + defp transcode_to_bytes_with_bitrate( + input_file, + preprocess, + output_format, + bitrate, + native_acceleration, + tmp_dir + ) do + tmp_path = tmp_path(tmp_dir, "bitrate") + pid = Testing.Pipeline.start_link_supervised!() + + spec = + child(%Membrane.File.Source{location: input_file}) + |> then(preprocess) + |> child(:transcoder, %Membrane.Transcoder{ + output_stream_format: output_format, + transcoding_policy: :always, + native_acceleration: native_acceleration, + bitrate: bitrate + }) + |> via_out(Membrane.Pad.ref(:output, 0)) + |> child(:sink, %Membrane.File.Sink{location: tmp_path}) + + Testing.Pipeline.execute_actions(pid, spec: spec) + assert_end_of_stream(pid, :sink, :input, 30_000) + Testing.Pipeline.terminate(pid) + + bytes = File.read!(tmp_path) + File.rm(tmp_path) + bytes + end + defp tmp_path(tmp_dir, prefix) do Path.join(tmp_dir, "#{prefix}_#{:erlang.unique_integer([:positive])}") end @@ -520,17 +553,143 @@ defmodule Membrane.Transcoder.IntegrationTest do assert_sink_stream_format(pid, :sink, _format) assert {:ok, _pid} = - Testing.Pipeline.get_child_pid(pid, [:transcoder, {:vk_h264_decoder, "output_0"}]) + Testing.Pipeline.get_child_pid(pid, [:transcoder, {:vk_h264_decoder, {0, :output}}]) assert {:ok, _pid} = - Testing.Pipeline.get_child_pid(pid, [:transcoder, {:vk_h264_encoder, "output_0"}]) + Testing.Pipeline.get_child_pid(pid, [:transcoder, {:vk_h264_encoder, {0, :output}}]) assert {:error, :child_not_found} = - Testing.Pipeline.get_child_pid(pid, [:transcoder, {:h264_decoder, "output_0"}]) + Testing.Pipeline.get_child_pid(pid, [:transcoder, {:h264_decoder, {0, :output}}]) assert {:error, :child_not_found} = - Testing.Pipeline.get_child_pid(pid, [:transcoder, {:h264_encoder, "output_0"}]) + Testing.Pipeline.get_child_pid(pid, [:transcoder, {:h264_encoder, {0, :output}}]) Testing.Pipeline.terminate(pid) end + + @tag :tmp_dir + test "bitrate conversion produces different output sizes", %{tmp_dir: tmp_dir} do + # Transcode the same input with different bitrates and verify output sizes differ + low_bitrate = %ConstantBitrate{bitrate: 100_000, virtual_buffer_size_ms: 2000} + high_bitrate = %ConstantBitrate{bitrate: 5_000_000, virtual_buffer_size_ms: 2000} + + low_output = + transcode_to_bytes_with_bitrate( + "./test/fixtures/video.h264", + &Preprocessors.parse_h264/1, + H264, + low_bitrate, + :never, + tmp_dir + ) + + high_output = + transcode_to_bytes_with_bitrate( + "./test/fixtures/video.h264", + &Preprocessors.parse_h264/1, + H264, + high_bitrate, + :never, + tmp_dir + ) + + # Low bitrate (100k) should produce output significantly smaller than high bitrate (5M) + # With a 50x bitrate difference, we expect the file size ratio to be substantial + # We check that low output is less than 25% of high output size + assert byte_size(low_output) > 0, "Low bitrate output is empty" + assert byte_size(high_output) > 0, "High bitrate output is empty" + + low_size = byte_size(low_output) + high_size = byte_size(high_output) + ratio = low_size / high_size + + assert ratio < 0.25, + "Low bitrate output (#{low_size} bytes) should be less than 25% of high bitrate output (#{high_size} bytes), but ratio is #{ratio}" + end + + @tag :tmp_dir + test "bitrate conversion with format change produces valid output", %{tmp_dir: tmp_dir} do + bitrate = %ConstantBitrate{bitrate: 1_000_000, virtual_buffer_size_ms: 2000} + + # Transcode H264 to H265 with bitrate + output = + transcode_to_bytes_with_bitrate( + "./test/fixtures/video.h264", + &Preprocessors.parse_h264/1, + H265, + bitrate, + :never, + tmp_dir + ) + + assert byte_size(output) > 0, "Output is empty" + end + + @tag :tmp_dir + test "variable bitrate produces valid output", %{tmp_dir: tmp_dir} do + bitrate = %VariableBitrate{ + average_bitrate: 1_000_000, + max_bitrate: 2_000_000, + virtual_buffer_size_ms: 2000 + } + + output = + transcode_to_bytes_with_bitrate( + "./test/fixtures/video.h264", + &Preprocessors.parse_h264/1, + H264, + bitrate, + :never, + tmp_dir + ) + + assert byte_size(output) > 0, "Variable bitrate output is empty" + end + + @tag :tmp_dir + test "per-output bitrate produces different sizes", %{tmp_dir: tmp_dir} do + low_bitrate = %ConstantBitrate{bitrate: 100_000, virtual_buffer_size_ms: 2000} + high_bitrate = %ConstantBitrate{bitrate: 3_000_000, virtual_buffer_size_ms: 2000} + + pid = Testing.Pipeline.start_link_supervised!() + low_tmp = tmp_path(tmp_dir, "mv_low") + high_tmp = tmp_path(tmp_dir, "mv_high") + + spec = [ + child(%Membrane.File.Source{location: "./test/fixtures/video.h264"}) + |> then(&Preprocessors.parse_h264/1) + |> child(:transcoder, %Membrane.Transcoder{ + output_stream_format: H264, + transcoding_policy: :always, + native_acceleration: :never + }), + get_child(:transcoder) + |> via_out(Membrane.Pad.ref(:output, 0), options: [bitrate: low_bitrate]) + |> child(:sink_low, %Membrane.File.Sink{location: low_tmp}), + get_child(:transcoder) + |> via_out(Membrane.Pad.ref(:output, 1), options: [bitrate: high_bitrate]) + |> child(:sink_high, %Membrane.File.Sink{location: high_tmp}) + ] + + Testing.Pipeline.execute_actions(pid, spec: spec) + assert_end_of_stream(pid, :sink_low, :input, 30_000) + assert_end_of_stream(pid, :sink_high, :input, 30_000) + Testing.Pipeline.terminate(pid) + + low_output = File.read!(low_tmp) + high_output = File.read!(high_tmp) + File.rm(low_tmp) + File.rm(high_tmp) + + assert byte_size(low_output) > 0, "Low bitrate multivariant output is empty" + assert byte_size(high_output) > 0, "High bitrate multivariant output is empty" + + low_size = byte_size(low_output) + high_size = byte_size(high_output) + ratio = low_size / high_size + + # With 30x bitrate difference (100k vs 3M), expect low to be less than 50% of high + assert ratio < 0.50, + "Low bitrate multivariant output (#{low_size} bytes) should be less than 50% of high bitrate (#{high_size} bytes), but ratio is #{ratio}" + end end