Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
_build
deps
cover
doc
.elixir_ls
.git
.gitignore
.formatter.exs
.terraform
.terraform.lock.hcl
terraform
k8s
tutorial
test
.expert
.claude
*.md
*.ez
erl_crash.dump
output
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ erl_crash.dump
# Ignore package tarball (built via "mix hex.build").
membrane_vk_hls-*.tar

# Terraform state and files containing secrets (AWS credentials, etc.)
terraform/.terraform/
terraform/*.tfstate
terraform/*.tfstate.*
terraform/terraform.tfvars
terraform/*.auto.tfvars
terraform/*.pem

100 changes: 100 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# syntax=docker/dockerfile:1.7

ARG ELIXIR_VERSION=1.17.3
ARG OTP_VERSION=27.1.2
Comment on lines +3 to +4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these two used at all? 🤔


# Builder stage - use CUDA base image
FROM nvcr.io/nvidia/cuda:12.4.1-base-ubuntu22.04 AS builder

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

ENV MIX_ENV=prod \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
ERL_AFLAGS="+JMsingle true"

RUN apt-get update
RUN apt-get install software-properties-common -y
RUN add-apt-repository ppa:rabbitmq/rabbitmq-erlang
RUN apt-get update

# Install build dependencies
RUN apt-get install -y --no-install-recommends \
build-essential \
cmake \
curl \
pkg-config \
libvulkan-dev \
git \
elixir \
erlang \
&& rm -rf /var/lib/apt/lists/*

# Download and install Erlang and Elixir using direct .deb downloads

# Install Rust
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH

RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \
&& rustc --version && cargo --version

WORKDIR /app

RUN mix local.hex --force && mix local.rebar --force

COPY mix.exs mix.lock ./
COPY config config
COPY --from=transcoder_plugin . /membrane_transcoder_plugin
RUN mix deps.get --only prod && mix deps.compile

COPY lib lib

ENV MIX_ENV=prod
RUN mix compile && mix release && cp -r _build/prod/rel/ex_broadcaster /app/release


# Runtime stage - use CUDA base image for GPU support with Vulkan
FROM nvcr.io/nvidia/cuda:12.4.1-base-ubuntu22.04 AS runtime

ENV LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
NVIDIA_DRIVER_CAPABILITIES=compute,graphics \
NVIDIA_VISIBLE_DEVICES=all

# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libstdc++6 \
libssl3 \
libncurses6 \
libsctp1 \
ca-certificates \
libegl1-mesa-dev \
libgl1-mesa-dri \
libxcb-xfixes0-dev \
mesa-vulkan-drivers \
locales \
gnupg \
wget \
&& rm -rf /var/lib/apt/lists/* \
&& locale-gen en_US.UTF-8

RUN wget -qO - https://packages.lunarg.com/lunarg-signing-key-pub.asc | gpg --dearmor -o /usr/share/keyrings/lunarg-signing-key-pub.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/lunarg-signing-key-pub.gpg] https://packages.lunarg.com/vulkan/ jammy main" \
> /etc/apt/sources.list.d/lunarg-vulkan-jammy.list \
&& apt-get update && apt-get install -y --no-install-recommends libvulkan1 \
&& rm -rf /var/lib/apt/lists/*

RUN groupadd --system app && useradd --system --gid app --create-home --home /app app

WORKDIR /app

COPY --from=builder --chown=app:app /app/release ./

USER app

EXPOSE 1935 8080

ENTRYPOINT ["/app/bin/ex_broadcaster"]
CMD ["start"]
12 changes: 6 additions & 6 deletions lib/ex_broadcaster.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ defmodule ExBroadcaster do
RTMP-to-HLS adaptive transcoding application built on the Membrane Framework.

Receives an RTMP stream (H.264/AAC) and produces multi-variant HLS output
using GPU-accelerated transcoding via `membrane_vk_video_plugin`.
using `Membrane.Transcoder` with Vulkan Video native acceleration via `membrane_vk_video_plugin`.

## Variants

| Name | Resolution | Bitrate |
|-------|-------------|------------|
| 1080p | 1920 × 1080 | 4 000 kbps |
| 720p | 1280 × 720 | 2 500 kbps |
| 480p | 854 × 480 | 1 000 kbps |
| Name | Resolution | Bitrate (avg / max) |
|-------|-------------|----------------------|
| 1080p | 1920 × 1080 | 5.0 Mbps / 6.0 Mbps |
| 720p | 1280 × 720 | 2.8 Mbps / 3.5 Mbps |
| 480p | 854 × 480 | 1.4 Mbps / 1.75 Mbps |
Comment on lines +12 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these bitrates are corresponding to the ones in code (we have @max_bitrate_factor equal to 1.1 and 5*1.1 is 5.5, not 6 :D)


## Usage

Expand Down
3 changes: 2 additions & 1 deletion lib/ex_broadcaster/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ defmodule ExBroadcaster.Application do

alias ExBroadcaster.Storages.S3Storage
alias Membrane.HTTPAdaptiveStream.Storages.FileStorage
alias Membrane.RTMPServer, as: RTMPServer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this as: RTMPServer is needed :D


@max_concurrent_pipelines Application.compile_env(
:ex_broadcaster,
Expand Down Expand Up @@ -55,7 +56,7 @@ defmodule ExBroadcaster.Application do
Each pipeline uploads HLS output to its own S3 prefix named after
the stream key, allowing multiple simultaneous streams.
"""
@spec handle_new_client(pid(), String.t(), String.t()) :: client_behaviour_spec()
@spec handle_new_client(pid(), String.t(), String.t()) :: RTMPServer.client_behaviour_spec()
def handle_new_client(client_ref, app, stream_key) do
Logger.info("[App] New RTMP client: app=#{app}, stream_key=#{stream_key}")

Expand Down
88 changes: 52 additions & 36 deletions lib/ex_broadcaster/pipeline.ex
Original file line number Diff line number Diff line change
@@ -1,63 +1,73 @@
defmodule ExBroadcaster.Pipeline do
@moduledoc """
Membrane pipeline that receives a single RTMP stream, transcodes it to
multiple H.264 variants using the GPU (via `Membrane.VKVideo.Transcoder`),
and uploads an adaptive HLS manifest + fMP4 segments to Amazon S3.
multiple H.264 variants using a single `Membrane.Transcoder` with multiple outputs
(v0.4.0+ feature) and Vulkan Video native acceleration via `membrane_vk_video_plugin`,
and uploads an adaptive HLS manifest + fMP4 segments.

Topology
--------

RTMP.SourceBin
├─ :video ──► H264.Parser ──► VKVideo.Transcoder ─┬─ :output(1080p) ─► CMAF.Muxer(1080p) ─► HLS.Sink
├─ :output(720p) ─► CMAF.Muxer(720p) ─►
└─ :output(480p) ─► CMAF.Muxer(480p) ─►
└─ :audio ──► AAC.Parser ──► Tee ─── :output(1080p) ──────────────────► CMAF.Muxer(1080p) ─►
├─ :output(720p) ──────────────────► CMAF.Muxer(720p) ─►
└─ :output(480p) ──────────────────► CMAF.Muxer(480p) ─►
├─ :video ──► Transcoder ─┬─ :output(:p1080) ─► CMAF.Muxer(1080p) ─►
│ ├─ :output(:p720) ─► CMAF.Muxer(720p) ─► HLS.Sink
│ └─ :output(:p480) ─► CMAF.Muxer(480p) ─►
└─ :audio ──► AAC.Parser ──► Tee ──────────┬─ :output(:p1080) ─► CMAF.Muxer(1080p) ─►
├─ :output(:p720) ─► CMAF.Muxer(720p) ─►
└─ :output(:p480) ─► CMAF.Muxer(480p) ─►

Each CMAF muxer produces a single muxed audio+video CMAF track delivered
to the shared HLS sink, which writes segments and a master playlist.

Each output pad also carries its own target bitrate (`Membrane.Transcoder.Video.VariableBitrate`),
so the transcoder encodes a proper bitrate ladder alongside the resolution ladder instead of
leaving every variant at the encoder's default rate control.
Comment on lines +24 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's quite obvious that it happens this way, so let's remove this comment


GPU requirements
----------------
`Membrane.VKVideo.Transcoder` requires Linux with a Vulkan-capable GPU
(NVIDIA or AMD with Mesa) and the Vulkan Video extension.
When `native_acceleration: :if_available` is set and `membrane_vk_video_plugin` is present,
`Membrane.Transcoder` uses Vulkan Video hardware acceleration for encoding/decoding.
This requires Linux with a Vulkan-capable GPU (NVIDIA or AMD with Mesa) and the Vulkan Video extension.
"""

use Membrane.Pipeline

require Membrane.Logger, as: Logger
require Membrane.Pad

alias Membrane.HTTPAdaptiveStream
alias Membrane.MP4.Muxer.CMAF, as: CMAFMuxer
alias Membrane.VKVideo
alias Membrane.Pad
alias Membrane.Transcoder.Video.VariableBitrate

@max_bitrate_factor 1.1

@variants [
%{
id: :p1080,
track_name: "1080p",
width: 1920,
height: 1080,
bitrate: 4_000_000,
framerate: {30, 1}
framerate: {30, 1},
average_bitrate: 5_000_000
},
%{
id: :p720,
track_name: "720p",
width: 1280,
height: 720,
bitrate: 2_500_000,
framerate: {30, 1}
framerate: {30, 1},
average_bitrate: 2_800_000
},
%{
id: :p480,
track_name: "480p",
width: 854,
height: 480,
bitrate: 1_000_000,
framerate: {30, 1}
framerate: {30, 1},
average_bitrate: 1_400_000
}
]

Expand Down Expand Up @@ -98,11 +108,10 @@ defmodule ExBroadcaster.Pipeline do
video_branch =
get_child(:rtmp_source)
|> via_out(:video)
|> child(:h264_parser, %Membrane.H264.Parser{
output_alignment: :au,
output_stream_structure: :annexb
|> child(:transcoder, %Membrane.Transcoder{
transcoding_policy: :always,
native_acceleration: :if_available
})
|> child(:transcoder, Membrane.VKVideo.Transcoder)

audio_branch =
get_child(:rtmp_source)
Expand All @@ -126,27 +135,34 @@ defmodule ExBroadcaster.Pipeline do
end

defp build_variant_spec(variant, segment_duration) do
%{id: id, track_name: name, width: w, height: h, bitrate: br, framerate: fps} = variant
%{
id: id,
track_name: name,
width: w,
height: h,
framerate: fps,
average_bitrate: average_bitrate
} = variant

bitrate = %VariableBitrate{
average_bitrate: average_bitrate,
max_bitrate: round(average_bitrate * @max_bitrate_factor)
}

video_to_muxer =
get_child(:transcoder)
|> via_out(Pad.ref(:output, id),
options: [
width: w,
height: h,
tune: :low_latency,
rate_control:
{:constant_bitrate,
%VKVideo.Encoder.ConstantBitrate{
bitrate: br
}},
scaling_algorithm: :bilinear
output_stream_format: %Membrane.H264{
width: w,
height: h,
framerate: fps,
alignment: :au,
stream_structure: :avc1
},
bitrate: bitrate
]
)
|> child({:h264_parser_out, id}, %Membrane.H264.Parser{
output_alignment: :au,
output_stream_structure: :avc1
})
|> via_in(Pad.ref(:input, {:video, id}))
|> child({:cmaf_muxer, id}, %CMAFMuxer{segment_min_duration: segment_duration})
|> via_in(Pad.ref(:input, id),
Expand Down
6 changes: 3 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,17 @@ defmodule ExBroadcaster.MixProject do
defp deps do
[
{:membrane_core, "~> 1.2"},
{:membrane_vk_video_plugin, "~> 0.2.1"},
{:membrane_transcoder_plugin, "~> 0.4.0"},
{:membrane_vk_video_plugin, "~> 0.2.0"},
{:membrane_rtmp_plugin, "~> 0.29.3"},
{:membrane_http_adaptive_stream_plugin, "~> 0.21.0"},
{:membrane_mp4_plugin, "~> 0.36.0"},
{:membrane_h26x_plugin, "~> 0.10.5"},
{:membrane_aac_plugin, "~> 0.19.0"},
{:bandit, "~> 1.0"},
{:plug, "~> 1.14"},
{:ex_aws_s3, "~> 2.5"},
{:hackney, ">= 0.0.0"},
{:credo, ">= 0.0.0", only: :dev, runtime: false}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this only: :dev part was removed? :D

{:credo, ">= 0.0.0", only: :dev, runtime: false, override: true}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we need this override: true? 🤔

]
end
end
Loading