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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ tmp/
.vscode/
.rsync-backup-*/
ffmpeg/
!deps/ffmpeg/
!deps/ffmpeg/**
*.log
*.csv
*.ts
Expand Down
6 changes: 6 additions & 0 deletions avpmixer/janus.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from dataclasses import dataclass

RTP_PACKET_SIZE = 1_200
DEFAULT_KEYFRAME_MIN_INTERVAL_MS = 150


@dataclass(frozen=True)
Expand All @@ -16,6 +17,7 @@ class JanusVideoConfig:
bitrate_kbps: int = 4_500
rtcp_bind: str = "0.0.0.0"
rtcp_port: int = 0
keyframe_min_interval_ms: int = DEFAULT_KEYFRAME_MIN_INTERVAL_MS

def __post_init__(self) -> None:
if not self.host:
Expand All @@ -30,6 +32,9 @@ def __post_init__(self) -> None:
raise ValueError("Janus bitrate must be positive")
if not 0 <= self.rtcp_port <= 65535:
raise ValueError("RTCP port must be between 0 and 65535")
if (type(self.keyframe_min_interval_ms) is not int
or not 0 <= self.keyframe_min_interval_ms <= 2_147_483_647):
raise ValueError("keyframe_min_interval_ms must be a non-negative integer")

@property
def rtcp_port_remote(self) -> int:
Expand Down Expand Up @@ -58,6 +63,7 @@ def build_janus_output(avp, api, src_edge: str, janus: JanusVideoConfig, *, fps:
avp.addNode(api.ForceKeyFrame({
"name": JANUS_KEYFRAME_NODE, "src": "janus_fps", "dst": "janus_keyframed",
"interval_sec": "1/1", "auto_restart": "panic", "group": group,
"min_interval_ms": janus.keyframe_min_interval_ms,
}))
avp.addNode(api.AssumeVideoFormat({
"name": "janus_format", "src": "janus_keyframed", "dst": "janus_video",
Expand Down
7 changes: 4 additions & 3 deletions demos/cuda-overlay/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FROM nvidia/cuda:11.7.1-devel-ubuntu22.04

ARG DEBIAN_FRONTEND=noninteractive
ARG AVPLUMBER_REVISION=workspace
ARG FFMPEG_TAG=n7.1.5
ARG FFMPEG_TAG=n8.1
ARG NV_CODEC_HEADERS_TAG=n12.1.14.0

RUN apt-get update \
Expand Down Expand Up @@ -35,13 +35,13 @@ RUN git clone --quiet --branch "${NV_CODEC_HEADERS_TAG}" --depth 1 \
&& make -C /tmp/nv-codec-headers install PREFIX=/usr/local \
&& rm -rf /tmp/nv-codec-headers

COPY deps/ffmpeg-patches /build/deps/ffmpeg-patches
COPY deps/ffmpeg /build/deps/ffmpeg

RUN git clone --quiet --branch "${FFMPEG_TAG}" --depth 1 \
https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg \
&& git -C /tmp/ffmpeg config user.name "cuda-overlay-demo builder" \
&& git -C /tmp/ffmpeg config user.email "cuda-overlay-demo@local" \
&& git -C /tmp/ffmpeg am /build/deps/ffmpeg-patches/*.patch
&& bash /build/deps/ffmpeg/apply.sh /tmp/ffmpeg

RUN cd /tmp/ffmpeg \
&& ./configure \
Expand Down Expand Up @@ -117,6 +117,7 @@ COPY demos/cuda-overlay /build/demos/cuda-overlay

ENV LD_LIBRARY_PATH=/usr/local/lib:/usr/local/cuda/lib64
ENV AVPLUMBER_REVISION=${AVPLUMBER_REVISION}
ENV FFMPEG_TAG=${FFMPEG_TAG}
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video
ENV NVIDIA_VISIBLE_DEVICES=all
ENV PYPLUMBER_PATH=/build
Expand Down
2 changes: 1 addition & 1 deletion demos/cuda-overlay/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ services:
dockerfile: demos/cuda-overlay/Dockerfile
args:
AVPLUMBER_REVISION: ${AVPLUMBER_REVISION:-workspace}
FFMPEG_TAG: n7.1.5
FFMPEG_TAG: ${FFMPEG_TAG:-n8.1}
image: avplumber-cuda-overlay-demo:local
gpus: all
environment:
Expand Down
3 changes: 2 additions & 1 deletion demos/cuda-overlay/docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
One 1080p base and 15 transparent, labeled overlays composed on the GPU by
`overlay_many_cuda`.

Build the repository's FFmpeg patch stack on public FFmpeg `n7.1.5`, run the
Build the repository's shared FFmpeg 8.x patch stack on public FFmpeg `n8.1`
(or select `n8.0` with `FFMPEG_TAG`), run the
patched `overlay_many_cuda` through a purpose-built PyPlumber graph, and compare
every output plane against an independent CPU reference.

Expand Down
34 changes: 32 additions & 2 deletions demos/cuda-overlay/tests/test_run_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,43 @@
class RunMatrixTest(unittest.TestCase):
def test_patch_identity_reads_the_repository_patch_series(self) -> None:
expected_paths = sorted(
(REPOSITORY_DIR / "deps" / "ffmpeg-patches").glob("*.patch")
(REPOSITORY_DIR / "deps" / "ffmpeg" / "8").glob("*.patch")
)

self.assertEqual(REPO_DIR, REPOSITORY_DIR)
self.assertEqual(len(expected_paths), 7)
self.assertGreater(len(expected_paths), 0)
self.assertEqual(list(_patch_identity()), [path.name for path in expected_paths])

def test_patch_identity_shares_series_between_ffmpeg80_and_81(self) -> None:
self.assertEqual(_patch_identity(), _patch_identity("n8.1"))
self.assertEqual(_patch_identity("n8.0"), _patch_identity("n8.1"))

def test_patch_identity_rejects_unknown_tags(self) -> None:
with self.assertRaises(ValueError):
_patch_identity("n7.1.5")

def test_cuda_images_keep_the_default_and_select_matching_patches(self) -> None:
for relative_path in (
"demos/cuda-overlay/Dockerfile",
"demos/mixer/Dockerfile",
"demos/dmabuf-browser/consumer/Dockerfile.cuda",
):
with self.subTest(dockerfile=relative_path):
dockerfile = (REPOSITORY_DIR / relative_path).read_text()
self.assertIn("ARG FFMPEG_TAG=n8.1", dockerfile)
self.assertIn("COPY deps/ffmpeg /build/deps/ffmpeg", dockerfile)
self.assertIn("/build/deps/ffmpeg/apply.sh /tmp/ffmpeg", dockerfile)

def test_overlay_image_reports_the_selected_version(self) -> None:
dockerfile = (REPOSITORY_DIR / "demos/cuda-overlay/Dockerfile").read_text()
self.assertIn("ENV FFMPEG_TAG=${FFMPEG_TAG}", dockerfile)

def test_mixer_checks_runtime_options_without_removed_command_marker(self) -> None:
dockerfile = (REPOSITORY_DIR / "demos/mixer/Dockerfile").read_text()
self.assertIn("-h filter=transition_cuda", dockerfile)
self.assertIn('$1 == "mode" && $3 ~ /T/', dockerfile)
self.assertNotIn('$1 ~ /C/', dockerfile)


if __name__ == "__main__":
unittest.main()
12 changes: 8 additions & 4 deletions demos/cuda-overlay/tools/run_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ def _command_text(command: list[str]) -> str:
return output if output else f"exit {result.returncode}"


def _patch_identity() -> dict[str, str]:
def _patch_identity(ffmpeg_tag: str = "n8.1") -> dict[str, str]:
if ffmpeg_tag not in ("n8.0", "n8.1"):
raise ValueError(f"unsupported FFmpeg tag: {ffmpeg_tag}")
identities: dict[str, str] = {}
for path in sorted((REPO_DIR / "deps" / "ffmpeg-patches").glob("*.patch")):
for path in sorted((REPO_DIR / "deps" / "ffmpeg" / "8").glob("*.patch")):
identities[path.name] = hashlib.sha256(path.read_bytes()).hexdigest()
return identities

Expand Down Expand Up @@ -78,6 +80,8 @@ def main() -> int:
parser.add_argument("--height", type=_positive_dimension, default=HEIGHT)
args = parser.parse_args()

ffmpeg_tag = os.environ.get("FFMPEG_TAG", "n8.1")

started_at = dt.datetime.now(dt.timezone.utc)
run_id = started_at.strftime("run-%Y%m%dT%H%M%SZ")
run_dir = args.artifacts / run_id
Expand All @@ -93,7 +97,7 @@ def main() -> int:
"run_id": run_id,
"started_at": started_at.isoformat(),
"status": "running",
"ffmpeg_tag": "n7.1.5",
"ffmpeg_tag": ffmpeg_tag,
"cuda_toolkit_minimum": "11.7",
"dimensions": {"width": args.width, "height": args.height},
"requested_overlay_counts": args.counts,
Expand All @@ -110,7 +114,7 @@ def main() -> int:
]
),
"repository_commit": os.environ.get("AVPLUMBER_REVISION", "workspace"),
"patches_sha256": _patch_identity(),
"patches_sha256": _patch_identity(ffmpeg_tag),
},
"cases": [],
}
Expand Down
6 changes: 3 additions & 3 deletions demos/dmabuf-browser/consumer/Dockerfile.cuda
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ ARG UBUNTU_VERSION=22.04
FROM nvidia/cuda:${CUDA_IMAGE_VERSION}-devel-ubuntu${UBUNTU_VERSION} AS builder

ARG DEBIAN_FRONTEND=noninteractive
ARG FFMPEG_TAG=n7.1.5
ARG FFMPEG_TAG=n8.1
ARG NV_CODEC_HEADERS_TAG=n12.1.14.0
ARG CUDA_NVCC_FLAGS="-gencode arch=compute_70,code=compute_70 -O2"

Expand Down Expand Up @@ -50,13 +50,13 @@ RUN git clone --quiet --branch "${NV_CODEC_HEADERS_TAG}" --depth 1 \
&& make -C /tmp/nv-codec-headers install PREFIX=/usr/local \
&& rm -rf /tmp/nv-codec-headers

COPY deps/ffmpeg-patches /build/deps/ffmpeg-patches
COPY deps/ffmpeg /build/deps/ffmpeg

RUN git clone --quiet --branch "${FFMPEG_TAG}" --depth 1 \
https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg \
&& git -C /tmp/ffmpeg config user.name "dmabuf-browser-demo builder" \
&& git -C /tmp/ffmpeg config user.email "dmabuf-browser-demo@local" \
&& git -C /tmp/ffmpeg am /build/deps/ffmpeg-patches/*.patch
&& bash /build/deps/ffmpeg/apply.sh /tmp/ffmpeg

RUN cd /tmp/ffmpeg \
&& ./configure \
Expand Down
10 changes: 5 additions & 5 deletions demos/mixer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FROM nvidia/cuda:11.7.1-devel-ubuntu22.04

ARG DEBIAN_FRONTEND=noninteractive
ARG AVPLUMBER_REVISION=workspace
ARG FFMPEG_TAG=n7.1.5
ARG FFMPEG_TAG=n8.1
ARG NV_CODEC_HEADERS_TAG=n12.1.14.0

RUN apt-get update \
Expand Down Expand Up @@ -36,13 +36,13 @@ RUN git clone --quiet --branch "${NV_CODEC_HEADERS_TAG}" --depth 1 \
&& make -C /tmp/nv-codec-headers install PREFIX=/usr/local \
&& rm -rf /tmp/nv-codec-headers

COPY deps/ffmpeg-patches /build/deps/ffmpeg-patches
COPY deps/ffmpeg /build/deps/ffmpeg

RUN git clone --quiet --branch "${FFMPEG_TAG}" --depth 1 \
https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg \
&& git -C /tmp/ffmpeg config user.name "mixer-demo builder" \
&& git -C /tmp/ffmpeg config user.email "mixer-demo@local" \
&& git -C /tmp/ffmpeg am /build/deps/ffmpeg-patches/*.patch
&& bash /build/deps/ffmpeg/apply.sh /tmp/ffmpeg

RUN cd /tmp/ffmpeg \
&& ./configure \
Expand Down Expand Up @@ -77,8 +77,8 @@ RUN cd /tmp/ffmpeg \
&& rm -rf /tmp/ffmpeg

RUN /usr/local/bin/ffmpeg -hide_banner -filters | grep -q ' overlay_many_cuda ' \
&& /usr/local/bin/ffmpeg -hide_banner -filters \
| awk '$2 == "transition_cuda" && $1 ~ /C/ { found = 1 } END { exit !found }'
&& /usr/local/bin/ffmpeg -hide_banner -h filter=transition_cuda \
| awk '$1 == "mode" && $3 ~ /T/ { found = 1 } END { exit !found }'

WORKDIR /build

Expand Down
6 changes: 6 additions & 0 deletions demos/mixer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ python3 demos/mixer/tests/frame_codes.py media --sources 16 --width 1920 --heigh

## Under the hood

Janus output limits forced keyframes to one per 150 ms by default (9 frames at
60 fps). Override with `--keyframe-min-interval-ms 200`; `0` disables the limit.
The option also applies to Janus renditions loaded with `--config`. Cuts and
ordinary frames are not delayed: pending cut/RTCP requests coalesce until the
next eligible frame. Periodic keyframes share the same limit.

<a href="https://amagimedia.github.io/avplumber/demos/graph.html?demo=mixer" target="_blank" rel="noopener noreferrer"><img src="https://amagimedia.github.io/avplumber/demos/mixer/docs/mixer-graph-grouped.png" alt="Grouped mixer graph: inputs, two compositor slots, transitions, media wipe and output. Click for the full ungrouped graph." width="640"></a>

Two compositor slots draw every scene; a transition filter blends them and the
Expand Down
2 changes: 1 addition & 1 deletion demos/mixer/docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ groups without reordering sources.
Follow the [shared NVIDIA setup guide](../../README.md) first. It also provides
a local Janus preview if you want WebRTC output.

The demo image builds FFmpeg 7.1 with `deps/ffmpeg-patches`, verifies the
The demo image defaults to FFmpeg 8.1 with the shared `deps/ffmpeg/8` series, verifies the
patched CUDA overlay and transition filters, and builds the CUDA-enabled
AVPlumber Python module against that FFmpeg installation:

Expand Down
14 changes: 13 additions & 1 deletion demos/mixer/mixer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
from avpmixer.dmabuf_inputs import (dmabuf_cuda_input_nodes, is_dmabuf_url, open_browser_windows,
open_windows, refresh_windows, wait_for_sockets, window_id)
from avpmixer.inputs import build_input
from avpmixer.janus import JANUS_KEYFRAME_NODE, JanusVideoConfig, build_janus_output
from avpmixer.janus import (DEFAULT_KEYFRAME_MIN_INTERVAL_MS, JANUS_KEYFRAME_NODE,
JanusVideoConfig, build_janus_output)

try:
from .layouts import (
Expand Down Expand Up @@ -69,6 +70,7 @@ class GraphOptions:
janus_video_pt: int = JANUS_DEFAULT_VIDEO_PT
janus_video_ssrc: int = JANUS_DEFAULT_VIDEO_SSRC
janus_video_bitrate_kbps: int = JANUS_DEFAULT_VIDEO_BITRATE_KBPS
keyframe_min_interval_ms: int = DEFAULT_KEYFRAME_MIN_INTERVAL_MS
janus_rtcp_bind: str = "0.0.0.0"
janus_rtcp_port: int = 0
preheat_timeout_sec: float = 60.0
Expand Down Expand Up @@ -107,6 +109,9 @@ def validate(self) -> None:
raise ValueError("janus_rtcp_port must be between 0 and 65535")
if self.preheat_timeout_sec <= 0:
raise ValueError("preheat_timeout_sec must be positive")
if (type(self.keyframe_min_interval_ms) is not int
or not 0 <= self.keyframe_min_interval_ms <= 2_147_483_647):
raise ValueError("keyframe_min_interval_ms must be a non-negative integer")
if any(v <= 0 for v in self.dmabuf_size):
raise ValueError("--dmabuf-size must be WxH with positive numbers")
ids = self.dmabuf_inputs
Expand Down Expand Up @@ -502,6 +507,7 @@ def _build_outputs(avp, api, options: GraphOptions, mixer_edge: str, *,
host=options.janus_host, video_port=options.janus_video_port,
payload_type=options.janus_video_pt, ssrc=options.janus_video_ssrc,
bitrate_kbps=options.janus_video_bitrate_kbps,
keyframe_min_interval_ms=options.keyframe_min_interval_ms,
rtcp_bind=options.janus_rtcp_bind, rtcp_port=options.janus_rtcp_port,
),
fps=options.fps, fps_den=FPS_DEN, width=width, height=height,
Expand Down Expand Up @@ -609,6 +615,7 @@ def _build_renditions(avp, api, options: GraphOptions, cfg, mixer_edge: str):
video_port=rendition.port or options.janus_video_port,
payload_type=options.janus_video_pt, ssrc=options.janus_video_ssrc,
bitrate_kbps=rendition.bitrate_kbps,
keyframe_min_interval_ms=options.keyframe_min_interval_ms,
rtcp_bind=options.janus_rtcp_bind, rtcp_port=options.janus_rtcp_port,
),
fps=rendition.fps, fps_den=FPS_DEN, width=rendition.width, height=rendition.height,
Expand Down Expand Up @@ -755,6 +762,10 @@ def parse_args(argv: list[str] | None = None) -> GraphOptions:
default=JANUS_DEFAULT_VIDEO_BITRATE_KBPS,
)
parser.add_argument("--janus-rtcp-bind", default="0.0.0.0")
parser.add_argument("--keyframe-min-interval-ms", type=int,
default=DEFAULT_KEYFRAME_MIN_INTERVAL_MS,
help="Minimum forced-keyframe spacing for Janus output in media time "
"(default: 150 ms; 0 disables rate limiting)")
parser.add_argument("--janus-rtcp-port", type=int, default=0)
parser.add_argument("--preheat-timeout", type=float, default=60.0)
parser.add_argument("--wipe-file", help="Alpha wipe clip to warm the media-wipe chain up with at start "
Expand Down Expand Up @@ -787,6 +798,7 @@ def parse_args(argv: list[str] | None = None) -> GraphOptions:
janus_video_pt=args.janus_video_pt,
janus_video_ssrc=args.janus_video_ssrc,
janus_video_bitrate_kbps=args.janus_video_bitrate_kbps,
keyframe_min_interval_ms=args.keyframe_min_interval_ms,
janus_rtcp_bind=args.janus_rtcp_bind,
janus_rtcp_port=args.janus_rtcp_port,
preheat_timeout_sec=args.preheat_timeout,
Expand Down
40 changes: 39 additions & 1 deletion demos/mixer/tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,14 +568,52 @@ def test_cli_requires_inputs_or_config():

def test_transitions_trigger_a_keyframe_only_when_streaming():
FakeMixer.instances.clear()
build_application(GraphOptions(inputs=("a.mp4",), janus_output=True), api=fake_api())
application = build_application(GraphOptions(inputs=("a.mp4",), janus_output=True), api=fake_api())
assert FakeMixer.instances[-1].parameters["keyframe_node"] == "janus_force_keyframe"
node = next(n for n in application.avp.nodes if n.parameters.get("name") == "janus_force_keyframe")
assert node.parameters["min_interval_ms"] == 150
assert node.parameters["interval_sec"] == "1/1"

FakeMixer.instances.clear()
build_application(GraphOptions(inputs=("a.mp4",), output="p.mp4"), api=fake_api())
assert FakeMixer.instances[-1].parameters["keyframe_node"] is None


@pytest.mark.parametrize("minimum", [0, 100, 150, 200, 500])
def test_janus_keyframe_limit_is_configurable(minimum):
from avpmixer.janus import JanusVideoConfig, build_janus_output
avp = FakeAvp()
build_janus_output(avp, fake_api(), "program", JanusVideoConfig(keyframe_min_interval_ms=minimum),
fps=60, width=1920, height=1080)
node = next(n for n in avp.nodes if n.parameters.get("name") == "janus_force_keyframe")
assert node.parameters["min_interval_ms"] == minimum


@pytest.mark.parametrize("minimum", [-1, 0.2, True, "200", 2**31])
def test_janus_rejects_invalid_keyframe_limit(minimum):
from avpmixer.janus import JanusVideoConfig
with pytest.raises(ValueError, match="keyframe_min_interval_ms"):
JanusVideoConfig(keyframe_min_interval_ms=minimum)


@pytest.mark.parametrize("configured", [False, True])
def test_mixer_keyframe_option_reaches_each_output_path(tmp_path, configured):
if configured:
path = tmp_path / "mixer.json"
path.write_text(json.dumps({
**CONFIG, "sources": CONFIG["sources"][:1], "scenes": CONFIG["scenes"][:1],
"initial_scene": "full", "wipes": [],
"renditions": [{"id": "program", "target": "janus"}],
}))
options = GraphOptions(config=str(path), janus_output=True, keyframe_min_interval_ms=200)
else:
options = parse_args(["--input", "a.mp4", "--janus-output", "--keyframe-min-interval-ms", "200"])
application = build_application(options, api=fake_api())
node = next(n for n in application.avp.nodes if n.parameters.get("name") == "janus_force_keyframe")
assert node.parameters["min_interval_ms"] == 200
assert parse_args(["--input", "a.mp4", "--janus-output"]).keyframe_min_interval_ms == 150


def test_wipe_dir_scans_a_library_and_explicit_entries_win(tmp_path):
from avpmixer import config as mc
for name in ("b_swoosh.mov", "a_dip.webm", "notes.txt", "c_star.mp4"):
Expand Down
2 changes: 1 addition & 1 deletion deps/avcpp
Loading
Loading