Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ jobs:
-pthread -o "$artifacts/mave-media-network.so"
sudo install -m 755 "$artifacts/mave-media-broker" "$artifacts/mave-media-sandbox" /usr/local/bin/
sudo install -m 644 "$artifacts/mave-media-network.so" /usr/local/lib/
- name: Test browser-compatible CPU encoder output
run: |
artifacts=$(mktemp -d)
trap 'sudo rm -rf "$artifacts"' EXIT
docker run --rm \
-v "$PWD/deploy/encoding-booster/encoder:/src:ro" \
-v "$artifacts:/out" -w /src golang:1.27.3-alpine3.23 \
sh -c 'CGO_ENABLED=0 go test -c -o /out/encoder.test'
"$artifacts/encoder.test" -test.run '^TestH264BrowserPixelFormats$' -test.v
- name: Exercise confinement and real codecs without root or capabilities
run: |
# GitHub's checkout parents are not traversable by nobody.
Expand Down
12 changes: 9 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,15 @@ WORKDIR /tmp/ffmpeg

# Bind verification to the pinned release key, even if the downloaded key bundle contains others.
# bsdtar also supports extracting this archive under Docker's amd64 emulation.
RUN curl -fsSLo /tmp/ffmpeg.tar.xz "https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" \
&& curl -fsSLo /tmp/ffmpeg.tar.xz.asc "https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz.asc" \
&& curl -fsSLo /tmp/ffmpeg-devel.asc "https://ffmpeg.org/ffmpeg-devel.asc" \
RUN curl --http1.1 --retry 5 --retry-all-errors --retry-delay 2 \
--retry-max-time 180 --connect-timeout 20 --max-time 120 \
-fsSLo /tmp/ffmpeg.tar.xz "https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" \
&& curl --http1.1 --retry 5 --retry-all-errors --retry-delay 2 \
--retry-max-time 180 --connect-timeout 20 --max-time 120 \
-fsSLo /tmp/ffmpeg.tar.xz.asc "https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz.asc" \
&& curl --http1.1 --retry 5 --retry-all-errors --retry-delay 2 \
--retry-max-time 180 --connect-timeout 20 --max-time 120 \
-fsSLo /tmp/ffmpeg-devel.asc "https://ffmpeg.org/ffmpeg-devel.asc" \
&& gpg --batch --import /tmp/ffmpeg-devel.asc \
&& gpg --batch --assert-signer FCF986EA15E6E293A5644F10B4322F04D67658D8 \
--verify /tmp/ffmpeg.tar.xz.asc /tmp/ffmpeg.tar.xz \
Expand Down
8 changes: 8 additions & 0 deletions deploy/encoding-booster/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ delaying or rejecting the upload when warmup fails.

## H.264 quality and bitrate

Browser-facing H.264 is encoded as 8-bit 4:2:0 (`yuv420p`), including
10-bit and 4:2:2/4:4:4 sources. HLS packages these compatible renditions.
Uploaded H.264 can only bypass transcoding when inspection confirms a
Baseline, Main or High profile with 8-bit 4:2:0 pixels; missing or unsupported
format information requires transcoding. Inline sources also transcode in `auto`
mode. Local FFmpeg outputs are checked before publication. Explicit `copy` mode
is a fixture/compatibility shortcut and does not normalize input.

The H.264 ladder uses capped CRF 20 with the `faster` x264 preset. Simple scenes
can use fewer bits, while complex scenes can use more up to the rate-control
limits below. CRF is a quality target, not a promise of a particular file size
Expand Down
5 changes: 4 additions & 1 deletion deploy/encoding-booster/encoder/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ RUN apk add --no-cache \
xz \
zlib-dev
WORKDIR /src/ffmpeg
RUN curl -fsSLo /tmp/ffmpeg.tar.xz \
# The release host can reset Alpine curl connections; keep downloads retryable.
RUN curl --http1.1 --retry 5 --retry-all-errors --retry-delay 2 \
--retry-max-time 180 --connect-timeout 20 --max-time 120 \
-fsSLo /tmp/ffmpeg.tar.xz \
"https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" \
&& echo "${FFMPEG_SHA256} /tmp/ffmpeg.tar.xz" | sha256sum -c - \
&& tar -xJf /tmp/ffmpeg.tar.xz --strip-components=1 \
Expand Down
5 changes: 4 additions & 1 deletion deploy/encoding-booster/encoder/Dockerfile.gpu
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ RUN apt-get update \
libx264-dev libx265-dev nasm pkg-config xz-utils zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src/ffmpeg
RUN curl -fsSLo /tmp/ffmpeg.tar.xz \
# Keep transient release-host connection resets from failing the image build.
RUN curl --http1.1 --retry 5 --retry-all-errors --retry-delay 2 \
--retry-max-time 180 --connect-timeout 20 --max-time 120 \
-fsSLo /tmp/ffmpeg.tar.xz \
"https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" \
&& echo "${FFMPEG_SHA256} /tmp/ffmpeg.tar.xz" | sha256sum -c - \
&& tar -xJf /tmp/ffmpeg.tar.xz --strip-components=1 \
Expand Down
74 changes: 74 additions & 0 deletions deploy/encoding-booster/encoder/browser_compatibility_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
)

func TestH264BrowserPixelFormats(t *testing.T) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg is not installed")
}
ffprobe, err := exec.LookPath("ffprobe")
if err != nil {
t.Skip("ffprobe is not installed")
}
probe := func(t *testing.T, path string) (string, string) {
t.Helper()
body, err := exec.Command(ffprobe, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,pix_fmt", "-of", "json", path).Output()
if err != nil {
t.Fatal(err)
}
var result struct {
Streams []struct {
Codec string `json:"codec_name"`
PixelFormat string `json:"pix_fmt"`
} `json:"streams"`
}
if err := json.Unmarshal(body, &result); err != nil || len(result.Streams) != 1 {
t.Fatalf("unexpected probe: %s (%v)", body, err)
}
return result.Streams[0].Codec, result.Streams[0].PixelFormat
}
for _, format := range []string{"yuv420p10le", "yuv422p10le", "yuv444p10le", "yuv444p"} {
t.Run(format, func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source.mp4")
if output, err := exec.Command(ffmpeg, "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=160x96:rate=12", "-t", "1", "-vf", "format="+format, "-c:v", "libx264", "-threads", "1", "-pix_fmt", format, source).CombinedOutput(); err != nil {
t.Fatalf("generate %s source: %v: %s", format, err, output)
}
if _, actual := probe(t, source); actual != format {
t.Fatalf("fixture must retain %s, got %s", format, actual)
}
request := encodeRequest{InputURL: "https://example.test/source.mp4", Operation: "video", Codec: "h264", Width: 160, VideoCRF: 20, Preset: "faster"}
if err := request.validate(); err != nil {
t.Fatal(err)
}
request.InputURL = source
command := exec.Command(ffmpeg, ffmpegArgs(request, "cpu")...)
var encoded, diagnostics bytes.Buffer
command.Stdout, command.Stderr = &encoded, &diagnostics
if err := command.Run(); err != nil {
t.Fatalf("encode: %v: %s", err, diagnostics.String())
}
output := filepath.Join(dir, "output.mp4")
if err := os.WriteFile(output, encoded.Bytes(), 0600); err != nil {
t.Fatal(err)
}
if out, err := exec.Command(ffmpeg, hlsArgs(output, dir)...).CombinedOutput(); err != nil {
t.Fatalf("package HLS: %v: %s", err, out)
}
for _, path := range []string{output, filepath.Join(dir, "playlist.m3u8")} {
codec, pixels := probe(t, path)
if codec != "h264" || pixels != "yuv420p" {
t.Fatalf("%s is not browser H.264: codec=%s pix_fmt=%s", path, codec, pixels)
}
}
})
}
}
4 changes: 3 additions & 1 deletion docs/flow_engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ For deterministic local tests, provide `source_body` with
`source_content_type`, `media_probe`, or `transcription_text` /
`transcription_vtt` / `transcription_segments` as appropriate.

Media step `*_mode` inputs select `auto`, `ffmpeg` or `copy`. Step-level
Media step `*_mode` inputs select `auto`, `ffmpeg` or `copy`. H.264 steps
transcode inline sources in `auto` mode; use explicit `copy` for opaque test
fixtures. Copy mode does not normalize media for browser playback. Step-level
`params.strict` makes errors fail the run; the corresponding run-input
`*_strict` override takes precedence. These overrides are for debugging and
custom flows. Discover current step options through `/v1/flow/step-types` and
Expand Down
25 changes: 11 additions & 14 deletions lib/mave_core/flow/steps/media_transcode_h264_ladder_step.ex
Original file line number Diff line number Diff line change
Expand Up @@ -187,19 +187,14 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeH264LadderStep do
end
end

defp maybe_copy_source_variants(%{mode: mode} = context, variants)
when mode in ["auto", "copy"] do
if Map.has_key?(context.run_input, "source_body") or mode == "copy" do
case StepSupport.resolve_source_body(
context.run_input,
context.source_url,
context.dependency_outputs
) do
{:ok, source_body} -> put_variant_bodies(context, variants, source_body)
{:error, reason} -> {:error, reason}
end
else
:skip
defp maybe_copy_source_variants(%{mode: "copy"} = context, variants) do
case StepSupport.resolve_source_body(
context.run_input,
context.source_url,
context.dependency_outputs
) do
{:ok, source_body} -> put_variant_bodies(context, variants, source_body)
{:error, reason} -> {:error, reason}
end
end

Expand Down Expand Up @@ -1335,6 +1330,8 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeH264LadderStep do
[
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-preset",
encoder_settings.preset
] ++
Expand Down Expand Up @@ -2002,7 +1999,7 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeH264LadderStep do
output_paths
|> Map.values()
|> Enum.reduce_while(:ok, fn output_path, :ok ->
case StepSupport.validate_media_file(output_path, :video) do
case StepSupport.validate_media_file(output_path, :h264) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
Expand Down
23 changes: 15 additions & 8 deletions lib/mave_core/flow/steps/media_transcode_video_step.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeVideoStep do
Produces a video rendition artifact for a configured codec/size step.

Default behavior:
- `auto`: uses FFmpeg for normal sources; copy-mode only for inline `source_body`.
- `auto`: transcodes sources, including inline `source_body`.
- `copy`: compatibility mode for inline `source_body`.
- `ffmpeg`: always attempts FFmpeg transcode.
"""
Expand Down Expand Up @@ -321,11 +321,7 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeVideoStep do
end

defp build_rendition_body("auto", context) do
if Map.has_key?(context.run_input, "source_body") do
build_rendition_body("copy", context)
else
transcode_with_ffmpeg(context)
end
transcode_with_ffmpeg(context)
end

defp run_booster_or_delegate(context, profile) do
Expand Down Expand Up @@ -727,9 +723,11 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeVideoStep do
end
end

defp ffmpeg_input_opts(_context, output_path, progress) do
defp ffmpeg_input_opts(context, output_path, progress) do
kind = if context.codec == "h264", do: :h264, else: :video

[
validate_output: fn -> StepSupport.validate_media_file(output_path, :video) end,
validate_output: fn -> StepSupport.validate_media_file(output_path, kind) end,
command_timeout_ms: video_command_timeout_ms(),
progress: progress
]
Expand Down Expand Up @@ -852,6 +850,11 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeVideoStep do
normalize_codec(map_get(inspect_output, "video_codec")) != "h264" ->
{:error, :source_video_codec_not_h264}

not StepSupport.browser_h264_stream?(
StepSupport.video_stream(map_get(inspect_output, "streams", []))
) ->
{:error, :source_video_format_not_browser_compatible}

not source_audio_copy_compatible?(inspect_output) ->
{:error, :source_audio_codec_not_copy_compatible}

Expand Down Expand Up @@ -1110,6 +1113,8 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeVideoStep do
[
"-c:v",
ffmpeg_codec,
"-pix_fmt",
"yuv420p",
"-crf",
"23",
"-g",
Expand Down Expand Up @@ -1139,6 +1144,8 @@ defmodule MaveCore.Flow.Steps.MediaTranscodeVideoStep do
[
"-c:v",
ffmpeg_codec,
"-pix_fmt",
"yuv420p",
"-preset",
"faster",
"-tune",
Expand Down
25 changes: 22 additions & 3 deletions lib/mave_core/flow/steps/support.ex
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,8 @@ defmodule MaveCore.Flow.Steps.Support do

def report_progress(_progress_reporter, _event), do: :ok

@spec validate_media_file(String.t(), :audio | :video) :: :ok | {:error, term()}
def validate_media_file(path, kind) when kind in [:audio, :video] do
@spec validate_media_file(String.t(), :audio | :video | :h264) :: :ok | {:error, term()}
def validate_media_file(path, kind) when kind in [:audio, :video, :h264] do
case do_validate_media_file(path, kind) do
:ok -> :ok
{:error, reason} -> {:error, {:invalid_media_output, kind, reason}}
Expand All @@ -331,6 +331,15 @@ defmodule MaveCore.Flow.Steps.Support do
def validate_media_file(_path, kind),
do: {:error, {:invalid_media_output, kind, :unsupported_media_kind}}

@doc "Checks the codec, profile and pixel format required by browser H.264 renditions."
def browser_h264_stream?(%{} = stream) do
map_value(stream, "codec_name") == "h264" and
map_value(stream, "pix_fmt") in ["yuv420p", "yuvj420p"] and
map_value(stream, "profile") in ["Baseline", "Constrained Baseline", "Main", "High"]
end

def browser_h264_stream?(_), do: false

@doc false
def decode_probe_output(output) when is_binary(output) do
case Jason.decode(String.trim(output)) do
Expand Down Expand Up @@ -386,7 +395,10 @@ defmodule MaveCore.Flow.Steps.Support do
"-select_streams",
"#{media_stream_selector(kind)}:0",
"-show_entries",
"stream=codec_type,codec_name",
if(kind == :h264,
do: "stream=codec_type,codec_name,profile,pix_fmt",
else: "stream=codec_type,codec_name"
),
"-of",
"json",
path
Expand Down Expand Up @@ -420,6 +432,12 @@ defmodule MaveCore.Flow.Steps.Support do
defp probe_decode_error(%_{} = error), do: Exception.message(error)
defp probe_decode_error(error), do: inspect(error)

defp validate_media_stream(%{"streams" => streams}, :h264) when is_list(streams) do
if browser_h264_stream?(video_stream(streams)),
do: :ok,
else: {:error, :incompatible_h264_stream}
end

defp validate_media_stream(%{"streams" => streams}, kind) when is_list(streams) do
expected_type = Atom.to_string(kind)

Expand All @@ -435,6 +453,7 @@ defmodule MaveCore.Flow.Steps.Support do

defp media_stream_selector(:audio), do: "a"
defp media_stream_selector(:video), do: "v"
defp media_stream_selector(:h264), do: "v"

@spec prepare_ffmpeg_input(String.t(), map(), term(), map(), keyword()) ::
{:ok, String.t()} | {:error, term()}
Expand Down
Loading
Loading