diff --git a/Makefile b/Makefile index 20da8691..e52a2dff 100644 --- a/Makefile +++ b/Makefile @@ -164,6 +164,8 @@ endif ifeq ($(HAVE_CUDA)$(HAVE_NVCC),11) NODES_SRC += $(SRCDIR)/nodes/hwaccel/v210_to_cuda.cpp $(eval $(call ptx_kernel,$(SRCDIR)/nodes/hwaccel/v210_unpack.cu,avpl_v210_unpack_ptx,objs/src/nodes/hwaccel/v210_to_cuda.o)) +NODES_SRC += $(SRCDIR)/nodes/hwaccel/cuda_to_v210.cpp +$(eval $(call ptx_kernel,$(SRCDIR)/nodes/hwaccel/v210_pack.cu,avpl_v210_pack_ptx,objs/src/nodes/hwaccel/cuda_to_v210.o)) override CXXFLAGS += -DHAVE_CUDA_RECT_SCALE=1 $(eval $(call ptx_kernel,$(SRCDIR)/nodes/hwaccel/cuda_rect_scale.cu,avpl_rect_scale_ptx,objs/src/nodes/hwaccel/cuda_rect_draw.o)) NODES_SRC += $(SRCDIR)/nodes/scene_cut/luma_diff.cpp diff --git a/demos/README.md b/demos/README.md index 80af485d..7f4de71c 100644 --- a/demos/README.md +++ b/demos/README.md @@ -89,4 +89,7 @@ docker compose --env-file demos/dmabuf-browser/.env.example \ a bundled animated page, Electron, GPU capture, Janus, and browser preview. - [CUDA overlay validation](cuda-overlay/README.md#run): run `./demos/cuda-overlay/run.sh` to build, generate fixtures, and compare results. +- [MXL round-trip](mxl/README.md): publish into and read back from an MXL + shared-memory domain. Linux-only, uses the shared mixer image + (`avplumber-mixer:local`). GPU optional. diff --git a/demos/mixer/Dockerfile b/demos/mixer/Dockerfile index 23e95dc7..8b76f7a2 100644 --- a/demos/mixer/Dockerfile +++ b/demos/mixer/Dockerfile @@ -4,38 +4,97 @@ ARG DEBIAN_FRONTEND=noninteractive ARG AVPLUMBER_REVISION=workspace ARG FFMPEG_TAG=n8.1 ARG NV_CODEC_HEADERS_TAG=n13.0.19.0 +ARG MXL_TAG=v1.1.0 +# gcc-13 is required by the MXL SDK (C++20 features + newer libstdc++). +# Ubuntu 22.04 defaults to gcc-11, so pull gcc-13 from the ubuntu +# toolchain PPA and keep it alongside; FFmpeg and avplumber continue +# to use gcc-11. RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + software-properties-common \ + && add-apt-repository -y ppa:ubuntu-toolchain-r/test \ + && apt-get update \ && apt-get install -y --no-install-recommends \ autoconf \ automake \ + bison \ build-essential \ ca-certificates \ cmake \ curl \ + flex \ + g++-13 \ + gcc-13 \ git \ libboost-all-dev \ libcurl4-openssl-dev \ libgnutls28-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libpcap-dev \ libssl-dev \ libtool \ + libx11-dev \ make \ nasm \ + ninja-build \ pkg-config \ python3 \ python3-dev \ python3-numpy \ python3-pil \ python3-pip \ + tar \ + unzip \ xxd \ + zip \ zlib1g-dev \ && rm -rf /var/lib/apt/lists/* +# Ubuntu 22.04 ships cmake 3.22 but the MXL SDK requires >= 3.24. +# Upgrade via pip so a modern cmake is on PATH before FFmpeg configure. +RUN python3 -m pip install --no-cache-dir --upgrade 'cmake>=3.28' + RUN git clone --quiet --branch "${NV_CODEC_HEADERS_TAG}" --depth 1 \ https://github.com/FFmpeg/nv-codec-headers.git /tmp/nv-codec-headers \ && make -C /tmp/nv-codec-headers install PREFIX=/usr/local \ && rm -rf /tmp/nv-codec-headers +# --- Rust (MXL's ada-url dep pulled through vcpkg needs a rustc) -------- +ENV RUSTUP_HOME=/opt/rustup CARGO_HOME=/opt/cargo +ENV PATH=/opt/cargo/bin:$PATH +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain 1.88.0 --profile minimal \ + && rustc --version + +# --- vcpkg (used by the MXL SDK's CMake toolchain) ---------------------- +ENV VCPKG_ROOT=/opt/vcpkg +ENV VCPKG_FORCE_SYSTEM_BINARIES=1 +RUN git clone --quiet https://github.com/microsoft/vcpkg.git "$VCPKG_ROOT" \ + && "$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics + +# --- MXL SDK ------------------------------------------------------------ +# Built with gcc-13 (required by the SDK). FFmpeg + avplumber below keep +# using the distro gcc-11 that the CUDA composition suite is validated +# with. libmxl statically links spdlog, so its pkg-config file is +# scrubbed of the private Requires before FFmpeg configure looks at it. +RUN git clone --quiet --branch "${MXL_TAG}" --depth 1 \ + https://github.com/dmf-mxl/mxl.git /tmp/mxl \ + && CC=/usr/bin/gcc-13 CXX=/usr/bin/g++-13 \ + cmake -S /tmp/mxl -B /tmp/mxl/build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_MANIFEST_MODE=ON \ + -DBUILD_DOCS=OFF \ + -DBUILD_TESTS=OFF \ + && cmake --build /tmp/mxl/build --parallel "$(nproc)" \ + && cmake --install /tmp/mxl/build \ + && ldconfig \ + && sed -i '/^Requires.private: spdlog$/d' /usr/local/lib/pkgconfig/libmxl.pc \ + && rm -rf /tmp/mxl + COPY deps/ffmpeg /build/deps/ffmpeg RUN git clone --quiet --branch "${FFMPEG_TAG}" --depth 1 \ @@ -45,7 +104,8 @@ RUN git clone --quiet --branch "${FFMPEG_TAG}" --depth 1 \ && bash /build/deps/ffmpeg/apply.sh /tmp/ffmpeg RUN cd /tmp/ffmpeg \ - && ./configure \ + && PKG_CONFIG_PATH=/usr/local/lib/pkgconfig \ + ./configure \ --prefix=/usr/local \ --libdir=/usr/local/lib \ --incdir=/usr/local/include \ @@ -61,6 +121,10 @@ RUN cd /tmp/ffmpeg \ --enable-nvdec \ --enable-nvenc \ --disable-libnpp \ + --enable-libmxl \ + --enable-demuxer=mxl \ + --enable-muxer=mxl \ + --enable-protocol=mxl \ --disable-doc \ --disable-htmlpages \ --disable-manpages \ @@ -78,7 +142,11 @@ RUN cd /tmp/ffmpeg \ RUN /usr/local/bin/ffmpeg -hide_banner -filters | grep -q ' overlay_many_cuda ' \ && /usr/local/bin/ffmpeg -hide_banner -h filter=transition_cuda \ - | awk '$1 == "mode" && $3 ~ /T/ { found = 1 } END { exit !found }' + | awk '$1 == "mode" && $3 ~ /T/ { found = 1 } END { exit !found }' \ + && /usr/local/bin/ffmpeg -hide_banner -demuxers 2>/dev/null \ + | awk '$1 ~ /D/ && $2 == "mxl" { found = 1 } END { exit !found }' \ + && /usr/local/bin/ffmpeg -hide_banner -muxers 2>/dev/null \ + | awk '$1 ~ /E/ && $2 == "mxl" { found = 1 } END { exit !found }' WORKDIR /build @@ -117,8 +185,12 @@ RUN test -f /build/_avplumber"$(python3-config --extension-suffix)" \ python3 -c 'import pyplumber' COPY demos/mixer /build/demos/mixer +COPY demos/mxl /build/demos/mxl -RUN python3 -m pip install --no-cache-dir -r /build/demos/mixer/requirements.txt +RUN python3 -m pip install --no-cache-dir -r /build/demos/mixer/requirements.txt \ + && if [ -s /build/demos/mxl/requirements.txt ]; then \ + python3 -m pip install --no-cache-dir -r /build/demos/mxl/requirements.txt; \ + fi ENV LD_LIBRARY_PATH=/usr/local/lib:/usr/local/cuda/lib64 ENV AVPLUMBER_REVISION=${AVPLUMBER_REVISION} diff --git a/demos/mxl/README.md b/demos/mxl/README.md new file mode 100644 index 00000000..8a8d8ff1 --- /dev/null +++ b/demos/mxl/README.md @@ -0,0 +1,463 @@ +# MXL demo + +Publishes and consumes flows on the +[MXL](https://github.com/dmf-mxl/mxl) shared-memory transport through +avplumber's generic `input` / `output` nodes with `format="mxl"` — no +MXL-specific node types. + +Two graphs run in one process: + +* **writer** — `lavfi testsrc` → decode → `realtime` + `force_fps` → + rescale to yuv422p10le → `v210` encode (10-bit 4:2:2 uncompressed, per + SMPTE ST 2110) → mux → `output(format="mxl", url="/dev/shm/mxl")`. The + MXL muxer takes a plain filesystem path. `--writer-pack gpu` converts + *and* packs on the GPU instead, with `cuda_to_v210` in place of the + encoder — see [Packing on the GPU](#packing-on-the-gpu). +* **reader** — `input(format="mxl", url="mxl:///dev/shm/mxl?id=")` + → demux → v210 unpack → encode → fragmented mp4. `--gpu-unpack` + (default `auto`) picks the unpack: + * **GPU** — `v210_to_cuda` copies each grain once into pinned memory + and unpacks it to a CUDA `p210le` frame with a PTX kernel; + `scale_cuda` and `h264_nvenc` finish the file. No CPU codec, no + swscale. + * **CPU** (no CUDA device) — libavcodec `v210` decoder, swscale to + yuv420p, `mpeg4`. + + The two ends are independent: `--reader-encoder` picks the encoder + whatever the unpack was, and `--gpu-scale` moves the conversion between + them onto the GPU on either leg — see + [Conversion on the GPU](#conversion-on-the-gpu). + +Grains are taken zero-copy by default — see [below](#zero-copy-grains). + +Verified end-to-end on x86_64 Fedora with an RTX 4000 Ada (driver +615.71, Docker + NVIDIA Container Toolkit) against FFmpeg +`n8.1-12-g93aafbb` from this patch stack: GPU zero-copy, GPU with +`--no-zero-copy`, `--gpu-unpack off`, from both `lavfi testsrc` and a +looped file, at 320×240p25 and 640×480p25. All started at PTS 0, +reported 25.0 fps (25.06 on NVENC) and decoded back to the source +pattern. `--gpu-scale` and `--writer-pack gpu` were checked separately at +1920×1080p59.94 — see [Conversion on the GPU](#conversion-on-the-gpu) and +[Packing on the GPU](#packing-on-the-gpu). + +## Requirements + +* Linux x86_64 — the MXL SDK is Linux-only. +* Docker with a shared `/dev/shm` (the default size is enough). +* NVIDIA GPU + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) + are optional here (`--gpus all` selects the GPU reader path), though + the other demos in the shared image do need CUDA. +* The FFmpeg 8.x series applied to `n8.1` (or `n8.0`). The MXL patch + `8/0011-*` is generated from `cbcrc/FFmpeg` branch `dmf-mxl/8.1` — + see [`deps/ffmpeg/README.md`](../../deps/ffmpeg/README.md). + +## Build + +Shares its runtime image with `demos/mixer/`. From the repository root +(after `git submodule update --init --recursive`): + +```sh +docker build -f demos/mixer/Dockerfile -t avplumber-mixer:local . +``` + +MXL-specific parts of that build: gcc-13 from `ppa:ubuntu-toolchain-r/test` +(the SDK needs C++20; FFmpeg and avplumber keep gcc-11), vcpkg and Rust +for the SDK's dependencies, the MXL SDK (`dmf-mxl/mxl` @ `v1.1.0`) into +`/usr/local` with the private `Requires` scrubbed from its `.pc` file +(libmxl links spdlog statically), and FFmpeg configured with +`--enable-libmxl --enable-demuxer=mxl --enable-muxer=mxl +--enable-protocol=mxl`. The build fails if the mxl demuxer and muxer are +not registered afterwards. + +## Run + +The demo needs a `/dev/shm/mxl` domain directory holding an +`options.json`. On a single host use `--ipc=host`: + +```sh +mkdir -p /dev/shm/mxl +# 1 s of history. The 100 ms the MXL samples use is ~2 grains at 25 fps, +# which leaves zero-copy readers no slack at all. +echo '{"urn:x-mxl:option:history_duration/v1.0": 1000000000}' \ + > /dev/shm/mxl/options.json + +docker run --rm --ipc=host --gpus all \ + --entrypoint python3 \ + -v /dev/shm/mxl:/dev/shm/mxl \ + -v "$PWD/demos/mxl/test-media:/media" \ + -e AVP_OUTPUT=/media/out.mp4 \ + avplumber-mixer:local /build/demos/mxl/mxl_demo.py +``` + +Drop `--gpus all` to take the CPU reader path; the demo prints which one +it picked. Geometry defaults to the built-in testsrc's 320×240p25; +`--width/--height/--fps` (or `AVP_WIDTH`, `AVP_HEIGHT`, `AVP_FPS`) +change it — the writer rescales to match, because packed v210 carries no +dimensions and the reader derives its row stride from `--width`. +`-e AVP_INPUT=/media/your-file.mp4` publishes a file instead of the +testsrc (looped and realtime-paced through `InputRec`). +`--writer-only` / `--reader-only` plus a shared `AVP_MXL_VIDEO_ID` split +the two halves across containers. + +`--bench-seconds N` samples `enqueued_total` on the writer and reader +encoder edges once a second, prints per-second fps plus a summary, and +exits without teardown. `--writer-pace off` drops `realtime` + +`force_fps` so the writer publishes as fast as it can — throughput +measurement only, since it starves readers of history. + +The summary ends with a per-node CPU table. Every avplumber node runs in +a thread named after the node, so reading `/proc/self/task` before and +after the measured window and dividing by the frames that crossed the +last edge gives the CPU cost of each node in milliseconds per frame — +no profiler, and nothing on the media path. + +`--reader-tail` shortens the reader for such measurements: `demux` drops +each grain as it leaves the demuxer, `unpack` drops the unpacked frame, +`scale` drops it after the conversion to the encoder's format, and the +default `encode` writes `--output`. The output encoder is the most +expensive node of the CPU reader by a factor of two, so with the full +tail a measurement of "the MXL reader" is mostly a measurement of mpeg4. + +The conversion itself can be moved or redirected. `--sws-flags` picks the +swscale algorithm for the writer's and the CPU reader's `rescale_video` +nodes (`fast_bilinear`, `neighbor`, ...). `--gpu-scale +{writer,reader,both}` replaces those nodes with +`hwupload,scale_cuda,hwdownload`, and `--cuda-interp` is its +`--sws-flags`. `--reader-encoder {mpeg4,nvenc}` decouples the output +encoder from `--gpu-unpack`, so either unpack can feed either encoder — +which is what makes the upload and the download visible one at a time. +See [Conversion on the GPU](#conversion-on-the-gpu). + +`--writer-pack {auto,cpu,gpu}` (or `AVP_WRITER_PACK`) picks the writer's +v210 pack the same way. `gpu` drops the `hwdownload` and the CPU `v210` +encoder for a single `cuda_to_v210` node, so it requires the conversion +on the GPU as well (`--gpu-scale writer` or `both`); `auto` turns it on +exactly when that holds. See [Packing on the GPU](#packing-on-the-gpu). + +Stop the demo with `docker kill` — Ctrl-C hangs in teardown (see +[Known gaps](#known-gaps)). The output stays playable regardless: the +reader's output sets `movflags=frag_keyframe+empty_moov+default_base_moof` +*and* `flush_packets=1`. The second one is what makes the promise real — +without it the muxer's 256 KiB avio buffer is written only when it +fills, so a killed low-bitrate run leaves a 28-byte file holding just +the `ftyp` box. + +## Measured throughput + +1920×1080p59.94 `smptehdbars`, 1 s of ring history, on the host above +(4 vCPU of an EPYC 9474F, RTX 4000 Ada), 60 s per case after a 10 s +warmup, via `--bench-seconds`. [`bench/`](bench/) holds the harness +behind every table here, one case group per table — this one is +`bench/cases.sh throughput`: + +| case | writer | reader | CPU | SM | NVENC | +|---|---|---|---|---|---| +| GPU unpack, zero-copy | 59.94 fps | 59.94 fps | 0.27 cores | 2% | 5% | +| GPU unpack, `--no-zero-copy` | 59.94 fps | 59.94 fps | 0.28 cores | 2% | 5% | +| CPU unpack, zero-copy | 59.94 fps | 59.94 fps | 0.51 cores | — | — | +| `--writer-pace off`, GPU, zero-copy | 507 fps | 507 fps | 1.71 cores | 16% | 42% | +| `--writer-pace off`, GPU, `--no-zero-copy` | 483 fps | 483 fps | 1.80 cores | 15% | 40% | +| `--writer-pace off`, CPU unpack | 419 fps | 192 fps | 3.05 cores | — | — | +| `--writer-pace off`, `--writer-only` | 567 fps | — | 1.33 cores | — | — | +| `--writer-pace off`, `--writer-only`, `--sws-flags fast_bilinear` | 707 fps | — | 1.41 cores | — | — | +| `--writer-pace off`, `--writer-only`, `--gpu-scale writer` | 855 fps | — | 1.21 cores | 51% | — | +| `--writer-pace off`, `--writer-only`, `--gpu-scale writer --writer-pack gpu` | 1583 fps | — | 1.32 cores | 82% | — | +| `--writer-pace off`, `--gpu-scale writer --writer-pack gpu`, GPU unpack | 526 fps | 526 fps | 0.81 cores | 43% | 48% | + +Realtime 1080p59.94 is not demanding: about a tenth of a core per side +and an idle GPU. The writer — swscale to yuv422p10le plus the CPU `v210` +encoder — reaches 567 fps (3.0 GB/s into `/dev/shm`) on its own, and the +GPU reader keeps up with it, so unpaced both sides settle at 507 fps on +1.7 of the 4 vCPU. Zero-copy buys 5% throughput at that rate for less +CPU. The GPU reader halves realtime CPU (0.51 → 0.27 cores) and, unpaced, +lets the reader follow the writer instead of capping at 192 fps against +its 419 while saturating all four cores — but that is the output encoder +talking, not the unpack; see below. + +The last two rows are what this host gets to once neither end packs or +unpacks on the CPU: a writer alone at 1583 fps, and a round trip that +edges past the CPU-packed one (507 fps) on less than half its CPU. That +round trip saturates nothing measurable — 0.81 of four cores, 43% SM, 48% +NVENC — and lands where it landed before the pack moved, so its 526 fps is +a limit in the reader rather than a resource the writer is short of. + +## Where the CPU goes + +Per-node CPU from the same paced case (1080p59.94, 20 s window, two runs +agreeing within 2%), in milliseconds of CPU per frame: + +| node | work | ms/frame | +|---|---|---| +| `r_enc` | `mpeg4` encoder, the demo's output | 3.58 | +| `r_scale` | swscale yuv422p10le → yuv420p | 1.79 | +| `w_scale` | swscale yuv420p → yuv422p10le | 1.67 | +| `r_unpack` | `v210_to_cuda`: stage into pinned host memory, PTX unpack | 0.40 | +| `r_dec` | `v210` decoder, the CPU unpack | 0.37 | +| `w_enc` | `v210` encoder, the pack | 0.30 | +| `w_output` | MXL muxer: `memcpy` into the opened grain | 0.27 | +| `r_input` | MXL demuxer, `--no-zero-copy` | 0.38 | +| `r_input` | MXL demuxer, `zero_copy=1` | 0.07 | + +Everything else — demux, mux, `realtime`, `force_fps`, +`assume_video_format`, the mp4 output — stays under 0.1 ms/frame together. + +So MXL itself is not where the time goes. Publishing costs one 5.5 MB +`memcpy` into the grain (0.27 ms), and reading costs nothing measurable +as long as `zero_copy` is on: 0.07 against 0.38 ms/frame, the difference +being exactly one more copy of the same 5.5 MB (three runs each way, +spread under 0.02 ms). Everything expensive is per-byte pixel work in +libswscale and libavcodec. + +That reframes the GPU reader too. `v210_to_cuda` spends as much *host* +CPU as the libavcodec `v210` decoder it replaces (0.40 vs 0.37 ms/frame), +because it still stages every grain through pinned memory and waits on +the stream; its win is what comes after — no swscale, no CPU encoder. + +The unpaced ladder makes the cap explicit. With `--reader-tail demux` the +reader follows the writer at 554 fps; adding the CPU unpack costs 38 fps +(516), and the GPU unpack lands in the same place (511) — in all three +cases the *writer* is the limit. Only the full tail caps the reader, at +195 fps, with `r_enc` pinned at exactly 1.00 core: mpeg4 gets no useful +thread parallelism here, so a quarter of this host is the ceiling. + +Of the remaining per-frame cost, the swscale flag matters more than one +would expect for a conversion that does not resize. Left to +`rescale_video`'s default (area), the writer's 4:2:0 → 4:2:2 upsample +takes 1.67 ms/frame and the reader's 4:2:2 → 4:2:0 downsample 1.79; +`--sws-flags fast_bilinear` takes the writer's to 1.31 (and raises peak +writer throughput from 561 to 707 fps) while leaving the reader's alone, +and `--sws-flags neighbor` takes the reader's to 1.55 and the writer's to +1.57. Each is a chroma-quality trade, so the demo keeps the default and +leaves the choice to the flag. + +Reproducing these numbers needs this branch's `rescale_video`: the node +parsed its `flags` parameter and then built the rescaler without it, so +every graph in the tree was scaling with the default algorithm. It also +now hands a frame straight through when it already has the requested +geometry and format, instead of paying swscale a full-frame copy — worth +1.0 ms/frame at 1080p 10-bit 4:2:2, which is what a writer fed by a +source that is already 10-bit 4:2:2 would otherwise burn for nothing. + +## Conversion on the GPU + +The writer's conversion is its largest cost and the reader's is second +only to the output encoder, so `--gpu-scale` hands them to `scale_cuda`. +What that saves depends entirely on how many times the frame has to cross +PCIe, which is why +`--reader-encoder` exists: naming the encoder independently of +`--gpu-unpack` puts the same conversion in front of an upload, a download, +both, or neither. 1080p59.94, paced, 30 s windows, host CPU of the +conversion node alone (`--reader-tail scale` where it is the reader's) — +the swscale rows repeat the previous section's measurement on the longer +window and land within 5% of it: + +| conversion | host↔device per frame | ms/frame | +|---|---|---| +| swscale, writer 4:2:0 → 4:2:2 10-bit, area | — | 1.64 | +| swscale, same with `--sws-flags fast_bilinear` | — | 1.20 | +| swscale, reader 4:2:2 10-bit → 4:2:0, area | — | 1.73 | +| `scale_cuda`, either leg, host frames both sides | 11.4 MB | 0.61 | +| `scale_cuda`, reader, GPU unpack → mpeg4 | 3.1 MB down | 0.22 | +| `scale_cuda`, reader, GPU unpack → NVENC | none | 0.04 | + +The line through those four GPU rows is 0.04 ms of fixed overhead plus +every transferred byte at about 19 GB/s — one host copy, near memcpy +speed. That is the price of unpinned frames: the driver stages each +transfer through its own pinned buffer, and the CPU pays for that copy +even though the DMA itself is free. The conversion adds nothing +measurable on top, which is what the 0.04 ms row says. With both legs +converting, `nvidia-smi dmon` reports 7% SM and 680 MB/s of PCIe traffic +in each direction — 11.4 MB per frame at 59.94 fps, i.e. exactly the two +transfers and nothing else. The all-GPU reader instead shows 289 MB/s in +(the v210 grains), 33 MB/s out (the H.264 stream), 2% SM and 6% NVENC. + +So the GPU wins a conversion it can be handed without a round trip, and +wins less than it looks like on one where it pays both transfers: 0.61 +against swscale's 1.2–1.7 ms/frame is a real saving, but a third of it +comes back elsewhere — with `--gpu-scale both` the mpeg4 encoder slows +from 3.66 to +4.16 ms/frame, its frames now arriving cold from a DMA rather than warm +from swscale. Paced 1080p59.94 round trips, total process CPU: + +| round trip | cores | +|---|---| +| all CPU | 0.50 | +| CPU unpack, `--gpu-scale both` | 0.41 | +| GPU unpack → mpeg4 (one download) | 0.41 | +| GPU unpack → NVENC (no transfer) | 0.17 | +| the same plus `--writer-pack gpu` (no transfer either way) | 0.09 | + +Unpaced, the same three shapes move the caps: all-CPU runs at 420 fps +writer / 200 fps reader on 3.14 cores, `--gpu-scale both` at 619 / 161 on +2.25 (the freed writer outruns the reader and takes memory bandwidth with +it), and GPU unpack into mpeg4 at 460 / 249 on 2.62 — the CPU reader +reaching its encoder ceiling instead of its swscale one. + +Where the GPU is simply better is a conversion that also resizes, because +there the CPU cost does not come from moving bytes. Publishing a 1080p +source into a 720p flow, unpaced `--writer-only`: + +| writer conversion | peak | ms/frame | +|---|---|---| +| swscale default (area) | 955 fps | 1.04 | +| `--sws-flags lanczos` | 314 fps | 3.18 | +| `--gpu-scale writer --cuda-interp lanczos` | 1435 fps | 0.36 | + +A lanczos resize on the GPU costs a third of what an area resize costs on +the CPU, and its transfers are smaller than the 1080p case's because the +downloaded frame is 720p. + +The pixels agree. Reading the same published flow back through the CPU +reader with the writer converting each way (`bench/pixel_compare.sh +psnr`), the two output files differ by PSNR y 90.3 dB, u 49.4, v 53.4 +(average 55.7) and SSIM 0.9996: the luma is untouched by either path at +this geometry, and the chroma difference is the two upsamplers' kernels +rather than an error. +`smptehdbars` is static, so that comparison needed no frame alignment. + +## Packing on the GPU + +`--writer-pack gpu` removes the writer's last two CPU costs at once. The +new `cuda_to_v210` node packs the converted CUDA frame with a kernel and +DMAs the v210 bytes into a pinned buffer the muxer hands straight to the +grain, so both the `hwdownload` that ended `--gpu-scale writer` and the +libavcodec `v210` encoder behind it disappear. It *is* the encoder as far +as the muxer is concerned — it answers `IEncoder`, so no `enc_video` +belongs between it and `output`. Paced 1080p59.94, `--writer-only`, 30 s +windows, host CPU per frame — the CPU-pack column re-measures the two +sections above on that window and agrees with them within 0.04 ms: + +| node | work | CPU pack | GPU pack | +|---|---|---|---| +| `w_scale` | `hwupload`, `scale_cuda`, and the `hwdownload` only the CPU pack needs | 0.62 | 0.21 | +| `w_enc` → `w_pack` | the v210 pack | 0.29 | 0.04 | +| `w_output` | MXL muxer: `memcpy` into the grain | 0.23 | 0.34 | +| whole process | every thread, `--writer-only` | 0.09 cores | 0.05 cores | + +Two of those lines are the point. The conversion node loses exactly its +download — 0.62 → 0.21 ms/frame, and what is left is the 3.1 MB upload at +the 19 GB/s of the section above plus the fixed 0.04. And the pack itself +costs that fixed overhead alone: the 5.5 MB leaves the GPU by DMA into +pinned memory, so unlike `hwdownload` the CPU pays nothing per byte. + +The third line is the same catch the mpeg4 encoder hit above, from the +other side: the muxer's `memcpy` gets more expensive (0.23 → 0.34 +ms/frame) because it now reads bytes no CPU has touched. Even so the +writer falls from 1.15 to 0.59 ms/frame of host CPU, and the paced +writer process from 0.09 to 0.05 cores. + +Unpaced the writer's peak nearly doubles, 855 → 1583 fps on the same +1.2–1.3 cores, and the GPU goes from 51% to 82% SM. `w_output` is then +0.42 ms/frame — 8.8 GB/s of `memcpy` into `/dev/shm` — which makes the +muxer's copy into the grain the writer's limit, the first time in this +demo that MXL itself is what caps anything. + +The grains are bit-exact. Dumped with `ffmpeg -c copy` from otherwise +identical runs (`bench/pack_bitexact.sh`), the kernel's v210 is +byte-for-byte what the libavcodec encoder produces at 1920×1080, +1280×720 and 1918×1080 — the last two exercising rows that end in a +partial 6-pixel block, all three the row padding to a 128-byte +multiple. Round trips still come out decodable: +paced 30 s runs with the CPU reader (0.38 cores) and with the GPU reader +(0.09, against 0.17 for the same reader behind a CPU-packed writer) each +wrote 1800 frames at 60000/1001. + +## Codec choices + +MXL flows carry uncompressed frames, and the muxer insists on `v210` for +video — `rawvideo` is rejected at header write. The CPU reader encodes +`mpeg4` rather than H.264 because the image does not link `libx264`. + +NVENC gets `g=`, one keyframe per second instead of its default +250-frame GOP, because `frag_keyframe` cuts a fragment per keyframe: the +growing file becomes playable a second in rather than ten. + +The GPU path converts to 8-bit nv12 before NVENC even though the flow is +10-bit 4:2:2, because NVENC only accepts 4:2:2 10-bit on Blackwell-class +hardware. Change `scale_cuda=format=nv12` to `p010le` plus +`hevc_nvenc`/`main10` to keep 10 bits through the encoder. + +## Timing and grain indices + +MXL is a wall-clock transport and the muxer ignores PTS: it takes its +grain index from `mxlGetCurrentIndex` at header write and increments it +per packet. An unpaced writer therefore runs the flow into the future — +lavfi published ~2300 grains/s here, collapsing the ring's usable +history to milliseconds so that every reader was "too late". Hence +`realtime(set_pts=1)` and `force_fps` ahead of the writer's v210 encoder. + +Reader-side demuxer options: + +* `blocking=1` — wait up to a frame period for the next grain instead of + returning `EAGAIN` immediately. +* `grain_index_init=head` — start at the newest grain. `tail` hangs even + plain `ffmpeg` on this build. +* `on_too_late=reset` — `avformat_open_input`'s probe reads a grain, so + the index is chosen at node *creation*, seconds before the group + starts (CUDA and NVENC init sit in between), by which time it is behind + the ring tail. Without `reset` the demuxer returns `EAGAIN`, which + `input.cpp` treats as fatal. +* `reset_on_drop=1` — rebases timestamps after such a reset, which + otherwise leaves a hole where the skipped grains would have been (a + 5.8 s leading PTS gap, and 19.4 fps in the container instead of 25). + The fork rebased onto the resumed grain, which is right at startup but + sends the PTS back to 0 on a *mid-stream* reset, tripping the + monotonic-PTS `av_assert0` a few lines below — reproducible in seconds + with `--writer-pace off --gpu-unpack off`, where the reader cannot keep + up with the flood. Patch `0011` now continues from the last delivered + PTS instead (see `deps/ffmpeg/mkpatch-fixups/`). + +## Zero-copy grains + +`zero_copy=1` (default; `--no-zero-copy` opts out) wraps the grain +payload in an `AVBufferRef` pointing into the shared-memory ring instead +of `memcpy`-ing it into a fresh packet. The patch's release callback is a +no-op — **nothing holds a reference on the grain** — so the bytes are +valid only until the writer laps that slot. Two things keep it safe here: +`queue.plan_capacity r_vpkt 1` bounds the packet queue to one in-flight +grain, and `history_duration` of 1 s (25 grains) leaves a wide margin +over that. A reader stalled longer than the ring depth silently sees +overwritten pixels rather than an error, which is why the FFmpeg option +is marked experimental. + +The copy it removes is worth 0.31 ms of CPU per frame at 1080p59.94 — see +[Where the CPU goes](#where-the-cpu-goes). + +Zero-copy removes the shm→packet copy only: `v210_to_cuda` still stages +each grain through pinned host memory. Making that leg copy-free would +mean `cudaHostRegister`-ing the MXL ring, i.e. a change to the node in +`src/`. + +## Known gaps + +* **Ctrl-C does not shut the demo down.** SIGINT leaves every node + thread in a timed poll and the main thread in `futex_do_wait`. Same on + the GPU and CPU paths, and it predates this demo's reader wiring, so it + looks like framework-level stop ordering rather than anything + MXL-specific. Restarting the reader group parks the same way, at + "Stopping node r_unpack ..." — hence `auto_restart` is `off` on every + reader node except `r_input`. Use `docker kill` meanwhile. +* The reader is unpaced and runs at wall-clock max. Harmless — `blocking=1` + parks it on the writer's head — it just spends CPU waiting. A + `realtime` node before the encoder would cap it. +* With a looped file source, each wraparound logs `EventLoop negative + time to wait, resyncing` on the writer, drops a couple of frames in + `force_fps`, and gives the reader a burst of "too early" waits. Output + is unaffected, but a live flow would want a seamless looper. +* `--writer-pack gpu` only helps a writer whose frames are already on the + GPU, which is why it insists on `--gpu-scale`. A writer fed 10-bit 4:2:2 + frames that need no conversion still ends in the libavcodec encoder: + uploading purely in order to pack would trade a 3.1 MB transfer for the + 0.29 ms/frame the CPU encoder costs, which is close to a wash. +* One copy is left on the write path — the muxer's `memcpy` into the + opened grain, 0.34 ms/frame and the unpaced writer's actual cap. Getting + rid of it means having the muxer hand out the grain's address before the + packet is built, so the pack could DMA into the ring itself; that is a + change to FFmpeg patch `0011`, and it mirrors the `cudaHostRegister` + note under [Zero-copy grains](#zero-copy-grains) on the read side. + +## References + +* MXL SDK: +* MXL FFmpeg fork (source for patch 0011, branch `dmf-mxl/8.1`): +* Reference build guidance: diff --git a/demos/mxl/bench/README.md b/demos/mxl/bench/README.md new file mode 100644 index 00000000..5a9c1d82 --- /dev/null +++ b/demos/mxl/bench/README.md @@ -0,0 +1,132 @@ +# Measuring the MXL demo + +The scripts behind every number in [`../README.md`](../README.md). They are +all wrappers around `mxl_demo.py --bench-seconds`; what they add is a clean +MXL domain per case, the host-side sampling the demo cannot do from inside +its own process, and a digest of the run instead of a log to read. + +Linux with Docker and the shared demo image (`avplumber-mixer:local`, see +[Build](../README.md#build)); an NVIDIA GPU and the container toolkit for +everything except the `nodecpu` group's CPU-only cases. `nvidia-smi` on the +host is optional — without it the GPU block is simply absent. + +## Two ways to reach the demo + +```sh +demos/mxl/bench/cases.sh throughput # a fresh container per case +demos/mxl/bench/cases.sh throughput --exec avpbuild # a container that is already up +``` + +The default (`--image`, run mode) starts one container per case. It is the +only mode whose cgroup holds nothing but the run, so the CPU figure is +trustworthy; it can also drop the GPU (`--no-gpu`) and bind this checkout's +`demos/mxl` over the image's copy (`--mount-demos`). + +`--exec CONTAINER` runs `docker exec` in a container that is already up — +the incremental build loop, where `/build` is a tree you keep rebuilding in +place. Per-node CPU is just as good there, because the demo measures its own +threads through `/proc/self/task`; whole-container CPU is not, and `--no-gpu` +cannot take a GPU away from a running container. The container needs +`--ipc=host` so that it sees the domain the harness creates in `/dev/shm`. + +## Quick start + +```sh +demos/mxl/bench/cases.sh list +demos/mxl/bench/cases.sh smoke # does every graph shape still run? +demos/mxl/bench/cases.sh pack # the GPU v210 pack, paced and unpaced +demos/mxl/bench/pack_bitexact.sh # the pack against libavcodec's, byte for byte +demos/mxl/bench/pixel_compare.sh psnr # the GPU conversion against swscale + +# One case, any demo flag, geometry and window of your own: +demos/mxl/bench/bench.sh --label mine --seconds 20 \ + --writer-only --writer-pace off --gpu-scale writer --writer-pack gpu +``` + +Anything `bench.sh` and `cases.sh` do not recognise goes to `mxl_demo.py`, and +arguments after a group name are appended to every case in it — so +`cases.sh pack --seconds 10 --no-sample` is a quick pass over the same cases. + +## What each group reproduces + +| group | cases | window | in [`../README.md`](../README.md) | +|---|---|---|---| +| `throughput` | 11 | 60 s | the [Measured throughput](../README.md#measured-throughput) table | +| `nodecpu` | 6 | 30 s | the [Where the CPU goes](../README.md#where-the-cpu-goes) table | +| `conversion` | 20 | 40 s | the three [Conversion on the GPU](../README.md#conversion-on-the-gpu) tables | +| `pack` | 7 | 30 s | the [Packing on the GPU](../README.md#packing-on-the-gpu) table and its round trips | +| `zerocopy` | 6 | 25 s | the two `r_input` rows, hence [Zero-copy grains](../README.md#zero-copy-grains) | +| `swsflags` | 7 | 30 s | the `--sws-flags` figures under "Where the CPU goes" | +| `srcfmt` | 4 | 30 s | that a source already in 10-bit 4:2:2 costs the writer no swscale | +| `tails` | 5 | 30 s | the unpaced ladder: which leg caps the reader | +| `smoke` | 11 | 14 s | nothing — the cases only have to build and run | + +`throughput` and `conversion` take 15–20 minutes each; the rest are a few +minutes. Start from `smoke` after touching the graph: 14 s windows make +worthless numbers, but a shape that no longer builds shows up immediately. + +The remaining two scripts answer questions an fps number cannot: + +* `pack_bitexact.sh` publishes the same converted frames with the CPU and the + GPU pack, dumps the grains with `ffmpeg -c copy` and compares them byte for + byte, at 1920×1080, 1280×720 and 1918×1080 — the last two ending each row + mid-block. Exits nonzero on any difference, so it works as a gate. +* `pixel_compare.sh` has three sections: `psnr` (swscale against `scale_cuda` + on the writer, read back through the CPU reader both times), `pcie` + (`nvidia-smi dmon` over a paced run, for the SM and PCIe columns) and + `formats` (which pixel formats this build's `scale_cuda` accepts). + +## Reading a case + +``` +=== gpu-zc-paced (exit 0, wall 71.3s) args: --gpu-unpack on +bench: target 60 fps, 1920x1080, pace on, ... +bench: writer: mean 59.94 fps (min 59.88, max 60.01) over 50 s, 100.0% of target, ... +bench: per-node CPU over 50.0 s, 13.4 s total = 0.27 cores +bench: r_enc 8.20 s, 0.16 cores, 3.58 ms/frame +--- log counts + too late: 3 +--- gpu (mean/max) + sm 2.0%/4% enc 5.1%/7% gpumem 412/418 MiB +--- cpu (whole container) + 0.27 cores mean over 70 s +--- output + width=1920 height=1080 pix_fmt=yuv420p nb_read_frames=2996 avg_frame_rate=60000/1001 +--- log: /tmp/mxl-bench/gpu-zc-paced.log +``` + +The `bench:` lines are the demo's own summary (the per-second rows stay in the +log). `log counts` is the triage: `too late` and `too early` grains are +expected in the unpaced cases, which publish faster than any reader can +follow, and suspicious anywhere else. Two entries are normal everywhere — a +handful of `setting edge more than once` while the graph is built, and the two +`libcuda` lines a `--no-gpu` container prints on the way to its CPU path. The +two sampler blocks are the host's +view — mean/max GPU utilisation, and cumulative cgroup CPU time over a known +window, which is an exact mean core count where `docker stats` percentages are +sampled too coarsely to trust. + +Then the caveats worth remembering: + +* Paced numbers only mean something on an otherwise idle host. One SM figure + in `../README.md` had to be re-measured because a leftover run from the + previous case was still on the GPU. +* The CPU block needs cgroup v2 with the systemd driver (`docker-.scope`); + elsewhere it is silently absent, and the per-node table from the demo is + the better figure anyway. +* Every case wipes the domain first, because a stale flow directory keeps the + old geometry and grain indices. The harness refuses to wipe a domain some + other container has bind-mounted — deleting it would leave that writer + publishing into an unlinked directory no reader can find. +* `--domain` defaults to `/dev/shm/mxlbench`, away from the demo's own + `/dev/shm/mxl`, so a bench run does not disturb one you are watching. + +## Files + +| file | | +|---|---| +| `bench.sh` | one case: fresh domain, samplers, run, digest | +| `cases.sh` | the groups above, as lists of `bench.sh` invocations | +| `pack_bitexact.sh` | GPU pack against the libavcodec `v210` encoder | +| `pixel_compare.sh` | PSNR/SSIM, PCIe and SM cost, `scale_cuda` formats | +| `lib.sh` | shared: container modes, domain reset, samplers, digest | diff --git a/demos/mxl/bench/bench.sh b/demos/mxl/bench/bench.sh new file mode 100755 index 00000000..43ec09ed --- /dev/null +++ b/demos/mxl/bench/bench.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# One measured run of demos/mxl/mxl_demo.py: fresh MXL domain, host-side GPU +# and CPU sampling, and a digest of what the demo printed. +# +# bench.sh [options] [mxl_demo.py args...] +# +# Anything this script does not recognise goes to the demo, so its own flags +# work here unchanged and win over the defaults below: +# +# bench.sh --label gpu-pack --gpu-scale writer --writer-pack gpu +# bench.sh --exec avpbuild --seconds 30 --reader-tail demux --gpu-unpack off +# +# cases.sh drives this for the groups of cases behind ../README.md's tables. +set -uo pipefail +source "$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +label= +output= +extra=() +while [[ $# -gt 0 ]]; do + bench_common_arg "$@"; consumed=$? + if [[ $consumed -gt 0 ]]; then shift "$consumed"; continue; fi + case $1 in + --label) label=${2:?missing value for --label}; shift 2 ;; + --output) output=${2:?missing value for --output}; shift 2 ;; + -h|--help) + awk 'NR>1 && !/^#/ {exit} NR>1 {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}" + echo; bench_common_usage + cat <<'EOF' + --label NAME names the log and sample files (default case-HHMMSS) + --output PATH as the demo sees it; /dev/null skips the output probe +EOF + exit 0 ;; + --) shift; extra+=("$@"); break ;; + *) extra+=("$1"); shift ;; + esac +done + +[[ -n $label ]] || label=case-$(date +%H%M%S) +bench_setup +[[ $mode == run ]] && bench_cgroup_name=mxlbench-$label +[[ -n $output ]] || output=$bench_out/$label.mp4 +prefix=$results/$label +log=$prefix.log + +bench_domain_reset +[[ $output == /dev/null ]] || bench_in_container_bash "rm -f '$output'" >/dev/null 2>&1 + +bench_sampler_start "$prefix" +start=$(date +%s.%N) +bench_run_demo "$log" \ + --domain "$domain" --width "$width" --height "$height" --fps "$fps" \ + --input "$input" --output "$output" \ + --bench-seconds "$seconds" --bench-warmup "$warmup" \ + "${extra[@]+"${extra[@]}"}" +rc=$? +end=$(date +%s.%N) +bench_sampler_stop + +printf '=== %s (exit %s, wall %.1fs)%s\n' "$label" "$rc" \ + "$(awk "BEGIN{print $end - $start}")" \ + "${extra[*]+ args: ${extra[*]}}" +bench_summary "$log" +bench_sampler_report "$prefix" +bench_probe_output "$output" +echo "--- log: $log" +exit "$rc" diff --git a/demos/mxl/bench/cases.sh b/demos/mxl/bench/cases.sh new file mode 100755 index 00000000..b286851b --- /dev/null +++ b/demos/mxl/bench/cases.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Groups of bench.sh cases. Each group reproduces one table or claim in +# ../README.md; `cases.sh list` says which. +# +# cases.sh [bench.sh or mxl_demo.py args...] +# +# Trailing arguments are appended to every case, so they beat the group's own +# defaults: `cases.sh smoke --exec avpbuild`, `cases.sh pack --seconds 10`. +set -uo pipefail +bench_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) + +# 1080p59.94 bars everywhere. The cases that publish a 1080p source into a +# smaller flow name it explicitly, since bench.sh otherwise derives the +# source geometry from the flow's. +src_1080=${AVP_BENCH_SRC_1080:-lavfi:smptehdbars=size=1920x1080:rate=60000/1001} +src_1080_10bit="$src_1080,format=yuv422p10le" + +groups=( + "throughput|Measured throughput: the 11-row table, 60 s per case" + "nodecpu|Where the CPU goes: per-node ms/frame, tail by tail" + "conversion|Conversion on the GPU: swscale against scale_cuda, both legs" + "pack|Packing on the GPU: the CPU and GPU v210 packs, paced and unpaced" + "zerocopy|The r_input rows: zero-copy on and off, three runs each" + "swsflags|The --sws-flags rows: flag sweep on the conversions" + "srcfmt|Whether a 10-bit 4:2:2 source removes the writer's swscale cost" + "tails|Where the unpaced CPU reader caps, tail by tail" + "smoke|Every graph shape builds and runs; short, unsampled" +) + +group_defaults=() +defaults() { group_defaults=("$@"); } + +run() { + local label=$1; shift + "$bench_dir/bench.sh" --label "$label" \ + "${group_defaults[@]+"${group_defaults[@]}"}" "$@" \ + "${passthrough[@]+"${passthrough[@]}"}" + echo +} + +# ../README.md "Measured throughput". One container per case so the cgroup +# CPU figure covers this run and nothing else. +group_throughput() { + defaults --seconds 60 --warmup 10 + run gpu-zc-paced --gpu-unpack on + run gpu-nozc-paced --gpu-unpack on --no-zero-copy + run cpu-zc-paced --gpu-unpack off + run gpu-zc-unpaced --gpu-unpack on --writer-pace off + run gpu-nozc-unpaced --gpu-unpack on --writer-pace off --no-zero-copy + run cpu-zc-unpaced --gpu-unpack off --writer-pace off + run writer-only-unpaced --writer-only --writer-pace off + run writer-only-unpaced-fastbl --writer-only --writer-pace off --sws-flags fast_bilinear + run writer-only-unpaced-gpu-scale --writer-only --writer-pace off --gpu-scale writer + run writer-only-unpaced-gpu-pack --writer-only --writer-pace off \ + --gpu-scale writer --writer-pack gpu + run roundtrip-unpaced-gpu-pack --writer-pace off --gpu-unpack on \ + --gpu-scale writer --writer-pack gpu +} + +# ../README.md "Where the CPU goes". The per-node table comes from the demo's +# own /proc/self/task sampling, so exec mode reports it just as well; the +# first five cases also show that the CPU path needs no GPU at all. +group_nodecpu() { + defaults --seconds 30 --warmup 10 --output /dev/null + run nodecpu-demux --no-gpu --gpu-unpack off --reader-tail demux + run nodecpu-unpack --no-gpu --gpu-unpack off --reader-tail unpack + run nodecpu-encode --no-gpu --gpu-unpack off --reader-tail encode + run nodecpu-encode-fastbl --no-gpu --gpu-unpack off --reader-tail encode \ + --sws-flags fast_bilinear + run nodecpu-encode-nozc --no-gpu --gpu-unpack off --reader-tail encode --no-zero-copy + run nodecpu-unpack-gpu --gpu-unpack on --reader-tail unpack +} + +# ../README.md "Conversion on the GPU". +group_conversion() { + defaults --seconds 40 --warmup 10 --output /dev/null + run w1-sws-default --writer-only + run w2-sws-fast-bilinear --writer-only --sws-flags fast_bilinear + run w3-scale-cuda --writer-only --gpu-scale writer + + run w4-sws-default-unpaced --writer-only --writer-pace off + run w5-sws-fastbl-unpaced --writer-only --writer-pace off --sws-flags fast_bilinear + run w6-scale-cuda-unpaced --writer-only --writer-pace off --gpu-scale writer + + # A real resize: 1080p source into a 720p flow. + run w7-720p-sws-default --width 1280 --height 720 --input "$src_1080" \ + --writer-only --writer-pace off + run w8-720p-sws-lanczos --width 1280 --height 720 --input "$src_1080" \ + --writer-only --writer-pace off --sws-flags lanczos + run w9-720p-scale-cuda-lanczos --width 1280 --height 720 --input "$src_1080" \ + --writer-only --writer-pace off \ + --gpu-scale writer --cuda-interp lanczos + + # The reader's conversion alone, with the output encoder out of the way. + run r1-sws --reader-tail scale --gpu-unpack off + run r2-scale-cuda-up-down --reader-tail scale --gpu-unpack off --gpu-scale reader + run r3-scale-cuda-down-only --reader-tail scale --gpu-unpack on --reader-encoder mpeg4 + run r4-scale-cuda-no-copy --reader-tail scale --gpu-unpack on + + run t1-all-cpu --gpu-unpack off + run t2-cpu-scale-cuda-both --gpu-unpack off --gpu-scale both + run t3-gpu-unpack-mpeg4 --gpu-unpack on --reader-encoder mpeg4 + run t4-gpu-unpack-nvenc --gpu-unpack on + + run t5-all-cpu-unpaced --gpu-unpack off --writer-pace off + run t6-cpu-scale-cuda-both-unpaced --gpu-unpack off --gpu-scale both --writer-pace off + run t7-gpu-unpack-mpeg4-unpaced --gpu-unpack on --reader-encoder mpeg4 --writer-pace off +} + +# ../README.md "Packing on the GPU". The round trips keep their output file: +# a GPU-packed grain still has to come back as a decodable 1800-frame mp4. +group_pack() { + defaults --seconds 30 --warmup 10 + run pack-cpu-paced --output /dev/null --writer-only --gpu-scale writer --writer-pack cpu + run pack-gpu-paced --output /dev/null --writer-only --gpu-scale writer --writer-pack gpu + run pack-cpu-unpaced --output /dev/null --writer-only --writer-pace off \ + --gpu-scale writer --writer-pack cpu + run pack-gpu-unpaced --output /dev/null --writer-only --writer-pace off \ + --gpu-scale writer --writer-pack gpu + run pack-gpu-roundtrip-cpu-reader --gpu-scale writer --writer-pack gpu --gpu-unpack off + run pack-gpu-roundtrip-gpu-reader --gpu-scale writer --writer-pack gpu --gpu-unpack on + run pack-cpu-roundtrip-gpu-reader --gpu-scale writer --writer-pack cpu --gpu-unpack on +} + +# ../README.md "Where the CPU goes" / "Zero-copy grains": the difference is +# one copy of 5.5 MB, which is small enough to want three runs each way. +group_zerocopy() { + defaults --seconds 25 --warmup 8 --output /dev/null \ + --gpu-unpack off --reader-tail demux + local i + for i in 1 2 3; do + run "zc-on-$i" + run "zc-off-$i" --no-zero-copy + done +} + +group_swsflags() { + defaults --seconds 30 --warmup 10 --output /dev/null + run sws-roundtrip-default --gpu-unpack off --reader-tail encode + run sws-roundtrip-fastbl --gpu-unpack off --reader-tail encode \ + --sws-flags fast_bilinear + run sws-roundtrip-point --gpu-unpack off --reader-tail encode --sws-flags point + run sws-roundtrip-bilinear --gpu-unpack off --reader-tail encode --sws-flags bilinear + run sws-writer-default --writer-only --writer-pace off + run sws-writer-fast-bilinear --writer-only --writer-pace off --sws-flags fast_bilinear + run sws-writer-point --writer-only --writer-pace off --sws-flags point +} + +group_srcfmt() { + defaults --seconds 30 --warmup 10 --output /dev/null --gpu-unpack off + run srcfmt-8bit-demux --input "$src_1080" --reader-tail demux + run srcfmt-10bit-demux --input "$src_1080_10bit" --reader-tail demux + run srcfmt-10bit-encode --input "$src_1080_10bit" --reader-tail encode + run srcfmt-10bit-demux-point --input "$src_1080_10bit" --reader-tail demux \ + --sws-flags point +} + +# Writer unpaced too, so the ring is always ahead of the reader: drops are +# expected here and do not affect the rate. +group_tails() { + defaults --seconds 30 --warmup 10 --output /dev/null --writer-pace off + run tail-cpu-demux --gpu-unpack off --reader-tail demux + run tail-cpu-unpack --gpu-unpack off --reader-tail unpack + run tail-cpu-encode --gpu-unpack off --reader-tail encode + run tail-cpu-encode-point --gpu-unpack off --reader-tail encode --sws-flags point + run tail-gpu-unpack --gpu-unpack on --reader-tail unpack +} + +# Does every shape still build? Numbers from 14 s windows are worthless, so +# read the exit status, the log counts and the output probe instead. +group_smoke() { + defaults --seconds 14 --warmup 6 --no-sample + run smoke-writer-gpu-scale --writer-only --gpu-scale writer + run smoke-writer-gpu-pack --writer-only --gpu-scale writer --writer-pack gpu + run smoke-cpu-unpack-gpu-scale --gpu-unpack off --gpu-scale reader + run smoke-gpu-unpack-mpeg4 --gpu-unpack on --reader-encoder mpeg4 + run smoke-cpu-unpack-nvenc --gpu-unpack off --reader-encoder nvenc + run smoke-gpu-full-tail --gpu-unpack on + run smoke-cpu-full-tail --gpu-unpack off + run smoke-scale-tail-cpu-sws --gpu-unpack off --reader-tail scale + run smoke-scale-tail-cpu-gpu --gpu-unpack off --reader-tail scale --gpu-scale reader + run smoke-scale-tail-gpu-mpeg4 --gpu-unpack on --reader-tail scale --reader-encoder mpeg4 + run smoke-scale-tail-gpu-nvenc --gpu-unpack on --reader-tail scale +} + +usage() { + echo "usage: ${0##*/} [bench.sh or mxl_demo.py args...]" + echo "groups:" + printf ' %-12s %s\n' "list" "this list" + local entry + for entry in "${groups[@]}"; do + printf ' %-12s %s\n' "${entry%%|*}" "${entry#*|}" + done +} + +group=${1:-} +[[ -n $group && $group != -h && $group != --help ]] || { usage; exit 2; } +[[ $group == list ]] && { usage; exit 0; } +declare -F "group_$group" >/dev/null || { echo "unknown group: $group" >&2; usage >&2; exit 2; } +shift +passthrough=("$@") +"group_$group" diff --git a/demos/mxl/bench/lib.sh b/demos/mxl/bench/lib.sh new file mode 100644 index 00000000..18a89f3d --- /dev/null +++ b/demos/mxl/bench/lib.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# Shared plumbing for the demos/mxl measurement scripts: how to reach a +# container, how to hand it a clean MXL domain, how to sample the host while +# the demo runs, and how to digest what it printed. Sourced, not executed. + +[[ -n ${bench_lib_sourced:-} ]] && return 0 +bench_lib_sourced=1 + +bench_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +demos_mxl_dir=$(dirname -- "$bench_dir") +demo_in_container=/build/demos/mxl/mxl_demo.py + +# Two ways to reach the demo, both ending in the same python3 command line: +# run a fresh container per case. The only mode whose cgroup holds +# nothing but the run, so whole-process CPU means something. +# exec an already-running container: the incremental build loop, i.e. +# whatever /build holds right now, bind-mounted sources included. +mode=${AVP_BENCH_MODE:-run} +container=${AVP_BENCH_CONTAINER:-avpbuild} +image=${AVP_BENCH_IMAGE:-avplumber-mixer:local} +results=${AVP_BENCH_RESULTS:-${TMPDIR:-/tmp}/mxl-bench} +domain=${AVP_MXL_DOMAIN:-/dev/shm/mxlbench} +history_ns=${AVP_MXL_HISTORY_NS:-1000000000} +width=${AVP_WIDTH:-1920} +height=${AVP_HEIGHT:-1080} +fps=${AVP_FPS:-60000/1001} +input=${AVP_INPUT:-} +seconds=${AVP_BENCH_SECONDS:-60} +warmup=${AVP_BENCH_WARMUP:-10} +gpu=${AVP_BENCH_GPU:-1} +sample=${AVP_BENCH_SAMPLE:-1} +mount_demos=${AVP_BENCH_MOUNT_DEMOS:-0} +timeout_slack=${AVP_BENCH_TIMEOUT_SLACK:-180} + +bench_die() { echo "${0##*/}: $*" >&2; exit 2; } + +# Options every script here takes. Returns how many arguments it consumed, 0 +# for "not mine" — callers add their own cases and pass the rest through to +# mxl_demo.py. +bench_common_arg() { + case ${1:-} in + --exec) mode=exec; container=${2:?missing value for --exec}; return 2 ;; + --image) mode=run; image=${2:?missing value for --image}; return 2 ;; + --domain) domain=${2:?missing value for --domain}; return 2 ;; + --history-ns) history_ns=${2:?missing value for --history-ns}; return 2 ;; + --width) width=${2:?missing value for --width}; return 2 ;; + --height) height=${2:?missing value for --height}; return 2 ;; + --fps) fps=${2:?missing value for --fps}; return 2 ;; + --input) input=${2:?missing value for --input}; return 2 ;; + --seconds) seconds=${2:?missing value for --seconds}; return 2 ;; + --warmup) warmup=${2:?missing value for --warmup}; return 2 ;; + --results) results=${2:?missing value for --results}; return 2 ;; + --no-gpu) gpu=0; return 1 ;; + --no-sample) sample=0; return 1 ;; + --mount-demos) mount_demos=1; return 1 ;; + *) return 0 ;; + esac +} + +bench_common_usage() { + cat <<'EOF' +common options: + --image IMAGE fresh container per case (default avplumber-mixer:local) + --exec CONTAINER run through `docker exec` in a container that is already up + --mount-demos run mode: bind this checkout's demos/mxl over the image's + --domain PATH MXL domain directory (default /dev/shm/mxlbench) + --history-ns N the domain's history_duration (default 1000000000) + --width N --height N --fps R --input URI flow geometry and source + --seconds N --warmup N measured window and its warmup + --results DIR logs, samples and outputs (default $TMPDIR/mxl-bench) + --no-gpu run mode: leave out --gpus all + --no-sample skip the host-side GPU and cgroup CPU samplers +EOF +} + +# The default source matches the flow geometry; the cases that publish a +# 1080p source into a smaller flow pass --input themselves. +bench_default_input() { + [[ -n $input ]] || input="lavfi:smptehdbars=size=${width}x${height}:rate=${fps}" +} + +# Derive what the options imply, once the caller has parsed them all. +bench_setup() { + command -v docker >/dev/null 2>&1 || bench_die "docker not found" + bench_default_input + mkdir -p -- "$results" + if [[ $mode == exec ]]; then + [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] \ + || bench_die "container $container is not running (start it, or use --image)" + # Its own filesystem: the host sees these only through `docker cp`. + bench_out=/tmp/mxl-bench + docker exec "$container" mkdir -p "$bench_out" + bench_cgroup_name=$container + else + docker image inspect "$image" >/dev/null 2>&1 \ + || bench_die "no image $image — build demos/mixer/Dockerfile first" + bench_out=$results/media + mkdir -p -- "$bench_out" + bench_cgroup_name= + fi +} + +# A stale flow directory keeps the old geometry and grain indices, so every +# case starts from an empty domain. +bench_domain_reset() { + local cid name + for cid in $(docker ps -q); do + docker inspect -f '{{range .Mounts}}{{.Source}}{{"\n"}}{{end}}' "$cid" 2>/dev/null \ + | grep -qx -- "$domain" || continue + # A container sharing the host's /dev/shm (--ipc=host, which is how + # the demo runs) picks the recreated directory up. One holding only a + # bind mount of it would be left publishing into an unlinked + # directory that no reader can find, so refuse that. + [[ $domain == /dev/shm/* \ + && $(docker inspect -f '{{.HostConfig.IpcMode}}' "$cid") == host ]] && continue + name=$(docker inspect -f '{{.Name}}' "$cid") + bench_die "refusing to reset $domain: container ${name#/} has it bind-mounted" + done + rm -rf -- "$domain" + mkdir -p -- "$domain" + printf '{"urn:x-mxl:option:history_duration/v1.0": %s}\n' "$history_ns" \ + >"$domain/options.json" +} + +# Extra `docker run` arguments a caller needs (mounts, -e); appended last. +bench_extra_run_args=() + +bench_run_args() { + bench_args=( --rm --ipc=host ) + [[ $gpu == 1 ]] && bench_args+=( --gpus all ) + [[ -n $bench_cgroup_name ]] && bench_args+=( --name "$bench_cgroup_name" ) + bench_args+=( -v "$domain:$domain" -v "$results:$results" ) + [[ $mount_demos == 1 ]] && bench_args+=( -v "$demos_mxl_dir:/build/demos/mxl:ro" ) + bench_args+=( "${bench_extra_run_args[@]+"${bench_extra_run_args[@]}"}" ) +} + +# Run the demo, log to $1, and return its exit status. +bench_run_demo() { + local log=$1 rc; shift + if [[ $mode == exec ]]; then + timeout --signal=KILL "$((seconds + timeout_slack))" \ + docker exec "$container" python3 "$demo_in_container" "$@" >"$log" 2>&1 + rc=$? + # `timeout` only kills the docker client; the demo keeps running. + [[ $rc == 124 || $rc == 137 ]] \ + && docker exec "$container" pkill -f mxl_demo.py >/dev/null 2>&1 + return $rc + fi + docker rm -f "$bench_cgroup_name" >/dev/null 2>&1 + bench_run_args + timeout --signal=KILL "$((seconds + timeout_slack))" \ + docker run "${bench_args[@]}" --entrypoint python3 "$image" \ + "$demo_in_container" "$@" >"$log" 2>&1 +} + +# Run a shell script inside the container: ffprobe, ffmpeg and a backgrounded +# writer all live there. Same mounts as bench_run_demo in run mode. +bench_in_container_bash() { + if [[ $mode == exec ]]; then + docker exec "$container" bash -c "$1" + return $? + fi + bench_run_args + docker run "${bench_args[@]}" --entrypoint bash "$image" -c "$1" +} + +bench_gpu_sampler= +bench_cpu_sampler= + +# Cumulative cgroup CPU time over a known window is an exact mean core count; +# docker stats' own percentages are sampled too coarsely to trust here. +bench_cpu_stat_path() { + local cid path + [[ -n $bench_cgroup_name ]] || return 0 + cid=$(docker inspect -f '{{.Id}}' "$bench_cgroup_name" 2>/dev/null) || return 0 + [[ -n $cid ]] || return 0 + for path in "/sys/fs/cgroup/system.slice/docker-$cid.scope/cpu.stat" \ + "/sys/fs/cgroup/docker/$cid/cpu.stat"; do + [[ -r $path ]] && { printf '%s\n' "$path"; return 0; } + done +} + +bench_sampler_start() { # $1 = result prefix + bench_gpu_sampler= bench_cpu_sampler= + [[ $sample == 1 ]] || return 0 + if command -v nvidia-smi >/dev/null 2>&1; then + nvidia-smi --query-gpu=utilization.gpu,utilization.encoder,memory.used \ + --format=csv,noheader,nounits -l 1 >"$1.gpu.csv" 2>/dev/null & + bench_gpu_sampler=$! + fi + ( while :; do + path=$(bench_cpu_stat_path) + [[ -n $path ]] && awk -v t="$(date +%s.%N)" \ + '/^usage_usec/ {print t "," $2}' "$path" + sleep 1 + done ) >"$1.cpu.csv" 2>/dev/null & + bench_cpu_sampler=$! +} + +bench_sampler_stop() { + local pid + for pid in $bench_gpu_sampler $bench_cpu_sampler; do + kill "$pid" 2>/dev/null + wait "$pid" 2>/dev/null + done + bench_gpu_sampler= bench_cpu_sampler= +} + +bench_sampler_report() { # $1 = result prefix + if [[ -s $1.gpu.csv ]]; then + echo "--- gpu (mean/max)" + awk -F', *' 'NF==3 {n++; s1+=$1; s2+=$2; s3+=$3 + if($1>m1)m1=$1; if($2>m2)m2=$2; if($3>m3)m3=$3} + END {if(n) printf " sm %.1f%%/%d%% enc %.1f%%/%d%% gpumem %.0f/%d MiB\n", + s1/n, m1, s2/n, m2, s3/n, m3}' "$1.gpu.csv" + fi + if [[ -s $1.cpu.csv ]]; then + echo "--- cpu (whole container)" + awk -F, 'NR==1 {t0=$1; u0=$2} {t1=$1; u1=$2; n++} + END {if(n>1) printf " %.2f cores mean over %.0f s\n", + (u1-u0)/1e6/(t1-t0), t1-t0}' "$1.cpu.csv" + fi + return 0 +} + +# Lines that mean the run was not clean: dropped or late grains, edge +# mistakes, and the usual failure words. +bench_log_patterns=('too late' 'too early' 'not consumed by libavformat' + 'setting edge more than once' 'EventLoop negative' + 'Traceback' 'Exception' 'ERROR' 'failed') + +# The demo's own summary, minus the per-second rows and their header — those +# stay in the log. +bench_summary() { # $1 = log + grep -E '^bench:' -- "$1" | grep -vE '^bench: +[0-9]|^bench: elapsed_s' + local pat n first=1 + for pat in "${bench_log_patterns[@]}"; do + n=$(grep -c -i -- "$pat" "$1") + [[ $n == 0 ]] && continue + [[ $first == 1 ]] && { echo "--- log counts"; first=0; } + printf ' %s: %s\n' "$pat" "$n" + done + return 0 +} + +# ffprobe lives in the image, so ask the container: in exec mode the file is +# inside it, and in run mode a throwaway container sees the same mounts. +bench_probe_output() { # $1 = path as the demo saw it + [[ -n $1 && $1 != /dev/null ]] || return 0 + echo "--- output" + bench_in_container_bash " + [ -s '$1' ] || { echo ' no output file'; exit 0; } + ffprobe -v error -select_streams v:0 -count_frames \ + -show_entries stream=nb_read_frames,avg_frame_rate,width,height,pix_fmt \ + -of default=noprint_wrappers=1 '$1' | tr '\n' ' ' | sed 's/^/ /' + echo + ls -l '$1' | awk '{print \" \" \$5 \" bytes\"}'" 2>&1 +} diff --git a/demos/mxl/bench/pack_bitexact.sh b/demos/mxl/bench/pack_bitexact.sh new file mode 100755 index 00000000..12a7df8a --- /dev/null +++ b/demos/mxl/bench/pack_bitexact.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# The CUDA v210 pack has to produce exactly what the libavcodec v210 encoder +# produces. The frames reaching it are the same either way (scale_cuda +# converts in both runs), so the published grains must match byte for byte — +# including the row padding to a 128-byte multiple and, at widths that are not +# a multiple of 6, the partial block at the end of every row. +# +# pack_bitexact.sh [options] [WxH...] +# +# Default geometries are 1920x1080 (whole blocks), 1280x720 and 1918x1080 +# (both ending a row mid-block). Exits nonzero unless every geometry matches. +set -uo pipefail +source "$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +# One fixed flow id: the dumping ffmpeg has to name the flow it reads. +flow_id=${AVP_MXL_VIDEO_ID:-11111111-2222-3333-4444-555555555555} +frames=${AVP_BENCH_PACK_FRAMES:-2} +settle=${AVP_BENCH_PACK_SETTLE:-12} +geometries=() +while [[ $# -gt 0 ]]; do + bench_common_arg "$@"; consumed=$? + if [[ $consumed -gt 0 ]]; then shift "$consumed"; continue; fi + case $1 in + --frames) frames=${2:?missing value for --frames}; shift 2 ;; + --settle) settle=${2:?missing value for --settle}; shift 2 ;; + -h|--help) + awk 'NR>1 && !/^#/ {exit} NR>1 {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}" + echo; bench_common_usage + cat <<'EOF' + --frames N grains to dump per run (default 2) + --settle N seconds to let the writer publish first (default 12) +EOF + exit 0 ;; + *) geometries+=("$1"); shift ;; + esac +done +[[ ${#geometries[@]} -gt 0 ]] || geometries=(1920x1080 1280x720 1918x1080) + +bench_setup +dump_dir=$bench_out/pack +trap 'rm -rf -- "$domain"' EXIT + +# Publish with one pack and dump the first grains of the ring. Writer and +# ffmpeg share a container: the reader has to see the same /dev/shm, and in +# exec mode there is only one container to begin with. +dump() { # $1 = WxH, $2 = cpu|gpu + local geom=$1 pack=$2 w=${1%x*} h=${1#*x} base + base=$dump_dir/$1.$2 + bench_domain_reset + bench_in_container_bash " + mkdir -p '$dump_dir' + rm -f '$base.v210' + python3 $demo_in_container --domain '$domain' --video-flow-id $flow_id \ + --writer-only --width $w --height $h --fps '$fps' \ + --input 'lavfi:smptehdbars=size=${w}x${h}:rate=$fps' \ + --gpu-scale writer --writer-pack $pack >'$base.writer.log' 2>&1 & + writer=\$! + sleep $settle + # -grain_index_init head starts at the oldest grain still in the ring, + # so both runs dump the same frames of a static pattern; -c copy keeps + # the v210 bytes as the muxer wrote them. + ffmpeg -nostdin -loglevel warning -blocking 1 -grain_index_init head \ + -f mxl -i 'mxl://$domain?id=$flow_id' -frames:v $frames \ + -c copy -f rawvideo -y '$base.v210' >'$base.ffmpeg.log' 2>&1 + rc=\$? + kill \$writer 2>/dev/null + wait \$writer 2>/dev/null + [ \$rc = 0 ] || { echo \" $geom $pack: ffmpeg exit \$rc\"; tail -3 '$base.ffmpeg.log'; } + [ -s '$base.v210' ] || { echo \" $geom $pack: nothing dumped\"; tail -5 '$base.writer.log'; } + exit \$rc" 2>&1 +} + +compare() { # $1 = WxH + bench_in_container_bash " + cd '$dump_dir' || exit 1 + ls -l '$1.cpu.v210' '$1.gpu.v210' | awk '{print \" \" \$5, \$9}' + if cmp '$1.cpu.v210' '$1.gpu.v210'; then + md5sum '$1.cpu.v210' | sed 's/^/ /' + exit 0 + fi + echo \" differing bytes: \$(cmp -l '$1.cpu.v210' '$1.gpu.v210' | wc -l)\" + exit 1" 2>&1 +} + +fail=0 +for geom in "${geometries[@]}"; do + echo "=== $geom" + dump "$geom" cpu + dump "$geom" gpu + if compare "$geom"; then + echo " $geom: IDENTICAL" + else + echo " $geom: DIFFERS" + fail=1 + fi + echo +done +if [[ $mode == exec ]]; then + echo "dumps and logs in $container:$dump_dir" +else + echo "dumps and logs: $dump_dir" +fi +exit "$fail" diff --git a/demos/mxl/bench/pixel_compare.sh b/demos/mxl/bench/pixel_compare.sh new file mode 100755 index 00000000..9b32ec95 --- /dev/null +++ b/demos/mxl/bench/pixel_compare.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# What the GPU conversion does to the pixels, and what the GPU pays for taking +# it — the checks behind ../README.md's "Conversion on the GPU" that are not +# an fps number. +# +# pixel_compare.sh [options] [psnr] [pcie] [formats] (default: all three) +# +# psnr the same flow published with swscale and with scale_cuda, read +# back through the CPU reader both times, compared by PSNR and SSIM +# pcie nvidia-smi dmon over a paced run: SM time and PCIe traffic +# formats which pixel formats this build's scale_cuda accepts +set -uo pipefail +source "$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +seconds=${AVP_BENCH_SECONDS:-40} +warmup=${AVP_BENCH_WARMUP:-8} +dmon_delay=${AVP_BENCH_DMON_DELAY:-20} +dmon_count=${AVP_BENCH_DMON_COUNT:-8} +sections=() +while [[ $# -gt 0 ]]; do + bench_common_arg "$@"; consumed=$? + if [[ $consumed -gt 0 ]]; then shift "$consumed"; continue; fi + case $1 in + --dmon-delay) dmon_delay=${2:?missing value for --dmon-delay}; shift 2 ;; + --dmon-count) dmon_count=${2:?missing value for --dmon-count}; shift 2 ;; + -h|--help) + awk 'NR>1 && !/^#/ {exit} NR>1 {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}" + echo; bench_common_usage + cat <<'EOF' + --dmon-delay N seconds into the run before dmon starts (default 20) + --dmon-count N dmon samples, one a second (default 8) +EOF + exit 0 ;; + psnr|pcie|formats) sections+=("$1"); shift ;; + *) bench_die "unknown argument: $1" ;; + esac +done +[[ ${#sections[@]} -gt 0 ]] || sections=(psnr pcie formats) + +bench_setup +sample=0 # these sections watch the GPU with dmon instead +trap 'rm -rf -- "$domain"' EXIT + +demo() { # $1 = label, $2 = output, rest: demo args + local label=$1 out=$2 rc; shift 2 + bench_domain_reset + [[ $mode == run ]] && bench_cgroup_name=mxlbench-$label + bench_run_demo "$results/$label.log" \ + --domain "$domain" --width "$width" --height "$height" --fps "$fps" \ + --input "$input" --output "$out" \ + --bench-seconds "$seconds" --bench-warmup "$warmup" "$@" + rc=$? + [[ $rc == 0 ]] || { + echo " $label: exit $rc — $results/$label.log" + bench_summary "$results/$label.log" | tail -3 + } + return $rc +} + +section_psnr() { + # Not named gpu/cpu: the library's $gpu is the --gpus all switch. + local out_cpu=$bench_out/pixel-cpu.mp4 out_gpu=$bench_out/pixel-gpu.mp4 + echo "=== psnr: writer conversion on the CPU against the GPU, CPU reader both times" + demo pixel-cpu "$out_cpu" --gpu-unpack off || return 1 + demo pixel-gpu "$out_gpu" --gpu-unpack off --gpu-scale writer || return 1 + # smptehdbars is static, so the comparison needs no frame alignment: any + # real difference in the conversion shows up as a PSNR drop. + bench_in_container_bash " + for f in '$out_cpu' '$out_gpu'; do + printf ' %s: ' \"\$f\" + ffprobe -v error -select_streams v:0 -count_frames \ + -show_entries stream=codec_name,width,height,pix_fmt,nb_read_frames \ + -of csv=p=0 \"\$f\" + done + # Both filters print their summary at INFO level, and -nostats plus + # the \r-to-newline pass keeps it off the progress line. + for filter in psnr ssim; do + ffmpeg -hide_banner -nostats -v info -i '$out_cpu' -i '$out_gpu' \ + -lavfi \"[0:v][1:v]\$filter\" -f null - 2>&1 \ + | tr '\r' '\n' | grep '^\[Parsed' | tail -1 | sed 's/^[^]]*] / /' + done" 2>&1 +} + +section_pcie() { + command -v nvidia-smi >/dev/null 2>&1 || { echo "=== pcie: no nvidia-smi, skipped"; return 0; } + [[ $seconds -ge $((dmon_delay + dmon_count + 5)) ]] \ + || echo "warning: --seconds $seconds leaves dmon outside the measured window" >&2 + local label + for label in pcie-gpu-scale-both pcie-gpu-reader; do + case $label in + pcie-gpu-scale-both) set -- --gpu-unpack off --gpu-scale both ;; + pcie-gpu-reader) set -- --gpu-unpack on ;; + esac + echo "=== $label: $*" + demo "$label" /dev/null "$@" & + local runner=$! + sleep "$dmon_delay" + nvidia-smi dmon -s put -c "$dmon_count" + wait "$runner" + bench_summary "$results/$label.log" + echo + done +} + +section_formats() { + echo "=== formats: scale_cuda output formats in this build" + bench_in_container_bash ' + for f in p010le p210le yuv422p10le nv16 yuv420p nv12 p216le; do + out=$(ffmpeg -hide_banner -loglevel error \ + -init_hw_device cuda=g -filter_hw_device g \ + -f lavfi -i testsrc2=s=320x240:d=0.1 \ + -vf "hwupload,scale_cuda=format=$f,hwdownload,format=$f" \ + -frames:v 1 -f null - 2>&1 | head -1) + echo " $f: ${out:-OK}" + done' 2>&1 +} + +for section in "${sections[@]}"; do + "section_$section" + echo +done diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py new file mode 100644 index 00000000..ffe40b76 --- /dev/null +++ b/demos/mxl/mxl_demo.py @@ -0,0 +1,963 @@ +#!/usr/bin/env python3 +"""MXL round-trip demo. + +Builds two avplumber graphs in one process: + +* writer: file -> demux -> decode -> v210 encode -> mxl mux -> mxl:// URL +* reader: mxl:// URL -> demux -> v210 unpack -> re-encode -> file + +The reader unpacks on the GPU (`v210_to_cuda` + NVENC) when a CUDA +device is present and falls back to the CPU v210 decoder otherwise, and +takes grains zero-copy out of the shared-memory ring by default. + +The two graphs share a `/dev/shm/mxl` domain and a set of flow UUIDs so +the reader picks up what the writer publishes. The point is to exercise +the FFmpeg MXL demuxer and muxer end-to-end through avplumber's generic +`input` / `output` nodes; MXL is passed via `format="mxl"` and per-stream +UUIDs in `options`, with no new node types. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +import uuid + +sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..")) + +import pyplumber +from pyplumber.node import ( + Demux, + DecVideo, + EncVideo, + ForceFPS, + Input, + InputRec, + Mux, + Output, + Realtime, +) +from pyplumber.mixer.inputs import v210_row_stride + +# Name of the CUDA device the GPU reader path initializes and shares. +_HWACCEL = "@gpu" + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--input", + default=os.environ.get("AVP_INPUT", "lavfi:testsrc=size=320x240:rate=25"), + help=( + "Source for the writer. A filesystem path is played back through " + "InputRec (looped, realtime-paced). A value prefixed with " + "'lavfi:' opens an FFmpeg libavfilter source (infinite, " + "monotonic timestamps) — the recommended default because MXL is " + "a live shared-memory transport." + ), + ) + p.add_argument( + "--output", + default=os.environ.get("AVP_OUTPUT", "output.mp4"), + help="Local file the reader side writes.", + ) + p.add_argument( + "--domain", + default=os.environ.get("AVP_MXL_DOMAIN", "/dev/shm/mxl"), + help="MXL shared-memory domain directory.", + ) + p.add_argument( + "--video-flow-id", + default=os.environ.get("AVP_MXL_VIDEO_ID"), + help="Video flow UUID. Random if unset.", + ) + p.add_argument( + "--audio-flow-id", + default=os.environ.get("AVP_MXL_AUDIO_ID"), + help="Audio flow UUID. Unused if input has no audio.", + ) + p.add_argument( + "--reader-only", + action="store_true", + help="Skip the writer side (attach to a domain another process is publishing to).", + ) + p.add_argument( + "--writer-only", + action="store_true", + help="Skip the reader side (publish only).", + ) + p.add_argument( + "--width", + type=int, + default=int(os.environ.get("AVP_WIDTH", 320)), + help="Flow width. The writer rescales to it; v210 requires it to be even.", + ) + p.add_argument( + "--height", + type=int, + default=int(os.environ.get("AVP_HEIGHT", 240)), + help="Flow height. The writer rescales to it.", + ) + p.add_argument( + "--fps", + default=os.environ.get("AVP_FPS", "25"), + help="Flow frame rate, '25' or '30000/1001'. Must match the source.", + ) + p.add_argument( + "--reader-tail", + choices=("encode", "scale", "unpack", "demux"), + default=os.environ.get("AVP_READER_TAIL", "encode"), + help=( + "What the reader does with each grain. 'encode' (default) " + "writes --output. The three measurement tails drop the data " + "instead: 'scale' after the conversion to the encoder's format, " + "'unpack' after the v210 unpack, 'demux' straight out of the " + "demuxer. They isolate the MXL read, the unpack and the " + "conversion from the output encoder, which otherwise dominates " + "the CPU path." + ), + ) + p.add_argument( + "--sws-flags", + default=os.environ.get("AVP_SWS_FLAGS"), + help=( + "swscale flags for the writer's and the CPU reader's rescale " + "nodes, comma-separated: 'fast_bilinear', 'bilinear', " + "'neighbor', 'area', ... Unset lets rescale_video choose, which " + "for these same-size conversions means area. The choice only " + "moves the chroma resampling cost; see README." + ), + ) + p.add_argument( + "--gpu-scale", + choices=("off", "writer", "reader", "both"), + default=os.environ.get("AVP_GPU_SCALE", "off"), + help=( + "Do the scaling and color conversion on the GPU instead of in " + "swscale: the rescale_video node is replaced by " + "hwupload,scale_cuda,hwdownload. Frames that are already on the " + "GPU (--gpu-unpack) skip the upload, and frames headed for NVENC " + "skip the download." + ), + ) + p.add_argument( + "--writer-pack", + choices=("auto", "cpu", "gpu"), + default=os.environ.get("AVP_WRITER_PACK", "auto"), + help=( + "Where the writer packs v210. 'gpu' uses cuda_to_v210, which " + "packs the CUDA frame with a kernel and DMAs it into the buffer " + "the muxer reads, replacing both the hwdownload and the CPU v210 " + "encoder; it needs the conversion on the GPU too. 'auto' (default) " + "picks it whenever --gpu-scale covers the writer." + ), + ) + p.add_argument( + "--cuda-interp", + choices=("nearest", "bilinear", "bicubic", "lanczos"), + default=os.environ.get("AVP_CUDA_INTERP"), + help=( + "scale_cuda interpolation algorithm, the GPU counterpart of " + "--sws-flags. Unset leaves the filter's default." + ), + ) + p.add_argument( + "--gpu-unpack", + choices=("auto", "on", "off"), + default=os.environ.get("AVP_GPU_UNPACK", "auto"), + help=( + "Read the flow straight onto the GPU: v210_to_cuda unpacks each " + "grain into CUDA P210 frames and NVENC encodes the output. " + "'auto' (default) uses it when a CUDA device is present, else " + "falls back to the CPU v210 decoder and mpeg4." + ), + ) + p.add_argument( + "--reader-encoder", + choices=("auto", "mpeg4", "nvenc"), + default=os.environ.get("AVP_READER_ENCODER", "auto"), + help=( + "Output encoder of the reader. 'auto' (default) follows " + "--gpu-unpack: NVENC for GPU frames, mpeg4 otherwise. Naming one " + "crosses the paths, which is what measures the download and the " + "upload: GPU unpack into mpeg4 pays one hwdownload, CPU unpack " + "into NVENC one hwupload." + ), + ) + p.add_argument( + "--writer-pace", + choices=("on", "off"), + default=os.environ.get("AVP_WRITER_PACE", "on"), + help=( + "Pace the writer to --fps with realtime + force_fps (default). " + "'off' publishes grains as fast as the source produces them, " + "which is only useful for throughput measurements: it runs the " + "flow into the future and starves readers of history." + ), + ) + p.add_argument( + "--bench-seconds", + type=int, + default=int(os.environ.get("AVP_BENCH_SECONDS", 0)), + help=( + "Instead of running until killed, sample throughput on the " + "writer and reader edges once a second for N seconds, print a " + "summary and exit. Skips teardown, which hangs (see README)." + ), + ) + p.add_argument( + "--bench-warmup", + type=int, + default=int(os.environ.get("AVP_BENCH_WARMUP", 5)), + help="Seconds of --bench-seconds to exclude from the summary.", + ) + p.add_argument( + "--no-zero-copy", + dest="zero_copy", + action="store_false", + default=os.environ.get("AVP_MXL_ZERO_COPY", "1") != "0", + help=( + "Copy each grain out of shared memory instead of pointing the " + "AVPacket at it. Zero-copy is the default; disable it if the " + "reader can fall behind the writer's ring buffer." + ), + ) + args = p.parse_args() + if args.writer_pack == "auto": + args.writer_pack = "gpu" if args.gpu_scale in ("writer", "both") else "cpu" + elif args.writer_pack == "gpu" and args.gpu_scale not in ("writer", "both"): + # Converting on the CPU and then uploading just to pack would pay a + # transfer in each direction to save a 0.3 ms encoder. Say so. + p.error("--writer-pack gpu needs the writer's frames on the GPU: " + "add --gpu-scale writer") + if args.gpu_unpack == "auto": + args.gpu_unpack = _cuda_present() + print(f"gpu unpack: {'on' if args.gpu_unpack else 'off'} (auto-detected)") + else: + args.gpu_unpack = args.gpu_unpack == "on" + if args.reader_encoder == "auto": + args.reader_encoder = "nvenc" if args.gpu_unpack else "mpeg4" + return args + + +def _cuda_present() -> bool: + """True if this container/host has an NVIDIA device node. + + Cheaper and more reliable than probing the driver: the NVIDIA + Container Toolkit maps /dev/nvidiactl in, and its absence is exactly + the case where v210_to_cuda cannot run. + """ + return os.path.exists("/dev/nvidiactl") + + +def _fps_ratio(fps: str) -> str: + """Accept '25' as well as '30000/1001'.""" + return fps if "/" in fps else f"{fps}/1" + + +def _fps_period(fps: str) -> str: + """Frame period as a timebase: '60000/1001' -> '1001/60000'.""" + num, _, den = _fps_ratio(fps).partition("/") + return f"{den}/{num}" + + +def _fps_rounded(fps: str) -> int: + """Nearest whole frame rate, for options that want one (GOP length).""" + num, _, den = _fps_ratio(fps).partition("/") + return max(1, round(float(num) / float(den))) + + +def _cuda_convert_graph(args: argparse.Namespace, dst_format: str, *, + upload: bool, download: bool, + width: int = 0, height: int = 0) -> str: + """A libavfilter graph that converts on the GPU, as filter_video wants. + + `upload` and `download` bracket the conversion when the frames live in + host memory on that side. Each is one PCIe transfer of a whole frame, + in the format on that side of the conversion, so which of the two a + leg pays for shows up directly in its CPU cost. + """ + opts = [] + if width: + opts.append(f"w={width}") + if height: + opts.append(f"h={height}") + opts.append(f"format={dst_format}") + if args.cuda_interp: + opts.append(f"interp_algo={args.cuda_interp}") + scale = "scale_cuda=" + ":".join(opts) + stages = (["hwupload"] if upload else []) + [scale] + if download: + # hwdownload alone yields the frames context's sw_format, which is + # what scale_cuda just produced; name it anyway so the graph fails + # loudly rather than silently handing on another layout. + stages += ["hwdownload", f"format={dst_format}"] + return ",".join(stages) + + +def _needs_cuda(args: argparse.Namespace) -> bool: + """Whether any node of the chosen graph wants the CUDA device.""" + if not args.reader_only and (args.gpu_scale in ("writer", "both") + or args.writer_pack == "gpu"): + return True + if args.writer_only: + return False + if args.reader_tail == "demux": # never unpacks + return False + if args.gpu_unpack: + return True + if args.reader_tail == "unpack": # CPU unpack, no tail to run + return False + return args.gpu_scale in ("reader", "both") or args.reader_encoder == "nvenc" + + +def _sws_flags(spec: str | None) -> list[str]: + """'fast_bilinear' -> ['SWS_FAST_BILINEAR'], which rescale_video wants.""" + if not spec: + return [] + flags = [] + for name in spec.split(","): + name = name.strip().upper() + flags.append(name if name.startswith("SWS_") else "SWS_" + name) + return flags + + +def _writer_url(domain: str) -> str: + """MXL muxer wants a plain filesystem path (AVFMT_NOFILE).""" + return domain + + +def _reader_url(domain: str, video_id: str) -> str: + """MXL demuxer takes an mxl:// URI with the flow id in a query param.""" + return f"mxl://{domain}?id={video_id}" + + +def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: + """source -> demux -> decode -> rescale -> v210 pack -> mxl output. + + The pack is the CPU `v210` encoder by default, or `cuda_to_v210` with + `--writer-pack gpu`, which packs the converted frame where it already + is and hands the muxer the bytes without a download. + """ + from pyplumber.node import RescaleVideo, AssumeVideoFormat + + if args.input.startswith("lavfi:"): + # Live libavfilter source: infinite, monotonic PTS, always + # ready. No loop-wraparound DTS glitches. + avp.addNode(Input({ + "url": args.input.removeprefix("lavfi:"), + "format": "lavfi", + "dst": "w_in", + "group": "w_in", + "name": "w_input", + "auto_restart": "off", + "timeout": -1, + })) + else: + # File input: loop and pace to source frame rate so the + # downstream MXL writer sees a steady live stream. + avp.addNode(InputRec({ + "url": args.input, + "dst": "w_in", + "group": "w_in", + "name": "w_input", + "auto_restart": "off", + "timeout": -1, + "loop": True, + "realtime": True, + })) + avp.addNode(Demux({ + "src": "w_in", + "routing": {"v:0": "w_vpkt"}, + "group": "w_in", + "name": "w_demux", + "auto_restart": "off", + })) + avp.addNode(DecVideo({ + "src": "w_vpkt", + "dst": "w_vframe", + "group": "w_in", + "name": "w_dec", + "auto_restart": "off", + })) + # MXL is a realtime transport: grain N is expected in shared memory + # at wall-clock N/fps. Nothing upstream paces a lavfi source, and an + # unpaced writer publishes thousands of grains per second, which + # shrinks the ring's history to milliseconds and makes every reader + # "too late". realtime(set_pts) rebases onto the host clock and + # force_fps pins the cadence the flow advertises. + if args.writer_pace == "on": + paced_src = "w_vpaced" + avp.addNode(Realtime({ + "src": "w_vframe", + "dst": "w_vrt", + "group": "w_in", + "name": "w_realtime", + "set_pts": True, + "auto_restart": "off", + })) + avp.addNode(ForceFPS({ + "src": "w_vrt", + "dst": "w_vpaced", + "group": "w_in", + "name": "w_fps", + "fps": _fps_ratio(args.fps), + "auto_restart": "off", + })) + else: + paced_src = "w_vframe" + # v210 requires 10-bit 4:2:2 planar. Convert explicitly so the + # encoder's metadata chain is unambiguous. This up-convert is the + # writer's single most expensive step, so it takes --sws-flags — or + # --gpu-scale, which hands the whole conversion to scale_cuda. + # + # Either way the declared geometry is forced: the reader derives the + # v210 row stride from --width, and packed v210 carries no dimensions, + # so a source of another size would silently break the contract. + if args.gpu_scale in ("writer", "both"): + from pyplumber.node import FilterVideo + + avp.addNode(FilterVideo({ + "src": paced_src, + "dst": "w_vscaled", + "group": "w_in", + "name": "w_scale", + # With --writer-pack gpu the frame stays on the GPU: cuda_to_v210 + # packs it there, so the download the CPU encoder needs is gone. + "graph": _cuda_convert_graph(args, "yuv422p10le", + upload=True, + download=args.writer_pack != "gpu", + width=args.width, height=args.height), + "hwaccel": _HWACCEL, + "auto_restart": "off", + })) + else: + scale_params = { + "src": paced_src, + "dst": "w_vscaled", + "group": "w_in", + "name": "w_scale", + "dst_pixel_format": "yuv422p10le", + "dst_width": args.width, + "dst_height": args.height, + "auto_restart": "off", + } + if flags := _sws_flags(args.sws_flags): + scale_params["flags"] = flags + avp.addNode(RescaleVideo(scale_params)) + if args.writer_pack == "gpu": + from pyplumber.node import CudaToV210 + + # cuda_to_v210 is the muxer's encoder: it packs the CUDA frame with a + # kernel and DMAs the v210 bytes into the buffer the muxer copies into + # the grain, so the writer pays neither a download nor a CPU encoder. + # It reads the flow's geometry and rate from the graph above, but the + # demo knows them, and passing them keeps the contract explicit. + avp.addNode(CudaToV210({ + "src": "w_vscaled", + "dst": "w_venc", + "group": "w_in", + "name": "w_pack", + "width": args.width, + "height": args.height, + "fps": _fps_ratio(args.fps), + "timebase": _fps_period(args.fps), + "auto_restart": "off", + })) + else: + avp.addNode(AssumeVideoFormat({ + "src": "w_vscaled", + "dst": "w_vscaled_assumed", + "group": "w_in", + "name": "w_assume", + "width": args.width, + "height": args.height, + "pixel_format": "yuv422p10le", + "real_pixel_format": "yuv422p10le", + "auto_restart": "off", + })) + avp.addNode(EncVideo({ + "src": "w_vscaled_assumed", + "dst": "w_venc", + "group": "w_in", + "name": "w_enc", + "codec": "v210", + "auto_restart": "off", + })) + avp.addNode(Mux({ + "src": ["w_venc"], + "dst": "w_mux", + "group": "w_in", + "name": "w_mux", + })) + mxl_options: dict[str, str] = {} + if args.video_flow_id: + mxl_options["video_flow_id"] = args.video_flow_id + if args.audio_flow_id: + mxl_options["audio_flow_id"] = args.audio_flow_id + avp.addNode(Output({ + "src": "w_mux", + "url": _writer_url(args.domain), + "format": "mxl", + "options": mxl_options, + "group": "w_in", + "name": "w_output", + "auto_restart": "off", + })) + + +def _drop(avp: pyplumber.AVPlumber, src: str) -> None: + """Sink an edge into nothing, which is what the measurement tails do.""" + from pyplumber.node import NullSink + + avp.addNode(NullSink({ + "src": src, + "group": "r_in", + "name": "r_sink", + "auto_restart": "off", + })) + + +def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: + """mxl:// -> demux -> v210 unpack -> convert -> encode -> file. + + With `--gpu-unpack` the packed grains go straight to the GPU + (`v210_to_cuda`) and NVENC writes the file; otherwise the CPU v210 + decoder plus swscale feed the mpeg4 encoder. `--gpu-unpack` and + `--reader-encoder` pick those two ends independently, and the + conversion in between follows from them. `--reader-tail` cuts the + chain short after any of the three, for measuring them in isolation. + """ + if args.zero_copy: + # Zero-copy hands out AVPackets pointing straight into the MXL + # ring buffer in /dev/shm, with no refcount held on the grain: the + # bytes stay valid only until the writer laps that slot. Keep the + # packet queue at one frame so nothing ages while it waits. See + # also the history_duration note in README.md. + avp.executeCommandsFromString("queue.plan_capacity r_vpkt 1") + + # `blocking=1` makes the demuxer wait up to one frame period for + # the next grain instead of returning EAGAIN immediately, so the + # reader stays open once the writer has produced its first grain. + # `auto_restart:"group"` still covers the brief startup race + # before grain 0 is available. + avp.addNode(Input({ + "url": _reader_url(args.domain, args.video_flow_id), + "format": "mxl", + "dst": "r_in", + "group": "r_in", + "name": "r_input", + "auto_restart": "group", + "timeout": -1, + "options": { + "blocking": "1", + "grain_index_init": "head", + # avformat_open_input's probe already reads a grain, so the + # index is picked when the node is *created* — seconds before + # the group starts, since CUDA and NVENC init sit in between. + # By then it is behind the ring tail; "reset" re-derives it + # from grain_index_init on the next read instead of failing. + "on_too_late": "reset", + # ...which leaves a hole in the timestamps where the skipped + # grains would have been. Rebase PTS to zero after it so the + # output file does not start with a multi-second gap. + "reset_on_drop": "1", + "zero_copy": "1" if args.zero_copy else "0", + }, + })) + avp.addNode(Demux({ + "src": "r_in", + "routing": {"v:0": "r_vpkt"}, + "group": "r_in", + "name": "r_demux", + "auto_restart": "off", + })) + if args.reader_tail == "demux": + _drop(avp, "r_vpkt") + return + unpacked = (_build_gpu_unpack(avp, args) if args.gpu_unpack + else _build_cpu_unpack(avp, args)) + if args.reader_tail == "unpack": + _drop(avp, unpacked) + return + converted = _build_convert(avp, args, unpacked) + if args.reader_tail == "scale": + _drop(avp, converted) + return + _build_encoder(avp, args, converted) + avp.addNode(Mux({ + "src": ["r_venc"], + "dst": "r_mux", + "group": "r_in", + "name": "r_mux", + })) + avp.addNode(Output({ + "src": "r_mux", + "url": args.output, + "format": "mp4", + # Fragmented MP4 so the file stays playable even if the demo + # is interrupted before the mp4 trailer is written. + # flush_packets is what actually makes that true: without it the + # muxer's 256 KiB avio buffer is only written out when it fills, + # so a low-bitrate run killed after 25 s leaves a 28-byte file + # holding nothing but the ftyp box. + "options": { + "movflags": "frag_keyframe+empty_moov+default_base_moof", + "flush_packets": "1", + }, + "group": "r_in", + "name": "r_output", + "auto_restart": "off", + })) + + +def _build_gpu_unpack(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> str: + """r_vpkt -> v210_to_cuda -> r_vcuda, returning the frame edge. + + One packed frame per grain is exactly `v210_to_cuda`'s input + contract, so the whole CPU v210 decoder and swscale drop out: the + grain is copied once into pinned memory, unpacked to CUDA P210 by + the PTX kernel, and never touches host memory again. + """ + from pyplumber.node import V210ToCuda + + avp.addNode(V210ToCuda({ + "src": "r_vpkt", + "dst": "r_vcuda", + "group": "r_in", + "name": "r_unpack", + "hwaccel": _HWACCEL, + "width": args.width, + "height": args.height, + "stride": v210_row_stride(args.width), + "fps": _fps_ratio(args.fps), + # Packed v210 has no timebase either, so the node picks one. 90 kHz + # is the usual choice, but mpeg4 refuses any denominator above + # 65535, so stamp the frame period when it is the output encoder. + "timebase": ("1/90000" if args.reader_encoder == "nvenc" + else _fps_period(args.fps)), + "format": "p210le", + # Packed v210 carries no metadata at all, so the node stamps it. + "colorspace": "bt709", + "color_primaries": "bt709", + "color_trc": "bt709", + "color_range": "tv", + "auto_restart": "off", + })) + return "r_vcuda" + + +def _build_cpu_unpack(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> str: + """r_vpkt -> libavcodec v210 decoder -> r_vframe (yuv422p10le).""" + avp.addNode(DecVideo({ + "src": "r_vpkt", + "dst": "r_vframe", + "group": "r_in", + "name": "r_dec", + "auto_restart": "off", + })) + return "r_vframe" + + +def _build_convert(avp: pyplumber.AVPlumber, args: argparse.Namespace, + src: str) -> str: + """Bring the unpacked frames to what the output encoder takes. + + NVENC wants CUDA nv12 -- it only accepts 4:2:2 10-bit on + Blackwell-class hardware -- and mpeg4 wants host yuv420p, so either + way the 10-bit 4:2:2 flow is converted here. The frames arrive either + on the GPU (`v210_to_cuda`) or in host memory (the `v210` decoder), + which leaves four shapes for the same step: swscale, scale_cuda with + an upload and a download around it (`--gpu-scale reader`), scale_cuda + plus one download, or scale_cuda alone. It is the reader's most + expensive step after the encoder itself. + """ + from pyplumber.node import RescaleVideo, AssumeVideoFormat, FilterVideo + + gpu_in = args.gpu_unpack + gpu_out = args.reader_encoder == "nvenc" + dst_format = "nv12" if gpu_out else "yuv420p" + if gpu_in or gpu_out or args.gpu_scale in ("reader", "both"): + avp.addNode(FilterVideo({ + "src": src, + "dst": "r_vconv", + "group": "r_in", + "name": "r_scale", + "graph": _cuda_convert_graph(args, dst_format, + upload=not gpu_in, + download=not gpu_out), + "hwaccel": _HWACCEL, + "auto_restart": "off", + })) + else: + scale_params = { + "src": src, + "dst": "r_vconv", + "group": "r_in", + "name": "r_scale", + "dst_pixel_format": dst_format, + "auto_restart": "off", + } + if flags := _sws_flags(args.sws_flags): + scale_params["flags"] = flags + avp.addNode(RescaleVideo(scale_params)) + avp.addNode(AssumeVideoFormat({ + "src": "r_vconv", + "dst": "r_vassumed", + "group": "r_in", + "name": "r_assume", + "width": args.width, + "height": args.height, + "pixel_format": "cuda" if gpu_out else dst_format, + "real_pixel_format": dst_format, + "auto_restart": "off", + })) + return "r_vassumed" + + +def _build_encoder(avp: pyplumber.AVPlumber, args: argparse.Namespace, + src: str) -> None: + """The reader's output encoder: NVENC on the GPU, else mpeg4. + + NVENC sidesteps the CPU path's mpeg4 fallback, which the demo uses + only because the image does not link libx264. + """ + if args.reader_encoder != "nvenc": + avp.addNode(EncVideo({ + "src": src, + "dst": "r_venc", + "group": "r_in", + "name": "r_enc", + "codec": "mpeg4", + "auto_restart": "off", + })) + return + avp.addNode(EncVideo({ + "src": src, + "dst": "r_venc", + "group": "r_in", + "name": "r_enc", + "codec": "h264_nvenc", + "hwaccel": _HWACCEL, + # One keyframe per second: movflags=frag_keyframe cuts a fragment + # per keyframe, so the output file becomes playable a second in + # instead of after NVENC's default 250-frame GOP. + "options": {"preset": "p4", "profile": "high", "bf": 0, + "g": _fps_rounded(args.fps)}, + "auto_restart": "off", + })) + + +def _edge_counter(avp: pyplumber.AVPlumber, name: str, timeout: float = 30.0): + """Total-enqueued reader for an edge, or None if it never appears. + + `startNodes()` only queues the group's state change, so the edges + materialize on an avplumber worker thread some time after it returns — + CUDA and NVENC init keep the reader busy for seconds. Wait for the + edge instead of asking `getEdge()` for it: with no edge of that name + yet, `getEdge()` *creates* one of the requested type, and the real + node then fails forever with "Edge r_vcuda has type av::Packet, not + av::VideoFrame". The stats JSON is the type-agnostic view, so it + neither creates an edge nor has to guess the queue type. + """ + deadline = time.monotonic() + timeout + while not any(edge["name"] == name for edge in avp.edges.edgesStatsJson()): + if time.monotonic() > deadline: + print(f"bench: no edge {name} after {timeout:.0f} s: node failed?") + return None + time.sleep(0.1) + edge = avp.getEdge(name) + return lambda: edge.enqueued_total + + +def _reader_bench_edge(args: argparse.Namespace) -> str: + """Last edge of the reader chain, which depends on --reader-tail.""" + if args.reader_tail == "encode": + return "r_venc" + if args.reader_tail == "scale": + return "r_vassumed" + if args.reader_tail == "demux": + return "r_vpkt" + return "r_vcuda" if args.gpu_unpack else "r_vframe" + + +_CLK_TCK = os.sysconf("SC_CLK_TCK") + + +def _thread_cpu() -> dict[str, float]: + """CPU seconds consumed so far, per thread name. + + Every avplumber node runs in a thread named after the node, so + /proc/self/task is a per-node profiler that costs nothing on the media + path. Linux keeps 15 characters of each name; nodes created without a + "name" show up as "@
". + """ + totals: dict[str, float] = {} + for task in os.scandir("/proc/self/task"): + try: + with open(os.path.join(task.path, "stat")) as stat_file: + stat = stat_file.read() + except OSError: # thread exited while we were reading + continue + name = stat[stat.index("(") + 1:stat.rindex(")")] + # Fields after comm: state is 3, so utime (14) and stime (15) are + # at index 11 and 12. + fields = stat[stat.rindex(")") + 2:].split() + cpu = (int(fields[11]) + int(fields[12])) / _CLK_TCK + totals[name] = totals.get(name, 0.0) + cpu + return totals + + +def _print_thread_cpu(before: dict[str, float], elapsed: float, + frames: dict[str, int]) -> None: + """Per-node CPU over the measured window, most expensive node first. + + `frames` maps a node name prefix ("w_", "r_") to the number of frames + that half of the demo produced in the window, which turns CPU seconds + into the per-frame cost of each node. + """ + rows = [] + after = _thread_cpu() + for name, cpu in after.items(): + delta = cpu - before.get(name, 0.0) + if delta >= 0.01: + rows.append((name, delta)) + rows.sort(key=lambda row: -row[1]) + total = sum(delta for _, delta in rows) + print(f"bench: per-node CPU over {elapsed:.1f} s, " + f"{total:.1f} s total = {total / elapsed:.2f} cores") + for name, delta in rows: + count = frames.get(name[:2], 0) + per_frame = f", {delta / count * 1000:6.2f} ms/frame" if count else "" + print(f"bench: {name:<16} {delta:6.2f} s, " + f"{delta / elapsed:5.2f} cores{per_frame}") + + +def _bench(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> int: + """Sample per-second throughput on the writer and reader edges. + + `enqueued_total` on an edge is a plain counter, so polling it once a + second costs nothing on the media path — unlike a wiretap callback, + which would take the GIL per packet. + """ + target = _fps_rounded(args.fps) + counters = [(n, p, c) for n, p, c in ( + ("writer", "w_", None if args.reader_only else _edge_counter(avp, "w_venc")), + ("reader", "r_", None if args.writer_only + else _edge_counter(avp, _reader_bench_edge(args))), + ) if c is not None] + if not counters: + print("bench: nothing to measure", file=sys.stderr) + return 1 + + print("bench: elapsed_s," + ",".join(f"{n}_fps" for n, _, _ in counters), flush=True) + samples: dict[str, list[float]] = {n: [] for n, _, _ in counters} + start = time.monotonic() + prev_t = start + prev = {n: c() for n, _, c in counters} + # The per-node CPU window opens once the warmup is over, so that CUDA, + # NVENC and MXL attach do not land in the per-frame figures. + cpu_before: dict[str, float] | None = None + frames_before: dict[str, int] = {} + window_start = start + while (elapsed := time.monotonic() - start) < args.bench_seconds: + time.sleep(1.0) + avp.heartbeat() + now = time.monotonic() + dt = now - prev_t + row = [] + for name, _, counter in counters: + total = counter() + fps = (total - prev[name]) / dt + prev[name] = total + row.append(fps) + # Skip the warmup and, beyond it, the leading zeros: the + # reader only starts producing once CUDA, NVENC and the MXL + # attach are done, and that startup is not a stall. + if now - start > args.bench_warmup and (fps > 0 or samples[name]): + samples[name].append(fps) + prev_t = now + if cpu_before is None and now - start > args.bench_warmup: + cpu_before = _thread_cpu() + frames_before = {p: c() for _, p, c in counters} + window_start = now + print(f"bench: {now - start:6.1f}," + + ",".join(f"{fps:8.2f}" for fps in row), flush=True) + + print(f"bench: target {target} fps, " + f"{args.width}x{args.height}, " + f"pace {args.writer_pace}, " + f"gpu_unpack {'on' if args.gpu_unpack else 'off'}, " + f"zero_copy {'on' if args.zero_copy else 'off'}, " + f"reader_tail {args.reader_tail}, " + f"reader_encoder {args.reader_encoder}, " + f"gpu_scale {args.gpu_scale}, " + f"writer_pack {args.writer_pack}, " + f"cuda_interp {args.cuda_interp or 'default'}, " + f"sws_flags {args.sws_flags or 'default'}") + for name, _, counter in counters: + got = samples[name] + if not got: + continue + mean = sum(got) / len(got) + print(f"bench: {name}: mean {mean:.2f} fps " + f"(min {min(got):.2f}, max {max(got):.2f}) " + f"over {len(got)} s, " + f"{mean / target * 100:.1f}% of target, " + f"{counter()} frames total") + if cpu_before is not None: + window = time.monotonic() - window_start + _print_thread_cpu(cpu_before, window, + {p: c() - frames_before[p] for _, p, c in counters}) + # Teardown hangs (see README); the mp4 is flushed per packet, so the + # output is complete without it. + sys.stdout.flush() + os._exit(0) + + +def main() -> int: + args = _parse_args() + if args.reader_only and args.writer_only: + print("--reader-only and --writer-only are mutually exclusive", file=sys.stderr) + return 2 + if not args.video_flow_id: + args.video_flow_id = str(uuid.uuid4()) + print(f"video flow id: {args.video_flow_id}") + + avp = pyplumber.AVPlumber() + + # Before either half is built: --gpu-scale puts CUDA filters in the + # writer too, and a node's create() resolves the device by name. + if _needs_cuda(args): + avp.executeCommandsFromString( + 'hwaccel.init { "name": "%s", "type": "cuda" }' % _HWACCEL + ) + if not args.reader_only: + _build_writer(avp, args) + if not args.writer_only: + _build_reader(avp, args) + + if not args.reader_only: + avp.group("w_in").startNodes() + if not args.writer_only: + # Give the writer a moment to open the flow before the reader + # tries to attach. + if not args.reader_only: + time.sleep(1.0) + avp.group("r_in").startNodes() + + if args.bench_seconds: + return _bench(avp, args) + + try: + while True: + time.sleep(1) + avp.heartbeat() + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/demos/mxl/requirements.txt b/demos/mxl/requirements.txt new file mode 100644 index 00000000..e6cda319 --- /dev/null +++ b/demos/mxl/requirements.txt @@ -0,0 +1,2 @@ +# No extra Python deps yet. Kept so the Dockerfile pip step stays uniform +# with the mixer/playlist demos and future TUI/tests can slot in here. diff --git a/demos/mxl/test-media/.gitkeep b/demos/mxl/test-media/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch b/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch new file mode 100644 index 00000000..d2ac069c --- /dev/null +++ b/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch @@ -0,0 +1,14065 @@ +From b3e1c3d9516d3d5af85f66f7a4aee3ab1d80862f Mon Sep 17 00:00:00 2001 +From: avplumber mxl builder +Date: Mon, 21 Sep 2026 13:22:04 +0000 +Subject: [PATCH] avformat/mxl: add libmxl demuxer and muxer + +Adds the MXL demuxer, muxer, URI parser, JSON/diagnostic helpers +and --enable-libmxl configure glue. + +Cherry-picked from https://github.com/cbcrc/FFmpeg (branch +dmf-mxl/8.1) and squashed onto n8.1 on top of the avplumber +series 0001-0010, with the configure hunks re-anchored so the +patch applies to both n8.0 and n8.1. + +Original commits: + * 2dc6c6ea57 avformat/mxldec: keep pts monotonic across a reset_on_drop reset + * 8797c76db9 ffmpeg 8.1 port + * 1a14ce7583 Apply FFmpeg 8 MXL integration to FFmpeg 8.1 + +Co-authored-by: avplumber mxl builder +Co-authored-by: jpt +Co-authored-by: jpt +--- + configure | 6 + + libavformat/Makefile | 5 + + libavformat/allformats.c | 2 + + libavformat/jsmn.c | 27 + + libavformat/jsmn.h | 468 ++++++++ + libavformat/mxl_common.h | 29 + + libavformat/mxl_diag.c | 379 ++++++ + libavformat/mxl_diag.h | 227 ++++ + libavformat/mxl_dump.c | 92 ++ + libavformat/mxl_dump.h | 36 + + libavformat/mxl_flow_def.h | 272 +++++ + libavformat/mxl_json.c | 449 +++++++ + libavformat/mxl_json.h | 225 ++++ + libavformat/mxl_loc.c | 217 ++++ + libavformat/mxl_loc.h | 42 + + libavformat/mxl_log.h | 67 ++ + libavformat/mxl_status.h | 59 + + libavformat/mxl_uri.c | 304 +++++ + libavformat/mxl_uri.h | 118 ++ + libavformat/mxldec.c | 1990 +++++++++++++++++++++++++++++++ + libavformat/mxlenc.c | 893 ++++++++++++++ + libavformat/tests/mxl_diag.c | 1471 +++++++++++++++++++++++ + libavformat/tests/mxl_json.c | 1076 +++++++++++++++++ + libavformat/tests/mxl_loc.c | 587 +++++++++ + libavformat/tests/mxl_uri.c | 1408 ++++++++++++++++++++++ + tests/Makefile | 1 + + tests/fate/mxl.mak | 249 ++++ + tests/ref/fate/mxl-audio-encdec | 14 + + tests/ref/fate/mxl-audio-probe | 9 + + tests/ref/fate/mxl-av-encdec | 24 + + tests/ref/fate/mxl-av-probe | 17 + + tests/ref/fate/mxl-bad-domain | 8 + + tests/ref/fate/mxl-bad-flow | 8 + + tests/ref/fate/mxl-bad-loc | 43 + + tests/ref/fate/mxl-diag | 82 ++ + tests/ref/fate/mxl-json | 1041 ++++++++++++++++ + tests/ref/fate/mxl-loc | 564 +++++++++ + tests/ref/fate/mxl-uri | 1176 ++++++++++++++++++ + tests/ref/fate/mxl-video-encdec | 14 + + tests/ref/fate/mxl-video-probe | 10 + + 40 files changed, 13709 insertions(+) + create mode 100644 libavformat/jsmn.c + create mode 100644 libavformat/jsmn.h + create mode 100644 libavformat/mxl_common.h + create mode 100644 libavformat/mxl_diag.c + create mode 100644 libavformat/mxl_diag.h + create mode 100644 libavformat/mxl_dump.c + create mode 100644 libavformat/mxl_dump.h + create mode 100644 libavformat/mxl_flow_def.h + create mode 100644 libavformat/mxl_json.c + create mode 100644 libavformat/mxl_json.h + create mode 100644 libavformat/mxl_loc.c + create mode 100644 libavformat/mxl_loc.h + create mode 100644 libavformat/mxl_log.h + create mode 100644 libavformat/mxl_status.h + create mode 100644 libavformat/mxl_uri.c + create mode 100644 libavformat/mxl_uri.h + create mode 100644 libavformat/mxldec.c + create mode 100644 libavformat/mxlenc.c + create mode 100644 libavformat/tests/mxl_diag.c + create mode 100644 libavformat/tests/mxl_json.c + create mode 100644 libavformat/tests/mxl_loc.c + create mode 100644 libavformat/tests/mxl_uri.c + create mode 100644 tests/fate/mxl.mak + create mode 100644 tests/ref/fate/mxl-audio-encdec + create mode 100644 tests/ref/fate/mxl-audio-probe + create mode 100644 tests/ref/fate/mxl-av-encdec + create mode 100644 tests/ref/fate/mxl-av-probe + create mode 100644 tests/ref/fate/mxl-bad-domain + create mode 100644 tests/ref/fate/mxl-bad-flow + create mode 100644 tests/ref/fate/mxl-bad-loc + create mode 100644 tests/ref/fate/mxl-diag + create mode 100644 tests/ref/fate/mxl-json + create mode 100644 tests/ref/fate/mxl-loc + create mode 100644 tests/ref/fate/mxl-uri + create mode 100644 tests/ref/fate/mxl-video-encdec + create mode 100644 tests/ref/fate/mxl-video-probe + +diff --git a/configure b/configure +index 8f3a2df56f..25a484e6b6 100755 +--- a/configure ++++ b/configure +@@ -2066,2 +2066,3 @@ EXTERNAL_LIBRARY_LIST=" + libmp3lame ++ libmxl + libmysofa +@@ -3854,2 +3855,4 @@ libxvid_encoder_deps="libxvid" + libzvbi_teletext_decoder_deps="libzvbi" ++mxl_muxer_deps="libmxl swresample" ++mxl_demuxer_deps="libmxl swresample" + vapoursynth_demuxer_deps="vapoursynth" +@@ -7475,2 +7478,5 @@ enabled mmal && { check_lib mmal interface/mmal/mmal.h mmal_port_co + check_func_headers interface/mmal/mmal.h "MMAL_PARAMETER_VIDEO_MAX_NUM_CALLBACKS"; } ++enabled libmxl && require_pkg_config libmxl libmxl mxl/mxl.h mxlCreateInstance ++enabled mxl_demuxer && prepend avformat_deps "swresample" ++enabled mxl_muxer && prepend avformat_deps "swresample" + enabled ohcodec && { check_lib ohcodec "multimedia/player_framework/native_avcodec_videodecoder.h multimedia/player_framework/native_avcodec_videoencoder.h" \ +diff --git a/libavformat/Makefile b/libavformat/Makefile +index 4786a9345a..54cd71df87 100644 +--- a/libavformat/Makefile ++++ b/libavformat/Makefile +@@ -677,2 +677,6 @@ OBJS-$(CONFIG_VAPOURSYNTH_DEMUXER) += vapoursynth.o + ++# MXL demuxer/muxer ++OBJS-$(CONFIG_MXL_DEMUXER) += mxldec.o mxl_dump.o mxl_diag.o mxl_uri.o mxl_loc.o mxl_json.o jsmn.o ++OBJS-$(CONFIG_MXL_MUXER) += mxlenc.o mxl_diag.o mxl_json.o jsmn.o ++ + # protocols I/O +@@ -784,2 +788,3 @@ TESTPROGS-$(CONFIG_SRTP) += srtp + TESTPROGS-$(CONFIG_IMF_DEMUXER) += imf ++TESTPROGS-$(CONFIG_MXL_DEMUXER) += mxl_diag mxl_uri mxl_loc mxl_json + +diff --git a/libavformat/allformats.c b/libavformat/allformats.c +index 6ec361fb7b..fee0056bc7 100644 +--- a/libavformat/allformats.c ++++ b/libavformat/allformats.c +@@ -576,2 +576,4 @@ extern const FFInputFormat ff_image_xpm_pipe_demuxer; + extern const FFInputFormat ff_image_xwd_pipe_demuxer; ++extern const FFInputFormat ff_mxl_demuxer; ++extern const FFOutputFormat ff_mxl_muxer; + +diff --git a/libavformat/jsmn.c b/libavformat/jsmn.c +new file mode 100644 +index 0000000000..e36effacfa +--- /dev/null ++++ b/libavformat/jsmn.c +@@ -0,0 +1,27 @@ ++/* ++ * MXL JSON parser for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++/* ++ * jsmn implementation file ++ * see: https://github.com/zserge/jsmn/blob/v1.1.0/README.md#usage ++ */ ++ ++#include "jsmn.h" +diff --git a/libavformat/jsmn.h b/libavformat/jsmn.h +new file mode 100644 +index 0000000000..b95368a206 +--- /dev/null ++++ b/libavformat/jsmn.h +@@ -0,0 +1,468 @@ ++/* ++ * MIT License ++ * ++ * Copyright (c) 2010 Serge Zaitsev ++ * ++ * Permission is hereby granted, free of charge, to any person obtaining a copy ++ * of this software and associated documentation files (the "Software"), to deal ++ * in the Software without restriction, including without limitation the rights ++ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ++ * copies of the Software, and to permit persons to whom the Software is ++ * furnished to do so, subject to the following conditions: ++ * ++ * The above copyright notice and this permission notice shall be included in ++ * all copies or substantial portions of the Software. ++ * ++ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ++ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ++ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ++ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ++ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ++ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ++ * SOFTWARE. ++ */ ++#ifndef JSMN_H ++#define JSMN_H ++ ++#include ++ ++#ifdef __cplusplus ++extern "C" { ++#endif ++ ++#ifdef JSMN_STATIC ++#define JSMN_API static ++#else ++#define JSMN_API extern ++#endif ++ ++/** ++ * JSON type identifier. Basic types are: ++ * o Object ++ * o Array ++ * o String ++ * o Other primitive: number, boolean (true/false) or null ++ */ ++typedef enum { ++ JSMN_UNDEFINED = 0, ++ JSMN_OBJECT = 1, ++ JSMN_ARRAY = 2, ++ JSMN_STRING = 3, ++ JSMN_PRIMITIVE = 4 ++} jsmntype_t; ++ ++enum jsmnerr { ++ /* Not enough tokens were provided */ ++ JSMN_ERROR_NOMEM = -1, ++ /* Invalid character inside JSON string */ ++ JSMN_ERROR_INVAL = -2, ++ /* The string is not a full JSON packet, more bytes expected */ ++ JSMN_ERROR_PART = -3 ++}; ++ ++/** ++ * JSON token description. ++ * type type (object, array, string etc.) ++ * start start position in JSON data string ++ * end end position in JSON data string ++ */ ++typedef struct { ++ jsmntype_t type; ++ int start; ++ int end; ++ int size; ++#ifdef JSMN_PARENT_LINKS ++ int parent; ++#endif ++} jsmntok_t; ++ ++/** ++ * JSON parser. Contains an array of token blocks available. Also stores ++ * the string being parsed now and current position in that string. ++ */ ++typedef struct { ++ unsigned int pos; /* offset in the JSON string */ ++ unsigned int toknext; /* next token to allocate */ ++ int toksuper; /* superior token node, e.g. parent object or array */ ++} jsmn_parser; ++ ++/** ++ * Create JSON parser over an array of tokens ++ */ ++JSMN_API void jsmn_init(jsmn_parser *parser); ++ ++/** ++ * Run JSON parser. It parses a JSON data string into and array of tokens, each ++ * describing ++ * a single JSON object. ++ */ ++JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len, ++ jsmntok_t *tokens, const unsigned int num_tokens); ++ ++#ifndef JSMN_HEADER ++/** ++ * Allocates a fresh unused token from the token pool. ++ */ ++static jsmntok_t *jsmn_alloc_token(jsmn_parser *parser, jsmntok_t *tokens, ++ const size_t num_tokens) { ++ jsmntok_t *tok; ++ if (parser->toknext >= num_tokens) { ++ return NULL; ++ } ++ tok = &tokens[parser->toknext++]; ++ tok->start = tok->end = -1; ++ tok->size = 0; ++#ifdef JSMN_PARENT_LINKS ++ tok->parent = -1; ++#endif ++ return tok; ++} ++ ++/** ++ * Fills token type and boundaries. ++ */ ++static void jsmn_fill_token(jsmntok_t *token, const jsmntype_t type, ++ const int start, const int end) { ++ token->type = type; ++ token->start = start; ++ token->end = end; ++ token->size = 0; ++} ++ ++/** ++ * Fills next available token with JSON primitive. ++ */ ++static int jsmn_parse_primitive(jsmn_parser *parser, const char *js, ++ const size_t len, jsmntok_t *tokens, ++ const size_t num_tokens) { ++ jsmntok_t *token; ++ int start; ++ ++ start = parser->pos; ++ ++ for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { ++ switch (js[parser->pos]) { ++#ifndef JSMN_STRICT ++ /* In strict mode primitive must be followed by "," or "}" or "]" */ ++ case ':': ++#endif ++ case '\t': ++ case '\r': ++ case '\n': ++ case ' ': ++ case ',': ++ case ']': ++ case '}': ++ goto found; ++ } ++ if (js[parser->pos] < 32 || js[parser->pos] >= 127) { ++ parser->pos = start; ++ return JSMN_ERROR_INVAL; ++ } ++ } ++#ifdef JSMN_STRICT ++ /* In strict mode primitive must be followed by a comma/object/array */ ++ parser->pos = start; ++ return JSMN_ERROR_PART; ++#endif ++ ++found: ++ if (tokens == NULL) { ++ parser->pos--; ++ return 0; ++ } ++ token = jsmn_alloc_token(parser, tokens, num_tokens); ++ if (token == NULL) { ++ parser->pos = start; ++ return JSMN_ERROR_NOMEM; ++ } ++ jsmn_fill_token(token, JSMN_PRIMITIVE, start, parser->pos); ++#ifdef JSMN_PARENT_LINKS ++ token->parent = parser->toksuper; ++#endif ++ parser->pos--; ++ return 0; ++} ++ ++/** ++ * Fills next token with JSON string. ++ */ ++static int jsmn_parse_string(jsmn_parser *parser, const char *js, ++ const size_t len, jsmntok_t *tokens, ++ const size_t num_tokens) { ++ jsmntok_t *token; ++ ++ int start = parser->pos; ++ ++ parser->pos++; ++ ++ /* Skip starting quote */ ++ for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { ++ char c = js[parser->pos]; ++ ++ /* Quote: end of string */ ++ if (c == '\"') { ++ if (tokens == NULL) { ++ return 0; ++ } ++ token = jsmn_alloc_token(parser, tokens, num_tokens); ++ if (token == NULL) { ++ parser->pos = start; ++ return JSMN_ERROR_NOMEM; ++ } ++ jsmn_fill_token(token, JSMN_STRING, start + 1, parser->pos); ++#ifdef JSMN_PARENT_LINKS ++ token->parent = parser->toksuper; ++#endif ++ return 0; ++ } ++ ++ /* Backslash: Quoted symbol expected */ ++ if (c == '\\' && parser->pos + 1 < len) { ++ int i; ++ parser->pos++; ++ switch (js[parser->pos]) { ++ /* Allowed escaped symbols */ ++ case '\"': ++ case '/': ++ case '\\': ++ case 'b': ++ case 'f': ++ case 'r': ++ case 'n': ++ case 't': ++ break; ++ /* Allows escaped symbol \uXXXX */ ++ case 'u': ++ parser->pos++; ++ for (i = 0; i < 4 && parser->pos < len && js[parser->pos] != '\0'; ++ i++) { ++ /* If it isn't a hex character we have an error */ ++ if (!((js[parser->pos] >= 48 && js[parser->pos] <= 57) || /* 0-9 */ ++ (js[parser->pos] >= 65 && js[parser->pos] <= 70) || /* A-F */ ++ (js[parser->pos] >= 97 && js[parser->pos] <= 102))) { /* a-f */ ++ parser->pos = start; ++ return JSMN_ERROR_INVAL; ++ } ++ parser->pos++; ++ } ++ parser->pos--; ++ break; ++ /* Unexpected symbol */ ++ default: ++ parser->pos = start; ++ return JSMN_ERROR_INVAL; ++ } ++ } ++ } ++ parser->pos = start; ++ return JSMN_ERROR_PART; ++} ++ ++/** ++ * Parse JSON string and fill tokens. ++ */ ++JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len, ++ jsmntok_t *tokens, const unsigned int num_tokens) { ++ int r; ++ int i; ++ jsmntok_t *token; ++ int count = parser->toknext; ++ ++ for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { ++ char c; ++ jsmntype_t type; ++ ++ c = js[parser->pos]; ++ switch (c) { ++ case '{': ++ case '[': ++ count++; ++ if (tokens == NULL) { ++ break; ++ } ++ token = jsmn_alloc_token(parser, tokens, num_tokens); ++ if (token == NULL) { ++ return JSMN_ERROR_NOMEM; ++ } ++ if (parser->toksuper != -1) { ++ jsmntok_t *t = &tokens[parser->toksuper]; ++#ifdef JSMN_STRICT ++ /* In strict mode an object or array can't become a key */ ++ if (t->type == JSMN_OBJECT) { ++ return JSMN_ERROR_INVAL; ++ } ++#endif ++ t->size++; ++#ifdef JSMN_PARENT_LINKS ++ token->parent = parser->toksuper; ++#endif ++ } ++ token->type = (c == '{' ? JSMN_OBJECT : JSMN_ARRAY); ++ token->start = parser->pos; ++ parser->toksuper = parser->toknext - 1; ++ break; ++ case '}': ++ case ']': ++ if (tokens == NULL) { ++ break; ++ } ++ type = (c == '}' ? JSMN_OBJECT : JSMN_ARRAY); ++#ifdef JSMN_PARENT_LINKS ++ if (parser->toknext < 1) { ++ return JSMN_ERROR_INVAL; ++ } ++ token = &tokens[parser->toknext - 1]; ++ for (;;) { ++ if (token->start != -1 && token->end == -1) { ++ if (token->type != type) { ++ return JSMN_ERROR_INVAL; ++ } ++ token->end = parser->pos + 1; ++ parser->toksuper = token->parent; ++ break; ++ } ++ if (token->parent == -1) { ++ if (token->type != type || parser->toksuper == -1) { ++ return JSMN_ERROR_INVAL; ++ } ++ break; ++ } ++ token = &tokens[token->parent]; ++ } ++#else ++ for (i = parser->toknext - 1; i >= 0; i--) { ++ token = &tokens[i]; ++ if (token->start != -1 && token->end == -1) { ++ if (token->type != type) { ++ return JSMN_ERROR_INVAL; ++ } ++ parser->toksuper = -1; ++ token->end = parser->pos + 1; ++ break; ++ } ++ } ++ /* Error if unmatched closing bracket */ ++ if (i == -1) { ++ return JSMN_ERROR_INVAL; ++ } ++ for (; i >= 0; i--) { ++ token = &tokens[i]; ++ if (token->start != -1 && token->end == -1) { ++ parser->toksuper = i; ++ break; ++ } ++ } ++#endif ++ break; ++ case '\"': ++ r = jsmn_parse_string(parser, js, len, tokens, num_tokens); ++ if (r < 0) { ++ return r; ++ } ++ count++; ++ if (parser->toksuper != -1 && tokens != NULL) { ++ tokens[parser->toksuper].size++; ++ } ++ break; ++ case '\t': ++ case '\r': ++ case '\n': ++ case ' ': ++ break; ++ case ':': ++ parser->toksuper = parser->toknext - 1; ++ break; ++ case ',': ++ if (tokens != NULL && parser->toksuper != -1 && ++ tokens[parser->toksuper].type != JSMN_ARRAY && ++ tokens[parser->toksuper].type != JSMN_OBJECT) { ++#ifdef JSMN_PARENT_LINKS ++ parser->toksuper = tokens[parser->toksuper].parent; ++#else ++ for (i = parser->toknext - 1; i >= 0; i--) { ++ if (tokens[i].type == JSMN_ARRAY || tokens[i].type == JSMN_OBJECT) { ++ if (tokens[i].start != -1 && tokens[i].end == -1) { ++ parser->toksuper = i; ++ break; ++ } ++ } ++ } ++#endif ++ } ++ break; ++#ifdef JSMN_STRICT ++ /* In strict mode primitives are: numbers and booleans */ ++ case '-': ++ case '0': ++ case '1': ++ case '2': ++ case '3': ++ case '4': ++ case '5': ++ case '6': ++ case '7': ++ case '8': ++ case '9': ++ case 't': ++ case 'f': ++ case 'n': ++ /* And they must not be keys of the object */ ++ if (tokens != NULL && parser->toksuper != -1) { ++ const jsmntok_t *t = &tokens[parser->toksuper]; ++ if (t->type == JSMN_OBJECT || ++ (t->type == JSMN_STRING && t->size != 0)) { ++ return JSMN_ERROR_INVAL; ++ } ++ } ++#else ++ /* In non-strict mode every unquoted value is a primitive */ ++ default: ++#endif ++ r = jsmn_parse_primitive(parser, js, len, tokens, num_tokens); ++ if (r < 0) { ++ return r; ++ } ++ count++; ++ if (parser->toksuper != -1 && tokens != NULL) { ++ tokens[parser->toksuper].size++; ++ } ++ break; ++ ++#ifdef JSMN_STRICT ++ /* Unexpected char in strict mode */ ++ default: ++ return JSMN_ERROR_INVAL; ++#endif ++ } ++ } ++ ++ if (tokens != NULL) { ++ for (i = parser->toknext - 1; i >= 0; i--) { ++ /* Unmatched opened object or array */ ++ if (tokens[i].start != -1 && tokens[i].end == -1) { ++ return JSMN_ERROR_PART; ++ } ++ } ++ } ++ ++ return count; ++} ++ ++/** ++ * Creates a new parser based over a given buffer with an array of tokens ++ * available. ++ */ ++JSMN_API void jsmn_init(jsmn_parser *parser) { ++ parser->pos = 0; ++ parser->toknext = 0; ++ parser->toksuper = -1; ++} ++ ++#endif /* JSMN_HEADER */ ++ ++#ifdef __cplusplus ++} ++#endif ++ ++#endif /* JSMN_H */ +diff --git a/libavformat/mxl_common.h b/libavformat/mxl_common.h +new file mode 100644 +index 0000000000..baf8877e39 +--- /dev/null ++++ b/libavformat/mxl_common.h +@@ -0,0 +1,29 @@ ++/* ++ * MXL commond definitions for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#ifndef AVFORMAT_MXL_COMMON_H ++#define AVFORMAT_MXL_COMMON_H ++ ++#define MXL_FLOW_EXT "mxl-flow" ++#define MXL_DOT_FLOW_EXT ".mxl-flow" ++#define MXL_LONG_NAME "Media eXchange Layer" ++ ++#endif /* AVFORMAT_MXL_COMMON_H */ +diff --git a/libavformat/mxl_diag.c b/libavformat/mxl_diag.c +new file mode 100644 +index 0000000000..e056d7887c +--- /dev/null ++++ b/libavformat/mxl_diag.c +@@ -0,0 +1,379 @@ ++/* ++ * MXL runtime diagnostics for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#include "mxl_diag.h" ++#include "mxl_log.h" ++ ++#include "libavutil/avassert.h" ++#include ++#include ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++static const char *msg_type_to_string(enum mxl_diag_msg_type type) ++{ ++ switch (type) { ++ case MXL_DIAG_MSG_UNDEFINED: ++ return "UNDEFINED"; ++ case MXL_DIAG_MSG_CONNECT: ++ return "CONNECT"; ++ case MXL_DIAG_MSG_RELEASE: ++ return "RELEASE"; ++ case MXL_DIAG_MSG_VIDEO_READ: ++ return "VIDEO_READ"; ++ case MXL_DIAG_MSG_AUDIO_READ: ++ return "AUDIO_READ"; ++ default: ++ return "UNKNOWN"; ++ } ++} ++ ++static int set_nonblocking(int fd) ++{ ++ int flags = fcntl(fd, F_GETFL, 0); ++ if (flags < 0) { ++ return AVERROR(errno); ++ } ++ if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) { ++ return AVERROR(errno); ++ } ++ return 0; ++} ++ ++static int is_path_socket(const char *path) ++{ ++ struct stat st; ++ if (lstat(path, &st) < 0) { ++ return 0; ++ } ++ return S_ISSOCK(st.st_mode); ++} ++ ++static int is_same_addr(const struct sockaddr_un *a, socklen_t a_len, ++ const struct sockaddr_un *b, socklen_t b_len) ++{ ++ if (a_len != b_len) { ++ return 0; ++ } ++ return memcmp(a, b, (size_t)a_len) == 0; ++} ++ ++static void clear_client(mxl_diag_server *s) ++{ ++ memset(&s->client_addr, 0, sizeof(s->client_addr)); ++ s->client_addr_len = 0; ++ s->has_client = 0; ++} ++ ++static int validate_path(const char *path) ++{ ++ size_t len; ++ ++ if (!path || !*path) { ++ return AVERROR(EINVAL); ++ } ++ ++ len = strlen(path); ++ if (len >= sizeof(((struct sockaddr_un *)0)->sun_path)) { ++ return AVERROR(ENAMETOOLONG); ++ } ++ ++ return 0; ++} ++ ++int mxl_diag_init(void *logctx, mxl_diag_server *server, const char *server_path) ++{ ++ (void)logctx; ++ struct sockaddr_un addr; ++ int fd = -1; ++ int ret; ++ ++ if (!server) ++ return AVERROR(EINVAL); ++ ++ memset(server, 0, sizeof(*server)); ++ server->fd = -1; ++ ++ ret = validate_path(server_path); ++ if (ret < 0) { ++ return ret; ++ } ++ ++ // must not exist, or must be a socket ++ if (access(server_path, F_OK) == 0) { ++ if (!is_path_socket(server_path)) { ++ return AVERROR(EEXIST); ++ } ++ if (unlink(server_path) < 0) { ++ return AVERROR(errno); ++ } ++ } else if (errno != ENOENT) { ++ return AVERROR(errno); ++ } ++ ++ fd = socket(AF_UNIX, SOCK_DGRAM, 0); ++ if (fd < 0) { ++ return AVERROR(errno); ++ } ++ ++ ret = set_nonblocking(fd); ++ if (ret < 0) { ++ close(fd); ++ return ret; ++ } ++ ++ memset(&addr, 0, sizeof(addr)); ++ addr.sun_family = AF_UNIX; ++ strcpy(addr.sun_path, server_path); ++ ++ if (bind(fd, (const struct sockaddr *)&addr, sizeof(addr)) < 0) { ++ ret = AVERROR(errno); ++ close(fd); ++ return ret; ++ } ++ ++ server->fd = fd; ++ strcpy(server->server_path, server_path); ++ clear_client(server); ++ ++ return 0; ++} ++ ++void mxl_diag_close(void *logctx, mxl_diag_server *server) ++{ ++ (void)logctx; ++ ++ if (!server) ++ return; ++ ++ if (server->fd >= 0) { ++ close(server->fd); ++ server->fd = -1; ++ } ++ ++ if (server->server_path[0] != '\0') { ++ unlink(server->server_path); ++ server->server_path[0] = '\0'; ++ } ++ ++ clear_client(server); ++} ++ ++int mxl_diag_poll(void *logctx, mxl_diag_server *server) ++{ ++ mxl_diag_msg msg; ++ struct sockaddr_un from_addr; ++ socklen_t from_addr_len = (socklen_t)sizeof(from_addr); ++ int same_sender = 0; ++ ssize_t n; ++ ++ if (!server || server->fd < 0) ++ return AVERROR(EINVAL); ++ ++ memset(&msg, 0, sizeof(msg)); ++ memset(&from_addr, 0, sizeof(from_addr)); ++ ++ struct iovec iov = { ++ .iov_base = &msg, ++ .iov_len = sizeof(msg), ++ }; ++ struct msghdr mh = { ++ .msg_name = &from_addr, ++ .msg_namelen = from_addr_len, ++ .msg_iov = &iov, ++ .msg_iovlen = 1, ++ .msg_control = NULL, ++ .msg_controllen = 0, ++ .msg_flags = 0, ++ }; ++ ++ n = recvmsg(server->fd, &mh, 0); ++ from_addr_len = mh.msg_namelen; ++ ++ // system error ++ if (n < 0) { ++ // transient, retry later ++ if (EAGAIN == errno || EWOULDBLOCK == errno || EINTR == errno) ++ return 0; ++ ++ logd(logctx, "DIAG server recvmsg error: %s\n", av_err2str(AVERROR(errno))); ++ return AVERROR(errno); ++ } ++ ++ if (mh.msg_flags & MSG_TRUNC) { ++ logd(logctx, "DIAG server datagram was truncated\n"); ++ return AVERROR(MXL_DIAG_EDATATRUNCATED); ++ } ++ ++ // data size smaller than sizeof(msg.header) ++ if ((size_t)n < sizeof(msg.header)) { ++ logd(logctx, "DIAG server message is smaller than sizeof(msg.header)\n"); ++ return AVERROR(MXL_DIAG_EDATATOOSMALL); ++ } ++ ++ // header.size smaller than sizeof(msg.header) ++ if (msg.header.size < (uint16_t)sizeof(msg.header)) { ++ logd(logctx, "DIAG header.size is smaller than sizeof(msg.header)\n"); ++ return AVERROR(MXL_DIAG_ESIZETOOSMALL); ++ } ++ ++ // header.size bigger than largest message ++ if (msg.header.size > (uint16_t)sizeof(msg)) { ++ logd(logctx, "DIAG server message header.size is larger than " ++ "maximum possible message size\n"); ++ return AVERROR(MXL_DIAG_ESIZETOOBIG); ++ } ++ ++ // datagram size vs header.size mismatch ++ if ((uint16_t)n != msg.header.size) { ++ logd(logctx, "DIAG server message size mismatch\n"); ++ return AVERROR(MXL_DIAG_ESIZEMISMATCH); ++ } ++ ++ // header.version bad ++ if (msg.header.version != MXL_DIAG_MSG_VERSION) { ++ logd(logctx, "DIAG server message bad version: %d\n", msg.header.version); ++ return AVERROR(MXL_DIAG_EBADVERSION); ++ } ++ ++ if (server->has_client) { ++ same_sender = is_same_addr(&from_addr, from_addr_len, ++ &server->client_addr, server->client_addr_len); ++ } ++ ++ logd(logctx, "DIAG server received control message: %s\n", ++ msg_type_to_string(msg.header.type)); ++ ++ switch (msg.header.type) { ++ case MXL_DIAG_MSG_CONNECT: ++ ++ if ((size_t)n != sizeof(msg.header)) { ++ logd(logctx, "DIAG connect message size mismatch (%d != %d)\n", n, sizeof(msg.header)); ++ return AVERROR(MXL_DIAG_EMSGSIZEMISMATCH); ++ } ++ ++ if (same_sender) { ++ logd(logctx, "DIAG server duplicate connect from client '%s'\n", ++ from_addr.sun_path); ++ return 1; ++ } ++ ++ if (server->has_client) { ++ logd(logctx, "DIAG server ignored registration request from client '%s'\n", ++ from_addr.sun_path); ++ return 1; ++ } ++ ++ server->client_addr = from_addr; ++ server->client_addr_len = from_addr_len; ++ server->has_client = 1; ++ ++ logd(logctx, "DIAG server registered client '%s'\n", from_addr.sun_path); ++ ++ return 1; ++ ++ case MXL_DIAG_MSG_RELEASE: ++ if ((size_t)n != sizeof(msg.header)) { ++ logd(logctx, "DIAG release message size mismatch (%d != %d)\n", n, sizeof(msg.header)); ++ return AVERROR(MXL_DIAG_EMSGSIZEMISMATCH); ++ } ++ ++ if (same_sender) { ++ logd(logctx, "DIAG server released client '%s'\n", from_addr.sun_path); ++ clear_client(server); ++ } ++ else ++ logd(logctx, "DIAG server ignored release from an unregistered client '%s'\n", ++ from_addr.sun_path); ++ ++ return 1; ++ ++ default: ++ logd(logctx, "DIAG server unknown client message from '%s'\n", from_addr.sun_path); ++ return AVERROR(MXL_DIAG_EUNKNOWN); ++ } ++} ++ ++int mxl_diag_send(void *logctx, mxl_diag_server *server, const mxl_diag_msg *msg) ++{ ++ ssize_t n; ++ ++ if (!server || !msg) { ++ return AVERROR(EINVAL); ++ } ++ ++ if (server->fd < 0) { ++ return AVERROR(EBADF); ++ } ++ ++ // lower bound sanity ++ if ((size_t)msg->header.size < sizeof(msg->header)) { ++ logd(logctx, "DIAG server sendto sanity, header.size is smaller " ++ "than minimum message size\n"); ++ return AVERROR(EINVAL); ++ } ++ ++ // upper bound sanity ++ if (msg->header.size > sizeof(*msg)) { ++ logd(logctx, "DIAG server sendto sanity, header.size is larger" ++ " than maximum message size\n"); ++ return AVERROR(EINVAL); ++ } ++ ++ if (!server->has_client) { ++ return 0; ++ } ++ ++ n = sendto(server->fd, msg, (size_t)msg->header.size, 0, ++ (const struct sockaddr *)&server->client_addr, ++ server->client_addr_len); ++ if (n < 0) { ++ // client disappeared ++ if (ENOENT == errno || ECONNREFUSED == errno) { ++ clear_client(server); ++ return 0; ++ } ++ ++ // transient, retry later ++ if (EAGAIN == errno || EWOULDBLOCK == errno || EINTR == errno) ++ return 0; ++ ++ // an error worth logging ++ logd(logctx, "DIAG server sendto failed: %s\n", strerror(errno)); ++ return AVERROR(errno); ++ } ++ ++ if ((size_t)n != (size_t)msg->header.size) { ++ logd(logctx, "DIAG server send length sanity: expected=%zu actual=%zd\n", ++ (size_t)msg->header.size, n); ++ return AVERROR(EIO); ++ } ++ ++ return 1; ++} +diff --git a/libavformat/mxl_diag.h b/libavformat/mxl_diag.h +new file mode 100644 +index 0000000000..d2ea097792 +--- /dev/null ++++ b/libavformat/mxl_diag.h +@@ -0,0 +1,227 @@ ++/* ++ * MXL runtime diagnostics for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#ifndef MXL_DIAG_H ++#define MXL_DIAG_H ++ ++#include "libavutil/avassert.h" ++ ++#include ++ ++#include ++#include ++#include ++#include ++#include ++ ++typedef struct mxl_diag_server { ++ int fd; ++ char server_path[sizeof(((struct sockaddr_un *)0)->sun_path)]; ++ struct sockaddr_un client_addr; ++ socklen_t client_addr_len; ++ int has_client; ++} mxl_diag_server; ++ ++enum mxl_diag_error_type { ++ MXL_DIAG_EDATATOOSMALL = 2001, ++ MXL_DIAG_EDATATRUNCATED, ++ MXL_DIAG_ESIZETOOSMALL, ++ MXL_DIAG_ESIZETOOBIG, ++ MXL_DIAG_ESIZEMISMATCH, ++ MXL_DIAG_EMSGSIZEMISMATCH, ++ MXL_DIAG_EBADVERSION, ++ MXL_DIAG_EUNKNOWN ++}; ++ ++enum { MXL_DIAG_MSG_VERSION = 1 }; ++ ++enum mxl_diag_msg_type { ++ MXL_DIAG_MSG_UNDEFINED = 0, ++ MXL_DIAG_MSG_CONNECT, ++ MXL_DIAG_MSG_RELEASE, ++ MXL_DIAG_MSG_VIDEO_READ, ++ MXL_DIAG_MSG_AUDIO_READ, ++}; ++ ++typedef struct mxl_diag_msg { ++ struct { ++ uint16_t version; ++ uint16_t type; ++ uint16_t size; ++ uint8_t reserved[10]; ++ } header; ++ union { ++ struct { ++ uint64_t timestamp; ++ uint64_t exec_dur; ++ uint64_t tail_index; ++ uint64_t head_index; ++ uint64_t read_index; ++ uint32_t mxl_status; ++ uint8_t reserved[4]; ++ } video_read; ++ struct { ++ uint64_t timestamp; ++ uint64_t exec_dur; ++ uint64_t tail_index; ++ uint64_t head_index; ++ uint64_t read_index; ++ uint32_t read_size; ++ uint32_t mxl_status; ++ } audio_read; ++ } u; ++} mxl_diag_msg; ++ ++#define MXL_DIAG_LAYOUT_CHECK(cond, name) \ ++ typedef char mxl_diag_layout_check_##name[(cond) ? 1 : -1] ++MXL_DIAG_LAYOUT_CHECK(sizeof(((mxl_diag_msg *)0)->header) == 16, header_size_must_be_16); ++MXL_DIAG_LAYOUT_CHECK(sizeof(((mxl_diag_msg *)0)->u.video_read) == 48, video_read_size_must_be_48); ++MXL_DIAG_LAYOUT_CHECK(sizeof(((mxl_diag_msg *)0)->u.audio_read) == 48, audio_read_size_must_be_48); ++MXL_DIAG_LAYOUT_CHECK(sizeof(mxl_diag_msg) == 64, msg_size_must_be_64); ++ ++static inline void mxl_diag_init_msg_video_read( ++ mxl_diag_msg *msg, ++ uint64_t timestamp, ++ uint64_t exec_dur, ++ uint64_t tail_index, ++ uint64_t head_index, ++ uint64_t read_index, ++ mxlStatus mxl_status) ++{ ++ av_assert1(msg); ++ int size = sizeof(msg->header) + sizeof(msg->u.video_read); ++ memset(msg, 0, size); ++ msg->header.version = MXL_DIAG_MSG_VERSION; ++ msg->header.type = MXL_DIAG_MSG_VIDEO_READ; ++ msg->header.size = size; ++ msg->u.video_read.timestamp = timestamp; ++ msg->u.video_read.exec_dur = exec_dur; ++ msg->u.video_read.tail_index = tail_index; ++ msg->u.video_read.head_index = head_index; ++ msg->u.video_read.read_index = read_index; ++ msg->u.video_read.mxl_status = mxl_status; ++} ++ ++static inline void mxl_diag_init_msg_audio_read( ++ mxl_diag_msg *msg, ++ uint64_t timestamp, ++ uint64_t exec_dur, ++ uint64_t tail_index, ++ uint64_t head_index, ++ uint64_t read_index, ++ uint32_t read_size, ++ mxlStatus mxl_status) ++{ ++ av_assert1(msg); ++ int size = sizeof(msg->header) + sizeof(msg->u.audio_read); ++ memset(msg, 0, size); ++ msg->header.version = MXL_DIAG_MSG_VERSION; ++ msg->header.type = MXL_DIAG_MSG_AUDIO_READ; ++ msg->header.size = size; ++ msg->u.audio_read.timestamp = timestamp; ++ msg->u.audio_read.exec_dur = exec_dur; ++ msg->u.audio_read.tail_index = tail_index; ++ msg->u.audio_read.head_index = head_index; ++ msg->u.audio_read.read_index = read_index; ++ msg->u.audio_read.read_size = read_size; ++ msg->u.audio_read.mxl_status = mxl_status; ++} ++ ++/** ++ * Initialize a diagnostic server socket. ++ * ++ * Creates a Unix domain datagram socket at `server_path`, configures it ++ * non-blocking, and prepares it to accept registration from a single client. ++ * If a stale socket file already exists at `server_path`, it is unlinked ++ * before bind. If a non-socket filesystem entry exists there, initialization ++ * fails. ++ * ++ * @param logctx Log context for `av_log()`. May be NULL. ++ * @param server Server object to initialize. ++ * @param server_path Filesystem path for the server Unix datagram socket. ++ * ++ * @return 0 on success, or a negative AVERROR(...) code on failure. ++ */ ++int mxl_diag_init(void *logctx, ++ mxl_diag_server *server, ++ const char *server_path); ++ ++/** ++ * Close a diagnostic server socket and release its resources. ++ * ++ * Closes the server socket, forgets any registered client, and unlinks the ++ * server socket pathname if one is present. ++ * ++ * @param logctx Log context for `av_log()`. May be NULL. ++ * @param server Server object to close. ++ */ ++void mxl_diag_close(void *logctx, ++ mxl_diag_server *server); ++ ++/** ++ * Poll the inbound socket and process at most one datagram. ++ * ++ * Transient errors EAGAIN, EWOULDBLOCK, and EINTR are ignored and ++ * return zero. Other errors are returned as negative AVERROR(...) ++ * values wrapping either a mxl_diag_error_type code or a system ++ * error code. ++ * ++ * @param logctx Log context for `av_log()`. May be NULL. ++ * @param server Server object to poll. ++ * @return 0 If no datagram is available ++ * @return 1 A datagram was processed ++ * @return <0 A malformed-message or system error occurred ++ */ ++int mxl_diag_poll(void *logctx, mxl_diag_server *server); ++ ++/** ++ * Send one diagnostic message to the currently registered client. ++ * ++ * The message size is taken from msg->header.size and transmitted as-is. ++ * The caller must ensure that the message header is initialized correctly ++ * and that header.size reflects the number of bytes to send. ++ * ++ * If no client is currently registered, the message is silently ++ * dropped and 0 is returned. ++ * ++ * Call mxl_diag_poll() periodically to process client connect and ++ * release messages. ++ * ++ * If the client appears to have gone away, its registration is ++ * cleared. ++ * ++ * System error codes ENOENT and ECONNREFUSED are handled, clear the ++ * registered client, and return 0. Transient errors EAGAIN, ++ * EWOULDBLOCK, and EINTR are ignored and return zero. Any other ++ * system error is returned as a negative AVERROR(...) code. ++ * ++ * @param logctx Log context for `av_log()`. May be NULL. ++ * @param server Diagnostic server state. ++ * @param msg Message to send. Must not be NULL. ++ * ++ * @return 1 On successful send. ++ * @return 0 If no client is registered, or if a transient send error ++ * occurred that was handled ++ * @return <0 A send error occurred ++ */ ++int mxl_diag_send(void *logctx, mxl_diag_server *server, const mxl_diag_msg *msg); ++ ++#endif /* MXL_DIAG_H */ +diff --git a/libavformat/mxl_dump.c b/libavformat/mxl_dump.c +new file mode 100644 +index 0000000000..976ea7d4e3 +--- /dev/null ++++ b/libavformat/mxl_dump.c +@@ -0,0 +1,92 @@ ++/* ++ * MXL raw sample dump for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#include "mxl_dump.h" ++ ++#include ++#include ++#include ++ ++int mxl_dump_init_audio_writer(mxl_dump_audio_writer *writer, const char *path) ++{ ++ int fd; ++ ++ if (!writer || !path) ++ return EINVAL; ++ ++ writer->fd = -1; ++ ++ fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); ++ if (fd < 0) ++ return errno; ++ ++ writer->fd = fd; ++ return 0; ++} ++ ++int mxl_dump_write_audio(mxl_dump_audio_writer *writer, const uint8_t *data, size_t size) ++{ ++ const uint8_t *p; ++ size_t remaining; ++ ++ if (!writer || writer->fd < 0 || (!data && size > 0)) ++ return EINVAL; ++ ++ p = data; ++ remaining = size; ++ ++ while (remaining > 0) { ++ ssize_t n = write(writer->fd, p, remaining); ++ ++ if (n < 0) { ++ if (errno == EINTR) ++ continue; ++ return errno; ++ } ++ ++ if (n == 0) ++ return EIO; ++ ++ p += n; ++ remaining -= (size_t)n; ++ } ++ ++ return 0; ++} ++ ++int mxl_dump_close_audio_writer(mxl_dump_audio_writer *writer) ++{ ++ int ret; ++ ++ if (!writer) ++ return EINVAL; ++ ++ if (writer->fd < 0) ++ return 0; ++ ++ ret = close(writer->fd); ++ writer->fd = -1; ++ ++ if (ret < 0) ++ return errno; ++ ++ return 0; ++} +diff --git a/libavformat/mxl_dump.h b/libavformat/mxl_dump.h +new file mode 100644 +index 0000000000..b556e27bb7 +--- /dev/null ++++ b/libavformat/mxl_dump.h +@@ -0,0 +1,36 @@ ++/* ++ * MXL raw sample dump for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#ifndef MXL_DUMP_H ++#define MXL_DUMP_H ++ ++#include ++#include ++ ++typedef struct mxl_dump_audio_writer { ++ int fd; ++} mxl_dump_audio_writer; ++ ++int mxl_dump_init_audio_writer(mxl_dump_audio_writer *writer, const char *path); ++int mxl_dump_write_audio(mxl_dump_audio_writer *writer, const uint8_t *data, size_t size); ++int mxl_dump_close_audio_writer(mxl_dump_audio_writer *writer); ++ ++#endif +diff --git a/libavformat/mxl_flow_def.h b/libavformat/mxl_flow_def.h +new file mode 100644 +index 0000000000..48f947342c +--- /dev/null ++++ b/libavformat/mxl_flow_def.h +@@ -0,0 +1,272 @@ ++/* ++ * MXL json flow definition for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++/* ++ * See: https://github.com/dmf-mxl/mxl ++ */ ++ ++#ifndef MXL_FLOW_DEF_H ++#define MXL_FLOW_DEF_H ++ ++#include "libavutil/mem.h" ++ ++#include ++#include ++ ++/* ++ * Sample flow definitions for reference ++{ ++ "$copyright": "SPDX-FileCopyrightText: 2025 Contributors to the Media eXchange Layer project.", ++ "$license": "SPDX-License-Identifier: Apache-2.0", ++ "description": "MXL Test Flow, 1080p29", ++ "id": "5fbec3b1-1b0f-417d-9059-8b94a47197ed", ++ "tags": { ++ "urn:x-nmos:tag:grouphint/v1.0": [ ++ "Media Function XYZ:Video" ++ ] ++ }, ++ "format": "urn:x-nmos:format:video", ++ "label": "MXL Test Flow, 1080p29", ++ "parents": [], ++ "media_type": "video/v210", ++ "grain_rate": { ++ "numerator": 30000, ++ "denominator": 1001 ++ }, ++ "frame_width": 1920, ++ "frame_height": 1080, ++ "interlace_mode": "progressive", ++ "colorspace": "BT709", ++ "components": [ ++ { ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ } ++ ] ++} ++ ++{ ++ "$copyright": "SPDX-FileCopyrightText: 2025 Contributors to the Media eXchange Layer project.", ++ "$license": "SPDX-License-Identifier: Apache-2.0", ++ "description": "MXL Audio Flow", ++ "format": "urn:x-nmos:format:audio", ++ "tags": { ++ "urn:x-nmos:tag:grouphint/v1.0": [ ++ "Media Function XYZ:Audio" ++ ] ++ }, ++ "label": "MXL Audio Flow", ++ "version": "1441812152:154331951", ++ "id": "b3bb5be7-9fe9-4324-a5bb-4c70e1084449", ++ "media_type": "audio/float32", ++ "sample_rate": { ++ "numerator": 48000 ++ }, ++ "channel_count": 2, ++ "bit_depth": 32, ++ "parents": [], ++ "source_id": "2aa143ac-0ab7-4d75-bc32-5c00c13d186f", ++ "device_id": "169feb2c-3fae-42a5-ae2e-f6f8cbce29cf" ++} ++*/ ++ ++typedef struct VideoFlowDefParams { ++ const char *id; ++ int grain_rate_num; ++ int grain_rate_den; ++ int frame_width; ++ int frame_height; ++ int y_width, y_height, y_bit_depth; ++ int cb_width, cb_height, cb_bit_depth; ++ int cr_width, cr_height, cr_bit_depth; ++} VideoFlowDefParams; ++ ++static const char *video_flow_def_fmt = ++"{\n" ++" \"description\": \"FFmpeg video stream\",\n" ++" \"id\": \"%s\",\n" ++" \"tags\": {\n" ++" \"urn:x-nmos:tag:grouphint/v1.0\": [\n" ++" \"Media Function XYZ:Video\"\n" ++" ]\n" ++" },\n" ++" \"format\": \"urn:x-nmos:format:video\",\n" ++" \"label\": \"FFmpeg video stream\",\n" ++" \"parents\": [],\n" ++" \"media_type\": \"video/v210\",\n" ++" \"grain_rate\": {\n" ++" \"numerator\": %d,\n" ++" \"denominator\": %d\n" ++" },\n" ++" \"frame_width\": %d,\n" ++" \"frame_height\": %d,\n" ++" \"interlace_mode\": \"progressive\",\n" ++" \"colorspace\": \"BT709\",\n" ++" \"components\": [\n" ++" {\n" ++" \"name\": \"Y\",\n" ++" \"width\": %d,\n" ++" \"height\": %d,\n" ++" \"bit_depth\": %d\n" ++" },\n" ++" {\n" ++" \"name\": \"Cb\",\n" ++" \"width\": %d,\n" ++" \"height\": %d,\n" ++" \"bit_depth\": %d\n" ++" },\n" ++" {\n" ++" \"name\": \"Cr\",\n" ++" \"width\": %d,\n" ++" \"height\": %d,\n" ++" \"bit_depth\": %d\n" ++" }\n" ++" ]\n" ++"}\n"; ++ ++typedef struct AudioFlowDefParams { ++ const char *id; ++ int sample_rate; ++ int channel_count; ++} AudioFlowDefParams; ++ ++static const char *audio_flow_def_fmt = ++"{\n" ++" \"description\": \"FFmpeg audio stream\",\n" ++" \"id\": \"%s\",\n" ++" \"tags\": {\n" ++" \"urn:x-nmos:tag:grouphint/v1.0\": [\n" ++" \"Media Function XYZ:Audio\"\n" ++" ]\n" ++" },\n" ++" \"format\": \"urn:x-nmos:format:audio\",\n" ++" \"label\": \"FFmpeg audio stream\",\n" ++" \"parents\": [],\n" ++" \"media_type\": \"audio/float32\",\n" ++" \"sample_rate\": {\n" ++" \"numerator\": %d\n" ++" },\n" ++" \"channel_count\": %d,\n" ++" \"bit_depth\": 32\n" ++"}\n"; ++ ++/** ++ * Create an MXL video flow definition. ++ * ++ * Formats a JSON video flow definition using fields from ++ * VideoFlowDefParams p and returns a newly allocated string. The ++ * caller owns the returned string. ++ * ++ * @param p Pointer to a VideoFlowDefParams structure containing all values. ++ * @return Newly allocated null-terminated JSON string, or NULL on failure. ++ */ ++static char *make_video_flow_def(const VideoFlowDefParams *p) ++{ ++ if (!p || !p->id) ++ return NULL; ++ ++ int len = snprintf(NULL, 0, video_flow_def_fmt, ++ p->id, ++ p->grain_rate_num, p->grain_rate_den, ++ p->frame_width, p->frame_height, ++ p->y_width, p->y_height, p->y_bit_depth, ++ p->cb_width, p->cb_height, p->cb_bit_depth, ++ p->cr_width, p->cr_height, p->cr_bit_depth ++ ); ++ ++ if (len < 0) ++ return NULL; ++ ++ size_t buf_size = len+1; ++ char *buf = av_malloc(buf_size); ++ if (!buf) ++ return NULL; ++ ++ int rc = snprintf(buf, buf_size, video_flow_def_fmt, ++ p->id, ++ p->grain_rate_num, p->grain_rate_den, ++ p->frame_width, p->frame_height, ++ p->y_width, p->y_height, p->y_bit_depth, ++ p->cb_width, p->cb_height, p->cb_bit_depth, ++ p->cr_width, p->cr_height, p->cr_bit_depth ++ ); ++ if (rc < 0 || (size_t)rc >= buf_size) { ++ av_free(buf); ++ return NULL; ++ } ++ ++ return buf; ++} ++ ++/** ++ * Create an MXL audio flow definition. ++ * ++ * Formats a JSON audio flow definition using fields from ++ * AudioFlowDefParams p and returns a newly allocated string. The ++ * caller owns the returned string. ++ * ++ * @param p Pointer to an AudioFlowDefParams structure containing all values. ++ * @return Newly allocated null-terminated JSON string, or NULL on failure. ++ */ ++static char *make_audio_flow_def(const AudioFlowDefParams *p) ++{ ++ if (!p || !p->id) ++ return NULL; ++ ++ int len = snprintf(NULL, 0, audio_flow_def_fmt, ++ p->id, ++ p->sample_rate, ++ p->channel_count); ++ if (len < 0) ++ return NULL; ++ ++ size_t buf_size = len+1; ++ char *buf = av_malloc(buf_size); ++ if (!buf) ++ return NULL; ++ ++ int rc = snprintf(buf, buf_size, audio_flow_def_fmt, ++ p->id, ++ p->sample_rate, ++ p->channel_count); ++ if (rc < 0 || (size_t)rc >= buf_size) { ++ av_free(buf); ++ return NULL; ++ } ++ ++ return buf; ++} ++ ++#endif /* MXL_FLOW_DEF_H */ +diff --git a/libavformat/mxl_json.c b/libavformat/mxl_json.c +new file mode 100644 +index 0000000000..602a63dbce +--- /dev/null ++++ b/libavformat/mxl_json.c +@@ -0,0 +1,449 @@ ++/* ++ * MXL JSON parser for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++/* ++ * Wrapper around jsmn (https://github.com/zserge/jsmn). ++ * Depends on vendored jsmn v1.1.0 (see jsmn.h). ++ */ ++ ++#include "mxl_json.h" ++#include "mxl_common.h" ++#include "mxl_log.h" ++ ++#include "libavutil/error.h" ++#include "libavutil/avassert.h" ++#include "libavutil/log.h" ++#include "libavutil/mem.h" ++ ++#include ++#include ++#include ++#include ++ ++// maximum json string length to log on parse error ++enum { MAX_JSON_LOG = 256 }; ++ ++// compare jsmn string token to value ++static int is_token_name_equal(const char *js, const jsmntok_t *t, const char *s) ++{ ++ av_assert1(js); ++ av_assert1(s); ++ ++ if (!t || t->type != JSMN_STRING) ++ return 0; ++ ++ // jsmn parser invariant debug assert and release safety check ++ av_assert1(t->end >= t->start); ++ if (t->end < t->start) ++ return 0; ++ ++ size_t tok_len = (size_t)(t->end - t->start); ++ ++ return strlen(s) == tok_len && ++ strncmp(js + t->start, s, tok_len) == 0; ++} ++ ++static unsigned int last_token_of_subtree(const jsmntok_t *tokens, unsigned int ntok, ++ unsigned int node_idx, unsigned int depth); ++ ++// return the last jsmn token of an object ++static unsigned int last_token_of_object(const jsmntok_t *tokens, unsigned int ntok, ++ unsigned int node_idx, unsigned int depth) ++{ ++ if (node_idx >= ntok || depth > MXL_JSON_MAX_DEPTH) ++ return ntok; ++ ++ jsmntok_t t = tokens[node_idx]; ++ ++ // type sanity ++ av_assert0(t.type == JSMN_OBJECT); ++ ++ int num_children = t.size; ++ for (int child = 0; child < num_children; child++) { ++ // skip ahead to the key ++ node_idx++; ++ ++ // node_idx sanity ++ av_assert0(node_idx < ntok); ++ ++ // key type sanity ++ av_assert0(tokens[node_idx].type == JSMN_STRING); ++ ++ // jump to end of value ++ node_idx = last_token_of_subtree(tokens, ntok, ++ node_idx + 1, ++ depth+1); ++ // depth guard ++ if (node_idx >= ntok) ++ return ntok; ++ } ++ ++ return node_idx; ++} ++ ++// return the last jsmn token of an array ++static unsigned int last_token_of_array(const jsmntok_t *tokens, unsigned int ntok, ++ unsigned int node_idx, unsigned int depth) ++{ ++ if (node_idx >= ntok || depth > MXL_JSON_MAX_DEPTH) ++ return ntok; ++ ++ jsmntok_t t = tokens[node_idx]; ++ ++ // type sanity ++ av_assert0(t.type == JSMN_ARRAY); ++ ++ int num_children = t.size; ++ for (int child = 0; child < num_children; child++) { ++ // jump to end of value ++ node_idx = last_token_of_subtree(tokens, ntok, ++ node_idx + 1, ++ depth+1); ++ // depth guard ++ if (node_idx >= ntok) ++ return ntok; ++ } ++ ++ return node_idx; ++} ++ ++// return the index of the last jsmn token of a json subtree ++static unsigned int last_token_of_subtree(const jsmntok_t *tokens, unsigned int ntok, ++ unsigned int node_idx, unsigned int depth) ++{ ++ if (node_idx >= ntok || depth > MXL_JSON_MAX_DEPTH) ++ return ntok; ++ ++ jsmntok_t t = tokens[node_idx]; ++ ++ switch (t.type) { ++ case JSMN_OBJECT: ++ return last_token_of_object(tokens, ntok, node_idx, depth); ++ case JSMN_ARRAY: ++ return last_token_of_array(tokens, ntok, node_idx, depth); ++ case JSMN_STRING: ++ case JSMN_PRIMITIVE: ++ return node_idx; ++ default: ++ av_assert0(!"unknown jsmn token type"); ++ return ntok; ++ } ++} ++ ++// jsmn token to string value with special case json null detection ++static mxl_json_str jsmn_tok_to_string(const char *json, const jsmntok_t *t) ++{ ++ if (!t) { ++ return (mxl_json_str){ .value = NULL, .err = MXL_JSON_EBADTYPE }; ++ } ++ ++ // jsmn parser invariant debug check and release safety check ++ av_assert1(t->end >= t->start); ++ if (t->end < t->start) ++ return (mxl_json_str){ .value = NULL, .err = MXL_JSON_EINTERNAL }; ++ ++ if (t->type != JSMN_STRING) ++ if (t->type == JSMN_PRIMITIVE && ++ t->end - t->start == 4 && ++ !strncmp(json + t->start, "null", 4)) ++ return (mxl_json_str){ .value = NULL, .err = MXL_JSON_NULL }; ++ else ++ return (mxl_json_str){ .value = NULL, .err = MXL_JSON_EBADTYPE }; ++ ++ size_t len = t->end - t->start; ++ char *s = (char *)av_malloc(len + 1); ++ if (!s) ++ return (mxl_json_str){ .value = NULL, .err = ENOMEM }; ++ memcpy(s, json + t->start, len); ++ s[len] = '\0'; ++ ++ return (mxl_json_str){ .value = s, .err = 0 }; ++} ++ ++// jsmn token to double value with null, infinity, and strtod errno detection ++static mxl_json_num jsmn_tok_to_double(const char *json, const jsmntok_t *t) ++{ ++ if (!t || t->type != JSMN_PRIMITIVE) ++ return (mxl_json_num){.value = NAN, .err = MXL_JSON_EBADTYPE}; ++ ++ // jsmn parser invariant debug check and release safety check ++ av_assert1(t->end >= t->start); ++ if (t->end < t->start) ++ return (mxl_json_num){.value = NAN, .err = MXL_JSON_EINTERNAL}; ++ ++ char buf[MXL_JSON_MAX_NUMBER_CHARS+1]; ++ size_t len = t->end - t->start; ++ if (len >= sizeof(buf)) ++ return (mxl_json_num){ .value = NAN, .err = MXL_JSON_UNSUPPORTED}; ++ memcpy(buf, json + t->start, len); ++ buf[len] = '\0'; ++ ++ errno = 0; ++ char *endptr = NULL; ++ double v = strtod(buf, &endptr); ++ ++ // strtod() endptr no conversion case: detect attempts to convert ++ // null, boolean, or invalid jsmn primitives. ++ if (buf == endptr) ++ if (len == 4 && !strncmp(buf, "null", 4)) ++ return (mxl_json_num){ .value = NAN, .err = MXL_JSON_NULL }; ++ else ++ return (mxl_json_num){.value = NAN, .err = MXL_JSON_EBADTYPE}; ++ ++ // strtod() incomplete conversion, general case, indicating a ++ // malformed number ++ if ((size_t)(endptr - buf) != len) ++ return (mxl_json_num){.value = NAN, .err = MXL_JSON_MALFORMED}; ++ ++ // strtod errno cases ++ if (errno == ERANGE) ++ return (mxl_json_num){ .value = v, .err = ERANGE }; ++ // safety catch-all, shouldn't happen with post C99 strtod() ++ else if (errno) ++ return (mxl_json_num){ .value = NAN, .err = errno }; ++ ++ // nan/inf error flag ++ if (isnan(v) || isinf(fabs(v))) ++ return (mxl_json_num){.value = v, .err = MXL_JSON_EBADTYPE}; ++ ++ return (mxl_json_num){ .value = v, .err = 0 }; ++} ++ ++// jsmn token and its index together ++typedef struct token_idx_pair { ++ const jsmntok_t *tok; ++ const unsigned int node_idx; ++} token_idx_pair; ++ ++// json path follower, recursively descends object tree ++static token_idx_pair ++find_path_rec(const char *json, const jsmntok_t *tokens, unsigned int ntok, ++ unsigned int node_idx, const char **keys, unsigned int nkeys, ++ unsigned int depth) ++{ ++ // safety limits enforce ++ if (depth > MXL_JSON_MAX_DEPTH || node_idx >= ntok) ++ return (token_idx_pair){0,ntok}; ++ ++ // key exhaustion means we've found the token ++ if (nkeys == 0) ++ return (token_idx_pair){&tokens[node_idx], node_idx}; ++ ++ // cannot descend non object types ++ if (tokens[node_idx].type != JSMN_OBJECT) ++ return (token_idx_pair){0,ntok}; ++ ++ // iterate over object's children to find the named child ++ int num_children = tokens[node_idx].size; ++ unsigned int child_node_idx = node_idx+1; ++ for (int child = 0; child < num_children; child++) { ++ ++ // child_node_idx sanity ++ av_assert0(child_node_idx < ntok); ++ ++ const jsmntok_t *child_tok = &tokens[child_node_idx]; ++ ++ // keys[0] sanity ++ av_assert0(keys[0]); ++ ++ // is this the named child we are looking for? ++ if (is_token_name_equal(json, child_tok, keys[0])) { ++ // descend into the child to find the next key ++ return find_path_rec(json, tokens, ntok, ++ child_node_idx+1, ++ keys + 1, nkeys - 1, depth + 1); ++ } ++ ++ // else jump past the child's contained tokens ++ child_node_idx = last_token_of_subtree(tokens, ntok, child_node_idx+1, ++ depth + 1) + 1; ++ ++ // did we step past end? (because depth limit exceeded) ++ if (child_node_idx >= ntok) ++ break; ++ } ++ ++ // didn't find it ++ return (token_idx_pair){0, ntok}; ++} ++ ++// varargs adapter, build a key array and call the recursive finder ++static token_idx_pair find_path_va(const mxl_json_doc *doc, unsigned int nkeys, ++ va_list ap) ++{ ++ // doc and nkeys sanity ++ av_assert0(doc && doc->tokens && 0 < nkeys && nkeys <= MXL_JSON_MAX_DEPTH); ++ ++ const char *keys[MXL_JSON_MAX_DEPTH]; ++ for (unsigned int i = 0; i < nkeys; i++) ++ keys[i] = va_arg(ap, const char *); ++ ++ return find_path_rec(doc->json, doc->tokens, doc->ntok, 0, keys, nkeys, 0); ++} ++ ++// ++// public api ++// ++ ++int mxl_json_doc_build2(void* logctx, ++ const char *json, mxl_json_doc *out, ++ const unsigned int initial_token_capacity, ++ const unsigned int max_token_capacity) ++{ ++ if (!json || !out) ++ return AVERROR(EINVAL); ++ ++ if (0 == initial_token_capacity || initial_token_capacity > max_token_capacity) ++ return AVERROR(EINVAL); ++ ++ size_t json_len = strlen(json); ++ jsmn_parser p; ++ jsmntok_t *tokens = NULL; ++ unsigned int token_capacity = initial_token_capacity; ++ int rc; ++ ++ jsmn_init(&p); ++ ++ for (;;) { ++ ++ if (token_capacity > max_token_capacity) { ++ loge(logctx, "JSON token cap (%u) exceeded\n", ++ max_token_capacity); ++ av_free(tokens); ++ return AVERROR(ENOMEM); ++ } ++ ++ size_t token_buf_size = (size_t)token_capacity * sizeof(*tokens); ++ ++ // size_t overflow guard ++ if (token_buf_size/token_capacity != sizeof(*tokens)) { ++ av_free(tokens); ++ return AVERROR(ENOMEM); ++ } ++ ++ jsmntok_t *new_tokens = (jsmntok_t *)av_realloc(tokens, token_buf_size); ++ if (!new_tokens) { ++ av_free(tokens); ++ return AVERROR(ENOMEM); ++ } ++ tokens = new_tokens; ++ ++ rc = jsmn_parse(&p, json, json_len, tokens, token_capacity); ++ ++ if (JSMN_ERROR_NOMEM == rc) { ++ // paranoid double; force cap check if near limit ++ if (token_capacity > max_token_capacity / 2u) { ++ // will fail cap check on next loop ++ token_capacity = max_token_capacity + 1u; ++ } else { ++ token_capacity *= 2u; ++ } ++ continue; ++ } ++ ++ break; ++ } ++ ++ if (rc < 0) { ++ loge(logctx, "JSON failed to parse: >>%.*s%s<<\n", ++ MAX_JSON_LOG, json, json_len > MAX_JSON_LOG ? "..." : ""); ++ ++ av_free(tokens); ++ return AVERROR_INVALIDDATA; ++ } ++ ++ out->json = json; ++ out->tokens = tokens; ++ out->ntok = (unsigned int)rc; ++ ++ return 0; ++} ++ ++void mxl_json_doc_release(mxl_json_doc *doc) ++{ ++ if (!doc) ++ return; ++ ++ av_free(doc->tokens); ++ *doc = (mxl_json_doc){0}; ++} ++ ++mxl_json_str mxl_json_doc_get_stringv(const mxl_json_doc *doc, unsigned int nkeys, ++ va_list ap) ++{ ++ if (!doc || !doc->tokens) ++ return (mxl_json_str){ .value = NULL, .err = MXL_JSON_ENOTFOUND }; ++ ++ if (nkeys > MXL_JSON_MAX_DEPTH) ++ return (mxl_json_str){ .value = NULL, .err = MXL_JSON_UNSUPPORTED }; ++ ++ token_idx_pair t = find_path_va(doc, nkeys, ap); ++ if (!t.tok) ++ return (mxl_json_str){ .value = NULL, .err = MXL_JSON_ENOTFOUND }; ++ ++ return jsmn_tok_to_string(doc->json, t.tok); ++} ++ ++mxl_json_str mxl_json_doc_get_string(const mxl_json_doc *doc, unsigned int nkeys, ++ ...) ++{ ++ va_list ap; ++ va_start(ap, nkeys); ++ mxl_json_str str = mxl_json_doc_get_stringv(doc, nkeys, ap); ++ va_end(ap); ++ ++ return str; ++} ++ ++mxl_json_num mxl_json_doc_get_doublev(const mxl_json_doc *doc, unsigned int nkeys, ++ va_list ap) ++{ ++ if (!doc || !doc->tokens) ++ return (mxl_json_num){ .value = NAN, .err = MXL_JSON_ENOTFOUND }; ++ ++ if (nkeys > MXL_JSON_MAX_DEPTH) ++ return (mxl_json_num){ .value = NAN, .err = MXL_JSON_UNSUPPORTED }; ++ ++ token_idx_pair t = find_path_va(doc, nkeys, ap); ++ if (!t.tok) ++ return (mxl_json_num){ .value = NAN, .err = MXL_JSON_ENOTFOUND }; ++ ++ // Token look ahead to validate json formation, only one primitive ++ // should follow an object key. ++ if (t.tok->type == JSMN_PRIMITIVE && ++ t.node_idx+1 < doc->ntok && ++ doc->tokens[t.node_idx+1].type == JSMN_PRIMITIVE) { ++ return (mxl_json_num){ .value = NAN, .err = MXL_JSON_MALFORMED}; ++ } ++ ++ return jsmn_tok_to_double(doc->json, t.tok); ++} ++ ++mxl_json_num mxl_json_doc_get_double(const mxl_json_doc *doc, unsigned int nkeys, ++ ...) ++{ ++ va_list ap; ++ va_start(ap, nkeys); ++ mxl_json_num num = mxl_json_doc_get_doublev(doc, nkeys, ap); ++ va_end(ap); ++ ++ return num; ++} +diff --git a/libavformat/mxl_json.h b/libavformat/mxl_json.h +new file mode 100644 +index 0000000000..4781ba02e7 +--- /dev/null ++++ b/libavformat/mxl_json.h +@@ -0,0 +1,225 @@ ++/* ++ * MXL JSON parser for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++/* ++ * Wrapper around jsmn (https://github.com/zserge/jsmn). ++ * Depends on vendored jsmn v1.1.0 (see jsmn.h). ++ */ ++ ++#ifndef AVFORMAT_MXL_JSON_H ++#define AVFORMAT_MXL_JSON_H ++ ++// jsmn header file ++// see: https://github.com/zserge/jsmn/blob/v1.1.0/README.md#usage ++#define JSMN_HEADER ++#include "jsmn.h" ++ ++#include ++ ++/** ++ * Parsed JSON document wrapper. ++ * ++ * Produced by mxl_json_doc_build(). ++ * Released by mxl_json_doc_release() to free the token array. ++ * ++ * Fields: ++ * - json : Pointer to the original NUL-terminated JSON buffer. ++ * Owned by the caller; must remain valid while the doc is used. ++ * - tokens : Heap-allocated array of jsmntok_t tokens. ++ * - ntok : Number of tokens in tokens[] array. ++ */ ++typedef struct { ++ const char *json; ++ jsmntok_t *tokens; ++ unsigned int ntok; ++} mxl_json_doc; ++ ++/** ++ * mxl_json specific error codes ++ * ++ * These are distinct from system values and are returned in ++ * the .err field of json_num/json_str results. ++ */ ++enum MXL_JSON_ERROR { ++ MXL_JSON_EBADTYPE = 2001, // wrong token type ++ MXL_JSON_ENOTFOUND, // lookup failed, key path not present ++ MXL_JSON_NULL, // null primitive found where value requested ++ MXL_JSON_MALFORMED, // malformed json structure ++ MXL_JSON_UNSUPPORTED, // unsupported json, ++ MXL_JSON_EINTERNAL // unspecified internal error ++}; ++ ++/** ++ * Implementation limits ++ */ ++enum MXL_JSON_LIMITS { ++ // limit imposed on object depth and key path length ++ MXL_JSON_MAX_DEPTH = 16, ++ // limit imposed on the character count in a json number ++ MXL_JSON_MAX_NUMBER_CHARS = 127, ++ // the default token capacity used by mxl_json_doc_build ++ MXL_JSON_INITIAL_TOKEN_CAPACITY_DEFAULT = 128, ++ // default maximum token capacity ++ MXL_JSON_TOKEN_CAPACITY_MAX_DEFAULT = 8192 ++}; ++ ++/** ++ * Result of parsing a JSON token as a double. ++ * ++ * @field value Parsed numeric value. ++ * @field err Status code: ++ * 0 - success ++ * EINVAL - token not numeric or conversion failed ++ * ERANGE - overflow or underflow ++ * MXL_JSON_* - an MXL_JSON_ERROR ++ */ ++typedef struct { ++ double value; ++ int err; ++} mxl_json_num; ++ ++/** ++ * Result of parsing a JSON token as a string. The value pointer is ++ * heap allocated and owned by the caller. ++ * ++ * @field value Parsed string value. ++ * @field err Status code: ++ * 0 - success ++ * EINVAL - token is not a string ++ * ENOMEM - allocation failed ++ * MXL_JSON_* - an MXL_JSON_ERROR ++ */ ++typedef struct { ++ char *value; ++ int err; ++} mxl_json_str; ++ ++/** ++ * See @mxl_json_doc_build(). ++ * ++ * @initial_token_capacity is the initial size of the json token array ++ * allocated for @out. The capacity of the token array is doubled as ++ * necessary until it is sufficiently large to parse @json. Value of ++ * zero returns AVERROR(EINVAL). ++ * ++ * @max_token_capacity is a safety limit on the size of tokens ++ * allocated to store the parsed @json. It must be >= ++ * @initial_token_capacity. ++ * ++ */ ++int mxl_json_doc_build2(void* logctx, ++ const char *json, mxl_json_doc *out, ++ unsigned int initial_token_capacity, ++ unsigned int max_token_capacity); ++ ++/** ++ * Parse a JSON string into a json_doc. ++ * ++ * The caller retains ownership of @json, and it must remain ++ * valid. The token array is owned by the mxl_json_doc instance. ++ * ++ * The maximum json document size is limited by ++ * MXL_JSON_TOKEN_CAPACITY_MAX_DEFAULT. Use @mxl_json_doc_build2 to ++ * increase the maximum token capacity. ++ * ++ * @param json Raw JSON buffer (NUL-terminated). ++ * @param out Output json_doc (filled on success). ++ * @return 0 on success, or error code ++ * AVERROR(EINVAL) if args are invalid, ++ * AVERROR(ENOMEM) on token capacity over limit, or allocation failure ++ * AVERROR(EIO) on parse error (other than NOMEM). ++ */ ++static inline int mxl_json_doc_build(void* logctx, const char *json, mxl_json_doc *out) ++{ ++ return mxl_json_doc_build2(logctx, json, out, ++ MXL_JSON_INITIAL_TOKEN_CAPACITY_DEFAULT, ++ MXL_JSON_TOKEN_CAPACITY_MAX_DEFAULT); ++} ++ ++/** ++ * Release resource allocated by @mxl_json_doc_build and clear @doc. ++ */ ++void mxl_json_doc_release(mxl_json_doc *doc); ++ ++/** ++ * Look up a JSON key path and return its string value. ++ * ++ * Traverse the tokens in @doc along the specified sequence key ++ * path. If the path resolves to a string, then a copy of its value is ++ * returned in .value with .err == 0. The caller owns .value and is ++ * responsible for freeing it. ++ * ++ * Errors: ++ * - MXL_JSON_ENOTFOUND : key path not found or @doc is invalid ++ * - MXL_JSON_EBADTYPE : token at path was not string ++ * - MXL_JSON_NULL : token at path was json null, .value is null ++ * - MXL_JSON_UNSUPPORTED : token at path exceeds depth limit ++ * - ENOMEM : allocation failed ++ * ++ * @param doc JSON document (from json_doc_build()). ++ * @param nkeys Number of keys in the path. ++ * @param ap Sequence of (const char*) keys to descend. ++ * @return mxl_json_str with string in .value, and .err status. ++ */ ++mxl_json_str mxl_json_doc_get_stringv(const mxl_json_doc *doc, unsigned int nkeys, va_list ap); ++mxl_json_str mxl_json_doc_get_string(const mxl_json_doc *doc, unsigned int nkeys, ...); ++ ++/** ++ * Look up a JSON key path and return its numeric value. ++ * ++ * Traverse the tokens in @doc along the specified sequence key ++ * path. If the path resolves to a number, then its value is returned ++ * in .value with .err == 0. ++ * ++ * Error: ++ * - MXL_JSON_ENOTFOUND : key path not found or @doc invalid ++ * - MXL_JSON_EBADTYPE : token at path was not numeric ++ * - MXL_JSON_NULL : token at path was json null, .value is NaN ++ * - MXL_JSON_MALFORMED : token at path was a malformed number ++ * - MXL_JSON_UNSUPPORTED : token path length exceeds depth limit ++ * - ERANGE : overflow or underflow during conversion ++ * ++ * @param doc Parsed JSON document (from mxl_json_doc_build()). ++ * @param nkeys Number of keys in the path. ++ * @param ap Sequence of (const char*) keys to descend. ++ * @return mxl_json_num with numeric value in .value, and .err status. ++ */ ++mxl_json_num mxl_json_doc_get_doublev(const mxl_json_doc *doc, unsigned int nkeys, va_list ap); ++mxl_json_num mxl_json_doc_get_double(const mxl_json_doc *doc, unsigned int nkeys, ...); ++ ++/* Fixed-arity convenience wrappers */ ++static inline mxl_json_num mxl_json_doc_get_double1(const mxl_json_doc *doc, ++ const char *k1) ++{ return mxl_json_doc_get_double(doc, 1, k1); } ++ ++static inline mxl_json_num mxl_json_doc_get_double2(const mxl_json_doc *doc, ++ const char *k1, const char *k2) ++{ return mxl_json_doc_get_double(doc, 2, k1, k2); } ++ ++static inline mxl_json_str mxl_json_doc_get_string1(const mxl_json_doc *doc, ++ const char *k1) ++{ return mxl_json_doc_get_string(doc, 1, k1); } ++ ++static inline mxl_json_str mxl_json_doc_get_string2(const mxl_json_doc *doc, ++ const char *k1, const char *k2) ++{ return mxl_json_doc_get_string(doc, 2, k1, k2); } ++ ++#endif /* AVFORMAT_MXL_JSON_H */ +diff --git a/libavformat/mxl_loc.c b/libavformat/mxl_loc.c +new file mode 100644 +index 0000000000..f7fd6e06b8 +--- /dev/null ++++ b/libavformat/mxl_loc.c +@@ -0,0 +1,217 @@ ++/* ++ * MXL domain and flow locator for Media eXchange Layer flows ++ * ++ * AVFMT_NOFILE demuxer; requires "/path/to/domain/.mxl-flow" ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#include "mxl_loc.h" ++#include "mxl_uri.h" ++#include "mxl_log.h" ++#include "mxl_common.h" ++ ++#include "libavutil/error.h" ++#include "libavutil/avassert.h" ++#include "libavutil/avstring.h" ++#include "libavutil/mem.h" ++#include "libavformat/avformat.h" ++ ++#include ++/** ++ * Extract the directory from a path of the form ++ * "/path/to/domain/.mxl-flow". ++ * ++ * Returns a newly allocated string or null if the path contains no ++ * usable domain component. Caller must free the returned string. ++ */ ++static char *extract_domain_from_path(const char *path) ++{ ++ if (NULL == path) ++ return NULL; ++ ++ char *tmp = av_strdup(path); ++ if (NULL == tmp) ++ return NULL; ++ ++ const char* dir = av_dirname(tmp); ++ av_assert0(dir); ++ ++ // av_dirname special case ++ if (*dir == '\0') ++ dir = "/"; ++ ++ // stable copy ++ char *out = av_strdup(dir); ++ av_free(tmp); ++ ++ return out; ++} ++ ++/** ++ * Extract the id from a path of the form "/path/to/domain/.mxl-flow". ++ * ++ * Returns a newly allocated string or NULL if the path has no usable ++ * id component. Caller must free the returned string. ++ */ ++static char *extract_flowid_from_path(const char *path) ++{ ++ if (NULL == path) ++ return NULL; ++ ++ const char *base = av_basename(path); ++ av_assert0(base); ++ ++ const char *ext = MXL_DOT_FLOW_EXT; ++ size_t ext_len = sizeof(MXL_DOT_FLOW_EXT) - 1; ++ size_t base_len = strlen(base); ++ ++ if (base_len <= ext_len) ++ return NULL; ++ ++ if (strcmp(base + base_len - ext_len, ext) != 0) ++ return NULL; ++ ++ size_t id_len = base_len - ext_len; ++ av_assert1(id_len > 0); ++ ++ char *id = av_malloc(id_len + 1); ++ if (NULL == id) ++ return NULL; ++ ++ memcpy(id, base, id_len); ++ id[id_len] = '\0'; ++ ++ return id; ++} ++ ++int mxl_loc_parse(void* logctx, const char *locator, mxl_loc *loc) ++{ ++ if (NULL == locator || NULL == loc) ++ return AVERROR(EINVAL); ++ ++ if (*locator == '\0') ++ return AVERROR(ENOENT); ++ ++ char *domain_path = NULL; ++ char **flow_ids = NULL; ++ int nb_flow_ids = 0; ++ mxl_uri uri = {0}; ++ int exit_status = AVERROR(EINVAL); ++ ++ // URI case ++ if (mxl_uri_is_mxl_scheme(locator)) { ++ int parse_rc = mxl_parse_uri(logctx, locator, &uri); ++ if (parse_rc) { ++ logv(logctx, "LOC URI scheme found but parse failed: \"%s\"\n", locator); ++ exit_status = parse_rc; ++ goto finally; ++ } ++ ++ av_assert0(uri.host); ++ if (uri.host[0] != '\0') { ++ logv(logctx, "LOC URI host not supported: \"%s\"\n", locator); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ // take ownership of URI domain ++ av_assert0(uri.domain); ++ domain_path = uri.domain; ++ uri.domain = NULL; ++ ++ // take ownership of URI flow_ids array ++ nb_flow_ids = uri.nb_flow_ids; ++ flow_ids = uri.flow_ids; ++ uri.flow_ids = NULL; ++ uri.nb_flow_ids = 0; ++ } ++ // filesystem path case ++ else if (av_match_ext(locator, MXL_FLOW_EXT)) { ++ domain_path = extract_domain_from_path(locator); ++ if (NULL == domain_path) { ++ logv(logctx, "LOC URI failed to extract domain\n"); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ char *flow_id = extract_flowid_from_path(locator); ++ if (NULL == flow_id) { ++ logv(logctx, "LOC URI failed to extract MXL flow id\n"); ++ exit_status = AVERROR(EINVAL);; ++ goto finally; ++ } ++ if (!mxl_uri_is_valid_uuid(flow_id)) { ++ logv(logctx, "LOC URI flow ID is not a valid uuid: %s\n", flow_id); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ // transfer ownership of flow_id to flow_ids array ++ nb_flow_ids = 1; ++ flow_ids = av_malloc_array(nb_flow_ids, sizeof(*flow_ids)); ++ if (NULL == flow_ids) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ flow_ids[0] = flow_id; ++ flow_id = NULL; ++ } ++ else { ++ domain_path = av_strdup(locator); ++ nb_flow_ids = 0; ++ flow_ids = NULL; ++ } ++ ++ // loc takes ownerhsip of domain_path and flow_ids ++ loc->domain_path = domain_path; ++ domain_path = NULL; ++ loc->nb_flow_ids = nb_flow_ids; ++ loc->flow_ids = flow_ids; ++ flow_ids = NULL; ++ ++ exit_status = 0; ++ ++finally: ++ ++ // no execution path will leave flow_ids set ++ av_assert0(NULL == flow_ids); ++ ++ av_free(domain_path); ++ mxl_uri_free(&uri); ++ ++ if (exit_status) { ++ memset(loc, 0, sizeof(*loc)); ++ } ++ ++ return exit_status; ++} ++ ++void mxl_loc_free(mxl_loc *loc) ++{ ++ if (NULL == loc) ++ return; ++ ++ av_free(loc->domain_path); ++ ++ for (int i = 0; i < loc->nb_flow_ids; i++) ++ av_free(loc->flow_ids[i]); ++ av_free(loc->flow_ids); ++ ++ memset(loc, 0, sizeof(*loc)); ++} +diff --git a/libavformat/mxl_loc.h b/libavformat/mxl_loc.h +new file mode 100644 +index 0000000000..19e963f8d8 +--- /dev/null ++++ b/libavformat/mxl_loc.h +@@ -0,0 +1,42 @@ ++/* ++ * MXL domain and flow locator for Media eXchange Layer flows ++ * ++ * AVFMT_NOFILE demuxer; requires "/path/to/domain/.mxl-flow" ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#ifndef AVFORMAT_MXL_LOC_H ++#define AVFORMAT_MXL_LOC_H ++ ++typedef struct mxl_loc { ++ char *domain_path; ++ char **flow_ids; ++ int nb_flow_ids; ++} mxl_loc; ++ ++/** ++ * domain must exist ++ * host must not exist ++ * one or more flow IDs must exist ++ */ ++int mxl_loc_parse(void *logctx, const char *input, mxl_loc *loc); ++ ++void mxl_loc_free(mxl_loc *loc); ++ ++#endif /* AVFORMAT_MXL_LOC_H */ +diff --git a/libavformat/mxl_log.h b/libavformat/mxl_log.h +new file mode 100644 +index 0000000000..2823861d6c +--- /dev/null ++++ b/libavformat/mxl_log.h +@@ -0,0 +1,67 @@ ++/* ++ * MXL logging for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#ifndef AVFORMAT_MXL_LOG_H ++#define AVFORMAT_MXL_LOG_H ++ ++#include "libavutil/log.h" ++ ++static inline void logd(const void *avcl, const char *fmt, ...) ++{ ++ va_list ap; ++ va_start(ap, fmt); ++ av_vlog((void *)avcl, AV_LOG_DEBUG, fmt, ap); ++ va_end(ap); ++} ++ ++static inline void logv(const void *avcl, const char *fmt, ...) ++{ ++ va_list ap; ++ va_start(ap, fmt); ++ av_vlog((void *)avcl, AV_LOG_VERBOSE, fmt, ap); ++ va_end(ap); ++} ++ ++static inline void logi(const void *avcl, const char *fmt, ...) ++{ ++ va_list ap; ++ va_start(ap, fmt); ++ av_vlog((void *)avcl, AV_LOG_INFO, fmt, ap); ++ va_end(ap); ++} ++ ++static inline void logw(const void *avcl, const char *fmt, ...) ++{ ++ va_list ap; ++ va_start(ap, fmt); ++ av_vlog((void *)avcl, AV_LOG_WARNING, fmt, ap); ++ va_end(ap); ++} ++ ++static inline void loge(const void *avcl, const char *fmt, ...) ++{ ++ va_list ap; ++ va_start(ap, fmt); ++ av_vlog((void *)avcl, AV_LOG_ERROR, fmt, ap); ++ va_end(ap); ++} ++ ++#endif /* AVFORMAT_MXL_LOG_H */ +diff --git a/libavformat/mxl_status.h b/libavformat/mxl_status.h +new file mode 100644 +index 0000000000..8186328f48 +--- /dev/null ++++ b/libavformat/mxl_status.h +@@ -0,0 +1,59 @@ ++/* ++ * MXL status functions for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#ifndef AVFORMAT_MXL_STATUS_H ++#define AVFORMAT_MXL_STATUS_H ++ ++#include ++ ++static const char* mxl_status_to_str(mxlStatus status) { ++ ++ switch(status) { ++ case MXL_STATUS_OK: ++ return "MXL_STATUS_OK"; ++ case MXL_ERR_UNKNOWN: ++ return "MXL_ERR_UNKNOWN"; ++ case MXL_ERR_FLOW_NOT_FOUND: ++ return "MXL_ERR_FLOW_NOT_FOUND"; ++ case MXL_ERR_OUT_OF_RANGE_TOO_LATE: ++ return "MXL_ERR_OUT_OF_RANGE_TOO_LATE"; ++ case MXL_ERR_OUT_OF_RANGE_TOO_EARLY: ++ return "MXL_ERR_OUT_OF_RANGE_TOO_EARLY"; ++ case MXL_ERR_INVALID_FLOW_READER: ++ return "MXL_ERR_INVALID_FLOW_READER"; ++ case MXL_ERR_INVALID_FLOW_WRITER: ++ return "MXL_ERR_INVALID_FLOW_WRITER"; ++ case MXL_ERR_TIMEOUT: ++ return "MXL_ERR_TIMEOUT"; ++ case MXL_ERR_INVALID_ARG: ++ return "MXL_ERR_INVALID_ARG"; ++ case MXL_ERR_CONFLICT: ++ return "MXL_ERR_CONFLICT"; ++ case MXL_ERR_PERMISSION_DENIED: ++ return "MXL_ERR_PERMISSION_DENIED"; ++ case MXL_ERR_FLOW_INVALID: ++ return "MXL_ERR_FLOW_INVALID"; ++ default: ++ return "UNKNOWN"; ++ } ++} ++ ++#endif /* AVFORMAT_MXL_STATUS_H */ +diff --git a/libavformat/mxl_uri.c b/libavformat/mxl_uri.c +new file mode 100644 +index 0000000000..335473e9cf +--- /dev/null ++++ b/libavformat/mxl_uri.c +@@ -0,0 +1,304 @@ ++/* ++ * MXL URI parser for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#include "mxl_uri.h" ++#include "mxl_log.h" ++ ++#include "libavformat/avformat.h" ++#include "libavutil/mem.h" ++#include "libavutil/error.h" ++#include "libavutil/avstring.h" ++ ++#include ++#include ++ ++/* returns 0 if flow_ids are unique */ ++static int validate_unique_ids(void* logctx, int nb_flow_ids, char** flow_ids) ++{ ++ // demuxer requires unique flow IDs ++ for(int i = 0; i < nb_flow_ids; i++) ++ for(int j = i+1; j < nb_flow_ids; j++) ++ if (0 == av_strcasecmp(flow_ids[i], flow_ids[j])) { ++ logv(logctx, "URI locator flow ID not unique: %s\n", flow_ids[j]); ++ return -1; ++ } ++ ++ return 0; ++} ++ ++/* returns boolean if uri is valid (0 = not valid) */ ++int mxl_uri_is_valid_uuid(const char *s) ++{ ++ if (!s) ++ return 0; ++ ++ if (strlen(s) != 36) ++ return 0; ++ ++ for (int i = 0; i < 36; i++) { ++ switch (i) { ++ case 8: ++ case 13: ++ case 18: ++ case 23: ++ if (s[i] != '-') ++ return 0; ++ break; ++ ++ default: ++ if (!isxdigit((unsigned char)s[i])) ++ return 0; ++ } ++ } ++ ++ return 1; ++} ++ ++void mxl_uri_free(mxl_uri *u) ++{ ++ if (!u) ++ return; ++ ++ av_free(u->host); ++ av_free(u->domain); ++ ++ for (int i = 0; i < u->nb_flow_ids; i++) ++ av_free(u->flow_ids[i]); ++ av_free(u->flow_ids); ++ ++ memset(u, 0, sizeof(*u)); ++} ++ ++int mxl_uri_is_mxl_scheme(const char *uri) ++{ ++ return uri && av_stristart(uri, "mxl:", NULL); ++} ++ ++int mxl_parse_uri(void *logctx, const char *uri, mxl_uri *out) ++{ ++ if (out) { ++ memset(out, 0, sizeof(*out)); ++ out->port = -1; ++ } ++ else ++ return AVERROR(EINVAL); ++ ++ if (!uri) ++ return AVERROR(EINVAL); ++ ++ /* protect against runaway reads */ ++ size_t len = av_strnlen(uri, MXL_URI_MAX + 1); ++ if (len == MXL_URI_MAX + 1) ++ return AVERROR(ENAMETOOLONG); ++ ++ const char *p = strchr(uri, ':'); ++ if (!p) { ++ logv(logctx, "URI scheme not found\n"); ++ return AVERROR(EINVAL); ++ } ++ ++ if ((size_t)(p - uri) >= MXL_SCHEME_MAX) { ++ logv(logctx, "URI scheme too long\n"); ++ return AVERROR(ENAMETOOLONG); ++ } ++ ++ if (strchr(uri, '#')) { ++ logv(logctx, "URI fragments not supported\n"); ++ return AVERROR(EINVAL); ++ } ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ char *out_host = NULL; ++ int out_port = -1; ++ char *out_domain = NULL; ++ char **out_flow_ids = NULL; ++ int out_nb_flow_ids = 0; ++ ++ int port = -1; ++ char scheme[MXL_SCHEME_MAX] = { 0 }; ++ char auth[2]; ++ char *host = av_mallocz(MXL_HOST_MAX); ++ char *path = av_mallocz(MXL_PATH_MAX); ++ ++ if (!host || !path) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ av_url_split(scheme, sizeof(scheme), ++ auth, sizeof(auth), ++ host, MXL_HOST_MAX, ++ &port, ++ path, MXL_PATH_MAX, ++ uri); ++ ++ if (av_strcasecmp(scheme, "mxl") != 0) { ++ logv(logctx, "URI unknown scheme: %s\n", scheme); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ /* require mxl:// (authority marker present) */ ++ if (len < 6 || av_strncasecmp(uri, "mxl://", 6) != 0) { ++ logv(logctx, "URI authority marker required in (\"mxl://\")\n"); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ if (auth[0] != '\0') { ++ logv(logctx, "URI user authorization info not supported\n"); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ /* reject empty IPv6 literal: mxl://[]/... */ ++ const char *auth_start = uri + 6; ++ if (auth_start[0] == '[' && host[0] == '\0') { ++ logv(logctx, "URI invalid IPv6 host literal: []\n"); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ /* reject host without domain path */ ++ if (!path || path[0] != '/' || path[1] == '\0' || path[1] == '?') { ++ logv(logctx, "URI must contain a non-empty domain path\n"); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ // port=-1 is returned by av_url_split to indicate that the port ++ // is absent, therefore, anything less is an invalid negative ++ // port. Port equal to 0 is returned in abberrant cases such as ++ // absent port or non integer port number. ++ if (port < -1 || port == 0 || port > 65535) { ++ logv(logctx, "URI invalid port: %d\n", port); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ else ++ out_port = port; ++ ++ /* copy host */ ++ out_host = av_strdup(host); ++ if (!out_host) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ /* split path and query */ ++ char *query = strchr(path, '?'); ++ if (query) { ++ *query++ = '\0'; ++ } ++ ++ /* domain must exist */ ++ if (!path[0]) { ++ logv(logctx, "URI invalid missing domain path\n"); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ out_domain = av_strdup(path); ++ if (!out_domain) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ if (query) { ++ char *strtok_context = NULL; ++ char *token = strtok_r(query, "&", &strtok_context); ++ while (token) { ++ ++ if (!strncmp(token, "id=", 3)) { ++ char *flow_id = av_strdup(token + 3); ++ char **tmp; ++ ++ if (!flow_id) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ if (flow_id[0] == '\0') { ++ logv(logctx, "URI invalid query parameter: %s\n", token); ++ av_free(flow_id); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ if (!mxl_uri_is_valid_uuid(flow_id)) { ++ logv(logctx, "URI invalid flow ID parameter: %s\n", flow_id); ++ av_free(flow_id); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ tmp = av_realloc_array(out_flow_ids, ++ out_nb_flow_ids + 1, ++ sizeof(char *)); ++ if (!tmp) { ++ av_free(flow_id); ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ out_flow_ids = tmp; ++ out_flow_ids[out_nb_flow_ids++] = flow_id; ++ } ++ ++ token = strtok_r(NULL, "&", &strtok_context); ++ } ++ } ++ ++ int rc = validate_unique_ids(logctx, out_nb_flow_ids, out_flow_ids); ++ if (rc) { ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ out->host = out_host; ++ out->port = out_port; ++ out->domain = out_domain; ++ out->flow_ids = out_flow_ids; ++ out->nb_flow_ids = out_nb_flow_ids; ++ ++ out_host = NULL; ++ out_flow_ids = NULL; ++ out_domain = NULL; ++ ++ exit_status = 0; ++ ++finally: ++ ++ av_free(host); ++ av_free(path); ++ ++ av_free(out_host); ++ av_free(out_domain); ++ ++ if (out_flow_ids) { ++ for (int i = 0; i < out_nb_flow_ids; i++) ++ av_free(out_flow_ids[i]); ++ av_free(out_flow_ids); ++ } ++ ++ return exit_status; ++} +diff --git a/libavformat/mxl_uri.h b/libavformat/mxl_uri.h +new file mode 100644 +index 0000000000..ce0e67131a +--- /dev/null ++++ b/libavformat/mxl_uri.h +@@ -0,0 +1,118 @@ ++/* ++ * MXL URI parser for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++/* ++ * Parse MXL v1.0.0 URIs. ++ * ++ * See: https://github.com/dmf-mxl/mxl/blob/release/v1.0/docs/Addressability.md ++ */ ++ ++#ifndef AVFORMAT_MXL_URI_H ++#define AVFORMAT_MXL_URI_H ++ ++/* Parsing limits: av_url_split() silently truncates into ++ * caller-provided buffers. We therefore cap the total URI length ++ * (MXL_URI_MAX) and require MXL_URI_MAX <= the host/path buffer ++ * sizes, so overlong inputs can be rejected deterministically ++ * (ENAMETOOLONG) and no truncation occurs. */ ++enum MXL_URI_LIMITS { ++ MXL_SCHEME_MAX = sizeof("mxl"), ++ MXL_URI_MAX = 1024, ++ MXL_HOST_MAX = MXL_URI_MAX, ++ MXL_PATH_MAX = MXL_URI_MAX ++}; ++ ++#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L ++_Static_assert(MXL_URI_MAX > 0, ++ "MXL_URI_MAX must be > 0"); ++_Static_assert(MXL_URI_MAX <= MXL_HOST_MAX, ++ "MXL_URI_MAX must be <= MXL_HOST_MAX"); ++_Static_assert(MXL_URI_MAX <= MXL_PATH_MAX, ++ "MXL_URI_MAX must be <= MXL_PATH_MAX"); ++#endif ++ ++typedef struct mxl_uri { ++ char *host; ++ int port; ++ char *domain; ++ char **flow_ids; ++ int nb_flow_ids; ++} mxl_uri; ++ ++/** ++ * Returns 1 if @s is a value UUID, or else returns 0. ++ */ ++int mxl_uri_is_valid_uuid(const char *s); ++ ++/** ++ * Test whether @uri uses the "mxl" scheme (i.e. begins with "mxl:"). ++ * ++ * Performs a simple prefix check for "mxl:" and does not parse or ++ * validate the remainder of the URI. ++ * ++ * @param uri URI string to test (may be NULL) ++ * @return 1 if the URI begins with "mxl:", 0 otherwise. ++ */ ++int mxl_uri_is_mxl_scheme(const char *uri); ++ ++/** ++ * Parse an MXL URI and return the parsed result in @out. ++ * ++ * The URI grammar is: ++ * ++ * mxl://[authority]/domain/path?id=[&id=...] ++ * ++ * The authority component is optional. ++ * ++ * Example of a valid MXL URI that specifies a local domain path: ++ * ++ * "mxl:///dev/shm/mxl?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ * ++ * "/dev/shm/mxl" is the local filesystem path to the MXL domain. The ++ * "id" query parameters carry the flow IDs. This is the format that ++ * is supported by the MXL demuxer. ++ * ++ * Note that the parser will accept URIs that include an authority ++ * component, e.g. "mxl://host:port/domain/path?id=...", but the ++ * demuxer implementation currently supports only local filesystem ++ * domains. ++ * ++ * @param logctx the logger context ++ * @param uri the URI to parse ++ * @param out the parsed URI ++ * @return 0 on success, or error code ++ * AVERROR(EINVAL) URI is invalid and cannot be parsed ++ * AVERROR(ENAMETOOLONG) URI exceeds parser implementation limits ++ * AVERROR(ENOMEM) memory allocation failed ++ * AVERROR_UNKNOWN an unspecified error occurred ++ */ ++int mxl_parse_uri(void *logctx, const char *uri, mxl_uri *out); ++ ++/* ++ * Free a parse result produced by mxl_parse_uri(). The fields of ++ * @parsed_uri are freed and cleared. The @parsed_uri pointer itself ++ * is not freed. ++ * ++ * @param parsed_uri The parsed URI to free (may be NULL). ++ */ ++void mxl_uri_free(mxl_uri *parsed_uri); ++ ++#endif /* AVFORMAT_MXL_URI_H */ +diff --git a/libavformat/mxldec.c b/libavformat/mxldec.c +new file mode 100644 +index 0000000000..655ccb5152 +--- /dev/null ++++ b/libavformat/mxldec.c +@@ -0,0 +1,1990 @@ ++/* ++ * MXL demuxer for Media eXchange Layer flows ++ * ++ * AVFMT_NOFILE demuxer; requires either ++ * "/path/to/domain/.mxl-flow" ++ * or "mxl:///path/to/domain?id=[&id=]" ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++/* ++ * See: https://github.com/dmf-mxl/mxl ++ */ ++ ++#include "mxl_json.h" ++#include "mxl_uri.h" ++#include "mxl_loc.h" ++#include "mxl_diag.h" ++#include "mxl_dump.h" ++#include "mxl_log.h" ++#include "mxl_common.h" ++#include "mxl_status.h" ++#include "demux.h" ++#include "avformat.h" ++ ++#include "libswresample/swresample.h" ++#include "libavutil/channel_layout.h" ++#include "libavcodec/avcodec.h" ++#include "libavutil/avassert.h" ++#include "libavutil/avstring.h" ++#include "libavutil/rational.h" ++#include "libavutil/error.h" ++#include "libavutil/time.h" ++#include "libavutil/opt.h" ++#include "libavutil/log.h" ++#include "libavutil/mem.h" ++ ++#include ++#include ++#include ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++typedef enum GrainIndexInit { ++ GRAIN_INDEX_INIT_CURRENT = 0, ++ GRAIN_INDEX_INIT_HEAD, ++ GRAIN_INDEX_INIT_TAIL, ++} GrainIndexInit; ++ ++typedef enum OnTooLate { ++ ON_TOO_LATE_INCREMENT = 0, ++ ON_TOO_LATE_RESET ++} OnTooLate; ++ ++typedef struct VideoState { ++ mxlRational mxl_grain_rate; ++ uint64_t mxl_start_grain_index; ++ uint64_t mxl_grain_index; ++ uint64_t last_pts; ++ int frame_count; ++} VideoState; ++ ++typedef struct AudioState { ++ mxlRational mxl_sample_rate; ++ uint64_t mxl_start_sample_index; ++ uint64_t mxl_sample_index; ++ struct SwrContext *swr_context; ++ int sample_point_count; ++ uint64_t last_pts; ++} AudioState; ++ ++typedef enum FlowType { ++ UNDEFINED_FLOW = 0, ++ VIDEO_FLOW = 1, ++ AUDIO_FLOW = 2 ++} FlowType; ++ ++typedef union FlowState { ++ VideoState video; ++ AudioState audio; ++} FlowState; ++ ++typedef struct StreamContext { ++ mxlFlowReader mxl_flow_reader; ++ FlowType flow_type; ++ FlowState flow; ++ int stream_index; ++} StreamContext; ++ ++typedef struct MXLTuningParams { ++ int audio_samples_per_read; ++ bool video_blocking_read; ++} MXLTuningParams; ++ ++typedef struct MXLContext { ++ ++ const AVClass *class; ++ ++ // domain and flow_ids parsed out of input url ++ mxl_loc loc; ++ ++ // options ++ int zero_copy; ++ int blocking; ++ int reset_on_drop; ++ int max_video_frames; ++ int max_audio_samples; ++ int max_audio_samples_per_read; ++ GrainIndexInit grain_index_init; ++ OnTooLate on_too_late; ++ char *diag_socket; ++ char *audio_dump_file; ++ ++ // interface objects ++ mxlInstance mxl_instance; ++ ++ // stream context array (size is loc.nb_flow_ids) ++ StreamContext* stream_contexts; ++ ++ // stream configuration dependant tuning parameters ++ MXLTuningParams tuning; ++ ++ // flag end of streams ++ bool at_eof; ++ ++ // the index of the next stream context to read ++ int next_stream_context_idx; ++ ++ // diagnostic server ++ mxl_diag_server* diag_server; ++ ++ // audio sample writer ++ mxl_dump_audio_writer* audio_dump_writer; ++} MXLContext; ++ ++#define OFFSET(x) offsetof(MXLContext, x) ++#define FLAGS AV_OPT_FLAG_DECODING_PARAM ++ ++static const AVOption mxl_options[] = { ++ { ++ .name = "zero_copy", ++ .help = "Use zero-copy packet delivery (experimental).", ++ .offset = OFFSET(zero_copy), ++ .type = AV_OPT_TYPE_BOOL, ++ .default_val = { .i64 = 0 }, ++ .min = 0, ++ .max = 1, ++ .flags = FLAGS ++ }, ++ { ++ .name = "blocking", ++ .help = "Use blocking video read: auto (default), 0=non-blocking, 1=blocking" , ++ .offset = OFFSET(blocking), ++ .type = AV_OPT_TYPE_INT, ++ .default_val = { .i64 = -1 }, ++ .min = -1, ++ .max = 1, ++ .flags = FLAGS ++ }, ++ { ++ .name = "reset_on_drop", ++ .help = "Reset presentation timestamp to zero on frame drop.", ++ .offset = OFFSET(reset_on_drop), ++ .type = AV_OPT_TYPE_BOOL, ++ .default_val = { .i64 = 0 }, ++ .min = 0, ++ .max = 1, ++ .flags = FLAGS ++ }, ++ { ++ .name = "max_video_frames", ++ .help = "stop at exactly N video frames", ++ .offset = OFFSET(max_video_frames), ++ .type = AV_OPT_TYPE_INT, ++ .default_val = { .i64 = 0 }, ++ .min = 0, ++ .max = INT_MAX, ++ .flags = FLAGS ++ }, ++ { ++ .name = "max_audio_samples", ++ .help = "stop at exactly N audio sample points", ++ .offset = OFFSET(max_audio_samples), ++ .type = AV_OPT_TYPE_INT, ++ .default_val = { .i64 = 0 }, ++ .min = 0, ++ .max = INT_MAX, ++ .flags = FLAGS ++ }, ++ { ++ .name = "max_audio_samples_per_read", ++ .help = "max audio sample points per MXL read", ++ .offset = OFFSET(max_audio_samples_per_read), ++ .type = AV_OPT_TYPE_INT, ++ .default_val = { .i64 = 0 }, ++ .min = 0, ++ .max = INT_MAX, ++ .flags = FLAGS ++ }, ++ ++ // grain_index_init option group ++ { ++ .name = "grain_index_init", ++ .help = "initial MXL grain index", ++ .offset = OFFSET(grain_index_init), ++ .type = AV_OPT_TYPE_INT, ++ .default_val = { .i64 = GRAIN_INDEX_INIT_CURRENT }, ++ .min = GRAIN_INDEX_INIT_CURRENT, ++ .max = GRAIN_INDEX_INIT_TAIL, ++ .flags = FLAGS, ++ .unit = "grain_index_init", ++ }, ++ { ++ .name = "current", ++ .help = "current time", ++ .offset = 0, ++ .type = AV_OPT_TYPE_CONST, ++ .default_val = { .i64 = GRAIN_INDEX_INIT_CURRENT }, ++ .flags = FLAGS, ++ .unit = "grain_index_init", ++ }, ++ { ++ .name = "head", ++ .help = "ring buffer head", ++ .offset = 0, ++ .type = AV_OPT_TYPE_CONST, ++ .default_val = { .i64 = GRAIN_INDEX_INIT_HEAD }, ++ .flags = FLAGS, ++ .unit = "grain_index_init", ++ }, ++ { ++ .name = "tail", ++ .help = "ring buffer tail", ++ .offset = 0, ++ .type = AV_OPT_TYPE_CONST, ++ .default_val = { .i64 = GRAIN_INDEX_INIT_TAIL }, ++ .flags = FLAGS, ++ .unit = "grain_index_init", ++ }, ++ ++ // on_too_late option group ++ { ++ .name = "on_too_late", ++ .help = "action when MXL reports grain index too late", ++ .offset = OFFSET(on_too_late), ++ .type = AV_OPT_TYPE_INT, ++ .default_val = { .i64 = ON_TOO_LATE_INCREMENT }, ++ .min = ON_TOO_LATE_INCREMENT, ++ .max = ON_TOO_LATE_RESET, ++ .flags = FLAGS, ++ .unit = "on_too_late", ++ }, ++ { ++ .name = "increment", ++ .help = "increment the grain index", ++ .offset = 0, ++ .type = AV_OPT_TYPE_CONST, ++ .default_val = { .i64 = ON_TOO_LATE_INCREMENT }, ++ .flags = FLAGS, ++ .unit = "on_too_late", ++ }, ++ { ++ .name = "reset", ++ .help = "reset to position defined by grain_index_init", ++ .offset = 0, ++ .type = AV_OPT_TYPE_CONST, ++ .default_val = { .i64 = ON_TOO_LATE_RESET }, ++ .flags = FLAGS, ++ .unit = "on_too_late", ++ }, ++ ++ { ++ .name = "diag_socket", ++ .help = "Unix domain socket path for diagnostic monitoring", ++ .offset = OFFSET(diag_socket), ++ .type = AV_OPT_TYPE_STRING, ++ .default_val = { .str = NULL }, ++ .flags = FLAGS, ++ }, ++ ++ { ++ .name = "audio_dump_file", ++ .help = "Write demuxed audio PCM samples to a raw output file", ++ .offset = OFFSET(audio_dump_file), ++ .type = AV_OPT_TYPE_STRING, ++ .default_val = { .str = NULL }, ++ .flags = FLAGS, ++ }, ++ ++ { NULL } ++}; ++ ++#undef OFFSET ++#undef FLAGS ++ ++static const AVClass mxl_demuxer_class = { ++ .class_name = "mxl demuxer", ++ .item_name = av_default_item_name, ++ .option = mxl_options, ++ .version = LIBAVUTIL_VERSION_INT ++}; ++ ++static void log_probe_data(const AVProbeData *p) ++{ ++ logv(NULL, ++ "mxl AVProbeData:\n" ++ " filename: %s\n" ++ " buf_size: %d\n" ++ " mime_type: %s\n", ++ p->filename ? p->filename : "(null)", ++ p->buf_size, ++ p->mime_type ? p->mime_type : "(null)"); ++} ++ ++static void log_format_context(const AVFormatContext *s) ++{ ++ av_assert1(s); ++ ++ logv(s, "mxl AVFormatContext:\n"); ++ logv(s, " url: %s\n", s->url ? s->url : "(null)"); ++} ++ ++static bool probe_is_flow_active(mxlInstance mxl_instance, const char* flow_id) ++{ ++ bool active = false; ++ mxlFlowReader flow_reader = NULL; ++ ++ mxlStatus mxl_status = mxlCreateFlowReader(mxl_instance, flow_id, "", ++ &flow_reader); ++ ++ if (MXL_STATUS_OK != mxl_status) { ++ logv(NULL, "mxlCreateFlowReader error %s for flow: \"%s\"\n", ++ mxl_status_to_str(mxl_status), flow_id); ++ goto finally; ++ } ++ ++ mxlFlowInfo info = {0}; ++ mxl_status = mxlFlowReaderGetInfo(flow_reader, &info); ++ if (MXL_STATUS_OK != mxl_status) { ++ logv(NULL, "mxlFlowReaderGetInfo error %s\n", ++ mxl_status_to_str(mxl_status)); ++ goto finally; ++ } ++ ++ mxl_status = mxlIsFlowActive(mxl_instance, flow_id, &active); ++ if (MXL_STATUS_OK != mxl_status) { ++ logv(NULL, "mxlIsFlowActive error %s\n", ++ mxl_status_to_str(mxl_status)); ++ goto finally; ++ } ++ ++finally: ++ ++ if (flow_reader) { ++ av_assert0(mxl_instance); ++ mxl_status = mxlReleaseFlowReader(mxl_instance, flow_reader); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(NULL, "mxlReleaseFlowReader error %s for flow_id %s\n", ++ mxl_status_to_str(mxl_status), flow_id); ++ } ++ ++ return active; ++} ++ ++ ++static int validate_locator(void *logctx, const mxl_loc* loc) { ++ av_assert0(loc); ++ ++ if (loc->nb_flow_ids < 1) { ++ logv(logctx, "locator requires at least one flow ID\n"); ++ return -1; ++ } ++ ++ // mxl_loc invariants (for nb_flow_ids > 0) ++ av_assert0(loc->domain_path); ++ av_assert0(loc->flow_ids); ++ ++ return 0; ++} ++ ++ ++/** ++ * Identify MXL flow inputs by filename pattern and state. ++ * ++ * Expected identifier: ++ * /path/to/domain/.mxl-flow ++ * mxl:///path/to/domain?id= ++ * ++ * Returns a probe score: ++ * 0 : not recognized ++ * AVPROBE_SCORE_EXTENSION : recognized by URI scheme or file extension ++ * AVPROBE_SCORE_EXTENSION+: flow exists but inactive ++ * AVPROBE_SCORE_MAX : active MXL flow detected ++ */ ++static int mxl_probe(const AVProbeData *p) { ++ ++ av_assert0(p); ++ ++ log_probe_data(p); ++ ++ mxlInstance mxl_instance = NULL; ++ int score = 0; ++ mxl_loc loc = {0}; ++ ++ if (!p->filename) ++ goto finally; ++ ++ int rc = mxl_loc_parse(NULL, p->filename, &loc); ++ if (rc) { ++ logv(NULL, "failed to parse locator: \"%s\"\n", p->filename); ++ goto finally; ++ } ++ ++ rc = validate_locator(NULL, &loc); ++ if (rc) { ++ logv(NULL, "invalid locator: %s\n", p->filename); ++ goto finally; ++ } ++ ++ score = AVPROBE_SCORE_EXTENSION; ++ ++ mxl_instance = mxlCreateInstance(loc.domain_path, NULL); ++ if (NULL == mxl_instance) { ++ logv(NULL, "mxlCreateInstance error\n"); ++ goto finally; ++ } ++ ++ logv(NULL, "probe MXL domain: \"%s\"\n", loc.domain_path); ++ logv(NULL, "probe %d MXL flows:\n", loc.nb_flow_ids); ++ int i = 0; ++ for (i = 0; i < loc.nb_flow_ids; i++) ++ logv(NULL, " flow id: \"%s\"\n", loc.flow_ids[i]); ++ ++ bool all_flows_active = true; ++ for (i = 0; i < loc.nb_flow_ids; i++) { ++ if (!probe_is_flow_active(mxl_instance, loc.flow_ids[i])) { ++ all_flows_active = false; ++ goto finally; ++ } ++ } ++ ++ if (all_flows_active) ++ score = AVPROBE_SCORE_MAX; ++ else ++ score = (score + AVPROBE_SCORE_MAX)/2; ++ ++finally: ++ ++ mxl_loc_free(&loc); ++ ++ if (mxl_instance) { ++ mxlStatus mxl_status = mxlDestroyInstance(mxl_instance); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(NULL, "mxlDestroyInstance error %s\n", mxl_status_to_str(mxl_status)); ++ } ++ ++ if (score) { ++ const char* reason = "unknown"; ++ ++ if (score <= AVPROBE_SCORE_EXTENSION) { ++ reason = MXL_DOT_FLOW_EXT " extension or URI scheme match"; ++ } ++ else if (score < AVPROBE_SCORE_MAX) { ++ reason = "one or more flows is inactive"; ++ } ++ else { ++ av_assert0(score == AVPROBE_SCORE_MAX); ++ reason = "all flows are active"; ++ } ++ ++ logv(NULL, "probe successful (%s)\n", reason); ++ } ++ ++ logv(NULL, "probe score = %d\n", score); ++ ++ return score; ++} ++ ++#define GET1(X,Y,D,K1) \ ++ X = mxl_json_doc_get_##Y(D, K1); \ ++ if (X.err) { \ ++ loge(s, "error reading flow def parameter \"%s\"\n", #K1); \ ++ exit_status = AVERROR_INVALIDDATA; \ ++ goto finally; \ ++ } ++#define GET2(X,Y,D,K1,K2) \ ++ X = mxl_json_doc_get_##Y(D, K1, K2); \ ++ if (X.err) { \ ++ loge(s, "error reading flow def parameter \"%s.%s\"\n", #K1, #K2); \ ++ exit_status = AVERROR_INVALIDDATA; \ ++ goto finally; \ ++ } ++ ++#define SET_META(ST, X, Y) \ ++ if (av_dict_set(&ST->metadata, X, Y, 0) < 0) { \ ++ logw(s, "failed to set stream metadata %s=%s\n", X, Y); \ ++ } ++ ++static inline int pos_int_range_sanity(AVFormatContext *s, double value, const char* detail) ++{ ++ if (value <= 0 || value > (double)INT_MAX) { ++ loge(s, "%s sanity, range error, %.0f\n", detail, value); ++ return AVERROR_INVALIDDATA; ++ } ++ ++ if (floor(value) != value) { ++ loge(s, "%s sanity, non-integer value %.6f\n", detail, value); ++ return AVERROR_INVALIDDATA; ++ } ++ ++ return 0; ++} ++ ++#define POS_INT_SANITY(S, VALUE, DETAIL) \ ++ { int rc = pos_int_range_sanity(S, VALUE, DETAIL); \ ++ if (rc) { exit_status = rc; goto finally; } } ++ ++static int read_video_header(AVFormatContext *s, mxlFlowInfo *flow_info, ++ mxl_json_doc *flow_def_doc, AVStream *st, ++ FlowState* flow_state, const char* media_type) ++{ ++ av_assert0(s && flow_info && flow_def_doc && st && flow_state && media_type); ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ mxl_json_str colorspace = {0}; ++ mxl_json_num frame_width = {0}; ++ mxl_json_num frame_height = {0}; ++ mxl_json_num grain_rate_num = {0}; ++ mxl_json_num grain_rate_den = {0}; ++ ++ if (strcmp(media_type, "video/v210")) { ++ loge(s, "unexpected video type \"%s\"\n", media_type); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ ++ GET1(colorspace, string1, flow_def_doc, "colorspace"); ++ GET1(frame_width, double1, flow_def_doc, "frame_width"); ++ GET1(frame_height, double1, flow_def_doc, "frame_height"); ++ GET2(grain_rate_num, double2, flow_def_doc, "grain_rate", "numerator"); ++ GET2(grain_rate_den, double2, flow_def_doc, "grain_rate", "denominator"); ++ ++ POS_INT_SANITY(s, frame_width.value, "frame_width"); ++ POS_INT_SANITY(s, frame_height.value, "frame_height"); ++ POS_INT_SANITY(s, grain_rate_num.value, "grain_rate_num"); ++ POS_INT_SANITY(s, grain_rate_den.value, "grain_rate_den"); ++ ++ if ((int64_t)grain_rate_num.value != flow_info->config.common.grainRate.numerator || ++ (int64_t)grain_rate_den.value != flow_info->config.common.grainRate.denominator) { ++ loge(s, "grain rate sanity, flow def rate != flow info rate" ++ ", {%"PRId64",%"PRId64"} != {%"PRId64",%"PRId64"}\n", ++ (int64_t)grain_rate_num.value, (int64_t)grain_rate_den.value, ++ flow_info->config.common.grainRate.numerator, ++ flow_info->config.common.grainRate.denominator); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ logv(s, "good video flow def: %s %dx%d at %d/%d fps\n", ++ media_type, ++ (int)frame_width.value, (int)frame_height.value, ++ (int)grain_rate_num.value, (int)grain_rate_den.value); ++ ++ st->codecpar->codec_type = AVMEDIA_TYPE_VIDEO; ++ st->codecpar->codec_id = AV_CODEC_ID_V210; ++ st->codecpar->codec_tag = MKTAG('v','2','1','0'); ++ st->codecpar->bits_per_coded_sample = 10; ++ st->codecpar->bits_per_raw_sample = 10; ++ st->codecpar->width = (int)frame_width.value; ++ st->codecpar->height = (int)frame_height.value; ++ st->codecpar->sample_aspect_ratio = (AVRational){1,1}; ++ st->codecpar->framerate = (AVRational){(int)grain_rate_num.value, (int)grain_rate_den.value}; ++ st->codecpar->field_order = AV_FIELD_PROGRESSIVE; ++ ++ st->avg_frame_rate = st->codecpar->framerate; ++ st->time_base = av_inv_q(st->codecpar->framerate); ++ ++ SET_META(st, "mxl_colorspace", colorspace.value); ++ ++ flow_state->video.mxl_grain_rate = flow_info->config.common.grainRate; ++ flow_state->video.mxl_start_grain_index = 0; ++ flow_state->video.mxl_grain_index = 0; ++ ++ exit_status = 0; ++ ++finally: ++ ++ av_free(colorspace.value); ++ ++ return exit_status; ++} ++ ++// create SwrContext for planar to interleaved audio conversion ++static int init_swr_context(struct SwrContext **swr_context, ++ int channels, int sample_rate) ++{ ++ AVChannelLayout in_layout, out_layout; ++ av_channel_layout_default(&in_layout, channels); ++ av_channel_layout_default(&out_layout, channels); ++ ++ int rc = swr_alloc_set_opts2(swr_context, ++ &out_layout, AV_SAMPLE_FMT_FLT, sample_rate, ++ &in_layout, AV_SAMPLE_FMT_FLTP, sample_rate, ++ 0, NULL); ++ if (rc < 0) ++ return rc; ++ ++ rc = swr_init(*swr_context); ++ if (rc < 0) { ++ swr_free(swr_context); ++ return rc; ++ } ++ ++ return 0; ++} ++ ++static int read_audio_header(AVFormatContext *s, mxlFlowInfo *flow_info, ++ mxl_json_doc *flow_def_doc, AVStream *st, ++ FlowState* flow_state, const char* media_type) ++{ ++ av_assert0(s && flow_info && flow_def_doc && st && flow_state && media_type); ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ mxl_json_num sample_rate_num = {0}; ++ mxl_json_num sample_rate_den = {0}; ++ mxl_json_num channel_count; ++ mxl_json_num bit_depth; ++ ++ if (strcmp(media_type, "audio/float32")) { ++ loge(s, "unexpected audio type \"%s\"\n", media_type); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ ++ GET2(sample_rate_num, double2, flow_def_doc, "sample_rate", "numerator"); ++ GET1(channel_count, double1, flow_def_doc, "channel_count"); ++ GET1(bit_depth, double1, flow_def_doc, "bit_depth"); ++ sample_rate_den = mxl_json_doc_get_double2(flow_def_doc, "sample_rate", "denominator"); ++ ++ // if sample_rate.denominator is present then it must have value 1.0 ++ if (0 == sample_rate_den.err && 1.0 != sample_rate_den.value) { ++ loge(s, "sample_rate_den sanity, must be absent or value 1.0, actual %f\n", ++ sample_rate_den.value); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ if (1LL != flow_info->config.common.grainRate.denominator) { ++ loge(s, "grainRate.denominator sanity, must be value 1, actual %"PRId64"\n", ++ flow_info->config.common.grainRate.denominator); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ if ((int64_t)sample_rate_num.value != flow_info->config.common.grainRate.numerator) { ++ loge(s, "sample rate sanity, flow def rate != flow info rate" ++ ", {%"PRId64",%"PRId64"} != {%"PRId64",%"PRId64"}\n", ++ (int64_t)sample_rate_num.value, 1LL, ++ flow_info->config.common.grainRate.numerator, ++ flow_info->config.common.grainRate.denominator); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ POS_INT_SANITY(s, sample_rate_num.value, "sample_rate_num"); ++ POS_INT_SANITY(s, channel_count.value, "channel_count"); ++ POS_INT_SANITY(s, bit_depth.value, "bit_depth"); ++ ++ if ((uint32_t)channel_count.value != flow_info->config.continuous.channelCount) { ++ loge(s, "channel count sanity, flow def != flow info (%u != %u)\n", ++ (uint32_t)channel_count.value, flow_info->config.continuous.channelCount); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ if ((int)bit_depth.value != 32) { ++ loge(s, "bit_depth sanity, bit_depth != 32, actual %d\n", ++ (int)bit_depth.value); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ logv(s, "good audio flow def: %s %d channels at %d hz\n", ++ media_type, ++ (int)channel_count.value, (int)sample_rate_num.value); ++ ++ st->codecpar->codec_type = AVMEDIA_TYPE_AUDIO; ++ st->codecpar->codec_id = AV_CODEC_ID_PCM_F32LE; ++ st->codecpar->codec_tag = 0; ++ ++ st->codecpar->format = AV_SAMPLE_FMT_FLT; ++ st->codecpar->bits_per_coded_sample = 32; ++ st->codecpar->bits_per_raw_sample = 32; ++ ++ st->codecpar->sample_rate = (int)sample_rate_num.value; ++ st->codecpar->ch_layout.nb_channels = (int)channel_count.value; ++ st->codecpar->ch_layout.order = AV_CHANNEL_ORDER_UNSPEC; ++ ++ st->time_base = (AVRational){ 1, st->codecpar->sample_rate }; ++ st->avg_frame_rate = (AVRational){ 0, 1 }; ++ ++ flow_state->audio.mxl_sample_rate = flow_info->config.common.grainRate; ++ int rc = init_swr_context(&flow_state->audio.swr_context, ++ st->codecpar->ch_layout.nb_channels, ++ st->codecpar->sample_rate); ++ if (rc < 0) { ++ loge(s, "init_swr_context failed\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ ++ exit_status = 0; ++ ++finally: ++ ++ return exit_status; ++} ++ ++static int read_flow(AVFormatContext *s, mxlInstance mxl_instance, ++ const char* flow_id, StreamContext* stream_ctx) ++{ ++ av_assert0(s && s->priv_data && ++ mxl_instance && flow_id && ++ stream_ctx); ++ ++ int exit_status = AVERROR_UNKNOWN; ++ mxlFlowReader flow_reader = NULL; ++ FlowType flow_type = UNDEFINED_FLOW; ++ FlowState flow_state = {0}; ++ ++ char *flow_def_json = NULL; ++ mxl_json_doc flow_def_doc = {0}; ++ mxl_json_str id = {0}; ++ mxl_json_str desc = {0}; ++ mxl_json_str label = {0}; ++ mxl_json_str format = {0}; ++ mxl_json_str media_type = {0}; ++ char *unescaped_media_type = NULL; ++ ++ mxlStatus mxl_status = mxlCreateFlowReader(mxl_instance, flow_id, "", ++ &flow_reader); ++ if (MXL_STATUS_OK != mxl_status) { ++ loge(s, "mxlCreateFlowReader error %s for flow ID %s\n", ++ mxl_status_to_str(mxl_status), flow_id); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ mxlFlowInfo flow_info = {0}; ++ mxl_status = mxlFlowReaderGetInfo(flow_reader, &flow_info); ++ if (mxl_status != MXL_STATUS_OK) { ++ loge(s, "mxlFlowReaderGetInfo failed\n"); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ bool active = false; ++ mxl_status = mxlIsFlowActive(mxl_instance, flow_id, &active); ++ if (mxl_status != MXL_STATUS_OK) { ++ loge(s, "mxlIsFlowActive failed\n"); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ if (!active) { ++ loge(s, "flow exists but is not active\n"); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ logv(s, "active flow with ID %s\n", flow_id); ++ ++ size_t flow_def_json_size = 0; ++ // initial call with NULL buffer gets size ++ mxl_status = mxlGetFlowDef(mxl_instance, flow_id, NULL, &flow_def_json_size); ++ if (MXL_ERR_INVALID_ARG == mxl_status) { ++ if (flow_def_json_size <= 0) { ++ loge(s, "flow def buffer size sanity (%zu)\n", flow_def_json_size); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ flow_def_json = av_malloc(flow_def_json_size); ++ if (!flow_def_json) { ++ loge(s, "failed to allocate flow_def_json of size %zu\n", flow_def_json_size); ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ } ++ else { ++ loge(s, "unexpected mxlGetFlowDef status (%s)\n", mxl_status_to_str(mxl_status)); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ mxl_status = mxlGetFlowDef(mxl_instance, flow_id, flow_def_json, &flow_def_json_size); ++ if (mxl_status != MXL_STATUS_OK) { ++ loge(s, "mxlGetFlowDef failed\n"); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ logv(s, "good flow definition read for flow ID %s:\n%s\n", flow_id, flow_def_json); ++ ++ int json_status = mxl_json_doc_build(s, flow_def_json, &flow_def_doc); ++ if (json_status) { ++ loge(s, "json flow def parse error %d\n", json_status); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ GET1(id, string1, &flow_def_doc, "id"); ++ GET1(desc, string1, &flow_def_doc, "description"); ++ GET1(label, string1, &flow_def_doc, "label"); ++ GET1(format, string1, &flow_def_doc, "format"); ++ GET1(media_type, string1, &flow_def_doc, "media_type"); ++ ++ if (!id.value || av_strcasecmp(id.value, flow_id) != 0) { ++ loge(s, "flow id sanity, flow def ID != flow locator ID, %s != %s\n", ++ id.value, flow_id); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ AVStream *st = avformat_new_stream(s, NULL); ++ if (!st) { ++ loge(s, "failed to allocate new stream\n"); ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ const char *buf = media_type.value; ++ unescaped_media_type = av_get_token(&buf, ""); ++ if (!unescaped_media_type) { ++ loge(s, "failed to allocate unescaped_media_type\n"); ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ SET_META(st, "mxl_id", id.value); ++ SET_META(st, "mxl_description", desc.value); ++ SET_META(st, "mxl_label", label.value); ++ SET_META(st, "mxl_format", format.value); ++ SET_META(st, "mxl_media_type", unescaped_media_type); ++ ++ if ( strcmp(unescaped_media_type, "audio/float32") == 0 ) { ++ flow_type = AUDIO_FLOW; ++ int rc = read_audio_header(s, &flow_info, &flow_def_doc, st, &flow_state, ++ unescaped_media_type); ++ if (rc) { ++ loge(s, "read audio header error\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ } ++ else if ( strcmp(unescaped_media_type, "video/v210") == 0 ) { ++ flow_type = VIDEO_FLOW; ++ int rc = read_video_header(s, &flow_info, &flow_def_doc, st, &flow_state, ++ unescaped_media_type); ++ if (rc) { ++ loge(s, "read video header error\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ } ++ else { ++ loge(s, "unsupported media_type \"%s\"\n", unescaped_media_type); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ // success, transfer state to stream_ctx ++ ++ stream_ctx->mxl_flow_reader = flow_reader; ++ flow_reader = NULL; ++ ++ stream_ctx->flow_type = flow_type; ++ flow_type = UNDEFINED_FLOW; ++ ++ stream_ctx->flow= flow_state; ++ memset(&flow_state, 0, sizeof(flow_state)); ++ ++ stream_ctx->stream_index = st->index; ++ ++ exit_status = 0; ++ ++finally: ++ ++ mxl_json_doc_release(&flow_def_doc); ++ ++ av_free(id.value); ++ av_free(desc.value); ++ av_free(label.value); ++ av_free(format.value); ++ av_free(media_type.value); ++ av_free(unescaped_media_type); ++ av_free(flow_def_json); ++ ++ if (flow_reader) { ++ av_assert0(mxl_instance); ++ mxl_status = mxlReleaseFlowReader(mxl_instance, flow_reader); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(s, "mxlReleaseFlowReader error %s\n", mxl_status_to_str(mxl_status)); ++ } ++ ++ ++ return exit_status; ++} ++ ++/** ++ * Examine the configured flows and compute tuning parameters to ++ * optimize FFmpeg's streaming performance. ++ * 1. audio samples per read set to one video frame, or 1/60s ++ * 2. video blocking read set to false if both audio and video flows exit ++ */ ++static void header_init_tuning_params(AVFormatContext *s) ++{ ++ av_assert0(s); ++ ++ MXLContext *p = s->priv_data; ++ ++ AVStream *vst = NULL; ++ AVStream *ast = NULL; ++ for (int i = 0; i < s->nb_streams; i++) { ++ AVStream *st = s->streams[i]; ++ if (st->codecpar->codec_type == AVMEDIA_TYPE_VIDEO && !vst) ++ vst = st; ++ else if (st->codecpar->codec_type == AVMEDIA_TYPE_AUDIO && !ast) ++ ast = st; ++ } ++ ++ StreamContext *vctx = NULL; ++ StreamContext *actx = NULL; ++ for (int i = 0; i < p->loc.nb_flow_ids; i++) { ++ if (p->stream_contexts[i].flow_type == VIDEO_FLOW && !vctx) ++ vctx = &p->stream_contexts[i]; ++ else if (p->stream_contexts[i].flow_type == AUDIO_FLOW && !actx) ++ actx = &p->stream_contexts[i]; ++ } ++ ++ int audio_samples_per_frame = 0; ++ if (ast && vst) { ++ av_assert0(actx && vctx); ++ ++ mxlRational frame_rate = vctx->flow.video.mxl_grain_rate; ++ av_assert0(frame_rate.numerator && frame_rate.denominator); ++ ++ mxlRational sample_rate = actx->flow.audio.mxl_sample_rate; ++ av_assert0(sample_rate.numerator && sample_rate.denominator == 1); ++ ++ audio_samples_per_frame = av_rescale_q( ++ sample_rate.numerator, ++ (AVRational){frame_rate.denominator, frame_rate.numerator}, ++ (AVRational){1, 1}); ++ } ++ else if (ast) { ++ av_assert0(actx); ++ ++ mxlRational sample_rate = actx->flow.audio.mxl_sample_rate; ++ av_assert0(sample_rate.numerator && sample_rate.denominator == 1); ++ ++ // 16.66 ms = 1/60 second ++ audio_samples_per_frame = sample_rate.numerator / 60; ++ } ++ ++ p->tuning.audio_samples_per_read = audio_samples_per_frame; ++ p->tuning.video_blocking_read = !(ast && vst); ++ ++ logv(s, "tuned audio_samples_per_read = %d\n", p->tuning.audio_samples_per_read); ++ logv(s, "tuned video_blocking_read = %d\n", p->tuning.video_blocking_read); ++} ++ ++static void release_stream_context(AVFormatContext *s, ++ mxlInstance mxl_instance, StreamContext* stream_ctx) ++{ ++ if (stream_ctx->mxl_flow_reader) { ++ av_assert0(mxl_instance); ++ if (MXL_STATUS_OK != mxlReleaseFlowReader(mxl_instance, stream_ctx->mxl_flow_reader)) ++ logw(s, "mxlReleaseFlowReader error\n"); ++ stream_ctx->mxl_flow_reader = NULL; ++ } ++ ++ switch(stream_ctx->flow_type) { ++ case VIDEO_FLOW: ++ stream_ctx->flow.video = (VideoState){0}; ++ break; ++ case AUDIO_FLOW: ++ swr_free(&stream_ctx->flow.audio.swr_context); ++ stream_ctx->flow.audio = (AudioState){0}; ++ break; ++ default: ++ logw(s, "unknown flow_type\n"); ++ } ++} ++ ++/** ++ * Reject configurations that have more than one video or more than ++ * one audio flow. ++ */ ++static int header_validate_flows(AVFormatContext *s, ++ int nb_stream_contexts, ++ const StreamContext* stream_contexts) ++{ ++ av_assert0(stream_contexts); ++ ++ int audio_count = 0; ++ int video_count = 0; ++ for (int i = 0; i < nb_stream_contexts; i++) { ++ switch(stream_contexts[i].flow_type) { ++ case AUDIO_FLOW: ++ audio_count++; ++ break; ++ case VIDEO_FLOW: ++ video_count++; ++ break; ++ default: ++ loge(s, "unknown stream context flow type\n"); ++ return -1; ++ } ++ } ++ ++ if (audio_count > 1) { ++ loge(s, "demuxer does not support multiple audio flows\n"); ++ return -1; ++ } ++ else if (video_count > 1) { ++ loge(s, "demuxer does not support multiple video flows\n"); ++ return -1; ++ } ++ ++ return 0; ++} ++ ++/** ++ * Open the MXL flow, parse the flow definition, and set up the stream. ++ * ++ * Return values: ++ * 0 - success ++ * AVERROR(EINVAL) - unrecognized URL ++ * AVERROR_INVALIDDATA - flow definition error ++ * AVERROR(EIO) - MXL API error, or flow inactive ++ * AVERROR(ENOMEM) - allocation failure ++ */ ++static int mxl_read_header(AVFormatContext *s) ++{ ++ av_assert0(s && s->priv_data); ++ ++ log_format_context(s); ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ mxl_loc loc = {0}; ++ mxlInstance mxl_instance = NULL; ++ StreamContext *stream_contexts = NULL; ++ int initialized_stream_contexts_count = 0; ++ mxl_diag_server *diag_server = NULL; ++ mxl_dump_audio_writer *audio_dump_writer = NULL; ++ ++ MXLContext *p = s->priv_data; ++ ++ if (!s->url) { ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ int rc = mxl_loc_parse(s, s->url, &loc); ++ if (rc) { ++ logv(s, "failed to parse resource locator: %s\n", s->url); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ rc = validate_locator(s, &loc); ++ if (rc) { ++ logv(s, "invalid locator: %s\n", s->url); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ mxl_instance = mxlCreateInstance(loc.domain_path, NULL); ++ if (NULL == mxl_instance) { ++ loge(s, "mxlCreateInstance error\n"); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ stream_contexts = av_malloc_array(loc.nb_flow_ids, sizeof(StreamContext)); ++ if (!stream_contexts) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ memset(stream_contexts, 0, loc.nb_flow_ids * sizeof(StreamContext)); ++ ++ initialized_stream_contexts_count = 0; ++ for (int i = 0; i < loc.nb_flow_ids; i++) { ++ int flow_rc = read_flow(s, mxl_instance, loc.flow_ids[i], &stream_contexts[i]); ++ if (flow_rc) { ++ exit_status = flow_rc; ++ goto finally; ++ } ++ ++ initialized_stream_contexts_count++; ++ } ++ ++ rc = header_validate_flows(s, loc.nb_flow_ids, stream_contexts); ++ if (rc) { ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ if (p->diag_socket) { ++ logv(s, "diagnostic socket: %s\n", p->diag_socket); ++ ++ diag_server = av_mallocz(sizeof(*diag_server)); ++ if (!diag_server) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ rc = mxl_diag_init(s, diag_server, p->diag_socket); ++ if (rc) { ++ loge(s, "mxl_diag_init failed (%d)\n", rc); ++ exit_status = rc; ++ goto finally; ++ } ++ } ++ ++ if (p->audio_dump_file) { ++ logv(s, "audio dump path: %s\n", p->audio_dump_file); ++ ++ audio_dump_writer = av_mallocz(sizeof(*audio_dump_writer)); ++ if (!audio_dump_writer) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ int rc = mxl_dump_init_audio_writer(audio_dump_writer, p->audio_dump_file); ++ if (rc) { ++ loge(s, "failed to init audio dump writer (%d)\n", rc); ++ return AVERROR(rc); ++ } ++ } ++ ++ // success, transfer state ++ p->loc = loc; ++ memset(&loc, 0, sizeof(loc)); ++ ++ p->mxl_instance = mxl_instance; ++ mxl_instance = NULL; ++ ++ p->stream_contexts = stream_contexts; ++ stream_contexts = NULL; ++ ++ p->at_eof = false; ++ ++ p->diag_server = diag_server; ++ diag_server = NULL; ++ ++ p->audio_dump_writer = audio_dump_writer; ++ audio_dump_writer = NULL; ++ ++ header_init_tuning_params(s); ++ ++ exit_status = 0; ++ ++finally: ++ ++ mxl_loc_free(&loc); ++ ++ if (stream_contexts) { ++ av_assert0(mxl_instance); ++ for(int i = 0; i < initialized_stream_contexts_count; i++) ++ release_stream_context(s, mxl_instance, &stream_contexts[i]); ++ av_free(stream_contexts); ++ } ++ ++ if (mxl_instance) { ++ mxlStatus mxl_status = mxlDestroyInstance(mxl_instance); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(s, "mxlDestroyInstance error %s\n", mxl_status_to_str(mxl_status)); ++ } ++ ++ if (diag_server) ++ mxl_diag_close(s, diag_server); ++ ++ if (audio_dump_writer) ++ mxl_dump_close_audio_writer(audio_dump_writer); ++ ++ return exit_status; ++} ++ ++#undef GET1 ++#undef GET2 ++#undef SET_META ++#undef POS_INT_SANITY ++ ++// zero copy packet release callback ++static void mxl_zero_copy_release_cb(void *ctx, uint8_t *data) ++{ ++ MXLContext *p = (MXLContext*)ctx; ++ (void)p; ++ logv(NULL, "mxl_zero_copy_release_cb %p\n", data); ++} ++ ++static int read_video_packet(AVFormatContext *s, AVStream *st, AVPacket *pkt, ++ StreamContext *stream_ctx, ++ const mxlFlowInfo *flow_info) ++{ ++ MXLContext *p = s->priv_data; ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ uint64_t timestamp = mxlGetTime(); ++ uint64_t diag_read_index = 0; ++ ++ logd(s, "read video packet\n"); ++ ++ if (p->max_video_frames > 0 && stream_ctx->flow.video.frame_count >= p->max_video_frames) { ++ logv(s, "at max_video_frames (%d)\n", p->max_video_frames); ++ return AVERROR_EOF; ++ } ++ ++ // init grain index ++ if (0 == stream_ctx->flow.video.mxl_grain_index) { ++ switch (p->grain_index_init) { ++ case GRAIN_INDEX_INIT_CURRENT: { ++ uint64_t cur_grain_index = ++ mxlTimestampToIndex(&stream_ctx->flow.video.mxl_grain_rate, timestamp); ++ if (MXL_UNDEFINED_INDEX == cur_grain_index) { ++ loge(s, "mxlGetCurrentIndex error MXL_UNDEFINED_INDEX\n"); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ stream_ctx->flow.video.mxl_grain_index = cur_grain_index; ++ logv(s, "init video grain_index = %"PRIu64", policy: current time\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ break; ++ } ++ case GRAIN_INDEX_INIT_HEAD: ++ stream_ctx->flow.video.mxl_grain_index = flow_info->runtime.headIndex; ++ logv(s, "init video grain index = %"PRIu64", policy: headIndex\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ break; ++ case GRAIN_INDEX_INIT_TAIL: ++ stream_ctx->flow.video.mxl_grain_index = flow_info->runtime.headIndex - ++ flow_info->config.discrete.grainCount + 1; ++ logv(s, "init video grain index = %"PRIu64", policy: tail index\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ break; ++ default: ++ loge(s, "unrecognized grain_index_init = \"%d\"\n", p->grain_index_init); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ ++ if (p->reset_on_drop) { ++ // Presentation timestamps are mxl_start_grain_index relative. ++ // Rebase so the next packet continues one past the last one ++ // delivered: rebasing onto the resumed grain itself would send ++ // the pts back to 0 mid-stream and trip the monotonic check ++ // below. last_pts is 0 until the first grain is delivered, so ++ // the start-of-stream origin is unchanged. ++ uint64_t resume_pts = stream_ctx->flow.video.last_pts ? ++ stream_ctx->flow.video.last_pts + 1 : 0; ++ stream_ctx->flow.video.mxl_start_grain_index = ++ stream_ctx->flow.video.mxl_grain_index >= resume_pts ? ++ stream_ctx->flow.video.mxl_grain_index - resume_pts : ++ stream_ctx->flow.video.mxl_grain_index; ++ } ++ } ++ ++ // init the start grain index ++ if (0 == stream_ctx->flow.video.mxl_start_grain_index) ++ stream_ctx->flow.video.mxl_start_grain_index = stream_ctx->flow.video.mxl_grain_index; ++ ++ mxlGrainInfo grain_info = {0}; ++ uint8_t *mxl_payload = NULL; ++ mxlStatus mxl_status = MXL_ERR_UNKNOWN; ++ diag_read_index = stream_ctx->flow.video.mxl_grain_index; ++ if ((p->blocking == -1 && p->tuning.video_blocking_read) || p->blocking == 1) { ++ logd(s, "blocking video frame read, grain index %"PRIu64"\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ const AVRational ns_time_base = {1, 1000000000}; ++ uint64_t timeout_ns = (uint64_t)av_rescale_q(1, st->time_base, ns_time_base); ++ mxl_status = mxlFlowReaderGetGrain(stream_ctx->mxl_flow_reader, ++ stream_ctx->flow.video.mxl_grain_index, ++ timeout_ns, &grain_info, ++ &mxl_payload); ++ } ++ else { ++ logd(s, "non-blocking video read, 1 frame at index %"PRIu64"\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ mxl_status = mxlFlowReaderGetGrainNonBlocking(stream_ctx->mxl_flow_reader, ++ stream_ctx->flow.video.mxl_grain_index, ++ &grain_info, &mxl_payload); ++ } ++ ++ if (MXL_ERR_OUT_OF_RANGE_TOO_LATE == mxl_status) { ++ switch(p->on_too_late) { ++ case ON_TOO_LATE_INCREMENT: ++ logv(s, "no video grain, too late with grain index %"PRIu64 ++ ", increment index and try again\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ stream_ctx->flow.video.mxl_grain_index++; ++ exit_status = AVERROR(EAGAIN); ++ break; ++ case ON_TOO_LATE_RESET: ++ logv(s, "no grain, too late with grain index %"PRIu64 ++ ", reset index and try again\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ exit_status = AVERROR(EAGAIN); ++ stream_ctx->flow.video.mxl_grain_index = 0; ++ break; ++ default: ++ loge(s, "unrecognized on_too_late = \"%d\"\n", p->on_too_late); ++ exit_status = AVERROR_BUG; ++ } ++ goto finally; ++ } ++ else if (MXL_ERR_OUT_OF_RANGE_TOO_EARLY == mxl_status) { ++ logd(s, "no grain, too early with grain index %"PRIu64", try again\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ exit_status = AVERROR(EAGAIN); ++ goto finally; ++ } ++ else if (MXL_ERR_TIMEOUT == mxl_status) { ++ logv(s, "no grain, timed out with grain index %"PRIu64", try again\n", ++ stream_ctx->flow.video.mxl_grain_index); ++ exit_status = AVERROR(EAGAIN); ++ goto finally; ++ } ++ else if (MXL_STATUS_OK != mxl_status) { ++ logv(s, "mxlFlowReaderGetGrain error %s (%d)\n", ++ mxl_status_to_str(mxl_status), mxl_status); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ if (grain_info.totalSlices != grain_info.validSlices) { ++ loge(s, "grain size sanity error, totalSlices != validSlices, %" ++ PRIu16" != %"PRIu16"\n", ++ grain_info.totalSlices, grain_info.validSlices); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ size_t grain_size = grain_info.totalSlices * flow_info->config.discrete.sliceSizes[0]; ++ if (p->zero_copy) { ++ AVBufferRef *zcbuf = av_buffer_create(mxl_payload, grain_size, ++ mxl_zero_copy_release_cb, p, 0); ++ if (!zcbuf) { ++ loge(s, "av_buffer_create error\n"); ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ ++ pkt->buf = zcbuf; ++ pkt->data = mxl_payload; ++ pkt->size = grain_size; ++ } ++ else ++ { ++ av_assert1(!pkt->buf); ++ av_assert1(!pkt->data); ++ int rc = av_new_packet(pkt, grain_size); ++ if (rc < 0) { ++ loge(s, "av_new_packet error %d\n", rc); ++ exit_status = rc; ++ goto finally; ++ } ++ av_assert1(pkt->data); ++ memcpy(pkt->data, mxl_payload, grain_size); ++ } ++ ++ int64_t next_pts = ++ stream_ctx->flow.video.mxl_grain_index - stream_ctx->flow.video.mxl_start_grain_index; ++ ++ // monotonic pts sanity check ++ av_assert0(next_pts >= 0 && ++ (stream_ctx->flow.video.last_pts == 0 || ++ next_pts > stream_ctx->flow.video.last_pts)); ++ ++ if (next_pts - stream_ctx->flow.video.last_pts > 1) ++ logi(s, "video pts jumped %d frames\n", next_pts - stream_ctx->flow.video.last_pts); ++ ++ stream_ctx->flow.video.last_pts = next_pts; ++ ++ pkt->pos = -1; ++ pkt->pts = next_pts; ++ pkt->dts = next_pts; ++ pkt->duration = 1; ++ pkt->stream_index = stream_ctx->stream_index; ++ pkt->flags |= AV_PKT_FLAG_KEY; ++ ++ logd(s, "good frame, grain_index=%"PRIu64", next_pts=%"PRId64 ++ ", pkt->data=%p, mxl_payload=%p\n", ++ stream_ctx->flow.video.mxl_grain_index, next_pts, pkt->data, mxl_payload); ++ ++ // all good, advance the grain index ++ stream_ctx->flow.video.mxl_grain_index++; ++ stream_ctx->flow.video.frame_count++; ++ ++ exit_status = 0; ++ ++finally: ++ ++ if (exit_status < 0) ++ av_packet_unref(pkt); ++ ++ if (p->diag_server) { ++ uint64_t exec_dur = mxlGetTime() - timestamp; ++ uint64_t head_index = flow_info->runtime.headIndex; ++ uint64_t tail_index = head_index - flow_info->config.discrete.grainCount + 1; ++ mxl_diag_msg msg = {0}; ++ mxl_diag_init_msg_video_read(&msg, timestamp, exec_dur, ++ tail_index, head_index, ++ diag_read_index, mxl_status); ++ int diag_rc = mxl_diag_send(s, p->diag_server, &msg); ++ if (diag_rc < 0) ++ logd(s, "mxl_diag_send failed (%d)\n", diag_rc); ++ } ++ ++ return exit_status; ++} ++ ++#define USE_SWR_CONVERT ++#ifdef USE_SWR_CONVERT ++static int interleave_from_payload_fragments(AVFormatContext *s, ++ struct SwrContext *swr_context, ++ const mxlWrappedMultiBufferSlice *payload, ++ int fragment_idx, float *dst) { ++ ++ av_assert1(s && swr_context && payload && dst); ++ ++ int exit_status = 0; ++ ++ size_t fragment_size = payload->base.fragments[fragment_idx].size; ++ av_assert1(fragment_size % sizeof(float) == 0); ++ av_assert1(fragment_size / sizeof(float) <= INT_MAX); ++ ++ int nb_samples = (int)(fragment_size / sizeof(float)); ++ av_assert1(nb_samples > 0); ++ av_assert1(nb_samples*sizeof(float) == payload->base.fragments[fragment_idx].size); ++ ++ float **src_array = (float**)av_malloc_array(payload->count, sizeof(*src_array)); ++ if (!src_array) { ++ logw(s, "interleave_from_payload_fragments malloc failed\n"); ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ for (int channel = 0; channel < payload->count; ++channel) { ++ src_array[channel] = (float*)((uint8_t*)payload->base.fragments[fragment_idx].pointer ++ + channel*payload->stride); ++ } ++ // planar to interleaved convert ++ uint8_t *dst_array[1] = { (uint8_t *)dst }; ++ int rc = swr_convert(swr_context, ++ dst_array, nb_samples, ++ (const uint8_t**)src_array, nb_samples); ++ if (rc < 0) { ++ loge(s, "swr_convert error %d\n", rc); ++ exit_status = rc; ++ goto finally; ++ } ++ else if (rc != nb_samples) { ++ loge(s, "incomplete fragment %d planar to interleaved conversion " ++ "(expected %d samples, actual %d samples)\n", fragment_idx, nb_samples, rc); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ ++ exit_status = nb_samples; ++ ++finally: ++ ++ av_free(src_array); ++ ++ return exit_status; ++} ++ ++static int mxl_payload_to_ffmpeg_packet(AVFormatContext *s, ++ struct SwrContext *swr_context, ++ const mxlWrappedMultiBufferSlice *payload, ++ AVPacket *pkt) ++{ ++ av_assert1(s && swr_context && payload && pkt); ++ ++ int exit_status = 0; ++ ++ float* dst0 = (float*)(pkt->data); ++ int rc = interleave_from_payload_fragments(s, swr_context, payload, 0, dst0); ++ if (rc < 0) { ++ exit_status = rc; ++ goto finally; ++ } ++ ++ int nb_samples0 = rc; ++ ++ if (payload->base.fragments[1].size > 0) { ++ float* dst1 = dst0 + nb_samples0*payload->count; ++ int rc = interleave_from_payload_fragments(s, swr_context, payload, 1, dst1); ++ if (rc < 0) { ++ exit_status = rc; ++ goto finally; ++ } ++ ++ int nb_samples1 = rc; ++ (void)nb_samples1; ++ av_assert1((nb_samples0 + nb_samples1)*sizeof(float) == ++ (payload->base.fragments[0].size + payload->base.fragments[1].size)); ++ } ++ else { ++ av_assert1((nb_samples0)*sizeof(float) == (payload->base.fragments[0].size)); ++ } ++ ++finally: ++ ++ return exit_status; ++} ++#else ++// unoptimized interlacer remains for reference ++static int mxl_payload_to_ffmpeg_packet(AVFormatContext *, struct SwrContext *, ++ const mxlWrappedMultiBufferSlice *payload, ++ AVPacket *pkt) ++{ ++ av_assert0(payload && pkt); ++ ++ for (int channel = 0; channel < payload->count; ++channel) { ++ float *dst = (float*)(pkt->data + channel*sizeof(float)); ++ ++ float *src0 = (float*)((uint8_t*)payload->base.fragments[0].pointer + channel*payload->stride); ++ size_t nb_samples0 = payload->base.fragments[0].size/sizeof(float); ++ av_assert0(nb_samples0*sizeof(float) == payload->base.fragments[0].size); ++ for(int i = 0; i < nb_samples0; i++) { ++ *dst = src0[i]; ++ dst += payload->count; ++ } ++ ++ if (payload->base.fragments[1].size > 0) { ++ float *src1 = (float*)((uint8_t*)payload->base.fragments[1].pointer + channel*payload->stride); ++ size_t nb_samples1 = payload->base.fragments[1].size/sizeof(float); ++ av_assert0(nb_samples1*sizeof(float) == payload->base.fragments[1].size); ++ for(int i = 0; i < nb_samples1; i++) { ++ *dst = src1[i]; ++ dst += payload->count; ++ } ++ } ++ } ++ ++ return 0; ++} ++#endif ++ ++static int read_audio_packet(AVFormatContext *s, AVStream *st, AVPacket *pkt, ++ StreamContext *stream_ctx, ++ const mxlFlowInfo *flow_info) ++{ ++ MXLContext *p = s->priv_data; ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ uint64_t timestamp = mxlGetTime(); ++ uint64_t diag_read_index = 0; ++ ++ logd(s, "read audio packet\n"); ++ ++ // default max_samples_per_read is the MXL batch size hint ++ av_assert0(0 < flow_info->config.common.maxCommitBatchSizeHint && ++ flow_info->config.common.maxCommitBatchSizeHint <= INT_MAX); ++ int max_samples_per_read = (int)flow_info->config.common.maxCommitBatchSizeHint; ++ ++ if (p->tuning.audio_samples_per_read > 0) { ++ max_samples_per_read = p->tuning.audio_samples_per_read; ++ } ++ ++ // override default max_samples_per_read with command line option if set ++ if (p->max_audio_samples_per_read > 0) { ++ max_samples_per_read = p->max_audio_samples_per_read; ++ } ++ ++ if (p->max_audio_samples > 0 && stream_ctx->flow.audio.sample_point_count >= p->max_audio_samples) { ++ logv(s, "at max_audio_samples (%d)\n", p->max_audio_samples); ++ return AVERROR_EOF; ++ } ++ ++ // init sample index ++ if (0 == stream_ctx->flow.audio.mxl_sample_index) { ++ switch (p->grain_index_init) { ++ case GRAIN_INDEX_INIT_CURRENT: { ++ uint64_t cur_sample_index = ++ mxlTimestampToIndex(&stream_ctx->flow.audio.mxl_sample_rate, timestamp); ++ if (MXL_UNDEFINED_INDEX == cur_sample_index) { ++ loge(s, "mxlGetCurrentIndex error MXL_UNDEFINED_INDEX\n"); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ stream_ctx->flow.audio.mxl_sample_index = cur_sample_index; ++ logv(s, "init audio sample_index = %"PRIu64", policy: current time\n", ++ stream_ctx->flow.audio.mxl_sample_index); ++ break; ++ } ++ case GRAIN_INDEX_INIT_HEAD: ++ stream_ctx->flow.audio.mxl_sample_index = flow_info->runtime.headIndex; ++ logv(s, "init audio sample index = %"PRIu64", policy: headIndex\n", ++ stream_ctx->flow.audio.mxl_sample_index); ++ break; ++ case GRAIN_INDEX_INIT_TAIL: { ++ // MXL limits readable audio ring buffer length to half ++ // the buffer size. The tail calc must handle ++ // max_audio_samples smaller than readableBufLen and, in ++ // that case, align to the final (possibly partial) read ++ // block. ++ int readableBufLen = (int)(flow_info->config.continuous.bufferLength / 2); ++ if (0 < p->max_audio_samples && p->max_audio_samples < readableBufLen) { ++ int rem = p->max_audio_samples % max_samples_per_read; ++ uint64_t start_index = flow_info->runtime.headIndex - ++ (uint64_t)(p->max_audio_samples - (rem ? rem : max_samples_per_read)); ++ av_assert0(flow_info->runtime.headIndex >= start_index); ++ stream_ctx->flow.audio.mxl_sample_index = start_index; ++ } ++ else { ++ uint64_t start_index = ++ flow_info->runtime.headIndex - readableBufLen; ++ av_assert0(flow_info->runtime.headIndex >= start_index); ++ stream_ctx->flow.audio.mxl_sample_index = start_index; ++ } ++ logv(s, "init audio sample index = %"PRIu64", policy: tail index\n", ++ stream_ctx->flow.audio.mxl_sample_index); ++ break; ++ } ++ default: ++ loge(s, "unrecognized grain_index_init = \"%d\"\n", p->grain_index_init); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ ++ if (p->reset_on_drop) { ++ // Same as the video path: keep the pts monotonic across a ++ // mid-stream reset instead of restarting it at 0. ++ uint64_t resume_pts = stream_ctx->flow.audio.last_pts ? ++ stream_ctx->flow.audio.last_pts + 1 : 0; ++ stream_ctx->flow.audio.mxl_start_sample_index = ++ stream_ctx->flow.audio.mxl_sample_index >= resume_pts ? ++ stream_ctx->flow.audio.mxl_sample_index - resume_pts : ++ stream_ctx->flow.audio.mxl_sample_index; ++ } ++ } ++ ++ // init the start grain index ++ if (0 == stream_ctx->flow.audio.mxl_start_sample_index) ++ stream_ctx->flow.audio.mxl_start_sample_index = stream_ctx->flow.audio.mxl_sample_index; ++ ++ int samples_this_read = max_samples_per_read; ++ if (p->max_audio_samples > 0) { ++ samples_this_read = p->max_audio_samples - stream_ctx->flow.audio.sample_point_count; ++ if (samples_this_read > max_samples_per_read) ++ samples_this_read = max_samples_per_read; ++ } ++ ++ const AVRational ns_time_base = {1, 1000000000}; ++ uint64_t timeout_ns = (uint64_t)av_rescale_q(max_samples_per_read, st->time_base, ns_time_base); ++ mxlWrappedMultiBufferSlice payload = {0}; ++ diag_read_index = stream_ctx->flow.audio.mxl_sample_index; ++ logd(s, "non-blocking audio read, %d samples at index %"PRIu64"\n", ++ samples_this_read, stream_ctx->flow.audio.mxl_sample_index); ++ mxlStatus mxl_status = mxlFlowReaderGetSamples( ++ stream_ctx->mxl_flow_reader, stream_ctx->flow.audio.mxl_sample_index, ++ (size_t)samples_this_read, timeout_ns, &payload); ++ ++ if (MXL_ERR_OUT_OF_RANGE_TOO_LATE == mxl_status) { ++ ++ static int64_t last_log_time; ++ int64_t now = av_gettime_relative(); ++ if (!last_log_time || now - last_log_time >= 1000000) { ++ last_log_time = now; ++ logv(s, "audio too late\n"); ++ } ++ ++ switch(p->on_too_late) { ++ case ON_TOO_LATE_INCREMENT: ++ logv(s, "headIndex = %"PRIu64" vs sample_indx = %"PRIu64" diff = %d\n", ++ flow_info->runtime.headIndex, stream_ctx->flow.audio.mxl_sample_index, ++ flow_info->runtime.headIndex - stream_ctx->flow.audio.mxl_sample_index); ++ logv(s, "no audio samples, too late with sample index %"PRIu64 ++ ", increment index and try again\n", ++ stream_ctx->flow.audio.mxl_sample_index); ++ stream_ctx->flow.audio.mxl_sample_index += max_samples_per_read; ++ exit_status = AVERROR(EAGAIN); ++ break; ++ case ON_TOO_LATE_RESET: ++ logv(s, "no audio samples, too late with sample index %"PRIu64 ++ ", reset index and try again\n", ++ stream_ctx->flow.audio.mxl_sample_index); ++ exit_status = AVERROR(EAGAIN); ++ stream_ctx->flow.audio.mxl_sample_index = 0; ++ break; ++ default: ++ loge(s, "unrecognized on_too_late = \"%d\"\n", p->on_too_late); ++ exit_status = AVERROR_BUG; ++ } ++ goto finally; ++ } ++ else if (MXL_ERR_OUT_OF_RANGE_TOO_EARLY == mxl_status) { ++ logd(s, "no audio samples, too early with sample index %"PRIu64", try again\n", ++ stream_ctx->flow.audio.mxl_sample_index); ++ exit_status = AVERROR(EAGAIN); ++ goto finally; ++ } ++ else if (MXL_ERR_TIMEOUT == mxl_status) { ++ logv(s, "no audio samples, timed out with sample index %"PRIu64", try again\n", ++ stream_ctx->flow.audio.mxl_sample_index); ++ exit_status = AVERROR(EAGAIN); ++ goto finally; ++ } ++ else if (MXL_STATUS_OK != mxl_status) { ++ logv(s, "mxlFlowReaderGetSamples error %s (%d)\n", ++ mxl_status_to_str(mxl_status), mxl_status); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ int payload_len_bytes = samples_this_read * payload.count * sizeof(float); ++ ++ // payload total length invariant ++ av_assert0((size_t)payload_len_bytes == ++ payload.count*(payload.base.fragments[0].size + payload.base.fragments[1].size)); ++ av_assert1(payload_len_bytes <= INT_MAX); ++ av_assert1(!pkt->buf); ++ int rc = av_new_packet(pkt, payload_len_bytes); ++ if (rc < 0) { ++ loge(s, "av_new_packet error %d\n", rc); ++ exit_status = rc; ++ goto finally; ++ } ++ av_assert1(pkt->buf); ++ av_assert1(pkt->size == payload_len_bytes); ++ ++ // stride and bufferLength relationship invariant ++ av_assert0(payload.stride == flow_info->config.continuous.bufferLength * sizeof(float)); ++ ++ // interleave planar source buffers into pkt->data ++ rc = mxl_payload_to_ffmpeg_packet(s, stream_ctx->flow.audio.swr_context, &payload, pkt); ++ if (rc < 0) { ++ loge(s, "audio payload to packet conversion failed\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ ++ int64_t next_pts = ++ stream_ctx->flow.audio.mxl_sample_index - stream_ctx->flow.audio.mxl_start_sample_index; ++ ++ // monotonic pts sanity check ++ av_assert0(next_pts >= 0 && ++ (stream_ctx->flow.audio.last_pts == 0 || ++ next_pts > stream_ctx->flow.audio.last_pts)); ++ ++ if (next_pts - stream_ctx->flow.audio.last_pts > samples_this_read) ++ logi(s, "audio pts jumped %d samples\n", next_pts - stream_ctx->flow.audio.last_pts); ++ ++ stream_ctx->flow.audio.last_pts = next_pts; ++ ++ pkt->pts = next_pts; ++ pkt->dts = next_pts; ++ pkt->duration = samples_this_read; ++ pkt->stream_index = stream_ctx->stream_index; ++ ++ logd(s, "good audio block, sample_index=%"PRIu64", next_pts=%"PRId64 ++ ", samples_this_read = %d, pkt->data=%p mxl_payload_0=%p, mxl_payload_1=%p\n", ++ stream_ctx->flow.audio.mxl_sample_index, next_pts, samples_this_read, pkt->data, ++ payload.base.fragments[0].pointer, ++ payload.base.fragments[1].size > 0 ? payload.base.fragments[1].pointer : NULL); ++ ++ // all good, advance the sample index ++ stream_ctx->flow.audio.mxl_sample_index += samples_this_read; ++ stream_ctx->flow.audio.sample_point_count += samples_this_read; ++ ++ exit_status = 0; ++ ++finally: ++ ++ if (exit_status == 0 && p->audio_dump_writer) { ++ mxl_dump_write_audio(p->audio_dump_writer, pkt->data, pkt->size); ++ } ++ ++ if (exit_status < 0) ++ av_packet_unref(pkt); ++ ++ if (p->diag_server) { ++ uint64_t duration = mxlGetTime() - timestamp; ++ uint64_t head_index = flow_info->runtime.headIndex; ++ uint64_t tail_index = head_index - flow_info->config.continuous.bufferLength + 1; ++ mxl_diag_msg msg = {0}; ++ mxl_diag_init_msg_audio_read(&msg, timestamp, duration, ++ tail_index, head_index, ++ diag_read_index, ++ (uint32_t)samples_this_read, mxl_status); ++ int diag_rc = mxl_diag_send(s, p->diag_server, &msg); ++ if (diag_rc < 0) ++ logd(s, "mxl_diag_send failed (%d)\n", diag_rc); ++ } ++ ++ return exit_status; ++} ++ ++static int mxl_read_packet(AVFormatContext *s, AVPacket *pkt) ++{ ++ av_assert0(s && s->priv_data); ++ ++ MXLContext *p = s->priv_data; ++ ++ int stream_ctx_idx = p->next_stream_context_idx; ++ p->next_stream_context_idx = ++ (p->next_stream_context_idx + 1) % p->loc.nb_flow_ids; ++ ++ av_assert0(stream_ctx_idx < p->loc.nb_flow_ids); ++ ++ // early bail-out to avoid flooding the log ++ if (p->at_eof) { ++ logv(s, "at eof\n"); ++ return AVERROR_EOF; ++ } ++ ++ StreamContext* stream_ctx = &p->stream_contexts[stream_ctx_idx]; ++ ++ av_assert0(stream_ctx->stream_index < s->nb_streams); ++ AVStream *st = s->streams[stream_ctx->stream_index]; ++ ++ int exit_status = AVERROR_UNKNOWN; ++ bool active = false; ++ ++ if (p->diag_server) { ++ int diag_rc = mxl_diag_poll(s, p->diag_server); ++ if (diag_rc < 0) ++ logd(s, "mxl_diag_poll failed (%d)\n", diag_rc); ++ } ++ ++ mxlStatus mxl_status = mxlIsFlowActive( ++ p->mxl_instance, p->loc.flow_ids[stream_ctx_idx], &active); ++ if (MXL_STATUS_OK == mxl_status && !active) { ++ logv(s, "flow not active, stopping\n"); ++ p->at_eof = true; ++ exit_status = AVERROR_EOF; ++ goto finally; ++ } ++ else if (MXL_ERR_FLOW_NOT_FOUND == mxl_status) { ++ logv(s, "flow not found, stopping\n"); ++ p->at_eof = true; ++ exit_status = AVERROR_EOF; ++ goto finally; ++ } ++ else if (mxl_status != MXL_STATUS_OK) { ++ loge(s, "mxlIsFlowActive failed %s, retrying\n", mxl_status_to_str(mxl_status)); ++ exit_status = AVERROR(EAGAIN); ++ goto finally; ++ } ++ ++ mxlFlowInfo flow_info = {0}; ++ mxl_status = mxlFlowReaderGetInfo(stream_ctx->mxl_flow_reader, &flow_info); ++ if (mxl_status != MXL_STATUS_OK) { ++ loge(s, "mxlFlowReaderGetInfo failed\n"); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ switch (stream_ctx->flow_type) { ++ case VIDEO_FLOW: { ++ int rc = read_video_packet(s, st, pkt, stream_ctx, &flow_info); ++ if (rc) { ++ exit_status = rc; ++ goto finally; ++ } ++ break; ++ } ++ case AUDIO_FLOW: { ++ int rc = read_audio_packet(s, st, pkt, stream_ctx, &flow_info); ++ if (rc) { ++ exit_status = rc; ++ goto finally; ++ } ++ break; ++ } ++ default: ++ loge(s, "unknown flow type\n"); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ ++ exit_status = 0; ++ ++finally: ++ ++ return exit_status; ++} ++ ++/** ++ * Free or release all allocated resources. ++ * ++ * - 0 - success ++ */ ++static int mxl_read_close(AVFormatContext *s) ++{ ++ av_assert0(s && s->priv_data); ++ ++ MXLContext *p = s->priv_data; ++ ++ for (int i = 0; i < p->loc.nb_flow_ids; i++) ++ release_stream_context(s, p->mxl_instance, &p->stream_contexts[i]); ++ av_free(p->stream_contexts); ++ p->stream_contexts = NULL; ++ ++ if (p->mxl_instance) { ++ if (MXL_STATUS_OK != mxlDestroyInstance(p->mxl_instance)) ++ logw(s, "mxlDestroyInstance error\n"); ++ p->mxl_instance = NULL; ++ } ++ ++ mxl_loc_free(&p->loc); ++ ++ p->at_eof = false; ++ ++ if (p->diag_server) { ++ mxl_diag_close(s, p->diag_server); ++ av_free(p->diag_server); ++ p->diag_server = NULL; ++ } ++ ++ if (p->audio_dump_writer) { ++ mxl_dump_close_audio_writer(p->audio_dump_writer); ++ av_free(p->audio_dump_writer); ++ p->audio_dump_writer = NULL; ++ } ++ ++ return 0; ++} ++ ++// MXL demuxer definition ++const FFInputFormat ff_mxl_demuxer = { ++ // AVInputFormat (public-facing fields) ++ .p.name = "mxl", ++ .p.long_name = NULL_IF_CONFIG_SMALL(MXL_LONG_NAME), ++ .p.extensions = MXL_FLOW_EXT, ++ .p.priv_class = &mxl_demuxer_class, ++ .p.flags = AVFMT_NOFILE // not a file ++ | AVFMT_NOBINSEARCH // no binary search seek ++ | AVFMT_NOGENSEARCH // no generic timestamp search ++ | AVFMT_NO_BYTE_SEEK, // no byte position seek ++ ++ // private state ++ .priv_data_size = sizeof(MXLContext), ++ ++ // demuxer callbacks ++ .read_probe = mxl_probe, ++ .read_header = mxl_read_header, ++ .read_packet = mxl_read_packet, ++ .read_close = mxl_read_close, ++ ++ // unimplemented ++ .read_seek = NULL, // not seekable ++ .read_seek2 = NULL, // no timestamp seeking ++ .read_set_state = NULL, // no pause/resume ++ .read_timestamp = NULL, // no timestamp probing ++ .get_device_list = NULL, // not a device input ++ ++ // internal behavior flags (init/cleanup semantic) ++ .flags_internal = FF_INFMT_FLAG_INIT_CLEANUP, ++}; +diff --git a/libavformat/mxlenc.c b/libavformat/mxlenc.c +new file mode 100644 +index 0000000000..a0599f1e37 +--- /dev/null ++++ b/libavformat/mxlenc.c +@@ -0,0 +1,893 @@ ++/* ++ * MXL muxer for Media eXchange Layer flows ++ * ++ * AVFMT_NOFILE muxer; requires "-{audio|video}_flow_id id /path/to/domain" ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++/* ++ * See: https://github.com/dmf-mxl/mxl ++ */ ++ ++#include "mxl_log.h" ++#include "mxl_common.h" ++#include "mxl_status.h" ++#include "mxl_flow_def.h" ++#include "mux.h" ++#include "avformat.h" ++#include "avio.h" ++#include "internal.h" ++ ++#include ++#include ++#include "libavutil/opt.h" ++#include "libavutil/avassert.h" ++#include "libavutil/file.h" ++#include "libavutil/mem.h" ++#include "libavutil/time.h" ++ ++#include ++#include ++#include ++ ++#include ++#include ++ ++typedef struct FlowState { ++ int stream_index; ++ const char *flow_id; ++ const char *detail; ++ mxlFlowConfigInfo *mxl_flow_config_info; ++ mxlFlowWriter mxl_flow_writer; ++ mxlRational mxl_grain_rate; ++ uint64_t mxl_start_index; ++ uint64_t mxl_cur_index; ++} FlowState; ++ ++typedef struct VideoStreamState { ++ VideoFlowDefParams flow_def_params; ++ FlowState flow; ++} VideoStreamState; ++ ++typedef struct AudioStreamState { ++ AudioFlowDefParams flow_def_params; ++ FlowState flow; ++ struct SwrContext *swr_context; ++} AudioStreamState; ++ ++typedef struct State { ++ mxlInstance mxl_instance; ++ ++ bool has_video_stream; ++ VideoStreamState video; ++ ++ bool has_audio_stream; ++ AudioStreamState audio; ++} State; ++ ++typedef struct MXLContext { ++ ++ const AVClass *class; ++ ++ // domain_path from input url ++ const char *domain_path; ++ ++ // options ++ const char *audio_flow_id; ++ const char *video_flow_id; ++ const char *teardown_sync_file; ++ int teardown_sync_timeout; ++ ++ // state ++ State state; ++} MXLContext; ++ ++ ++#define OFFSET(x) offsetof(MXLContext, x) ++#define FLAGS AV_OPT_FLAG_ENCODING_PARAM | AV_OPT_FLAG_VIDEO_PARAM ++static const AVOption mxl_options[] = { ++ { ++ .name = "audio_flow_id", ++ .help = "MXL audio flow ID", ++ .offset = OFFSET(audio_flow_id), ++ .type = AV_OPT_TYPE_STRING, ++ .default_val = { .str = NULL }, ++ .flags = FLAGS, ++ }, ++ { ++ .name = "video_flow_id", ++ .help = "MXL video flow ID", ++ .offset = OFFSET(video_flow_id), ++ .type = AV_OPT_TYPE_STRING, ++ .default_val = { .str = NULL }, ++ .flags = FLAGS, ++ }, ++ { ++ .name = "teardown_sync_file", ++ .help = "wait for sentinel file to appear before destroying flow", ++ .offset = OFFSET(teardown_sync_file), ++ .type = AV_OPT_TYPE_STRING, ++ .flags = FLAGS ++ }, ++ { ++ .name = "teardown_sync_timeout", ++ .help = "maximum wait time in ms for teardown sync file", ++ .offset = OFFSET(teardown_sync_timeout), ++ .type = AV_OPT_TYPE_INT, ++ .default_val = { .i64 = 2000 }, ++ .min = 1, ++ .max = 10000, ++ .flags = FLAGS ++ }, ++ { NULL }, ++}; ++#undef OFFSET ++#undef FLAGS ++ ++static const AVClass mxl_muxer_class = { ++ .class_name = "mxl muxer", ++ .item_name = av_default_item_name, ++ .option = mxl_options, ++ .version = LIBAVUTIL_VERSION_INT, ++}; ++ ++static int validate_streams(AVFormatContext *s) ++{ ++ MXLContext *p = s->priv_data; ++ int exit_status = AVERROR_UNKNOWN; ++ ++ p->state.has_video_stream = false; ++ p->state.video.flow.detail = "video"; ++ p->state.video.flow.stream_index = -1; ++ ++ p->state.has_audio_stream = false; ++ p->state.audio.flow.detail = "audio"; ++ p->state.audio.flow.stream_index = -1; ++ ++ for(int i = 0; i < s->nb_streams; i++) { ++ AVStream *st = s->streams[i]; ++ ++ switch(st->codecpar->codec_type) { ++ case AVMEDIA_TYPE_VIDEO: ++ if (p->video_flow_id) { ++ if (st->codecpar->codec_id != AV_CODEC_ID_V210) { ++ loge(s, "unsupported video stream, codec must be v210\n"); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ // v210 requires even frame width ++ if (st->codecpar->width & 1) { ++ loge(s, "v210 width must be even, got %d\n", st->codecpar->width); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ p->state.has_video_stream = true; ++ p->state.video.flow.flow_id = p->video_flow_id; ++ p->state.video.flow.stream_index = i; ++ } ++ break; ++ case AVMEDIA_TYPE_AUDIO: ++ if (p->audio_flow_id) { ++ if (st->codecpar->codec_id != AV_CODEC_ID_PCM_F32LE) { ++ loge(s, "unsupported audio stream, codec must be f32le\n"); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ p->state.has_audio_stream = true; ++ p->state.audio.flow.flow_id = p->audio_flow_id; ++ p->state.audio.flow.stream_index = i; ++ } ++ break; ++ default: ++ logv(s, "ignoring unsupported stream %d (%s/%s)\n", ++ i, ++ av_get_media_type_string(st->codecpar->codec_type), ++ avcodec_get_name(st->codecpar->codec_id)); ++ break; ++ } ++ } ++ ++ if (!(p->state.has_video_stream || p->state.has_audio_stream)) { ++ loge(s, "no audio or video stream detected\n"); ++ exit_status = AVERROR_INVALIDDATA; ++ goto finally; ++ } ++ ++ exit_status = 0; ++ ++finally: ++ ++ return exit_status; ++} ++ ++typedef struct FlowDefFactoryResult { ++ // borrowed, immutable, must not be freed ++ const char * const flow_id; ++ ++ // owned by factory caller, mutable, must be freed by caller ++ char *flow_def_json; ++ ++ // borrowed, mutable, must not be freed or reassigned ++ FlowState * const flow_state; ++} FlowDefFactoryResult; ++ ++typedef FlowDefFactoryResult (*FlowDefFactory)(AVStream *st, MXLContext *p); ++ ++static int create_mxl_flow(AVFormatContext *s, AVStream *st, ++ mxlInstance mxl_instance, ++ FlowDefFactory flow_def_factory) ++{ ++ av_assert0(s && st && mxl_instance && flow_def_factory); ++ ++ MXLContext *p = s->priv_data; ++ ++ mxlFlowWriter flow_writer = NULL; ++ mxlFlowConfigInfo *flow_config_info = NULL; ++ ++ FlowDefFactoryResult factory_res = flow_def_factory(st, p); ++ av_assert0(factory_res.flow_id && factory_res.flow_def_json && factory_res.flow_state); ++ logv(s, "%s flow definition:\n%s\n", factory_res.flow_state->detail, factory_res.flow_def_json); ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ flow_config_info = av_malloc(sizeof(*flow_config_info)); ++ if (!flow_config_info) { ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ *flow_config_info = (mxlFlowConfigInfo){0}; ++ ++ bool new_flow_created = false; ++ mxlStatus mxl_status = mxlCreateFlowWriter( ++ mxl_instance, factory_res.flow_def_json, ++ NULL, &flow_writer, flow_config_info, &new_flow_created); ++ if (MXL_STATUS_OK != mxl_status) { ++ loge(s, "mxlCreateFlowWriter error %s\n", mxl_status_to_str(mxl_status)); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ logv(s, "mxlCreateFlowWriter opened %s flow\n", ++ new_flow_created ? "a new" : "an existing"); ++ ++ // all good, transfer state to context ++ factory_res.flow_state->mxl_flow_config_info = flow_config_info; ++ flow_config_info = NULL; ++ ++ factory_res.flow_state->mxl_flow_writer = flow_writer; ++ flow_writer = NULL; ++ ++ exit_status = 0; ++ ++finally: ++ ++ if (flow_writer) { ++ mxl_status = mxlReleaseFlowWriter(mxl_instance, flow_writer); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(s, "mxlReleaseFlowWriter error %s\n", mxl_status_to_str(mxl_status)); ++ flow_writer = NULL; ++ } ++ ++ av_free(flow_config_info); ++ av_free(factory_res.flow_def_json); ++ ++ return exit_status; ++} ++ ++static FlowDefFactoryResult video_flow_def_factory(AVStream *st, MXLContext* p) ++{ ++ av_assert0(st && p); ++ ++ AVRational fr = st->avg_frame_rate.num ? st->avg_frame_rate : st->r_frame_rate; ++ p->state.video.flow.mxl_grain_rate = (mxlRational){fr.num, fr.den}; ++ AVCodecParameters *par = st->codecpar; ++ p->state.video.flow_def_params = (VideoFlowDefParams) { ++ .id = p->state.video.flow.flow_id, ++ .grain_rate_num = fr.num, ++ .grain_rate_den = fr.den, ++ .frame_width = par->width, ++ .frame_height = par->height, ++ .y_width = par->width, ++ .y_height = par->height, ++ .y_bit_depth = 10, ++ .cb_width = par->width/2, ++ .cb_height = par->height, ++ .cb_bit_depth = 10, ++ .cr_width = par->width/2, ++ .cr_height = par->height, ++ .cr_bit_depth = 10 ++ }; ++ ++ return (FlowDefFactoryResult){ ++ .flow_id = p->state.video.flow.flow_id, ++ .flow_def_json = make_video_flow_def(&p->state.video.flow_def_params), ++ .flow_state = &p->state.video.flow}; ++} ++ ++static int create_video_flow(AVFormatContext *s, AVStream *st, ++ mxlInstance mxl_instance) ++{ ++ return create_mxl_flow(s, st, mxl_instance, video_flow_def_factory); ++} ++ ++static FlowDefFactoryResult audio_flow_def_factory(AVStream *st, MXLContext* p) ++{ ++ av_assert0(st && p); ++ ++ AVCodecParameters *par = st->codecpar; ++ p->state.audio.flow.mxl_grain_rate = (mxlRational){par->sample_rate, 1}; ++ p->state.audio.flow_def_params = (AudioFlowDefParams) { ++ .id = p->state.audio.flow.flow_id, ++ .sample_rate = par->sample_rate, ++ .channel_count = par->ch_layout.nb_channels ++ }; ++ ++ return (FlowDefFactoryResult){ ++ .flow_id = p->state.audio.flow.flow_id, ++ .flow_def_json = make_audio_flow_def(&p->state.audio.flow_def_params), ++ .flow_state = &p->state.audio.flow}; ++} ++ ++static int create_audio_flow(AVFormatContext *s, AVStream *st, ++ mxlInstance mxl_instance) ++{ ++ return create_mxl_flow(s, st, mxl_instance, audio_flow_def_factory); ++} ++ ++// create SwrContext for interleaved to planar audio conversion ++static int init_swr_context(struct SwrContext **swr_context, ++ int channels, int sample_rate) ++{ ++ AVChannelLayout in_layout, out_layout; ++ av_channel_layout_default(&in_layout, channels); ++ av_channel_layout_default(&out_layout, channels); ++ ++ int rc = swr_alloc_set_opts2(swr_context, ++ &out_layout, AV_SAMPLE_FMT_FLTP, sample_rate, // planar out ++ &in_layout, AV_SAMPLE_FMT_FLT, sample_rate, // interleaved in ++ 0, NULL); ++ if (rc < 0) ++ return rc; ++ ++ rc = swr_init(*swr_context); ++ if (rc < 0) { ++ swr_free(swr_context); ++ return rc; ++ } ++ ++ return 0; ++} ++ ++static int mxl_write_header(AVFormatContext *s) ++{ ++ av_assert0(s && s->priv_data); ++ ++ MXLContext *p = s->priv_data; ++ int exit_status = AVERROR_UNKNOWN; ++ ++ // inputs ++ const char *domain_path = s->url; ++ ++ // state ++ mxlInstance mxl_instance = NULL; ++ ++ if (!domain_path) { ++ loge(s, "domain not set\n"); ++ exit_status = AVERROR(EINVAL); ++ goto finally; ++ } ++ logv(s, "domain: \"%s\"\n", domain_path); ++ ++ int rc = validate_streams(s); ++ if (rc < 0) { ++ loge(s, "stream validation error\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ ++ // post validation sanity ++ av_assert0(p->state.has_video_stream || p->state.has_audio_stream); ++ av_assert0(p->state.has_video_stream == !!p->state.video.flow.flow_id); ++ av_assert0(p->state.has_audio_stream == !!p->state.audio.flow.flow_id); ++ ++ mxl_instance = mxlCreateInstance(domain_path, NULL); ++ if (NULL == mxl_instance) { ++ logv(s, "mxlCreateInstance error\n"); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ if (p->state.has_video_stream) { ++ av_assert0(p->state.video.flow.stream_index >= 0 && ++ p->state.video.flow.flow_id && ++ p->state.video.flow.detail); ++ rc = create_video_flow(s, s->streams[p->state.video.flow.stream_index], mxl_instance); ++ if (rc < 0) { ++ loge(s, "error configuring mxl video flow\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ } ++ ++ if (p->state.has_audio_stream) { ++ av_assert0(p->state.audio.flow.stream_index >= 0 && ++ p->state.audio.flow.flow_id && ++ p->state.audio.flow.detail); ++ rc = create_audio_flow(s, s->streams[p->state.audio.flow.stream_index], mxl_instance); ++ if (rc < 0) { ++ loge(s, "error configuring mxl audio flow\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ ++ rc = init_swr_context(&p->state.audio.swr_context, ++ p->state.audio.flow_def_params.channel_count, ++ p->state.audio.flow_def_params.sample_rate); ++ if (rc < 0) { ++ loge(s, "init_swr_context failed\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ } ++ ++ // all good, transfer state to context ++ p->domain_path = domain_path; ++ domain_path = NULL; ++ ++ p->state.mxl_instance = mxl_instance; ++ mxl_instance = NULL; ++ ++ exit_status = 0; ++ ++finally: ++ ++ if (mxl_instance) { ++ mxlStatus mxl_status = mxlDestroyInstance(mxl_instance); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(s, "mxlDestroyInstance error %s\n", mxl_status_to_str(mxl_status)); ++ } ++ ++ return exit_status; ++ ++} ++ ++static int video_write_packet(AVFormatContext *s, AVPacket *pkt) ++{ ++ int exit_status = AVERROR_UNKNOWN; ++ MXLContext *p = s->priv_data; ++ ++ if (0 == p->state.video.flow.mxl_start_index) { ++ uint64_t cur_index = mxlGetCurrentIndex(&p->state.video.flow.mxl_grain_rate); ++ av_assert0(MXL_UNDEFINED_INDEX != cur_index); ++ p->state.video.flow.mxl_start_index = cur_index; ++ p->state.video.flow.mxl_cur_index = cur_index; ++ logv(s, "video start index = %" PRIu64 "\n", cur_index); ++ } ++ ++ mxlStatus mxl_status = MXL_ERR_UNKNOWN; ++ mxlGrainInfo grain_info = {0}; ++ bool grain_open = false; ++ uint8_t *payload = NULL; ++ ++ logd(s, "write video grain for index = %" PRIu64 "\n", ++ p->state.video.flow.mxl_cur_index); ++ ++ mxl_status = mxlFlowWriterOpenGrain(p->state.video.flow.mxl_flow_writer, ++ p->state.video.flow.mxl_cur_index, ++ &grain_info, &payload); ++ if (MXL_STATUS_OK != mxl_status) { ++ loge(s, "mxlFlowWriterOpenGrain error %s\n", mxl_status_to_str(mxl_status)); ++ exit_status = AVERROR(EAGAIN); ++ goto finally; ++ } ++ grain_open = true; ++ ++ size_t grain_size = grain_info.totalSlices * ++ p->state.video.flow.mxl_flow_config_info->discrete.sliceSizes[0]; ++ ++ if (pkt->size > grain_size) { ++ loge(s, "packet size exceeds grain size (%d > %zu)\n", ++ pkt->size, grain_size); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ ++ memcpy(payload, pkt->data, pkt->size); ++ ++ // mark all slices valid ++ grain_info.validSlices = grain_info.totalSlices; ++ ++ if (pkt->size < grain_info.grainSize) ++ logw(s, "grain under committed (pkt->size < grain_info.grainSize, %d < %" PRIu32 ")\n", ++ pkt->size, grain_info.grainSize); ++ ++ mxl_status = mxlFlowWriterCommitGrain(p->state.video.flow.mxl_flow_writer, &grain_info); ++ if (MXL_STATUS_OK != mxl_status) { ++ loge(s, "mxlFlowWriterCommitGrain error %s\n", mxl_status_to_str(mxl_status)); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ grain_open = false; ++ p->state.video.flow.mxl_cur_index++; ++ ++ exit_status = 0; ++ ++finally: ++ ++ if (grain_open) { ++ mxl_status = mxlFlowWriterCancelGrain(p->state.video.flow.mxl_flow_writer); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(s, "mxlFlowWriterCancelGrain error %s\n", mxl_status_to_str(mxl_status)); ++ grain_open = false; ++ } ++ ++ return exit_status; ++} ++ ++#define USE_SWR_CONVERT ++#ifdef USE_SWR_CONVERT ++static int deinterleave_to_payload_fragments(AVFormatContext *s, ++ struct SwrContext *swr_context, ++ mxlMutableWrappedMultiBufferSlice *payload, ++ int fragment_idx, const float *src) { ++ ++ av_assert1(s && swr_context && payload && src); ++ ++ int exit_status = 0; ++ ++ size_t nb_samples = payload->base.fragments[fragment_idx].size/sizeof(float); ++ av_assert1(nb_samples*sizeof(float) == payload->base.fragments[fragment_idx].size); ++ ++ float **dst_array = (float**)av_malloc_array(payload->count, sizeof(*dst_array)); ++ if (!dst_array) { ++ logw(s, "deinterleave_to_payload_fragments malloc failed\n"); ++ exit_status = AVERROR(ENOMEM); ++ goto finally; ++ } ++ for (int channel = 0; channel < payload->count; ++channel) { ++ dst_array[channel] = (float*)((uint8_t*)payload->base.fragments[fragment_idx].pointer ++ + channel*payload->stride); ++ } ++ ++ // interleaved to planar convert ++ const uint8_t *src_array[1] = { (const uint8_t *)src }; ++ int rc = swr_convert(swr_context, ++ (uint8_t**)dst_array, nb_samples, ++ src_array, nb_samples); ++ if (rc < 0) { ++ loge(s, "swr_convert error %d\n", rc); ++ exit_status = rc; ++ goto finally; ++ } ++ else if (rc != nb_samples) { ++ loge(s, "incomplete fragment %d interleaved to planar conversion " ++ "(expected %d samples, actual %d samples)\n", fragment_idx, nb_samples, rc); ++ exit_status = AVERROR_BUG; ++ } ++ ++ exit_status = nb_samples; ++ ++finally: ++ ++ av_free(dst_array); ++ ++ return exit_status; ++} ++ ++ ++static int ffmpeg_packet_to_mxl_payload(AVFormatContext *s, ++ struct SwrContext *swr_context, ++ mxlMutableWrappedMultiBufferSlice *payload, ++ const AVPacket *pkt) ++{ ++ av_assert0(s && swr_context && payload && pkt); ++ ++ int exit_status = 0; ++ ++ float *src0 = (float*)(pkt->data); ++ int rc = deinterleave_to_payload_fragments(s, swr_context, payload, 0, src0); ++ if (rc < 0) { ++ exit_status = rc; ++ goto finally; ++ } ++ ++ int nb_samples0 = rc; ++ ++ if (payload->base.fragments[1].size > 0) { ++ float* src1 = src0 + nb_samples0*payload->count; ++ int rc = deinterleave_to_payload_fragments(s, swr_context, payload, 1, src1); ++ if (rc < 0) { ++ exit_status = rc; ++ goto finally; ++ } ++ ++ int nb_samples1 = rc; ++ (void)nb_samples1; ++ av_assert1((nb_samples0 + nb_samples1)*sizeof(float) == ++ (payload->base.fragments[0].size + payload->base.fragments[1].size)); ++ } ++ else { ++ av_assert1(nb_samples0*sizeof(float) == payload->base.fragments[0].size); ++ } ++ ++finally: ++ ++ return exit_status; ++} ++#else ++// unoptimized deinterlacer, retained for reference ++static int ffmpeg_packet_to_mxl_payload(AVFormatContext *s, ++ struct SwrContext *swr_context, ++ mxlMutableWrappedMultiBufferSlice *payload, ++ const AVPacket *pkt) ++{ ++ av_assert0(payload && pkt); ++ ++ for (int channel = 0; channel < payload->count; ++channel) { ++ float *src = (float*)(pkt->data + channel*sizeof(float)); ++ ++ float *dst0 = (float*)((uint8_t*)payload->base.fragments[0].pointer + channel*payload->stride); ++ size_t nb_samples0 = payload->base.fragments[0].size/sizeof(float); ++ av_assert0(nb_samples0*sizeof(float) == payload->base.fragments[0].size); ++ for(int i = 0; i < nb_samples0; i++) { ++ dst0[i] = *src; ++ src += payload->count; ++ } ++ ++ if (payload->base.fragments[1].size > 0) { ++ float *dst1 = (float*)((uint8_t*)payload->base.fragments[1].pointer + channel*payload->stride); ++ size_t nb_samples1 = payload->base.fragments[1].size/sizeof(float); ++ av_assert0(nb_samples1*sizeof(float) == payload->base.fragments[1].size); ++ for(int i = 0; i < nb_samples1; i++) { ++ dst1[i] = *src; ++ src += payload->count; ++ } ++ } ++ } ++ ++ return 0; ++} ++#endif ++ ++static int audio_write_packet(AVFormatContext *s, AVPacket *pkt) ++{ ++ int exit_status = AVERROR_UNKNOWN; ++ MXLContext *p = s->priv_data; ++ ++ av_assert0(s->streams[pkt->stream_index]->codecpar->ch_layout.nb_channels == ++ p->state.audio.flow_def_params.channel_count); ++ av_assert0(pkt->duration <= ++ pkt->size / p->state.audio.flow_def_params.channel_count / sizeof(float)); ++ ++ if (0 == p->state.audio.flow.mxl_start_index) { ++ uint64_t cur_index = mxlGetCurrentIndex(&p->state.audio.flow.mxl_grain_rate); ++ av_assert0(MXL_UNDEFINED_INDEX != cur_index); ++ p->state.audio.flow.mxl_start_index = cur_index; ++ p->state.audio.flow.mxl_cur_index = cur_index; ++ logv(s, "audio start index = %" PRIu64 "\n", cur_index); ++ } ++ ++ mxlStatus mxl_status = MXL_ERR_UNKNOWN; ++ bool samples_open = false; ++ size_t sample_count = pkt->duration; ++ ++ logd(s, "write audio samples for index = %" PRIu64 ", sample_count = %zu\n", ++ p->state.audio.flow.mxl_cur_index, sample_count); ++ ++ mxlMutableWrappedMultiBufferSlice payload = (mxlMutableWrappedMultiBufferSlice){0}; ++ mxl_status = mxlFlowWriterOpenSamples(p->state.audio.flow.mxl_flow_writer, ++ p->state.audio.flow.mxl_cur_index, ++ sample_count, &payload); ++ if (MXL_STATUS_OK != mxl_status) { ++ loge(s, "mxlFlowWriterOpenSamples error %s\n", mxl_status_to_str(mxl_status)); ++ exit_status = AVERROR(EAGAIN); ++ goto finally; ++ } ++ ++ samples_open = true; ++ ++ int rc = ffmpeg_packet_to_mxl_payload(s, p->state.audio.swr_context, &payload, pkt); ++ if (rc < 0) { ++ loge(s, "audio packet to MXL payload conversion failed\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ ++ mxl_status = mxlFlowWriterCommitSamples(p->state.audio.flow.mxl_flow_writer); ++ if (MXL_STATUS_OK != mxl_status) { ++ loge(s, "mxlFlowWriterCommitSamples error %s\n", mxl_status_to_str(mxl_status)); ++ exit_status = AVERROR(EIO); ++ goto finally; ++ } ++ ++ samples_open = false; ++ p->state.audio.flow.mxl_cur_index += sample_count; ++ ++ exit_status = 0; ++ ++finally: ++ ++ if (samples_open) { ++ mxl_status = mxlFlowWriterCancelSamples(p->state.audio.flow.mxl_flow_writer); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(s, "mxlFlowWriterCancelSamples error %s\n", mxl_status_to_str(mxl_status)); ++ samples_open = false; ++ } ++ ++ return exit_status; ++} ++ ++static int mxl_write_packet(AVFormatContext *s, AVPacket *pkt) ++{ ++ av_assert0(s && pkt); ++ ++ MXLContext *p = s->priv_data; ++ AVStream *st = s->streams[pkt->stream_index]; ++ av_assert0(st); ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ switch(st->codecpar->codec_type) { ++ case AVMEDIA_TYPE_VIDEO: ++ av_assert0(p->state.has_video_stream); ++ exit_status = video_write_packet(s, pkt); ++ if (exit_status < 0) { ++ loge(s, "video_write_packet error\n"); ++ goto finally; ++ } ++ break; ++ case AVMEDIA_TYPE_AUDIO: ++ av_assert0(p->state.has_audio_stream); ++ exit_status = audio_write_packet(s, pkt); ++ if (exit_status < 0) { ++ loge(s, "audio_write_packet error\n"); ++ goto finally; ++ } ++ break; ++ default: ++ loge(s, "unexpected media type\n"); ++ exit_status = AVERROR_BUG; ++ goto finally; ++ } ++ ++finally: ++ ++ return exit_status; ++} ++ ++typedef enum { ++ SENTINEL_APPEARED = 0, ++ SENTINEL_TIMEOUT = -1 ++} SentinelStatus; ++ ++static int wait_for_sentinel(AVFormatContext *s, const char *path, int timeout_ms) ++{ ++ const int poll_ms = 100; ++ const int poll_us = 1000*poll_ms; ++ const int64_t start = av_gettime_relative(); ++ const int64_t timeout_us = 1000*timeout_ms; ++ int status = 0; ++ ++ while ((status = avio_check(path, AVIO_FLAG_READ)) < 0) { ++ if (av_gettime_relative() - start > timeout_us) ++ return SENTINEL_TIMEOUT; ++ logv(s, "waiting for sentinel: %s\n", path); ++ av_usleep(poll_us); ++ } ++ return SENTINEL_APPEARED; ++} ++ ++static const char *sentinel_status_to_str(SentinelStatus st) ++{ ++ switch (st) { ++ case SENTINEL_APPEARED: ++ return "SENTINEL_APPEARED"; ++ case SENTINEL_TIMEOUT: ++ return "SENTINEL_TIMEOUT"; ++ default: ++ return "UNKNOWN"; ++ } ++} ++ ++static void flow_teardown(AVFormatContext *s, FlowState *flow_state) ++{ ++ av_assert0(s && flow_state); ++ ++ MXLContext *p = s->priv_data; ++ ++ if (flow_state->mxl_flow_writer) { ++ av_assert0(p->state.mxl_instance); ++ mxlStatus mxl_status = mxlReleaseFlowWriter(p->state.mxl_instance, ++ flow_state->mxl_flow_writer); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(s, "mxlReleaseFlowWriter %s error %s\n", ++ flow_state->detail, mxl_status_to_str(mxl_status)); ++ flow_state->mxl_flow_writer = NULL; ++ } ++ ++ av_free(flow_state->mxl_flow_config_info); ++ flow_state->mxl_flow_config_info = NULL; ++} ++ ++static int mxl_write_trailer(AVFormatContext *s) ++{ ++ av_assert0(s && s->priv_data); ++ ++ MXLContext *p = s->priv_data; ++ ++ mxlStatus mxl_status = MXL_ERR_UNKNOWN; ++ ++ if (p->teardown_sync_file) { ++ av_assert0(p->teardown_sync_timeout > 0); ++ SentinelStatus sentinel_status = ++ wait_for_sentinel(s, p->teardown_sync_file, p->teardown_sync_timeout); ++ logv(s, "sentinel status = %s\n", sentinel_status_to_str(sentinel_status)); ++ } ++ ++ if (p->state.has_video_stream) { ++ flow_teardown(s, &p->state.video.flow); ++ } ++ ++ if (p->state.has_audio_stream) { ++ flow_teardown(s, &p->state.audio.flow); ++ swr_free(&p->state.audio.swr_context); ++ } ++ ++ if (p->state.mxl_instance) { ++ mxl_status = mxlDestroyInstance(p->state.mxl_instance); ++ if (MXL_STATUS_OK != mxl_status) ++ logw(s, "mxlDestroyInstance error %s\n", mxl_status_to_str(mxl_status)); ++ p->state.mxl_instance = NULL; ++ } ++ ++ return 0; ++} ++ ++static int mxl_query_codec(enum AVCodecID id, int std_compliance) ++{ ++ switch (id) { ++ case AV_CODEC_ID_PCM_F32LE: ++ case AV_CODEC_ID_V210: ++ return 1; ++ default: ++ return 0; ++ } ++} ++ ++// MXL muxer definition ++const FFOutputFormat ff_mxl_muxer = { ++ // AVOutputFormat (public-facing fields) ++ .p.name = "mxl", ++ .p.long_name = NULL_IF_CONFIG_SMALL(MXL_LONG_NAME), ++ .p.extensions = MXL_FLOW_EXT, ++ .p.priv_class = &mxl_muxer_class, ++ .p.flags = AVFMT_NOFILE, ++ ++ .p.audio_codec = AV_CODEC_ID_NONE, ++ .p.video_codec = AV_CODEC_ID_V210, ++ ++ // private state ++ .priv_data_size = sizeof(MXLContext), ++ ++ // muxer callbacks ++ .write_header = mxl_write_header, ++ .write_packet = mxl_write_packet, ++ .write_trailer = mxl_write_trailer, ++ .query_codec = mxl_query_codec, ++}; +diff --git a/libavformat/tests/mxl_diag.c b/libavformat/tests/mxl_diag.c +new file mode 100644 +index 0000000000..699f845ebe +--- /dev/null ++++ b/libavformat/tests/mxl_diag.c +@@ -0,0 +1,1471 @@ ++/* ++ * MXL runtime diagnostics tests for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#include "libavformat/mxl_diag.h" ++ ++#include ++#include "libavutil/avassert.h" ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#define TEST_DIR "/tmp/mxl_diag_test" ++ ++// simulated frame rate ++enum { FPS = 30 }; ++ ++/* ++ * file descriptor leak detection ++ */ ++enum { MAX_TRACKED_FDS = 8, ++ MAX_SCANNED_FDS = 64 }; ++static int startup_open_fds[MAX_TRACKED_FDS]; ++static int startup_open_fd_count; ++ ++static int capture_startup_open_fds(void) ++{ ++ int fd; ++ ++ startup_open_fd_count = 0; ++ ++ for (fd = 0; fd < MAX_SCANNED_FDS; ++fd) { ++ errno = 0; ++ if (fcntl(fd, F_GETFD) != -1 || errno != EBADF) { ++ if (startup_open_fd_count >= MAX_TRACKED_FDS) { ++ fprintf(stderr, "too many open fds at startup\n"); ++ return -1; ++ } ++ startup_open_fds[startup_open_fd_count++] = fd; ++ } ++ } ++ ++ return 0; ++} ++ ++static int fd_is_in_startup_set(int fd) ++{ ++ int i; ++ ++ for (i = 0; i < startup_open_fd_count; ++i) { ++ if (startup_open_fds[i] == fd) ++ return 1; ++ } ++ ++ return 0; ++} ++ ++static int check_open_fds_match_startup(void) ++{ ++ int fd; ++ ++ for (fd = 0; fd < MAX_SCANNED_FDS; ++fd) { ++ int is_open; ++ ++ errno = 0; ++ is_open = (fcntl(fd, F_GETFD) != -1 || errno != EBADF); ++ ++ if (is_open && !fd_is_in_startup_set(fd)) { ++ fprintf(stderr, "unexpected open fd: %d\n", fd); ++ return -1; ++ } ++ } ++ ++ return 0; ++} ++ ++/* ++ * test diag client ++ */ ++typedef struct mxl_diag_client { ++ int fd; ++ char client_path[sizeof(((struct sockaddr_un *)0)->sun_path)]; ++} mxl_diag_client; ++ ++static inline void mxl_diag_client_init_msg_connect(mxl_diag_msg *msg) ++{ ++ av_assert1(msg); ++ int size = sizeof(msg->header); ++ memset(msg, 0, size); ++ msg->header.version = MXL_DIAG_MSG_VERSION; ++ msg->header.type = MXL_DIAG_MSG_CONNECT; ++ msg->header.size = size; ++} ++ ++static inline void mxl_diag_client_init_msg_release(mxl_diag_msg *msg) ++{ ++ av_assert1(msg); ++ int size = sizeof(msg->header); ++ memset(msg, 0, size); ++ msg->header.version = MXL_DIAG_MSG_VERSION; ++ msg->header.type = MXL_DIAG_MSG_RELEASE; ++ msg->header.size = size; ++} ++ ++static int mxl_diag_client_connect(mxl_diag_client *client, ++ const char *server_path, ++ const char *client_path) ++{ ++ struct sockaddr_un local_addr; ++ struct sockaddr_un server_addr; ++ mxl_diag_msg msg; ++ ++ if (!client || !server_path || !client_path) { ++ return AVERROR(EINVAL); ++ } ++ ++ memset(client, 0, sizeof(*client)); ++ client->fd = -1; ++ ++ if (strlen(server_path) >= sizeof(server_addr.sun_path) || ++ strlen(client_path) >= sizeof(local_addr.sun_path)) { ++ return AVERROR(ENAMETOOLONG); ++ } ++ ++ client->fd = socket(AF_UNIX, SOCK_DGRAM, 0); ++ if (client->fd < 0) { ++ return AVERROR(errno); ++ } ++ ++ memset(&local_addr, 0, sizeof(local_addr)); ++ local_addr.sun_family = AF_UNIX; ++ strcpy(local_addr.sun_path, client_path); ++ ++ memset(&server_addr, 0, sizeof(server_addr)); ++ server_addr.sun_family = AF_UNIX; ++ strcpy(server_addr.sun_path, server_path); ++ ++ strncpy(client->client_path, client_path, sizeof(client->client_path) - 1); ++ client->client_path[sizeof(client->client_path) - 1] = '\0'; ++ ++ unlink(client_path); ++ ++ if (bind(client->fd, (const struct sockaddr *)&local_addr, sizeof(local_addr)) < 0) { ++ int err = errno; ++ close(client->fd); ++ client->fd = -1; ++ unlink(client_path); ++ return AVERROR(err); ++ } ++ ++ if (connect(client->fd, (const struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { ++ int err = errno; ++ close(client->fd); ++ client->fd = -1; ++ unlink(client_path); ++ return AVERROR(err); ++ } ++ ++ mxl_diag_client_init_msg_connect(&msg); ++ ++ if (send(client->fd, &msg, msg.header.size, 0) < 0) { ++ int err = errno; ++ close(client->fd); ++ client->fd = -1; ++ unlink(client_path); ++ return AVERROR(err); ++ } ++ ++ return 0; ++} ++ ++static int mxl_diag_client_release(mxl_diag_client *client) ++{ ++ mxl_diag_msg msg; ++ ++ av_assert0(client); ++ ++ if (!client) { ++ return AVERROR(EINVAL); ++ } ++ ++ if (client->fd < 0) { ++ return AVERROR(EBADF); ++ } ++ ++ mxl_diag_client_init_msg_release(&msg); ++ ++ if (send(client->fd, &msg, msg.header.size, 0) < 0) { ++ return AVERROR(errno); ++ } ++ ++ return 0; ++} ++ ++static int mxl_diag_client_close(mxl_diag_client *client) ++{ ++ int rc = 0; ++ ++ av_assert0(client); ++ ++ if (!client) { ++ return AVERROR(EINVAL); ++ } ++ ++ if (client->fd >= 0) { ++ rc = mxl_diag_client_release(client); ++ ++ if (close(client->fd) < 0 && rc >= 0) { ++ rc = AVERROR(errno); ++ } ++ client->fd = -1; ++ } ++ ++ if (client->client_path[0] != '\0') { ++ if (unlink(client->client_path) < 0 && errno != ENOENT && rc >= 0) { ++ rc = AVERROR(errno); ++ } ++ client->client_path[0] = '\0'; ++ } ++ ++ return rc; ++} ++ ++static int mxl_diag_client_recv(mxl_diag_client *client, ++ mxl_diag_msg *msg) ++{ ++ const int timeout_ms = 100; ++ ++ struct pollfd pfd; ++ ssize_t n; ++ int ret; ++ ++ if (!client || !msg) { ++ return AVERROR(EINVAL); ++ } ++ ++ pfd.fd = client->fd; ++ pfd.events = POLLIN; ++ pfd.revents = 0; ++ ++ ret = poll(&pfd, 1, timeout_ms); ++ if (ret < 0) { ++ return AVERROR(errno); ++ } ++ ++ if (ret == 0) { ++ return AVERROR(ETIMEDOUT); /* timeout */ ++ } ++ ++ n = recv(client->fd, msg, sizeof(*msg), 0); ++ if (n < 0) { ++ return AVERROR(errno); ++ } ++ ++ if ((size_t)n < sizeof(msg->header)) { ++ return AVERROR_INVALIDDATA; ++ } ++ ++ if (msg->header.size < sizeof(msg->header)) { ++ return AVERROR_INVALIDDATA; ++ } ++ ++ if ((size_t)n < msg->header.size) { ++ return AVERROR_INVALIDDATA; ++ } ++ ++ return 0; ++} ++ ++/* ++ * asynchronous tests ++ */ ++ ++static void print_message(const mxl_diag_msg *msg) ++{ ++ if (!msg) { ++ return; ++ } ++ ++ printf("msg: version=%"PRIu16", type=%"PRIu16", size=%"PRIu32"\n", ++ msg->header.version, ++ msg->header.type, ++ msg->header.size); ++ ++ switch (msg->header.type) { ++ case MXL_DIAG_MSG_VIDEO_READ: ++ printf("video_read: timestamp = %"PRIu64 ++ ", exec_dur = %"PRIu64 ++ ", tail_index = %"PRIu64 ++ ", head_index = %"PRIu64", read_index = %"PRIu64 ++ ", mxl_status = %"PRIu32"\n", ++ msg->u.video_read.timestamp, ++ msg->u.video_read.exec_dur, ++ msg->u.video_read.tail_index, ++ msg->u.video_read.head_index, ++ msg->u.video_read.read_index, ++ msg->u.video_read.mxl_status); ++ break; ++ case MXL_DIAG_MSG_AUDIO_READ: ++ printf("audio_read: timestamp = %"PRIu64 ++ ", exec_dur = %"PRIu64 ++ ", tail_index = %"PRIu64 ++ ", head_index = %"PRIu64", read_index = %"PRIu64 ++ ", read_size = %"PRIu32", mxl_status = %"PRIu32"\n", ++ msg->u.audio_read.timestamp, ++ msg->u.audio_read.exec_dur, ++ msg->u.audio_read.tail_index, ++ msg->u.audio_read.head_index, ++ msg->u.audio_read.read_index, ++ msg->u.audio_read.read_size, ++ msg->u.audio_read.mxl_status); ++ break; ++ default: ++ fprintf(stderr, "payload=\n"); ++ av_assert0(false); ++ break; ++ } ++} ++ ++// retry connection every millisecond for 1/FPS seconds ++static int test_client_connect(mxl_diag_client* client, ++ const char* server_path, ++ const char* client_path) ++{ ++ int rc = -1; ++ int retry = 0; ++ do { ++ rc = mxl_diag_client_connect(client, server_path, client_path); ++ if (rc) ++ usleep(1000); ++ } ++ while(rc && retry++ < 1000/FPS); ++ ++ return rc; ++} ++ ++static int test_client_print_messages(const char *server_path) ++{ ++ printf("\ntest: print message stream\n"); ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ const char *client_path = TEST_DIR "/test.client.sock"; ++ ++ mxl_diag_client client = {-1, ""}; ++ mxl_diag_msg msg; ++ ++ const int max_messages = 10; ++ ++ int rc = test_client_connect(&client, server_path, client_path); ++ if (rc) { ++ fprintf(stderr, "client connection failed\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ ++ for (int recv_count = 0; recv_count < max_messages; recv_count++) { ++ rc = mxl_diag_client_recv(&client, &msg); ++ if (rc) { ++ exit_status = rc; ++ goto finally; ++ } ++ print_message(&msg); ++ } ++ ++ rc = mxl_diag_client_close(&client); ++ if (rc) { ++ fprintf(stderr, "client close failed\n"); ++ exit_status = rc; ++ goto finally; ++ } ++ ++ exit_status = 0; ++ ++finally: ++ ++ if (client.fd >= 0) { ++ rc = mxl_diag_client_close(&client); ++ av_assert0(rc == 0); ++ } ++ ++ ++ return exit_status; ++} ++ ++ ++static int test_client_multi_connect(const char* server_path) ++{ ++ printf("\ntest: multiple connect attempts are ignored after the first\n"); ++ ++ const char *client_path_a = TEST_DIR "/test.a.client.sock"; ++ const char *client_path_b = TEST_DIR "/test.b.client.sock"; ++ const char *client_path_c = TEST_DIR "/test.c.client.sock"; ++ ++ // client A should connect and receive a message ++ mxl_diag_client client_a; ++ int rc = test_client_connect(&client_a, server_path, client_path_a); ++ if (rc) { ++ fprintf(stderr, "client A connection failed (%d)\n", rc); ++ return rc; ++ } ++ printf("client A connected\n"); ++ mxl_diag_msg msg_a_1 = {0}; ++ rc = mxl_diag_client_recv(&client_a, &msg_a_1); ++ if (rc) { ++ fprintf(stderr, "client A first recv failed (%d)\n", rc); ++ return rc; ++ } ++ print_message(&msg_a_1); ++ ++ // reconnect is harmless ++ { ++ mxl_diag_msg msg = {0}; ++ ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_CONNECT; ++ msg.header.size = sizeof(msg.header); ++ ++ if (send(client_a.fd, &msg, msg.header.size, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "client A re-connect failed (%d)\n", rc); ++ return rc; ++ } ++ } ++ ++ // client B should connect and be ignored ++ mxl_diag_client client_b; ++ rc = test_client_connect(&client_b, server_path, client_path_b); ++ if (rc) { ++ fprintf(stderr, "client B connection failed (%d)\n", rc); ++ return rc; ++ } ++ mxl_diag_msg msg_b_1 = {0}; ++ rc = mxl_diag_client_recv(&client_b, &msg_b_1); ++ if (rc != AVERROR(ETIMEDOUT)) { ++ fprintf(stderr, "client B recv failed to timeout (%d)\n", rc); ++ return rc; ++ } ++ printf("client B timed out (expected)\n"); ++ // releasing b without closing should not affect a ++ rc = mxl_diag_client_release(&client_b); ++ if (rc) { ++ fprintf(stderr, "client B release failed"); ++ return rc; ++ } ++ ++ // client A should still work ++ mxl_diag_msg msg_a_2 = {0}; ++ rc = mxl_diag_client_recv(&client_a, &msg_a_2); ++ if (rc) { ++ fprintf(stderr, "client A second recv failed (%d)\n", rc); ++ return rc; ++ } ++ print_message(&msg_a_2); ++ printf("client A was unaffected by client B\n"); ++ ++ // client C should connect and be ignored ++ mxl_diag_client client_c; ++ rc = test_client_connect(&client_c, server_path, client_path_c); ++ if (rc) { ++ fprintf(stderr, "client C connection failed (%d)\n", rc); ++ return rc; ++ } ++ mxl_diag_msg msg_c = {0}; ++ rc = mxl_diag_client_recv(&client_c, &msg_c); ++ if (rc != AVERROR(ETIMEDOUT)) { ++ fprintf(stderr, "client C recv failed to timeout (%d)\n", rc); ++ return rc; ++ } ++ printf("client C timed out (expected)\n"); ++ ++ // closing C not affect A ++ rc = mxl_diag_client_close(&client_c); ++ if (rc) { ++ fprintf(stderr, "client C close failed"); ++ return rc; ++ } ++ ++ // client A should still work ++ mxl_diag_msg msg_a_3 = {0}; ++ rc = mxl_diag_client_recv(&client_a, &msg_a_3); ++ if (rc) { ++ fprintf(stderr, "client A second recv failed (%d)\n", rc); ++ return rc; ++ } ++ print_message(&msg_a_3); ++ printf("client A was unaffected by client C\n"); ++ ++ // client B should still timeout ++ mxl_diag_msg msg_b_2 = {0}; ++ rc = mxl_diag_client_recv(&client_b, &msg_b_2); ++ if (rc != AVERROR(ETIMEDOUT)) { ++ fprintf(stderr, "client B recv failed to timeout (%d)\n", rc); ++ return rc; ++ } ++ printf("client B still times out\n"); ++ rc = mxl_diag_client_close(&client_b); ++ av_assert0(rc == 0); ++ ++ rc = mxl_diag_client_close(&client_a); ++ av_assert0(rc == 0); ++ ++ return 0; ++} ++ ++static int test_client_malformed(const char *server_path) ++{ ++ const int malformed_case_count = 7; ++ const int n = malformed_case_count * 2; ++ ++ const char *client_path_a = TEST_DIR "/test.malformed.a.client.sock"; ++ ++ mxl_diag_client client_a; ++ int rc; ++ int i; ++ ++ printf("\ntest: malformed messages from active client are ignored\n"); ++ ++ rc = test_client_connect(&client_a, server_path, client_path_a); ++ if (rc) { ++ fprintf(stderr, "client A connection failed (%d)\n", rc); ++ return rc; ++ } ++ ++ // Confirm A is active. ++ { ++ mxl_diag_msg msg = {0}; ++ rc = mxl_diag_client_recv(&client_a, &msg); ++ if (rc) { ++ fprintf(stderr, "client A initial recv failed (%d)\n", rc); ++ mxl_diag_client_close(&client_a); ++ return rc; ++ } ++ print_message(&msg); ++ } ++ ++ for (i = 0; i < n; ++i) { ++ mxl_diag_msg msg; ++ const void *buf; ++ size_t len; ++ ++ memset(&msg, 0, sizeof(msg)); ++ ++ switch (i % malformed_case_count) { ++ case 0: ++ printf("bad size, message too short\n"); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_CONNECT; ++ msg.header.size = sizeof(msg.header); ++ buf = &msg; ++ len = sizeof(msg.header)-1; ++ break; ++ ++ case 1: ++ printf("bad size, header size sanity (too small)\n"); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_RELEASE; ++ msg.header.size = sizeof(msg.header) - 1; ++ buf = &msg; ++ len = sizeof(msg.header); ++ break; ++ ++ case 2: ++ printf("bad size, header size sanity (too big)\n"); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_RELEASE; ++ msg.header.size = sizeof(msg)+1; ++ buf = &msg; ++ len = sizeof(msg.header); ++ break; ++ ++ case 3: { ++ // this will appear as a "message too short" error in the log ++ printf("unknown type, truncated header\n"); ++ static const unsigned char short_buf[sizeof(msg.header) - 1] = {0}; ++ buf = short_buf; ++ len = sizeof(short_buf); ++ break; ++ } ++ ++ case 4: ++ printf("bad version\n"); ++ msg.header.version = MXL_DIAG_MSG_VERSION + 1; ++ msg.header.type = MXL_DIAG_MSG_CONNECT; ++ msg.header.size = sizeof(msg.header); ++ buf = &msg; ++ len = msg.header.size; ++ break; ++ ++ case 5: ++ printf("unknown type\n"); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = 9999; ++ msg.header.size = sizeof(msg.header); ++ buf = &msg; ++ len = msg.header.size; ++ break; ++ ++ case 6: { ++ printf("garbage message\n"); ++ unsigned char garbage[128]; ++ size_t j; ++ ++ len = 1 + (size_t)(rand() % sizeof(garbage)); ++ for (j = 0; j < len; ++j) { ++ garbage[j] = (unsigned char)(rand() & 0xff); ++ } ++ ++ buf = garbage; ++ break; ++ } ++ ++ default: ++ av_assert0(false); ++ } ++ ++ if (send(client_a.fd, buf, len, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "client A malformed send %d failed (%d)\n", i, rc); ++ mxl_diag_client_close(&client_a); ++ return rc; ++ } ++ } ++ ++ printf("sent %d malformed messages from active client A\n", n); ++ ++ // give the test server time to catch up ++ sleep(n/FPS + 1); ++ ++ // A should still receive normal messages afterward. ++ { ++ mxl_diag_msg msg = {0}; ++ rc = mxl_diag_client_recv(&client_a, &msg); ++ if (rc) { ++ fprintf(stderr, "client A recv after malformed traffic failed (%d)\n", rc); ++ mxl_diag_client_close(&client_a); ++ return rc; ++ } ++ print_message(&msg); ++ printf("client A unaffected by malformed traffic\n"); ++ } ++ ++ rc = mxl_diag_client_close(&client_a); ++ if (rc) { ++ fprintf(stderr, "client A close failed (%d)\n", rc); ++ return rc; ++ } ++ ++ return 0; ++} ++ ++static void test_server_init_msg(mxl_diag_msg *msg, uint64_t base_index) ++{ ++ if (!msg) { ++ return; ++ } ++ ++ if (base_index%2) { ++ mxl_diag_init_msg_video_read( ++ msg, base_index, base_index + 1, base_index + 2, base_index + 3, base_index + 4, ++ MXL_STATUS_OK); ++ } ++ else { ++ mxl_diag_init_msg_audio_read( ++ msg, base_index, base_index + 1, base_index + 2, base_index + 3, base_index + 4, ++ 48000/FPS, MXL_STATUS_OK); ++ } ++} ++ ++typedef struct server_thread { ++ pthread_t thread; ++ pthread_mutex_t mutex; ++ bool stop; ++ bool started; ++ const char* server_path; ++} server_thread; ++ ++// send one message each simulated frame period ++static void *test_server_thread_main(void *arg) ++{ ++ server_thread *t = arg; ++ ++ mxl_diag_server server = {0}; ++ int rc = mxl_diag_init(NULL, &server, t->server_path); ++ if (rc) { ++ fprintf(stderr, "mxl_diag_init failed (%d)\n", rc); ++ return NULL; ++ } ++ ++ static uint64_t base_index = 0; ++ bool stop = false; ++ do { ++ usleep(1000000/FPS); ++ mxl_diag_msg msg = {0}; ++ test_server_init_msg(&msg, base_index++); ++ ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc < 0) ++ fprintf(stderr, "test_server_thread_main mxl_diag_poll error %d\n", rc); ++ ++ rc = mxl_diag_send(NULL, &server, &msg); ++ if (rc < 0) ++ fprintf(stderr, "test_server_thread_main mxl_diag_send error %d\n", rc); ++ ++ pthread_mutex_lock(&t->mutex); ++ stop = t->stop; ++ pthread_mutex_unlock(&t->mutex); ++ } while (!stop); ++ ++ mxl_diag_close(NULL, &server); ++ ++ return NULL; ++} ++ ++ ++static int test_server_thread_start(server_thread *t, ++ const char* server_path) ++{ ++ av_assert0(t && server_path); ++ ++ int exit_status = AVERROR_UNKNOWN; ++ ++ t->stop = false; ++ t->started = false; ++ t->server_path = server_path; ++ t->thread = 0; ++ pthread_mutex_init(&t->mutex, NULL); ++ ++ int rc = pthread_create(&t->thread, NULL, test_server_thread_main, t); ++ if (rc != 0) { ++ exit_status = AVERROR(rc); ++ goto finally; ++ } ++ ++ t->started = true; ++ exit_status = 0; ++ ++finally: ++ ++ return exit_status; ++} ++ ++static int test_server_thread_stop(server_thread *t) ++{ ++ av_assert0(t && t->started); ++ ++ pthread_mutex_lock(&t->mutex); ++ t->stop = true; ++ pthread_mutex_unlock(&t->mutex); ++ ++ int rc = pthread_join(t->thread, NULL); ++ if (rc) { ++ fprintf(stderr, "pthread_join error (%d)\n", rc); ++ return AVERROR(rc); ++ } ++ ++ pthread_mutex_destroy(&t->mutex); ++ ++ t->started = false; ++ ++ return 0; ++} ++ ++/* ++ * synchronous tests ++ */ ++static int test_server_path_collision_non_socket(const char *server_path) ++{ ++ mxl_diag_server server; ++ FILE *fp; ++ int rc; ++ ++ printf("\ntest: init fails if server path exists as a non-socket\n"); ++ ++ unlink(server_path); ++ ++ fp = fopen(server_path, "w"); ++ if (!fp) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "fopen failed (%d)\n", rc); ++ return rc; ++ } ++ fclose(fp); ++ ++ rc = mxl_diag_init(NULL, &server, server_path); ++ if (rc != AVERROR(EEXIST)) { ++ fprintf(stderr, "expected EEXIST, got (%d)\n", rc); ++ unlink(server_path); ++ return rc ? rc : AVERROR(EINVAL); ++ } ++ ++ if (access(server_path, F_OK) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "server path sanity failed (%d)\n", rc); ++ return rc; ++ } ++ ++ unlink(server_path); ++ ++ return 0; ++} ++ ++static int test_server_path_stale_socket_cleanup(const char *server_path) ++{ ++ mxl_diag_server server; ++ int stale_fd = -1; ++ struct sockaddr_un addr; ++ int rc; ++ ++ printf("\ntest: init cleans up stale socket path\n"); ++ ++ unlink(server_path); ++ ++ if (strlen(server_path) >= sizeof(addr.sun_path)) { ++ return AVERROR(ENAMETOOLONG); ++ } ++ ++ stale_fd = socket(AF_UNIX, SOCK_DGRAM, 0); ++ if (stale_fd < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "socket failed (%d)\n", rc); ++ return rc; ++ } ++ ++ memset(&addr, 0, sizeof(addr)); ++ addr.sun_family = AF_UNIX; ++ strcpy(addr.sun_path, server_path); ++ ++ if (bind(stale_fd, (const struct sockaddr *)&addr, sizeof(addr)) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "bind stale socket failed (%d)\n", rc); ++ close(stale_fd); ++ return rc; ++ } ++ ++ close(stale_fd); ++ stale_fd = -1; ++ ++ rc = mxl_diag_init(NULL, &server, server_path); ++ if (rc) { ++ fprintf(stderr, "mxl_diag_init failed (%d)\n", rc); ++ unlink(server_path); ++ return rc; ++ } ++ ++ if (access(server_path, F_OK) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "server path missing after init (%d)\n", rc); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ mxl_diag_close(NULL, &server); ++ ++ if (access(server_path, F_OK) == 0 || errno != ENOENT) { ++ fprintf(stderr, "server path still exists after close\n"); ++ unlink(server_path); ++ return AVERROR(EINVAL); ++ } ++ ++ return 0; ++} ++ ++static int test_release_with_no_active_client(const char *server_path) ++{ ++ const char *client_path_a = TEST_DIR "/test.no_active_release.a.client"; ++ ++ mxl_diag_server server; ++ mxl_diag_client client_a; ++ struct sockaddr_un local_addr; ++ struct sockaddr_un server_addr; ++ mxl_diag_msg msg; ++ int rc; ++ ++ printf("\ntest: release with no active client is ignored\n"); ++ ++ rc = mxl_diag_init(NULL, &server, server_path); ++ if (rc) { ++ fprintf(stderr, "server init failed (%d)\n", rc); ++ return rc; ++ } ++ ++ memset(&client_a, 0, sizeof(client_a)); ++ client_a.fd = -1; ++ ++ if (strlen(server_path) >= sizeof(server_addr.sun_path) || ++ strlen(client_path_a) >= sizeof(local_addr.sun_path)) { ++ mxl_diag_close(NULL, &server); ++ return AVERROR(ENAMETOOLONG); ++ } ++ ++ client_a.fd = socket(AF_UNIX, SOCK_DGRAM, 0); ++ if (client_a.fd < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "client socket failed (%d)\n", rc); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ memset(&local_addr, 0, sizeof(local_addr)); ++ local_addr.sun_family = AF_UNIX; ++ strcpy(local_addr.sun_path, client_path_a); ++ ++ memset(&server_addr, 0, sizeof(server_addr)); ++ server_addr.sun_family = AF_UNIX; ++ strcpy(server_addr.sun_path, server_path); ++ ++ strncpy(client_a.client_path, client_path_a, sizeof(client_a.client_path) - 1); ++ client_a.client_path[sizeof(client_a.client_path) - 1] = '\0'; ++ ++ unlink(client_path_a); ++ ++ if (bind(client_a.fd, (const struct sockaddr *)&local_addr, sizeof(local_addr)) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "client bind failed (%d)\n", rc); ++ close(client_a.fd); ++ client_a.fd = -1; ++ unlink(client_path_a); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ if (connect(client_a.fd, (const struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "client connect failed (%d)\n", rc); ++ close(client_a.fd); ++ client_a.fd = -1; ++ unlink(client_path_a); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ /* aberrant condition under test: sending release to idle server */ ++ av_assert0(!server.has_client); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_RELEASE; ++ msg.header.size = sizeof(msg.header); ++ if (send(client_a.fd, &msg, msg.header.size, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "client release send failed (%d)\n", rc); ++ mxl_diag_client_close(&client_a); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ mxl_diag_poll(NULL, &server); ++ if (server.has_client) { ++ fprintf(stderr, "server unexpectedly activated after idle release\n"); ++ mxl_diag_client_close(&client_a); ++ mxl_diag_close(NULL, &server); ++ return AVERROR(EINVAL); ++ } ++ ++ // connect should work as expected after the aberrant release send ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_CONNECT; ++ msg.header.size = sizeof(msg.header); ++ if (send(client_a.fd, &msg, msg.header.size, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "client connect send failed (%d)\n", rc); ++ mxl_diag_client_close(&client_a); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ mxl_diag_poll(NULL, &server); ++ if (!server.has_client) { ++ fprintf(stderr, "server failed to activate after valid connect\n"); ++ mxl_diag_client_close(&client_a); ++ mxl_diag_close(NULL, &server); ++ return AVERROR(EINVAL); ++ } ++ ++ // redundant, but should pass ++ rc = mxl_diag_client_close(&client_a); ++ if (rc) { ++ fprintf(stderr, "client close failed (%d)\n", rc); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ mxl_diag_close(NULL, &server); ++ return 0; ++} ++ ++ ++static int test_connect_after_junk(const char *server_path) ++{ ++ const char *junk_path = TEST_DIR "/test.connect_after_junk.junk.sock"; ++ const char *client_path_a = TEST_DIR "/test.connect_after_junk.a.client.sock"; ++ ++ mxl_diag_server server; ++ mxl_diag_client client_a; ++ struct sockaddr_un junk_addr; ++ struct sockaddr_un server_addr; ++ mxl_diag_msg msg; ++ int junk_fd = -1; ++ int rc; ++ ++ printf("\ntest: valid connect works after junk from unrelated socket\n"); ++ ++ rc = mxl_diag_init(NULL, &server, server_path); ++ if (rc) { ++ fprintf(stderr, "server init failed (%d)\n", rc); ++ return rc; ++ } ++ ++ if (strlen(server_path) >= sizeof(server_addr.sun_path) || ++ strlen(junk_path) >= sizeof(junk_addr.sun_path)) { ++ mxl_diag_close(NULL, &server); ++ return AVERROR(ENAMETOOLONG); ++ } ++ ++ junk_fd = socket(AF_UNIX, SOCK_DGRAM, 0); ++ if (junk_fd < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "junk socket failed (%d)\n", rc); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ memset(&junk_addr, 0, sizeof(junk_addr)); ++ junk_addr.sun_family = AF_UNIX; ++ strcpy(junk_addr.sun_path, junk_path); ++ ++ memset(&server_addr, 0, sizeof(server_addr)); ++ server_addr.sun_family = AF_UNIX; ++ strcpy(server_addr.sun_path, server_path); ++ ++ unlink(junk_path); ++ ++ if (bind(junk_fd, (const struct sockaddr *)&junk_addr, sizeof(junk_addr)) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "junk bind failed (%d)\n", rc); ++ close(junk_fd); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ if (connect(junk_fd, (const struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "junk connect failed (%d)\n", rc); ++ close(junk_fd); ++ unlink(junk_path); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ /* Send one junk message to the idle server (version too high). */ ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION + 1; ++ msg.header.type = MXL_DIAG_MSG_CONNECT; ++ msg.header.size = sizeof(msg.header); ++ ++ if (send(junk_fd, &msg, msg.header.size, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "junk send failed (%d)\n", rc); ++ close(junk_fd); ++ unlink(junk_path); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ mxl_diag_poll(NULL, &server); ++ ++ if (server.has_client) { ++ fprintf(stderr, "server unexpectedly activated after junk message\n"); ++ close(junk_fd); ++ unlink(junk_path); ++ mxl_diag_close(NULL, &server); ++ return AVERROR(EINVAL); ++ } ++ ++ close(junk_fd); ++ unlink(junk_path); ++ ++ /* Normal client connect should still work afterward. */ ++ rc = test_client_connect(&client_a, server_path, client_path_a); ++ if (rc) { ++ fprintf(stderr, "client A connection failed (%d)\n", rc); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ mxl_diag_poll(NULL, &server); ++ ++ if (!server.has_client) { ++ fprintf(stderr, "server failed to activate after valid connect\n"); ++ mxl_diag_client_close(&client_a); ++ mxl_diag_close(NULL, &server); ++ return AVERROR(EINVAL); ++ } ++ ++ rc = mxl_diag_client_close(&client_a); ++ if (rc) { ++ fprintf(stderr, "client A close failed (%d)\n", rc); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ mxl_diag_close(NULL, &server); ++ ++ return 0; ++} ++ ++static int test_poll_malformed_return_codes(const char *server_path) ++{ ++ const char *client_path_a = TEST_DIR "/test.poll_malformed.a.client.sock"; ++ ++ mxl_diag_server server; ++ mxl_diag_client client_a; ++ mxl_diag_msg msg; ++ int rc; ++ ++ printf("\ntest: mxl_diag_poll returns expected codes for malformed messages\n"); ++ ++ rc = mxl_diag_init(NULL, &server, server_path); ++ if (rc) { ++ fprintf(stderr, "server init failed (%d)\n", rc); ++ return rc; ++ } ++ ++ rc = test_client_connect(&client_a, server_path, client_path_a); ++ if (rc) { ++ fprintf(stderr, "client A connection failed (%d)\n", rc); ++ mxl_diag_close(NULL, &server); ++ return rc; ++ } ++ ++ /* ++ * Drain the initial valid CONNECT sent by test_client_connect(), ++ * so the following poll calls correspond exactly to the malformed ++ * messages below. ++ */ ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != 1) { ++ fprintf(stderr, "initial connect poll returned (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ printf("bad size, datagram smaller than sizeof(msg.header)\n"); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_CONNECT; ++ msg.header.size = sizeof(msg.header); ++ if (send(client_a.fd, &msg, sizeof(msg.header) - 1, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_EDATATOOSMALL)) { ++ fprintf(stderr, "unexpected rc for 'datagram smaller than sizeof(msg.header)' (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ printf("bad size, header.size smaller than sizeof(msg.header)\n"); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_RELEASE; ++ msg.header.size = sizeof(msg.header)-1; ++ if (send(client_a.fd, &msg, sizeof(msg.header), 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_ESIZETOOSMALL)) { ++ fprintf(stderr, "unexpected rc for 'header.size smaller than sizeof(msg.header)' (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ printf("bad size, header.size bigger than sizeof(msg)\n"); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_RELEASE; ++ msg.header.size = sizeof(msg) + 1; ++ if (send(client_a.fd, &msg, sizeof(msg.header), 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_ESIZETOOBIG)) { ++ fprintf(stderr, "unexpected rc for 'header.size bigger than sizeof(msg)' (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ printf("bad size, datagram size vs header.size mismatch\n"); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_RELEASE; ++ msg.header.size = sizeof(msg.header) + 1; ++ if (send(client_a.fd, &msg, sizeof(msg.header), 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_ESIZEMISMATCH)) { ++ fprintf(stderr, "unexpected rc for 'datagram size vs header.size mismatch' (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ printf("bad size, truncated datagram\n"); ++ char buf[sizeof(mxl_diag_msg) + 1]; ++ mxl_diag_msg *truncated_msg = (mxl_diag_msg *)buf; ++ memset(truncated_msg, 0, sizeof(msg)); ++ truncated_msg->header.version = MXL_DIAG_MSG_VERSION; ++ truncated_msg->header.type = MXL_DIAG_MSG_RELEASE; ++ truncated_msg->header.size = sizeof(*truncated_msg); ++ if (send(client_a.fd, truncated_msg, sizeof(buf), 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_EDATATRUNCATED)) { ++ fprintf(stderr, "unexpected rc for 'truncated datagram' (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ // bad version ++ printf("bad version\n"); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION + 1; ++ msg.header.type = MXL_DIAG_MSG_CONNECT; ++ msg.header.size = sizeof(msg.header); ++ if (send(client_a.fd, &msg, msg.header.size, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_EBADVERSION)) { ++ fprintf(stderr, "unexpected rc for bad version (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ // unknown type ++ printf("unknown type\n"); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = 9999; ++ msg.header.size = sizeof(msg.header); ++ if (send(client_a.fd, &msg, msg.header.size, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_EUNKNOWN)) { ++ fprintf(stderr, "unexpected rc for unknown type (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ printf("truncated header\n"); ++ { ++ static const unsigned char short_buf[sizeof(msg.header) - 1] = {0}; ++ if (send(client_a.fd, short_buf, sizeof(short_buf), 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_EDATATOOSMALL)) { ++ fprintf(stderr, "unexpected rc for truncated header (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ // more data than necessary for connect message ++ printf("connect message size mismatch\n"); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_CONNECT; ++ msg.header.size = sizeof(msg.header)+1; ++ if (send(client_a.fd, &msg, msg.header.size, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_EMSGSIZEMISMATCH)) { ++ fprintf(stderr, "unexpected rc for connect message size mismatch (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ // more data than necessary for release message ++ printf("release message size mismatch\n"); ++ memset(&msg, 0, sizeof(msg)); ++ msg.header.version = MXL_DIAG_MSG_VERSION; ++ msg.header.type = MXL_DIAG_MSG_RELEASE; ++ msg.header.size = sizeof(msg.header)+1; ++ if (send(client_a.fd, &msg, msg.header.size, 0) < 0) { ++ rc = AVERROR(errno); ++ fprintf(stderr, "send failed (%d)\n", rc); ++ goto finally; ++ } ++ rc = mxl_diag_poll(NULL, &server); ++ if (rc != AVERROR(MXL_DIAG_EMSGSIZEMISMATCH)) { ++ fprintf(stderr, "unexpected rc for release message size mismatch (%d)\n", rc); ++ rc = AVERROR(EINVAL); ++ goto finally; ++ } ++ ++ ++ ++ ++ rc = 0; ++ ++finally: ++ ++ int close_rc = mxl_diag_client_close(&client_a); ++ if (rc == 0 && close_rc) { ++ rc = close_rc; ++ } ++ ++ mxl_diag_close(NULL, &server); ++ ++ return rc; ++} ++ ++static int run_synchronous_tests(void) ++{ ++ int rc = -1; ++ const char* server_path = TEST_DIR "/sync.server.sock"; ++ ++ rc = test_server_path_collision_non_socket(server_path); ++ if (rc) { ++ fprintf(stderr, "test_server_path_collision_non_socket failed (%d)\n", rc); ++ return rc; ++ } ++ ++ rc = test_server_path_stale_socket_cleanup(server_path); ++ if (rc) { ++ fprintf(stderr, "test_server_path_stale_socket_cleanup (%d)\n", rc); ++ return rc; ++ } ++ ++ rc = test_release_with_no_active_client(server_path); ++ if (rc) { ++ fprintf(stderr, "test_release_with_no_active_client (%d)\n", rc); ++ return rc; ++ } ++ ++ rc = test_connect_after_junk(server_path); ++ if (rc) { ++ fprintf(stderr, "test_connect_after_junk (%d)\n", rc); ++ return rc; ++ } ++ ++ rc = test_poll_malformed_return_codes(server_path); ++ if (rc) { ++ fprintf(stderr, "test_poll_malformed_return_codes (%d)\n", rc); ++ return rc; ++ } ++ ++ return check_open_fds_match_startup(); ++} ++ ++static int run_asynchronous_tests(void) ++{ ++ int rc = -1; ++ const char* server_path = TEST_DIR "/async.server.sock"; ++ ++ server_thread instr_thread = {0}; ++ rc = test_server_thread_start(&instr_thread, server_path); ++ if (rc) { ++ fprintf(stderr, "test server thread start failed (%d)\n", rc); ++ return rc; ++ } ++ ++ rc = test_client_print_messages(server_path); ++ if (rc) ++ return rc; ++ ++ rc = test_client_multi_connect(server_path); ++ if (rc) ++ return rc; ++ ++ rc = test_client_malformed(server_path); ++ if (rc) ++ return rc; ++ ++ rc = test_server_thread_stop(&instr_thread); ++ if (rc) ++ return rc; ++ ++ unlink(server_path); ++ ++ return check_open_fds_match_startup(); ++} ++ ++int main(void) ++{ ++ printf("testing mxl_diag: mxl diagnostic server\n\n"); ++ ++ capture_startup_open_fds(); ++ ++ av_log_set_level(AV_LOG_QUIET); ++ ++ int rc = -1; ++ ++ if (mkdir(TEST_DIR, 0777) < 0 && errno != EEXIST) { ++ return AVERROR(errno); ++ } ++ ++ rc = run_synchronous_tests(); ++ if (rc) { ++ fprintf(stderr, "synchronous tests failed\n"); ++ goto finally; ++ } ++ ++ rc = run_asynchronous_tests(); ++ if (rc) { ++ fprintf(stderr, "asynchronous tests failed\n"); ++ goto finally; ++ } ++ ++ rmdir(TEST_DIR); ++ ++finally: ++ ++ if (!rc) ++ printf("\nPASS\n"); ++ else ++ printf("\nFAIL\n"); ++ ++ return rc; ++} +diff --git a/libavformat/tests/mxl_json.c b/libavformat/tests/mxl_json.c +new file mode 100644 +index 0000000000..ebe6be0a48 +--- /dev/null ++++ b/libavformat/tests/mxl_json.c +@@ -0,0 +1,1076 @@ ++/* ++ * MXL JSON parser tests for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#include "libavformat/mxl_json.h" ++#include "libavutil/avassert.h" ++#include "libavutil/error.h" ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++static const char* token_type_name(const jsmntok_t* tok) { ++ ++ switch(tok->type) { ++ case JSMN_OBJECT: ++ return "objc"; ++ case JSMN_ARRAY: ++ return "arry"; ++ case JSMN_STRING: ++ return "strn"; ++ case JSMN_PRIMITIVE: ++ return "prim"; ++ default: ++ return "unkn"; ++ } ++} ++ ++static void mxl_json_dump_doc(mxl_json_doc *doc) ++{ ++ if (!doc->json) { ++ printf("no json present in doc"); ++ return; ++ } ++ ++ // the original string ++ printf("%s\n", doc->json); ++ ++ // the parsed tokens ++ printf("%3s: %8s %3s %3s %3s\n", "idx", "type", "str", "end", "sz"); ++ for (int i = 0; i < doc->ntok; i++) { ++ jsmntok_t *tok = &doc->tokens[i]; ++ ++ const char* typename = token_type_name(tok); ++ int size = tok->end - tok->start + 1; ++ av_assert0(size > 0); ++ char* value = malloc(size); ++ av_assert0(value); ++ memcpy(value, doc->json + tok->start, size-1); ++ value[size-1] = 0; ++ ++ printf("%03d: %8s %03d %03d %03d \'%s\'\n", ++ i, typename, tok->start, tok->end, tok->size, value); ++ ++ free(value); ++ } ++} ++ ++static const char* err_to_str(int err) ++{ ++ switch(err) { ++ case MXL_JSON_EBADTYPE: ++ return "MXL_JSON_EBADTYPE"; ++ case MXL_JSON_ENOTFOUND: ++ return "MXL_JSON_ENOTFOUND"; ++ case MXL_JSON_NULL: ++ return "MXL_JSON_NULL"; ++ case MXL_JSON_MALFORMED: ++ return "MXL_JSON_MALFORMED"; ++ case MXL_JSON_UNSUPPORTED: ++ return "MXL_JSON_UNSUPPORTED"; ++ case MXL_JSON_EINTERNAL: ++ return "MXL_JSON_EINTERNAL"; ++ case ERANGE: ++ return "ERANGE"; ++ case AVERROR(ENOMEM): ++ return "AVERROR(ENOMEM)"; ++ case AVERROR(EIO): ++ return "AVERROR(EIO)"; ++ case AVERROR(EINVAL): ++ return "AVERROR(EINVAL)"; ++ case AVERROR_INVALIDDATA: ++ return "AVERROR_INVALIDDATA"; ++ case 0: ++ return "SUCCESS"; ++ default: ++ return "unknown"; ++ } ++} ++ ++static char* args_to_str(int depth, va_list ap) { ++ ++ const char sep = '.'; ++ const char* null = "NULL"; ++ ++ // start with len 1 for nul ++ size_t total_len = 1; ++ ++ // copy va list and traverse to compute length ++ va_list ap2; ++ va_copy(ap2, ap); ++ for (int i = 0; i < depth; i++) { ++ const char *s = va_arg(ap2, const char *); ++ total_len += (s ? strlen(s) : strlen(null)); ++ if (i < depth - 1) ++ total_len += 1; // comma ++ } ++ va_end(ap2); ++ ++ char *result = malloc(total_len); ++ if (!result) ++ return NULL; ++ ++ // second pass: build the string ++ char *p = result; ++ for (int i = 0; i < depth; i++) { ++ const char *s = va_arg(ap, const char *); ++ const char *part = s ? s : null; ++ size_t len = strlen(part); ++ memcpy(p, part, len); ++ p += len; ++ if (i < depth - 1) ++ *p++ = sep; ++ } ++ *p = '\0'; ++ ++ return result; ++} ++ ++static const char *null_str(const char* s) { ++ return s == NULL ? "(null)" : s; ++} ++ ++static int verify_json_string(const mxl_json_doc *doc, ++ const char *expected_value, ++ int expected_err, ++ int depth, ...) ++{ ++ va_list ap; ++ ++ va_start(ap, depth); ++ char* args_as_str = args_to_str(depth, ap); ++ va_end(ap); ++ ++ va_start(ap, depth); ++ mxl_json_str str = mxl_json_doc_get_stringv(doc, depth, ap); ++ va_end(ap); ++ ++ int rc = -1; ++ ++ // first: trivial error equality ++ if (expected_err != str.err) { ++ printf("FAIL \"%s\" expected error %s (actual %s)\n", ++ args_as_str, err_to_str(expected_err), err_to_str(str.err)); ++ rc = 1; ++ goto finally; ++ } ++ ++ // second: error aware string value compare ++ if (str.err != 0 && str.value != NULL) { ++ printf("FAIL \"%s\" string non-null value on error\n", ++ args_as_str); ++ rc = 1; ++ goto finally; ++ } ++ // else if no error then str.value must match expected ++ else if (str.err == 0 && strcmp(str.value, expected_value) != 0) { ++ printf("FAIL: \"%s\" str \"%s\" expected \"%s\"\n", ++ args_as_str, ++ str.value ? str.value : "(null)", expected_value); ++ rc = 1; ++ goto finally; ++ } ++ // else no failures, test passes ++ else { ++ rc = 0; ++ printf("PASS \"%s\" = {\"%s\", %s}\n", args_as_str, null_str(str.value), ++ err_to_str(str.err)); ++ } ++ ++finally: ++ free(args_as_str); ++ free(str.value); ++ ++ return rc; ++} ++ ++// The math.h signbit macro is not working with ffmpeg compile flags, ++// this is a compile flag independent alternative. ++static inline int is_neg_zero(double x) { ++ uint64_t u; ++ memcpy(&u, &x, sizeof u); ++ return u == 0x8000000000000000ULL; // IEEE-754 -0.0 value ++} ++ ++// compare two doubles with adapted tolerance ++static int dblcmp(double a, double b) { ++ ++ // must never attempt to compare NaN ++ av_assert0(!isnan(a) && !isnan(b)); ++ ++ // infinity compare ++ if (isinf(a) || isinf(b)) { ++ if (a == b) ++ return 0; ++ else ++ return (a < b) ? -1 : 1; ++ } ++ ++ // zero compare ++ if (a == 0.0 && b == 0.0) { ++ if (is_neg_zero(a) && !is_neg_zero(b)) ++ return -1; ++ else if (!is_neg_zero(a) && is_neg_zero(b)) ++ return 1; ++ else ++ return 0; ++ } ++ ++ // compute relative tolerance ++ double diff = fabs(a - b); ++ double tol = DBL_EPSILON * fmax(fabs(a), fabs(b)); ++ ++ // comparable if within tolerance ++ if (diff <= tol) ++ return 0; ++ else ++ return (a < b) ? -1 : 1; ++} ++ ++static int verify_json_double(const mxl_json_doc *doc, ++ double expected_value, ++ int expected_err, ++ int depth, ...) ++{ ++ va_list ap; ++ ++ va_start(ap, depth); ++ char* args_as_str = args_to_str(depth, ap); ++ va_end(ap); ++ ++ va_start(ap, depth); ++ mxl_json_num dbl = mxl_json_doc_get_doublev(doc, depth, ap); ++ va_end(ap); ++ ++ int rc = -1; ++ ++ // first: trivial error equality ++ if (expected_err != dbl.err) { ++ printf("FAIL \"%s\" expected error %s (actual %s)\n", ++ args_as_str, err_to_str(expected_err), err_to_str(dbl.err)); ++ rc = 1; ++ goto finally; ++ } ++ ++ // second: NaN aware value compare ++ bool both_nan_or_both_compare = 0; ++ if (isnan(expected_value) || isnan(dbl.value)) ++ both_nan_or_both_compare = isnan(expected_value) && isnan(dbl.value); ++ else ++ both_nan_or_both_compare = !dblcmp(expected_value, dbl.value); ++ ++ if (both_nan_or_both_compare) { ++ printf("PASS \"%s\" = {%.17g, %s}\n", ++ args_as_str, dbl.value, err_to_str(dbl.err)); ++ rc = 0; ++ } ++ else { ++ printf("FAIL \"%s\" expected value %.17g (actual %.17g)\n", ++ args_as_str, expected_value, dbl.value); ++ rc = 1; ++ } ++ ++finally: ++ free(args_as_str); ++ ++ return rc; ++} ++ ++// ++// sanity test ++// ++ ++static const char sanity_test_json[] = ++ "{" \ ++ "\"array_a\": [0,1,2,3]," \ ++ "\"object_a\": {\"a\": 1, \"b\": [0,1,2,3], \"c\": {\"x\": \"y\"} }," \ ++ "\"grain_rate\": {\"numerator\": 25, \"denominator\": 1}," \ ++ "\"format\": {\"name\": \"video\"}," \ ++ "\"media_type\": \"video/v210\"," \ ++ "\"frame_width\": 1920," \ ++ "\"array_b\": [{\"m\": n, \"o\": [4,5,6]}, \"p\", [\"q\",7,{\"r\":8}]]," \ ++ "\"object_b\": {\"s\": [9,\"t\",true], \"u\": {\"v\":{\"w\":10}}}," \ ++ "\"a_null\": null," \ ++ "\"a_true\": true," \ ++ "\"a_false\": false," \ ++ "\"array_c\": [0, \"one\", {\"three\": \"four\"}]," \ ++ "\"negnums\": {\"a\": -1.23, \"b\": -.123}," \ ++ "\"expnums\": {\"a\": 1.23e2, \"b\": 1.23e+2, \"c\": 1.23e-2}," \ ++ "\"object_c\": {\"last\": \"final\"}," \ ++ "\"ctrl_char\": {\"text\":\"line1\\nline2\"}" ++ "}"; ++ ++static int sanity_test(void) { ++ ++#define S1(VAL, ERR, P1) \ ++ rc = verify_json_string(&doc, VAL, ERR, 1, P1); \ ++ if (rc) goto finally; ++#define S2(VAL, ERR, P1, P2) \ ++ rc = verify_json_string(&doc, VAL, ERR, 2, P1, P2); \ ++ if (rc) goto finally; ++ ++#define D1(VAL, ERR, P1) \ ++ rc = verify_json_double(&doc, VAL, ERR, 1, P1); \ ++ if (rc) goto finally; ++#define D2(VAL, ERR, P1, P2) \ ++ rc = verify_json_double(&doc, VAL, ERR, 2, P1, P2); \ ++ if (rc) goto finally; ++#define D4(VAL, ERR, P1, P2, P3, P4) \ ++ rc = verify_json_double(&doc, VAL, ERR, 4, P1, P2, P3, P4); \ ++ if (rc) goto finally; ++ ++ printf("\nsanity test:\n"); ++ ++ mxl_json_doc doc; ++ int rc = -1; ++ ++ if (mxl_json_doc_build(NULL, sanity_test_json, &doc) < 0) { ++ printf("json_doc_build failed\n"); ++ rc = 1; ++ goto finally; ++ } ++ ++ mxl_json_dump_doc(&doc); ++ ++ // happy paths ++ S1("video/v210", 0, "media_type"); ++ S2("video", 0, "format", "name"); ++ S2("final", 0, "object_c", "last"); ++ D1(1920, 0, "frame_width"); ++ D2(25, 0, "grain_rate", "numerator"); ++ D2(1, 0, "grain_rate", "denominator"); ++ D2(1, 0, "object_a", "a"); ++ D4(10, 0, "object_b", "u", "v", "w"); ++ D2(-1.23, 0, "negnums", "a"); ++ D2(-0.123, 0, "negnums", "b"); ++ D2(1.23e2, 0, "expnums", "a"); ++ D2(1.23e+2, 0, "expnums", "b"); ++ D2(1.23e-2, 0, "expnums", "c"); ++ S2("line1\\nline2", 0, "ctrl_char", "text"); ++ ++ // impossible null primitive conversion ++ D1(NAN, MXL_JSON_NULL, "a_null"); ++ S1(NULL, MXL_JSON_NULL, "a_null"); ++ ++ // error: impossible null primitive conversion ++ D1(NAN, MXL_JSON_EBADTYPE, "a_true"); ++ D1(NAN, MXL_JSON_EBADTYPE, "a_false"); ++ S1(NULL, MXL_JSON_EBADTYPE, "a_false"); ++ S1(NULL, MXL_JSON_EBADTYPE, "a_true"); ++ ++ // error: bad type access to supported type ++ D2(NAN, MXL_JSON_EBADTYPE, "format", "name"); ++ S2(NULL, MXL_JSON_EBADTYPE, "grain_rate", "numerator"); ++ ++ // error: bad type access to unsupported array type ++ D1(NAN, MXL_JSON_EBADTYPE, "array_a"); ++ S1(NULL, MXL_JSON_EBADTYPE, "array_b"); ++ ++ // error: path not found ++ D1(NAN, MXL_JSON_ENOTFOUND, "does_not_exist"); ++ D2(NAN, MXL_JSON_ENOTFOUND, "does", "not_exist"); ++ S1(NULL, MXL_JSON_ENOTFOUND, "blah"); ++ S2(NULL, MXL_JSON_ENOTFOUND, "blah", "yadda"); ++ ++finally: ++ mxl_json_doc_release(&doc); ++ ++ if (doc.json || doc.tokens || doc.ntok) { ++ printf("FAIL mxl_json_doc_release did not clear structure values\n"); ++ rc = 1; ++ } ++ ++ return rc; ++ ++#undef D1 ++#undef D2 ++#undef D4 ++#undef S1 ++#undef S2 ++} ++ ++// ++// abberant input tests ++// ++ ++static const char invalid_empty[] = ""; ++static const char valid_empty_object[] = "{}"; ++static const char valid_empty_array[] = "[]"; ++static const char valid_empty_string[] = "\"\""; ++static const char valid_single_number[] = "123.346"; ++static const char valid_single_neg_number[] = "-123.346"; ++static const char valid_single_exp_number[] = "1.23346e2"; ++static const char valid_single_true[] = "true"; ++static const char valid_single_false[] = "false"; ++static const char valid_single_null[] = "null"; ++static const char misspelled_single_null[] = "nul"; ++static const char misspelled_contained_null[] = "{\"misspelled_null\": nul}"; ++static const char capital_contained_null[] = "{\"capital_null\": NULL}"; ++static const char invalid_leading_plus[] = "{\"a\": +123.346}"; ++static const char invalid_exp_number[] = "{\"a\": 1.23346e}"; ++static const char invalid_number_sep[] = "{\"a\": 1,23346}"; ++static const char not_json[] = "this is totally wrong"; ++static const char missing_key_quotes[] = "{foo: \"bar\"}"; ++static const char single_quotes[] = "{'foo': 'bar'}"; ++static const char trailing_comma_object[] = "{ \"foo\": \"bar\", }"; ++static const char trailing_comma_array[] = "[ \"a\", \"b\", ]"; ++static const char missing_both_braces[] = "\"foo\": \"bar\""; ++static const char missing_start_brace[] = "\"foo\": \"bar\"}"; ++static const char missing_end_brace[] = "{\"foo\": \"bar\""; ++static const char dangling_key[] = "{\"foo\":}"; ++static const char abnormal_termination[] = "{\"foo\": \"bar\"} what's this?"; ++static const char unescaped_ctl_char[] = "{ \"text\": \"line1\nline2\" }"; ++static const char unquoted_literal[] = "{\"what\": yes }"; ++static const char nan_value[] = "{\"value\": NaN }"; ++static const char inf_values[] = "{\"a\":Infinity,\"b\":-Infinity," \ ++ "\"c\":Inf, \"d\":-Inf}"; ++static const char valid_overflow_pos_number[] = "{\"a\": 1e400}"; ++static const char valid_overflow_neg_number[] = "{\"a\": -1e400}"; ++static const char valid_underflow_pos_number[] = "{\"a\": 1e-400}"; ++static const char valid_underflow_neg_number[] = "{\"a\": -1e-400}"; ++ ++static int test_input_build(const char* json, mxl_json_doc* doc) ++{ ++ int rc = mxl_json_doc_build(NULL, json, doc); ++ if (rc < 0) ++ printf("json_doc_build failed\n"); ++ return rc; ++} ++ ++static int abberant_input_tests(void) { ++ ++#define TS(VARIANT) \ ++ { \ ++ name = #VARIANT; \ ++ printf("\nSTART abberant test: %s\n", name); \ ++ rc = test_input_build(VARIANT, &doc); \ ++ if (rc) goto finally; \ ++ mxl_json_dump_doc(&doc); \ ++ } ++ ++#define S1(VAL, ERR, P1) \ ++ rc = verify_json_string(&doc, VAL, ERR, 1, P1); \ ++ if (rc) goto finally; ++ ++#define D1(VAL, ERR, P1) \ ++ rc = verify_json_double(&doc, VAL, ERR, 1, P1); \ ++ if (rc) goto finally; ++ ++#define TE(VARIANT) \ ++ av_assert0(strcmp(name, #VARIANT) == 0); \ ++ av_assert0(!rc); \ ++ printf("PASS abberant: %s\n", name); \ ++ mxl_json_doc_release(&doc); ++ ++#define BUILD_AND_ACCESS_TEST(VARIANT) \ ++ TS(VARIANT); \ ++ S1(NULL, MXL_JSON_ENOTFOUND, "absent"); \ ++ D1(NAN, MXL_JSON_ENOTFOUND, "absent"); \ ++ TE(VARIANT); ++ ++#define BUILD_EXPECT_ERROR_TEST(VARIANT, EXPECTED_ERR) \ ++ { \ ++ name = #VARIANT; \ ++ printf("\nSTART abberant test: %s\n", name); \ ++ rc = test_input_build(VARIANT, &doc); \ ++ if (rc != EXPECTED_ERR) { \ ++ printf("FAIL expected error %s (actual %s)\n", \ ++ err_to_str(EXPECTED_ERR), err_to_str(rc)); \ ++ if (!rc) rc = 1; \ ++ goto finally; \ ++ } \ ++ printf("PASS expected build error: %s (%s)\n", err_to_str(rc), err_to_str(rc)); \ ++ printf("PASS abberant: %s\n", name); \ ++ rc = 0; \ ++ } ++ ++ printf("\nabberant input tests:\n"); ++ ++ int rc = -1; ++ mxl_json_doc doc = {}; ++ const char* name; ++ ++ // verify that the json parses and can be traversed ++ BUILD_AND_ACCESS_TEST(invalid_empty); ++ BUILD_AND_ACCESS_TEST(valid_empty_object); ++ BUILD_AND_ACCESS_TEST(valid_empty_array); ++ BUILD_AND_ACCESS_TEST(valid_empty_string); ++ BUILD_AND_ACCESS_TEST(valid_single_number); ++ BUILD_AND_ACCESS_TEST(valid_single_neg_number); ++ BUILD_AND_ACCESS_TEST(valid_single_exp_number); ++ BUILD_AND_ACCESS_TEST(valid_single_true); ++ BUILD_AND_ACCESS_TEST(valid_single_false); ++ BUILD_AND_ACCESS_TEST(valid_single_null); ++ BUILD_AND_ACCESS_TEST(misspelled_single_null); ++ BUILD_AND_ACCESS_TEST(invalid_leading_plus); ++ BUILD_AND_ACCESS_TEST(invalid_exp_number); ++ BUILD_AND_ACCESS_TEST(invalid_number_sep); ++ BUILD_AND_ACCESS_TEST(not_json); ++ BUILD_AND_ACCESS_TEST(missing_key_quotes); ++ BUILD_AND_ACCESS_TEST(single_quotes); ++ BUILD_AND_ACCESS_TEST(trailing_comma_object); ++ BUILD_AND_ACCESS_TEST(trailing_comma_array); ++ BUILD_AND_ACCESS_TEST(dangling_key); ++ BUILD_AND_ACCESS_TEST(abnormal_termination); ++ BUILD_AND_ACCESS_TEST(unescaped_ctl_char); ++ BUILD_AND_ACCESS_TEST(missing_both_braces); ++ ++ // verify that the json parse fails ++ BUILD_EXPECT_ERROR_TEST(missing_start_brace, AVERROR_INVALIDDATA); ++ BUILD_EXPECT_ERROR_TEST(missing_end_brace, AVERROR_INVALIDDATA); ++ ++ ++ // verfiy failure accessing incorrect fields ++ ++ TS(misspelled_contained_null); ++ S1(NULL, MXL_JSON_ENOTFOUND, "absent"); ++ D1(NAN, MXL_JSON_ENOTFOUND, "absent"); ++ S1(NULL, MXL_JSON_EBADTYPE, "misspelled_null"); ++ D1(NAN, MXL_JSON_EBADTYPE, "misspelled_null"); ++ TE(misspelled_contained_null); ++ ++ TS(capital_contained_null); ++ S1(NULL, MXL_JSON_ENOTFOUND, "absent"); ++ D1(NAN, MXL_JSON_ENOTFOUND, "absent"); ++ S1(NULL, MXL_JSON_EBADTYPE, "capital_null"); ++ D1(NAN, MXL_JSON_EBADTYPE, "capital_null"); ++ TE(capital_contained_null); ++ ++ TS(unquoted_literal); ++ S1(NULL, MXL_JSON_ENOTFOUND, "absent"); ++ D1(NAN, MXL_JSON_ENOTFOUND, "absent"); ++ S1(NULL, MXL_JSON_EBADTYPE, "what"); ++ D1(NAN, MXL_JSON_EBADTYPE, "what"); ++ TE(unquoted_literal); ++ ++ TS(nan_value); ++ S1(NULL, MXL_JSON_ENOTFOUND, "absent"); ++ D1(NAN, MXL_JSON_ENOTFOUND, "absent"); ++ S1(NULL, MXL_JSON_EBADTYPE, "value"); ++ D1(NAN, MXL_JSON_EBADTYPE, "value"); ++ TE(nan_value); ++ ++ TS(inf_values); ++ D1(INFINITY, MXL_JSON_EBADTYPE, "a"); ++ D1(-INFINITY, MXL_JSON_EBADTYPE, "b"); ++ D1(INFINITY, MXL_JSON_EBADTYPE, "c"); ++ D1(-INFINITY, MXL_JSON_EBADTYPE, "d"); ++ TE(inf_values); ++ ++ TS(valid_overflow_pos_number); ++ D1(HUGE_VAL, ERANGE, "a"); ++ TE(valid_overflow_pos_number); ++ ++ TS(valid_overflow_neg_number); ++ D1(-HUGE_VAL, ERANGE, "a"); ++ TE(valid_overflow_neg_number); ++ ++ TS(valid_underflow_pos_number); ++ D1(0.0, ERANGE, "a"); ++ TE(valid_underflow_pos_number); ++ ++ TS(valid_underflow_neg_number); ++ D1(-0.0, ERANGE, "a"); ++ TE(valid_underflow_neg_number); ++ ++finally: ++ ++ return rc; ++ ++#undef BUILD_AND_ACCESS_TEST ++#undef BUILD_EXPECT_ERROR_TEST ++#undef TS ++#undef TE ++#undef S1 ++#undef D1 ++} ++ ++ ++// ++// non "C" locale tests: errant non "C" locale numbers in json string ++// ++ ++enum expect_what { ++ MALFORMED, ++ NOPARSE ++}; ++ ++typedef struct bad_number_case { ++ const char *num; ++ const char *name; ++ enum expect_what expect; ++} bad_number_case; ++ ++static const bad_number_case bad_number_cases[] = { ++ { "3,14", "comma_dec_a", MALFORMED}, ++ { "0,001", "comma_dec_b", MALFORMED }, ++ ++ { "1.234,56", "dot_thou_a", MALFORMED }, ++ { "12.345,67", "dot_thou_b", MALFORMED }, ++ ++ { "1,234.56", "comma_thou_a", MALFORMED}, ++ { "12,345.67", "comma_thou_b", MALFORMED}, ++ ++ { "1 234,56", "space_thou_a", MALFORMED}, ++ { "1 234.56", "space_thou_b", MALFORMED}, ++ ++ { "1" "\xC2\xA0" "234,56", "nbsp_thou_a", NOPARSE }, ++ { "1" "\xC2\xA0" "234.56", "nbsp_thou_b", NOPARSE }, ++ ++ { "1" "\xE2\x80\x89" "234,56", "thin_thou_a", NOPARSE }, ++ { "1" "\xE2\x80\x89" "234.56", "thin_thou_b", NOPARSE }, ++ ++ { "1'234,56", "swiss_thou_a", MALFORMED }, ++ { "1'234.56", "swiss_thou_b", MALFORMED }, ++ ++ { "\xD9\xA3" "\xDB\x8B" "\xD9\xA1" "\xD9\xA4", "arabic_314", NOPARSE }, ++ { "\xD9\xA1" "\xD9\xAC" "\xD9\xA2" "\xD9\xA3" "\xD9\xA4" "\xDB\x8B" ++ "\xD9\xA5" "\xD9\xA6", "arabic_1234", NOPARSE }, ++}; ++ ++static int test_bad_number_case(const bad_number_case* cse) { ++ printf("test %s\n", cse->name); ++ ++ mxl_json_doc doc = {0}; ++ char json[MXL_JSON_MAX_NUMBER_CHARS+1] = {0}; ++ ++ int rc = snprintf(json, sizeof(json), "{ \"%s\": %s }", ++ cse->name, cse->num); ++ if (rc < 0 || (size_t)rc >= sizeof(json)) ++ goto finally; ++ ++ rc = mxl_json_doc_build(NULL, json, &doc); ++ ++ if (!rc) ++ mxl_json_dump_doc(&doc); ++ ++ if (NOPARSE == cse->expect) ++ if (rc == 0) { ++ printf("FAIL expected NOPARSE but parse succeeded\n"); ++ rc = 1; ++ goto finally; ++ } ++ else { ++ printf("PASS expected NOPARSE and parse did not succeed\n"); ++ rc = 0; ++ goto finally; ++ } ++ ++ if (MALFORMED == cse->expect) { ++ av_assert0(!rc); ++ mxl_json_num num = mxl_json_doc_get_double1(&doc, cse->name); ++ ++ if (num.err != MXL_JSON_MALFORMED) { ++ printf("FAIL expected MXL_JSON_MALFORMED error (actual %s)\n", ++ err_to_str(num.err)); ++ rc = 1; ++ goto finally; ++ } ++ else { ++ printf("PASS %s identified as malformed\n", cse->name); ++ rc = 0; ++ goto finally; ++ } ++ } ++ ++finally: ++ if (!rc) ++ mxl_json_doc_release(&doc); ++ ++ return rc; ++} ++ ++static int non_c_locale_tests(void) ++{ ++ printf("\nnon C locale number tests:\n"); ++ ++ int rc = -1; ++ ++ for (int i = 0; i < sizeof(bad_number_cases)/sizeof(bad_number_case); i++) { ++ rc = test_bad_number_case(&bad_number_cases[i]); ++ if (rc) goto finally; ++ } ++ ++finally: ++ return rc; ++} ++ ++ ++// ++// real world MXL json string test ++// ++ ++static const char *real_world_mxl_json_str = ++"{\n" ++" \"description\": \"MXL Test File\",\n" ++" \"id\": \"5fbec3b1-1b0f-417d-9059-8b94a47197ef\",\n" ++" \"tags\": {},\n" ++" \"format\": \"urn:x-nmos:format:video\",\n" ++" \"label\": \"MXL Test File\",\n" ++" \"parents\": [],\n" ++" \"media_type\": \"video/v210\",\n" ++" \"grain_rate\": {\n" ++" \"numerator\": 50,\n" ++" \"denominator\": 1\n" ++" },\n" ++" \"frame_width\": 1920,\n" ++" \"frame_height\": 1080,\n" ++" \"colorspace\": \"BT709\",\n" ++" \"components\": [\n" ++" {\n" ++" \"name\": \"Y\",\n" ++" \"width\": 1920,\n" ++" \"height\": 1080,\n" ++" \"bit_depth\": 10\n" ++" },\n" ++" {\n" ++" \"name\": \"Cb\",\n" ++" \"width\": 960,\n" ++" \"height\": 1080,\n" ++" \"bit_depth\": 10\n" ++" },\n" ++" {\n" ++" \"name\": \"Cr\",\n" ++" \"width\": 960,\n" ++" \"height\": 1080,\n" ++" \"bit_depth\": 10\n" ++" }\n" ++" ]\n" ++"}\n"; ++ ++static int check_real_world_values(const mxl_json_doc *doc) ++{ ++#define S1(VAL, ERR, P1) \ ++ rc = verify_json_string(doc, VAL, ERR, 1, P1); \ ++ if (rc) goto finally; ++#define S2(VAL, ERR, P1, P2) \ ++ rc = verify_json_string(doc, VAL, ERR, 2, P1, P2); \ ++ if (rc) goto finally; ++#define D1(VAL, ERR, P1) \ ++ rc = verify_json_double(doc, VAL, ERR, 1, P1); \ ++ if (rc) goto finally; ++#define D2(VAL, ERR, P1, P2) \ ++ rc = verify_json_double(doc, VAL, ERR, 2, P1, P2); \ ++ if (rc) goto finally; ++ ++ int rc = -1; ++ ++ S1("5fbec3b1-1b0f-417d-9059-8b94a47197ef", 0, "id"); ++ S1( "MXL Test File", 0, "label"); ++ S1("video/v210", 0, "media_type"); ++ S1("urn:x-nmos:format:video", 0, "format"); ++ S1("BT709", 0, "colorspace"); ++ D2(50, 0, "grain_rate", "numerator"); ++ D2(1, 0, "grain_rate", "denominator"); ++ D1(1920, 0, "frame_width"); ++ D1(1080, 0, "frame_height"); ++ ++finally: ++ return rc; ++#undef S1 ++#undef S2 ++#undef D1 ++#undef D2 ++} ++ ++ ++static int real_world_mxl_json_test(void) { ++ ++ printf("\nreal world MXL test:\n"); ++ ++ mxl_json_doc doc; ++ int rc = -1; ++ ++ if (mxl_json_doc_build(NULL, real_world_mxl_json_str, &doc) < 0) { ++ printf("json_doc_build failed\n"); ++ rc = 1; ++ goto finally; ++ } ++ ++ mxl_json_dump_doc(&doc); ++ ++ rc = check_real_world_values(&doc); ++ ++finally: ++ mxl_json_doc_release(&doc); ++ ++ return rc; ++} ++ ++// ++// token capacity tests ++// ++ ++static int token_capacity_test(const unsigned int initial_token_capacity, ++ const unsigned int max_token_capacity, ++ const int expected_error, ++ const char *name) ++{ ++ mxl_json_doc doc; ++ int exit_status = -1; ++ ++ printf("parse with token capacity initial=%u max=%u\n", initial_token_capacity, max_token_capacity); ++ ++ int rc = mxl_json_doc_build2(NULL, real_world_mxl_json_str, &doc, ++ initial_token_capacity, max_token_capacity); ++ if (expected_error != rc) { ++ printf("FAIL json_doc_build status \"%s\"\n", name); ++ exit_status = 1; ++ goto finally; ++ } ++ ++ // expected error and got error? ++ if (expected_error != 0 && rc != 0) { ++ printf("PASS expected build error %s (%d)\n", err_to_str(expected_error), expected_error); ++ exit_status = 0; ++ goto finally; ++ } ++ ++ mxl_json_dump_doc(&doc); ++ ++ rc = check_real_world_values(&doc); ++ if (rc) { ++ printf("FAIL token capacity \"%s\"\n", name); ++ exit_status = 1; ++ goto finally ; ++ } ++ ++ exit_status = 0; ++ ++finally: ++ mxl_json_doc_release(&doc); ++ ++ if (0 == exit_status) ++ printf("PASS token capacity \"%s\"\n", name); ++ ++ return exit_status; ++} ++ ++static int token_capacity_tests(void) ++{ ++ printf("\ntoken_capacity test:\n"); ++ ++ int rc = -1; ++ const unsigned int max_cap_def = MXL_JSON_TOKEN_CAPACITY_MAX_DEFAULT; ++ ++ // initial token capcity tests ++ const unsigned int low_non_zero = 2; ++ if (low_non_zero > MXL_JSON_INITIAL_TOKEN_CAPACITY_DEFAULT) { ++ printf("FAIL MXL_JSON_INITIAL_TOKEN_CAPACITY_DEFAULT low_non_zero test mismatch\n"); ++ rc = 1; ++ goto finally; ++ } ++ ++ rc = token_capacity_test(low_non_zero, max_cap_def, 0, "low non zero initial capacity"); ++ if (rc) goto finally; ++ ++ rc = token_capacity_test(1, 2, AVERROR(ENOMEM), "exceed max capacity"); ++ if (rc) goto finally; ++ ++ rc = token_capacity_test(2, 2, AVERROR(ENOMEM), "initial equal to max"); ++ if (rc) goto finally; ++ ++ rc = token_capacity_test(0, max_cap_def, AVERROR(EINVAL), "zero initial capacity"); ++ if (rc) goto finally; ++ ++ rc = token_capacity_test(2, 1, AVERROR(EINVAL), "initial greater than max"); ++ if (rc) goto finally; ++ ++ rc = token_capacity_test(1, 0, AVERROR(EINVAL), "zero max capacity"); ++ if (rc) goto finally; ++ ++ ++finally: ++ return rc; ++} ++ ++// ++// limit tests: validate implementation limits ++// ++ ++static const char number_over_conversion_limit[] = "{\"too_long\":" ++ //0 1 2 3 4 5 ++ //12345678901234567890123456789012345678901234567890 ++ "11111111111111111111111111111111111111111111111111" ++ "11111111111111111111111111111111111111111111111111" ++ "1111111111111111111111111111}"; ++ ++static const char number_under_conversion_limit[] = "{\"not_too_long\":" ++ //0 1 2 3 4 5 ++ //12345678901234567890123456789012345678901234567890 ++ "11111111111111111111111111111111111111111111111111" ++ "11111111111111111111111111111111111111111111111111" ++ "111111111111111111111111111}"; ++ ++static const char object_depth_over_limit[] = ++ "{\"accessible_a\": 1," ++ "\"too_deep\":" "{\"a\":{\"a\":{\"a\":{\"a\":" ++ "{\"a\":{\"a\":{\"a\":{\"a\":" ++ "{\"a\":{\"a\":{\"a\":{\"a\":" ++ "{\"a\":{\"a\":{\"a\":{\"a\":2" ++ "}}}}}}}}}}}}}}}}," ++ "\"inaccessible_b\": 3}"; ++ ++static const char object_depth_under_limit[] = ++ "{\"accessible_a\": 1," ++ "\"not_too_deep\":" "{\"a\":{\"a\":{\"a\":{\"a\":" ++ "{\"a\":{\"a\":{\"a\":{\"a\":" ++ "{\"a\":{\"a\":{\"a\":{\"a\":" ++ "{\"a\":{\"a\":{\"a\":2" ++ "}}}}}}}}}}}}}}}," ++ "\"accessible_b\": 3}"; ++ ++static int limit_tests(void) ++{ ++ printf("\nlimit tests:\n"); ++ ++ const char* name; ++ mxl_json_doc doc; ++ int rc = -1; ++ ++#define TS(VARIANT) \ ++ { \ ++ name = #VARIANT; \ ++ printf("\nSTART limit test: %s\n", name); \ ++ rc = test_input_build(VARIANT, &doc); \ ++ if (rc) goto finally; \ ++ mxl_json_dump_doc(&doc); \ ++ } ++ ++#define D1(VAL, ERR, P1) \ ++ rc = verify_json_double(&doc, VAL, ERR, 1, P1); \ ++ if (rc) goto finally; ++ ++#define TE(VARIANT) \ ++ av_assert0(strcmp(name, #VARIANT) == 0); \ ++ av_assert0(!rc); \ ++ printf("PASS limit: %s\n", name); \ ++ mxl_json_doc_release(&doc); ++ ++ // over-limit numbes tests are hardcode for 127 max characters ++ if (127 != MXL_JSON_MAX_NUMBER_CHARS) { ++ printf("FAIL MXL_JSON_MAX_NUMBER_CHARS test mismatch\n"); ++ rc = 1; ++ goto finally; ++ } ++ else { ++ printf("PASS MXL_JSON_MAX_NUMBER_CHARS test match\n"); ++ } ++ ++ TS(number_over_conversion_limit); ++ D1(NAN, MXL_JSON_UNSUPPORTED, "too_long"); ++ TE(number_over_conversion_limit); ++ ++ TS(number_under_conversion_limit); ++ D1(1.1111111111111111e126, 0, "not_too_long"); ++ TE(number_under_conversion_limit); ++ ++ // over-limit depth tests are hard coded for a max depth of 16. ++ if (16 != MXL_JSON_MAX_DEPTH) { ++ printf("FAIL MXL_JSON_MAX_DEPTH test mismatch\n"); ++ rc = 1; ++ goto finally; ++ } ++ else { ++ printf("PASS MXL_JSON_MAX_DEPTH test match\n"); ++ } ++ ++ TS(object_depth_over_limit); ++ // works because the over-depth object does not have to be traversed ++ D1(1, 0, "accessible_a"); ++ // fails at the the key length limit ++ rc = verify_json_double(&doc, NAN, MXL_JSON_UNSUPPORTED, 17, ++ "too_deep", ++ "a","a","a","a","a","a","a","a", ++ "a","a","a","a","a","a","a","a"); ++ if (rc) goto finally; ++ // fails to traverse the over-depth object ++ D1(NAN, MXL_JSON_ENOTFOUND, "inaccessible_b"); ++ TE(object_depth_over_limit); ++ ++ ++ TS(object_depth_under_limit); ++ D1(1, 0, "accessible_a"); ++ rc = verify_json_double(&doc, 2, 0, 16, ++ "not_too_deep", ++ "a","a","a","a","a","a","a","a", ++ "a","a","a","a","a","a","a"); ++ if (rc) goto finally; ++ // fails at the key length limit ++ rc = verify_json_double(&doc, NAN, MXL_JSON_UNSUPPORTED, 17, ++ "too_deep", ++ "a","a","a","a","a","a","a","a", ++ "a","a","a","a","a","a","a","a"); ++ if (rc) goto finally; ++ D1(3, 0, "accessible_b"); ++ TE(object_depth_under_limit); ++ ++finally: ++ ++ return rc; ++ ++#undef TS ++#undef TE ++#undef D1 ++} ++ ++int main(void) { ++ ++ printf("testing mxl_json: jsmn based MXL resource definition parser\n"); ++ ++ int rc = -1; ++ ++ // FFmpeg expects C locale, this JSON parser depends on that. ++ if (!setlocale(LC_ALL, "C")) ++ goto finally; ++ ++ rc = sanity_test(); ++ if (rc) goto finally; ++ ++ rc = abberant_input_tests(); ++ if (rc) goto finally; ++ ++ rc = non_c_locale_tests(); ++ if (rc) goto finally; ++ ++ rc = real_world_mxl_json_test(); ++ if (rc) goto finally; ++ ++ rc = token_capacity_tests(); ++ if (rc) goto finally; ++ ++ rc = limit_tests(); ++ if (rc) goto finally; ++ ++finally: ++ ++ if (rc) ++ printf("\nFAIL\n"); ++ else ++ printf("\nPASS\n"); ++ ++ return rc; ++} +diff --git a/libavformat/tests/mxl_loc.c b/libavformat/tests/mxl_loc.c +new file mode 100644 +index 0000000000..221dac8c76 +--- /dev/null ++++ b/libavformat/tests/mxl_loc.c +@@ -0,0 +1,587 @@ ++/* ++ * MXL locator tests for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#include "libavformat/mxl_loc.h" ++#include "libavutil/error.h" ++#include "libavutil/avassert.h" ++#include "libavutil/log.h" ++ ++#include ++#include ++#include ++ ++typedef struct mxl_loc_const { ++ const char *domain_path; ++ const char * const *flow_ids; ++ int nb_flow_ids; ++} mxl_loc_const; ++ ++typedef struct test_case { ++ const char *name; ++ const char *locator; ++ int expected_rc; ++ mxl_loc_const expected; ++} test_case; ++ ++static void dump_locator(const mxl_loc *loc) ++{ ++ if (!loc) { ++ printf("mxl_loc : (null)\n"); ++ return; ++ } ++ ++ printf("mxl_loc {\n"); ++ printf(" domain_path: \"%s\"\n", loc->domain_path ? loc->domain_path : "(null)"); ++ printf(" nb_flow_ids: %d\n", loc->nb_flow_ids); ++ printf(" flow_ids: [\n"); ++ for (int i = 0; i < loc->nb_flow_ids; i++) ++ printf(" \"%s\"\n", loc->flow_ids[i] ? loc->flow_ids[i] : "(null)"); ++ printf(" ]\n"); ++ printf("}\n"); ++} ++ ++static void dump_expected_locator(const mxl_loc_const *expected) { ++ mxl_loc loc = { ++ .domain_path = (char *)expected->domain_path, ++ .flow_ids = (char **)expected->flow_ids, ++ .nb_flow_ids = expected->nb_flow_ids ++ }; ++ ++ dump_locator(&loc); ++} ++ ++static void dump_test_case(const test_case *tc, int actual_rc, const mxl_loc *actual) ++{ ++ printf("==== test case: %s\n", tc->name); ++ ++ printf("locator: \"%s\"\n", tc->locator ? tc->locator : "(null)"); ++ printf("expected return code: %d\n", tc->expected_rc); ++ printf("actual return code: %d\n", actual_rc); ++ ++ printf("expected locator:\n"); ++ dump_expected_locator(&tc->expected); ++ printf("actual locator:\n"); ++ dump_locator(actual); ++} ++ ++static int compare_loc(const mxl_loc_const *expected, const mxl_loc *actual) ++{ ++ av_assert0(expected && actual); ++ ++ if ((expected->domain_path == NULL) != (actual->domain_path == NULL)) { ++ fprintf(stderr, "compare_loc: domain_path NULL mismatch\n"); ++ return -1; ++ } ++ ++ if (expected->domain_path && actual->domain_path && ++ strcmp(expected->domain_path, actual->domain_path) != 0) { ++ fprintf(stderr, "compare_loc: domain_path mismatch: expected '%s' actual '%s'\n", ++ expected->domain_path, actual->domain_path); ++ return -1; ++ } ++ ++ if ((expected->flow_ids == NULL) != (actual->flow_ids == NULL)) { ++ fprintf(stderr, "compare_loc: flow_ids NULL mismatch\n"); ++ return -1; ++ } ++ ++ if (expected->nb_flow_ids != actual->nb_flow_ids) { ++ fprintf(stderr, "compare_loc: nb_flow_ids mismatch: expected %d actual %d\n", ++ expected->nb_flow_ids, actual->nb_flow_ids); ++ return -1; ++ } ++ ++ for (int i = 0; i < expected->nb_flow_ids; i++) { ++ const char *e = expected->flow_ids[i]; ++ const char *a = actual->flow_ids[i]; ++ ++ if (e == NULL || a == NULL) { ++ fprintf(stderr, "compare_loc: flow_id[%d] NULL: expected %s actual %s\n", ++ i, ++ e ? e : "(null)", ++ a ? a : "(null)"); ++ return -1; ++ } ++ ++ if (strcmp(e, a) != 0) { ++ fprintf(stderr, "compare_loc: flow_id[%d] mismatch: expected '%s' actual '%s'\n", ++ i, e, a); ++ return -1; ++ } ++ } ++ ++ return 0; ++} ++ ++/* returns 0 if invariants are satisfied */ ++static int verify_invariants(int parse_rc, const mxl_loc* actual) { ++ if (!actual) ++ return -1; ++ ++ if (!parse_rc) { ++ /* parse success invariants */ ++ if (actual->domain_path && ++ actual->nb_flow_ids >= 0 && ++ (actual->nb_flow_ids == 0 ? actual->flow_ids == NULL : actual->flow_ids != NULL)) { ++ return 0; ++ } ++ } ++ else { ++ /* parse failure invariants */ ++ if (!actual->domain_path && ++ actual->nb_flow_ids == 0 && actual->flow_ids == NULL) { ++ return 0; ++ } ++ } ++ ++ return -1; ++} ++ ++static int run_test_case(const test_case *tc) ++{ ++ mxl_loc actual = {0}; ++ int test_case_rc = -1; ++ ++ int parse_rc = mxl_loc_parse(NULL, tc->locator, &actual); ++ ++ dump_test_case(tc, parse_rc, &actual); ++ ++ int invariants_rc = verify_invariants(parse_rc, &actual); ++ if (invariants_rc != 0) { ++ fprintf(stderr, ++ "==== test '%s' FAIL: parsed locator invariants violated\n", ++ tc->name); ++ goto finally; ++ } ++ ++ int compare_rc = compare_loc(&tc->expected, &actual); ++ if (compare_rc != 0) { ++ fprintf(stderr, ++ "==== test '%s' FAIL: parsed locator mismatch\n", ++ tc->name); ++ goto finally; ++ } ++ ++ if (parse_rc != tc->expected_rc) { ++ fprintf(stderr, ++ "==== test '%s' FAIL: return code mismatch " ++ "(expected %d got %d)\n", ++ tc->name, tc->expected_rc, parse_rc); ++ goto finally; ++ } ++ ++ test_case_rc = 0; ++ printf("==== test case: %s PASS\n", tc->name); ++ ++finally: ++ ++ if (!parse_rc) ++ mxl_loc_free(&actual); ++ ++ return test_case_rc; ++} ++ ++// expected mxl_loc value on error ++static const mxl_loc_const expected_error_loc = { ++ .domain_path = NULL, ++ .flow_ids = NULL, ++ .nb_flow_ids = 0, ++}; ++ ++static test_case abs_filesystem_path = { ++ .name = "abs_filesystem_path", ++ .locator = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case abs_filesystem_path_root = { ++ .name = "abs_filesystem_path_root", ++ .locator = "/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case rel_filesystem_path = { ++ .name = "rel_filesystem_path", ++ .locator = "mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "mxl", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case rel_filesystem_path_none = { ++ .name = "rel_filesystem_path_none", ++ .locator = "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = ".", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case rel_filesystem_path_dot = { ++ .name = "rel_filesystem_path_dot", ++ .locator = "./71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = ".", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case rel_filesystem_path_dot_dot = { ++ .name = "rel_filesystem_path_dot_dot", ++ .locator = "../71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "..", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case abs_filesystem_path_zero_flow = { ++ .name = "abs_filesystem_path_zero_flow", ++ .locator = "/tests/data/tmp/mxl", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/tests/data/tmp/mxl", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case abs_filesystem_no_flow = { ++ .name = "abs_filesystem_no_flow", ++ .locator = "/dev/shm/mxl", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case abs_filesystem_no_flow_root = { ++ .name = "abs_filesystem_no_flow_root", ++ .locator = "/", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case rel_filesystem_no_flow = { ++ .name = "rel_filesystem_no_flow", ++ .locator = "rel/shm/mxl", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "rel/shm/mxl", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case rel_filesystem_no_flow_pwd = { ++ .name = "rel_filesystem_no_flow_pwd", ++ .locator = ".", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = ".", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case rel_filesystem_path_trailing_slash = { ++ .name = "rel_filesystem_path_trailing_slash", ++ .locator = "mxl/", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "mxl/", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case rel_filesystem_path_double_sep = { ++ .name = "rel_filesystem_path_double_sep", ++ .locator = "mxl//71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "mxl/", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case abs_filesystem_path_wrong_ext = { ++ .name = "abs_filesystem_path_wrong_ext", ++ .locator = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.txt", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.txt", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case abs_filesystem_path_trailing_ext_junk = { ++ .name = "abs_filesystem_path_trailing_ext_junk", ++ .locator = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow.tmp", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow.tmp", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case abs_filesystem_path_no_ext = { ++ .name = "abs_filesystem_path_no_ext", ++ .locator = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case abs_filesystem_path_upcase_id = { ++ .name = "abs_filesystem_path_upcase_id", ++ .locator = "/dev/shm/mxl/71F000DC-2578-4A06-B4EF-3E0E2B1E46E1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl", ++ .flow_ids = (const char *[]) { ++ "71F000DC-2578-4A06-B4EF-3E0E2B1E46E1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case abs_filesystem_path_double_sep_a = { ++ .name = "abs_filesystem_path_double_sep_a", ++ .locator = "//dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "//dev/shm/mxl", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case abs_filesystem_path_double_sep_b = { ++ .name = "abs_filesystem_path_double_sep_b", ++ .locator = "/dev/shm//mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm//mxl", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case abs_filesystem_path_double_sep_c = { ++ .name = "abs_filesystem_path_double_sep_c", ++ .locator = "/dev/shm/mxl//71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl/", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case abs_filesystem_directory_named_flow = { ++ .name = "abs_filesystem_directory_named_flow", ++ .locator = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow/", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow/", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case abs_filesystem_path_bad_id = { ++ .name = "abs_filesystem_path_bad_id", ++ .locator = "/dev/shm/mxl/bad_id.mxl-flow", ++ .expected_rc = AVERROR(EINVAL), ++ .expected = expected_error_loc, ++}; ++ ++static test_case abs_filesystem_path_missing_id = { ++ .name = "abs_filesystem_path_missing_id", ++ .locator = "/dev/shm/mxl/.mxl-flow", ++ .expected_rc = AVERROR(EINVAL), ++ .expected = expected_error_loc, ++}; ++ ++static test_case null_locator = { ++ .name = "null_locator", ++ .locator = NULL, ++ .expected_rc = AVERROR(EINVAL), ++ .expected = expected_error_loc, ++}; ++ ++static test_case empty_locator = { ++ .name = "empty_locator", ++ .locator = "", ++ .expected_rc = AVERROR(ENOENT), ++ .expected = expected_error_loc, ++}; ++ ++static test_case uri_zero_flow = { ++ .name = "uri_zero_flow", ++ .locator = "mxl:///dev/shm/mxl", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0 ++ } ++}; ++ ++static test_case uri_one_flow = { ++ .name = "uri_one_flow", ++ .locator = "mxl:///dev/shm/mxl?id=71f000dc-2578-4a06-b4ef-3e0e2b1e46e1", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ }, ++ .nb_flow_ids = 1 ++ } ++}; ++ ++static test_case uri_two_flow = { ++ .name = "uri_two_flow", ++ .locator = "mxl:///dev/shm/mxl?" ++ "id=71f000dc-2578-4a06-b4ef-3e0e2b1e46e1&" ++ "id=ee43ad87-1ff5-43f5-a895-9cedbcc8884a", ++ .expected_rc = 0, ++ .expected = { ++ .domain_path = "/dev/shm/mxl", ++ .flow_ids = (const char *[]) { ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1", ++ "ee43ad87-1ff5-43f5-a895-9cedbcc8884a" ++ }, ++ .nb_flow_ids = 2 ++ } ++}; ++ ++static const test_case *tests[] = { ++ /* happy paths */ ++ &abs_filesystem_path, ++ &abs_filesystem_path_root, ++ &rel_filesystem_path, ++ &rel_filesystem_path_none, ++ &rel_filesystem_path_dot, ++ &rel_filesystem_path_dot_dot, ++ &abs_filesystem_no_flow, ++ &abs_filesystem_no_flow_root, ++ &rel_filesystem_no_flow, ++ &rel_filesystem_no_flow_pwd, ++ &rel_filesystem_path_trailing_slash, ++ &rel_filesystem_path_double_sep, ++ &abs_filesystem_path_zero_flow, ++ &abs_filesystem_path_wrong_ext, ++ &abs_filesystem_path_trailing_ext_junk, ++ &abs_filesystem_path_no_ext, ++ &abs_filesystem_path_upcase_id, ++ &abs_filesystem_path_double_sep_a, ++ &abs_filesystem_path_double_sep_b, ++ &abs_filesystem_path_double_sep_c, ++ &abs_filesystem_directory_named_flow, ++ &uri_zero_flow, ++ &uri_one_flow, ++ &uri_two_flow, ++ ++ /* aberrant cases */ ++ &abs_filesystem_path_bad_id, ++ &abs_filesystem_path_missing_id, ++ &null_locator, ++ &empty_locator, ++}; ++ ++int main(void) { ++ printf("testing mxl_loc: mxl resource location parser\n\n"); ++ ++ av_log_set_level(AV_LOG_QUIET); ++ ++ int rc = -1; ++ ++ // FFmpeg expects C locale, this URI parser depends on that. ++ if (!setlocale(LC_ALL, "C")) ++ return -1; ++ ++ for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { ++ rc = run_test_case(tests[i]); ++ if (rc) ++ break; ++ } ++ ++ if (rc) ++ printf("\nFAIL\n"); ++ else ++ printf("\nPASS\n"); ++ ++ return rc; ++} +diff --git a/libavformat/tests/mxl_uri.c b/libavformat/tests/mxl_uri.c +new file mode 100644 +index 0000000000..504e625cb9 +--- /dev/null ++++ b/libavformat/tests/mxl_uri.c +@@ -0,0 +1,1408 @@ ++/* ++ * MXL URI parser tests for Media eXchange Layer flows ++ * ++ * Copyright (c) 2025 CBC/Radio-Canada ++ * ++ * This file is part of FFmpeg. ++ * ++ * FFmpeg is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU Lesser General Public ++ * License as published by the Free Software Foundation; either ++ * version 2.1 of the License, or (at your option) any later version. ++ * ++ * FFmpeg is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU Lesser General Public License for more details. ++ * ++ * You should have received a copy of the GNU Lesser General Public ++ * License along with FFmpeg; if not, see . ++ */ ++ ++#include "libavformat/mxl_uri.h" ++#include "libavutil/avassert.h" ++#include "libavutil/error.h" ++#include "libavutil/log.h" ++ ++#include ++#include ++#include ++ ++typedef struct mxl_uri_const { ++ const char *host; ++ int port; ++ const char *domain; ++ const char * const *flow_ids; ++ int nb_flow_ids; ++} mxl_uri_const; ++ ++typedef struct test_case { ++ const char *name; ++ const char *uri; ++ int expected_rc; ++ mxl_uri_const expected; ++} test_case; ++ ++static int compare_uri(const mxl_uri_const *expected, const mxl_uri *actual) ++{ ++ av_assert0(expected && actual); ++ ++ if (expected->port != actual->port) { ++ fprintf(stderr, "compare_uri: port mismatch: expected %d actual %d\n", ++ expected->port, actual->port); ++ return -1; ++ } ++ ++ if ((expected->host == NULL) != (actual->host == NULL)) { ++ fprintf(stderr, "compare_uri: host NULL mismatch: expected %s actual %s\n", ++ expected->host ? expected->host : "(null)", ++ actual->host ? actual->host : "(null)"); ++ return -1; ++ } ++ ++ if (expected->host && strcmp(expected->host, actual->host) != 0) { ++ fprintf(stderr, "compare_uri: host mismatch: expected '%s' actual '%s'\n", ++ expected->host, actual->host); ++ return -1; ++ } ++ ++ if ((expected->domain == NULL) != (actual->domain == NULL)) { ++ fprintf(stderr, "compare_uri: domain NULL mismatch: expected %s actual %s\n", ++ expected->domain ? expected->domain : "(null)", ++ actual->domain ? actual->domain : "(null)"); ++ return -1; ++ } ++ ++ if (expected->domain && actual->domain && ++ strcmp(expected->domain, actual->domain) != 0) { ++ fprintf(stderr, "compare_uri: domain mismatch: expected '%s' actual '%s'\n", ++ expected->domain, actual->domain); ++ return -1; ++ } ++ ++ if ((expected->flow_ids == NULL) != (actual->flow_ids == NULL)) { ++ fprintf(stderr, "compare_uri: flow_ids pointer mismatch\n"); ++ return -1; ++ } ++ ++ if (expected->nb_flow_ids != actual->nb_flow_ids) { ++ fprintf(stderr, "compare_uri: flow_id count mismatch: expected %d actual %d\n", ++ expected->nb_flow_ids, actual->nb_flow_ids); ++ return -1; ++ } ++ ++ for (int i = 0; i < expected->nb_flow_ids; i++) { ++ const char *e = expected->flow_ids[i]; ++ const char *a = actual->flow_ids[i]; ++ ++ if (e == NULL || a == NULL) { ++ fprintf(stderr, "compare_uri: flow_id[%d] NULL: expected %s actual %s\n", ++ i, ++ e ? e : "(null)", ++ a ? a : "(null)"); ++ return -1; ++ } ++ ++ if (strcmp(e, a) != 0) { ++ fprintf(stderr, "compare_uri: flow_id[%d] mismatch: expected '%s' actual '%s'\n", ++ i, e, a); ++ return -1; ++ } ++ } ++ ++ return 0; ++} ++ ++static void dump_uri(const mxl_uri *u) ++{ ++ if (!u) { ++ printf("mxl_uri: (null)\n"); ++ return; ++ } ++ ++ printf("mxl_uri {\n"); ++ ++ printf(" host: \"%s\"\n", u->host ? u->host : "(null)"); ++ printf(" port: %d\n", u->port); ++ printf(" domain: \"%s\"\n", u->domain ? u->domain : "(null)"); ++ ++ printf(" flow_ids (%d):\n", u->nb_flow_ids); ++ for (int i = 0; i < u->nb_flow_ids; i++) { ++ printf(" [%d] \"%s\"\n", i, ++ (u->flow_ids && u->flow_ids[i]) ? u->flow_ids[i] : "(null)"); ++ } ++ ++ printf("}\n"); ++} ++ ++static void dump_expected_uri(const mxl_uri_const* expected_uri) ++{ ++ mxl_uri uri = { ++ .host = (char *)expected_uri->host, ++ .port = expected_uri->port, ++ .domain = (char *)expected_uri->domain, ++ .flow_ids = (char **)expected_uri->flow_ids, ++ .nb_flow_ids = expected_uri->nb_flow_ids ++ }; ++ ++ dump_uri(&uri); ++} ++ ++static void dump_test_case(const test_case *tc, int actual_parse_rc, const mxl_uri *actual) ++{ ++ printf("==== test case: %s\n", tc->name); ++ ++ printf("URI: \"%s\"\n", tc->uri); ++ ++ printf("expected return code: %d\n", tc->expected_rc); ++ printf("actual return code: %d\n", actual_parse_rc); ++ ++ printf("expected URI:\n"); ++ dump_expected_uri(&tc->expected); ++ printf("actual URI:\n"); ++ dump_uri(actual); ++} ++ ++/* returns 0 if invariants are satisfied */ ++static int verify_invariants(int parse_rc, const mxl_uri* actual) { ++ if (!actual) ++ return -1; ++ ++ if (!parse_rc) { ++ /* parse success invariants */ ++ if (actual->host && actual->port >= -1 && actual->domain && ++ actual->nb_flow_ids >= 0 && ++ (actual->nb_flow_ids == 0 ? actual->flow_ids == NULL : actual->flow_ids != NULL)) { ++ return 0; ++ } ++ } ++ else { ++ /* parse failure invariants */ ++ if (!actual->domain && ++ actual->nb_flow_ids == 0 && actual->flow_ids == NULL) { ++ return 0; ++ } ++ } ++ ++ return -1; ++} ++ ++static int run_test_case(const test_case *tc) ++{ ++ mxl_uri actual = {0}; ++ int invariants_rc = -1; ++ int compare_rc = -1; ++ int test_case_rc = -1; ++ ++ int parse_rc = mxl_parse_uri(NULL, tc->uri, &actual); ++ ++ dump_test_case(tc, parse_rc, &actual); ++ ++ invariants_rc = verify_invariants(parse_rc, &actual); ++ if (invariants_rc != 0) { ++ fprintf(stderr, ++ "==== test '%s' FAIL: parsed URI invariants violated\n", ++ tc->name); ++ goto finally; ++ } ++ ++ compare_rc = compare_uri(&tc->expected, &actual); ++ if (compare_rc != 0) { ++ fprintf(stderr, ++ "==== test '%s' FAIL: parsed URI mismatch\n", ++ tc->name); ++ goto finally; ++ } ++ ++ if (parse_rc != tc->expected_rc) { ++ fprintf(stderr, ++ "==== test '%s' FAIL: return code mismatch " ++ "(expected %d got %d)\n", ++ tc->name, tc->expected_rc, parse_rc); ++ goto finally; ++ } ++ ++ test_case_rc = 0; ++ printf("==== test case: %s PASS\n", tc->name); ++ ++finally: ++ if (!parse_rc) ++ mxl_uri_free(&actual); ++ ++ return test_case_rc; ++} ++ ++// expected mxl_uri value on error ++static const mxl_uri_const expected_error_uri = { ++ .host = NULL, ++ .port = -1, ++ .domain = NULL, ++ .flow_ids = NULL, ++ .nb_flow_ids = 0, ++}; ++ ++static test_case zero_id = { ++ .name = "zero_id", ++ ++ .uri = "mxl://host1.local/domain/path", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0, ++ }, ++}; ++ ++static test_case one_id = { ++ .name = "one_id", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ }, ++ ++ .nb_flow_ids = 1, ++ }, ++}; ++ ++static test_case two_id = { ++ .name = "two_id", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ "876fc244-9c99-48a8-a461-927e43a05bdc", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case three_id = { ++ .name = "three_id", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc&" ++ "id=2b528ffe-c94c-454f-984a-35c98c717006", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ "876fc244-9c99-48a8-a461-927e43a05bdc", ++ "2b528ffe-c94c-454f-984a-35c98c717006" ++ }, ++ ++ .nb_flow_ids = 3, ++ }, ++}; ++ ++static test_case two_id_upcase = { ++ .name = "two_id_upcase", ++ ++ .uri = ++ "MXL://HOST1.LOCAL/DOMAIN/PATH?" ++ "id=3645F1E2-90FC-49CB-AF9C-236C2AC124CD&" ++ "id=876FC244-9C99-48A8-A461-927E43A05BDC", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "HOST1.LOCAL", ++ .port = -1, ++ .domain = "/DOMAIN/PATH", ++ ++ .flow_ids = (const char *[]) { ++ "3645F1E2-90FC-49CB-AF9C-236C2AC124CD", ++ "876FC244-9C99-48A8-A461-927E43A05BDC", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case two_id_upcase_key = { ++ .name = "two_id_upcase_key", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "ID=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "876fc244-9c99-48a8-a461-927e43a05bdc", ++ }, ++ ++ .nb_flow_ids = 1, ++ }, ++}; ++ ++static test_case unknown_query_param = { ++ .name = "unknown_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "unknown=unknown&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ "876fc244-9c99-48a8-a461-927e43a05bdc", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case unknown_flag_query_param = { ++ .name = "unknown_flag_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "empty&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ "876fc244-9c99-48a8-a461-927e43a05bdc", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case empty_query = { ++ .name = "empty_query", ++ ++ .uri = "mxl://host1.local/domain/path?", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0, ++ }, ++}; ++ ++static test_case ipv4 = { ++ .name = "ipv4", ++ ++ .uri = ++ "mxl://10.1.2.3:5000/domain/path?" ++ "id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&" ++ "id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "10.1.2.3", ++ .port = 5000, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1", ++ "5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case zero_port = { ++ .name = "zero_port", ++ ++ .uri = ++ "mxl://10.1.2.3:0/domain/path?" ++ "id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&" ++ "id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case ipv6 = { ++ .name = "ipv6", ++ ++ .uri = ++ "mxl://[2001:db8::2]/domain/path?" ++ "id=1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92&" ++ "id=3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "2001:db8::2", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92", ++ "3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case ipv6_with_port = { ++ .name = "ipv6_with_port", ++ ++ .uri = ++ "mxl://[2001:db8::2]:5000/domain/path?" ++ "id=1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92&" ++ "id=3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "2001:db8::2", ++ .port = 5000, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92", ++ "3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case malformed_ipv6_bracket = { ++ .name = "malformed_ipv6_bracket", ++ ++ .uri = ++ "mxl://[2001:db8::2/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case bad_scheme = { ++ .name = "bad_scheme", ++ ++ .uri = ++ "ftp://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case no_scheme = { ++ .name = "no_scheme", ++ ++ .uri = ++ "/host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case foo_after_scheme = { ++ .name = "foo_after_scheme", ++ ++ .uri = "mxl:foo", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case null_uri = { ++ .name = "null_uri", ++ ++ .uri = NULL, ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case empty_uri = { ++ .name = "empty_uri", ++ ++ .uri = "", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L ++_Static_assert( ++ MXL_SCHEME_MAX == 4, ++ "Update equal_max_scheme and greater_max_scheme tests if MXL_SCHEME_MAX changes"); ++#endif ++// scheme too long (length == MXL_SCHEME_MAX) ++static test_case equal_max_scheme = { ++ .name = "equal_max_scheme", ++ ++ .uri = ++ "aaaa:/host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = AVERROR(ENAMETOOLONG), ++ ++ .expected = expected_error_uri ++}; ++ ++// scheme too long (length > MXL_SCHEME_MAX) ++static test_case greater_max_scheme = { ++ .name = "greater_max_scheme", ++ ++ .uri = ++ "aaaaa:/host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = AVERROR(ENAMETOOLONG), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case empty_middle_id_query_param = { ++ .name = "empty_middle_id_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case empty_end_id_query_param = { ++ .name = "empty_end_id_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc&" ++ "id=", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case bad_separator_query_param = { ++ .name = "bad_separator_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case bad_uuid_query_param = { ++ .name = "bad_uuid_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=X645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++// special case -1 port value, caller must interpret as "not set" ++static test_case valid_negative_one_port = { ++ .name = "valid_negative_one_port", ++ ++ .uri = ++ "mxl://10.1.2.3:-1/domain/path?" ++ "id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&" ++ "id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "10.1.2.3", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1", ++ "5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case invalid_negative_port = { ++ .name = "invalid_negative_port", ++ ++ .uri = ++ "mxl://host1.local:-2/domain/path?" ++ "id=645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case invalid_empty_port = { ++ .name = "invalid_empty_port", ++ ++ .uri = ++ "mxl://10.1.2.3:/domain/path?" ++ "id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&" ++ "id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case invalid_non_integer_port = { ++ .name = "invalid_non_integer_port", ++ ++ .uri = ++ "mxl://10.1.2.3:abc/domain/path?" ++ "id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&" ++ "id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case valid_at_range_limit_port = { ++ .name = "valid_at_range_limit_port", ++ ++ .uri = ++ "mxl://host1.local:65535/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = 65535, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ "876fc244-9c99-48a8-a461-927e43a05bdc", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case invalid_out_of_range_port = { ++ .name = "invalid_out_of_range_port", ++ ++ .uri = ++ "mxl://10.1.2.3:65536/domain/path?" ++ "id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&" ++ "id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case only_unknown_query_param = { ++ .name = "only_unknown_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?foo=bar&baz=qux", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0, ++ }, ++}; ++ ++static test_case leading_amp_query_param = { ++ .name = "leading_amp_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "&id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ }, ++ ++ .nb_flow_ids = 1, ++ }, ++}; ++ ++static test_case double_separator_query_param = { ++ .name = "double_separator_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ "876fc244-9c99-48a8-a461-927e43a05bdc", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case invalid_no_domain_path = { ++ .name = "invalid_no_domain_path", ++ ++ .uri = ++ "mxl://host1.local?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case fragment_after_query = { ++ .name = "fragment_after_query", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd#fragment", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case percent_encoded_in_uuid_query_value = { ++ .name = "percent_encoded_in_uuid_query_value", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd%26extra", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case percent_encoded_in_other_query_value = { ++ .name = "percent_encoded_in_other_query_value", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "other=%26extra&" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ "876fc244-9c99-48a8-a461-927e43a05bdc", ++ }, ++ ++ .nb_flow_ids = 2, ++ }, ++}; ++ ++static test_case percent_encoded_key = { ++ .name = "percent_encoded_key", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "%69d=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ .flow_ids = NULL, ++ .nb_flow_ids = 0, ++ }, ++}; ++ ++static test_case trailing_amp_query_param = { ++ .name = "trailing_amp_query_param", ++ ++ .uri = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "host1.local", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]){ ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ }, ++ ++ .nb_flow_ids = 1, ++ }, ++}; ++ ++static test_case userinfo_in_authority = { ++ .name = "userinfo_in_authority", ++ ++ .uri = ++ "mxl://user@host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case userpass_in_authority = { ++ .name = "userpass_in_authority", ++ ++ .uri = ++ "mxl://user:pass@host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case multiple_at_userinfo = { ++ .name = "multiple_at_userinfo", ++ ++ .uri = ++ "mxl://a@b@host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case local_domain_triple_slash = { ++ .name = "local_domain_triple_slash", ++ ++ .uri = ++ "mxl:///domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "", ++ .port = -1, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ }, ++ ++ .nb_flow_ids = 1, ++ }, ++}; ++ ++static test_case local_domain_looks_like_port_but_is_not = { ++ .name = "local_domain_looks_like_port_but_is_not", ++ ++ .uri = ++ "mxl:///domain/path:5000?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "", ++ .port = -1, ++ .domain = "/domain/path:5000", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ }, ++ ++ .nb_flow_ids = 1, ++ }, ++}; ++ ++static test_case local_domain_single_slash = { ++ .name = "local_domain_single_slash", ++ ++ .uri = ++ "mxl:/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case userinfo_with_empty_host = { ++ .name = "userinfo_with_empty_host", ++ ++ .uri = ++ "mxl://user@/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case empty_host_with_port = { ++ .name = "empty_host_with_port", ++ ++ .uri = ++ "mxl://:5000/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "", ++ .port = 5000, ++ .domain = "/domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ }, ++ ++ .nb_flow_ids = 1, ++ }, ++}; ++ ++static test_case query_without_path = { ++ .name = "query_without_path", ++ ++ .uri = ++ "mxl://?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case double_leading_slash_path = { ++ .name = "double_leading_slash_path", ++ ++ .uri = ++ "mxl:////domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = 0, ++ ++ .expected = { ++ .host = "", ++ .port = -1, ++ .domain = "//domain/path", ++ ++ .flow_ids = (const char *[]) { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ }, ++ ++ .nb_flow_ids = 1, ++ }, ++}; ++ ++static test_case empty_ipv6_host = { ++ .name = "empty_ipv6_host", ++ ++ .uri = ++ "mxl://[]/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case empty_ipv6_host_with_port = { ++ .name = "empty_ipv6_host_with_port", ++ ++ .uri = ++ "mxl://[]:5000/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case empty_domain_path = { ++ .name = "empty_domain_path", ++ ++ .uri = ++ "mxl://host1.local/?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case empty_domain_path_no_query = { ++ .name = "empty_domain_path_no_query", ++ ++ .uri = "mxl://host1.local", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case empty_domain_path_no_query_trailing_slash = { ++ .name = "empty_domain_path_no_query_trailing_slash", ++ ++ .uri = "mxl://host1.local/", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++static test_case non_unique_flow_ids = { ++ .name = "non_unique_flow_ids", ++ ++ .uri = ++ "mxl:///host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc&" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ ++ .expected_rc = AVERROR(EINVAL), ++ ++ .expected = expected_error_uri ++}; ++ ++ ++static const test_case *tests[] = { ++ /* happy path cases */ ++ &zero_id, ++ &one_id, ++ &two_id, ++ &three_id, ++ &two_id_upcase, ++ &two_id_upcase_key, ++ &unknown_query_param, ++ &unknown_flag_query_param, ++ &ipv4, ++ &ipv6, ++ &ipv6_with_port, ++ &valid_negative_one_port, ++ &only_unknown_query_param, ++ &local_domain_triple_slash, ++ &local_domain_single_slash, ++ ++ /* boundary cases */ ++ &zero_port, ++ &fragment_after_query, ++ &valid_at_range_limit_port, ++ &invalid_out_of_range_port, ++ &empty_query, ++ &leading_amp_query_param, ++ &double_separator_query_param, ++ &percent_encoded_in_other_query_value, ++ &percent_encoded_key, ++ &trailing_amp_query_param, ++ &local_domain_looks_like_port_but_is_not, ++ &empty_host_with_port, ++ &userinfo_with_empty_host, ++ &double_leading_slash_path, ++ &query_without_path, ++ &empty_ipv6_host, ++ &empty_ipv6_host_with_port, ++ &empty_domain_path, ++ &empty_domain_path_no_query, ++ &empty_domain_path_no_query_trailing_slash, ++ &non_unique_flow_ids, ++ ++ /* aberrant cases */ ++ &bad_scheme, ++ &no_scheme, ++ &foo_after_scheme, ++ &null_uri, ++ &empty_uri, ++ &equal_max_scheme, ++ &greater_max_scheme, ++ &empty_middle_id_query_param, ++ &empty_end_id_query_param, ++ &bad_separator_query_param, ++ &bad_uuid_query_param, ++ &invalid_negative_port, ++ &invalid_empty_port, ++ &invalid_non_integer_port, ++ &invalid_no_domain_path, ++ &malformed_ipv6_bracket, ++ &percent_encoded_in_uuid_query_value, ++ &userinfo_in_authority, ++ &userpass_in_authority, ++ &multiple_at_userinfo ++}; ++ ++static int run_uri_limit_test(const char *name, ++ size_t uri_len, ++ int expected_rc) ++{ ++ static char uri_buf[MXL_URI_MAX + 2]; ++ ++ const char *prefix = ++ "mxl://host1.local/domain/path?" ++ "id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++ "id=876fc244-9c99-48a8-a461-927e43a05bdc&" ++ "junk="; ++ ++ size_t p = strlen(prefix); ++ ++ av_assert0(uri_len >= p); ++ av_assert0(uri_len < sizeof(uri_buf)); ++ ++ memcpy(uri_buf, prefix, p); ++ ++ for (size_t i = p; i < uri_len; i++) ++ uri_buf[i] = 'a'; ++ ++ uri_buf[uri_len] = '\0'; ++ ++ test_case tc = { ++ .name = name, ++ .uri = uri_buf, ++ .expected_rc = expected_rc, ++ }; ++ ++ if (expected_rc == 0) { ++ tc.expected.host = "host1.local"; ++ tc.expected.port = -1; ++ tc.expected.domain = "/domain/path"; ++ ++ static const char *ids[] = { ++ "3645f1e2-90fc-49cb-af9c-236c2ac124cd", ++ "876fc244-9c99-48a8-a461-927e43a05bdc" ++ }; ++ ++ tc.expected.flow_ids = ids; ++ tc.expected.nb_flow_ids = 2; ++ } else { ++ tc.expected = expected_error_uri; ++ } ++ ++ return run_test_case(&tc); ++} ++ ++static int test_mxl_uri_free(void) ++{ ++ ++ // sanity check freeing a null ++ mxl_uri_free(NULL); ++ ++ // Parse a known good uri that sets all fields, validate, then free. ++ mxl_uri parsed = {0}; ++ int rc = mxl_parse_uri(NULL, ipv4.uri, &parsed); ++ if (rc) ++ return rc; ++ rc = compare_uri(&ipv4.expected, &parsed); ++ if (rc) ++ return rc; ++ ++ // every field should be set ++ if (parsed.host == NULL || parsed.port == 0 || parsed.port == 1 || ++ parsed.domain == NULL || ++ parsed.flow_ids == NULL || parsed.nb_flow_ids == 0) { ++ fprintf(stderr, "==== test_mxl_uri_free FAIL: parsed uri not set\n"); ++ return -1; ++ } ++ ++ mxl_uri_free(&parsed); ++ ++ // every field should be clear ++ if (parsed.host != NULL || parsed.port != 0 || parsed.domain != NULL || ++ parsed.flow_ids != NULL || parsed.nb_flow_ids != 0) { ++ fprintf(stderr, "==== test_mxl_uri_free FAIL: freed mxl_uri not cleared\n"); ++ return -1; ++ } ++ ++ return 0; ++} ++ ++static int test_scheme_prefix_check(void) ++{ ++ ++ if (!mxl_uri_is_mxl_scheme(zero_id.uri)) { ++ fprintf(stderr, "==== test_scheme_prefix_check FAIL: zero_id\n"); ++ return -1; ++ } ++ ++ if (!mxl_uri_is_mxl_scheme(two_id_upcase.uri)) { ++ fprintf(stderr, "==== test_scheme_prefix_check FAIL: two_id_upcase\n"); ++ return -1; ++ } ++ ++ if (!mxl_uri_is_mxl_scheme(foo_after_scheme.uri)) { ++ fprintf(stderr, "==== test_scheme_prefix_check FAIL: foo_scheme\n"); ++ return -1; ++ } ++ ++ if (mxl_uri_is_mxl_scheme(bad_scheme.uri)) { ++ fprintf(stderr, "==== test_scheme_prefix_check FAIL: bad_scheme\n"); ++ return -1; ++ } ++ ++ if (mxl_uri_is_mxl_scheme(no_scheme.uri)) { ++ fprintf(stderr, "==== test_scheme_prefix_check FAIL: no_scheme\n"); ++ return -1; ++ } ++ ++ if (mxl_uri_is_mxl_scheme(NULL)) { ++ fprintf(stderr, "==== test_scheme_prefix_check FAIL: NULL\n"); ++ return -1; ++ } ++ ++ return 0; ++} ++ ++int main(void) ++{ ++ printf("testing mxl_uri: mxl URI parser\n\n"); ++ ++ av_log_set_level(AV_LOG_QUIET); ++ ++ int rc = -1; ++ ++ // FFmpeg expects C locale, this URI parser depends on that. ++ if (!setlocale(LC_ALL, "C")) ++ return -1; ++ ++ for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { ++ rc = run_test_case(tests[i]); ++ if (rc) ++ goto finally; ++ } ++ ++ // URI max length limit tests use generated URI, hence are separate. ++ rc = run_uri_limit_test("equal_max_len_url", MXL_URI_MAX, 0); ++ if (rc) ++ goto finally; ++ rc = run_uri_limit_test("greater_max_len_url", MXL_URI_MAX + 1, AVERROR(ENAMETOOLONG)); ++ if (rc) ++ goto finally; ++ ++ // test freeing a mxl_uri parse result ++ rc = test_mxl_uri_free(); ++ if (rc) ++ goto finally; ++ ++ // test scheme prefix check (i.e. starts with "mxl:") ++ rc = test_scheme_prefix_check(); ++ ++finally: ++ ++ if (rc) ++ printf("\nFAIL\n"); ++ else ++ printf("\nPASS\n"); ++ ++ return rc; ++} +diff --git a/tests/Makefile b/tests/Makefile +index 4b3fa6a54a..a340c2c6e2 100644 +--- a/tests/Makefile ++++ b/tests/Makefile +@@ -228,2 +228,3 @@ include $(SRC_PATH)/tests/fate/mpegts.mak + include $(SRC_PATH)/tests/fate/mxf.mak ++include $(SRC_PATH)/tests/fate/mxl.mak + include $(SRC_PATH)/tests/fate/ogg-vorbis.mak +diff --git a/tests/fate/mxl.mak b/tests/fate/mxl.mak +new file mode 100644 +index 0000000000..c7a23d68cc +--- /dev/null ++++ b/tests/fate/mxl.mak +@@ -0,0 +1,249 @@ ++fate-mxl-json: libavformat/tests/mxl_json$(EXESUF) ++fate-mxl-json: CMD = run libavformat/tests/mxl_json$(EXESUF) ++FATE-yes += $(if $(filter yes,$(CONFIG_MXL_DEMUXER)),fate-mxl-json) ++ ++fate-mxl-uri: libavformat/tests/mxl_uri$(EXESUF) ++fate-mxl-uri: CMD = run libavformat/tests/mxl_uri$(EXESUF) ++FATE-yes += $(if $(filter yes,$(CONFIG_MXL_DEMUXER)),fate-mxl-uri) ++ ++fate-mxl-loc: libavformat/tests/mxl_loc$(EXESUF) ++fate-mxl-loc: CMD = run libavformat/tests/mxl_loc$(EXESUF) ++FATE-yes += $(if $(filter yes,$(CONFIG_MXL_DEMUXER)),fate-mxl-loc) ++ ++fate-mxl-diag: libavformat/tests/mxl_diag$(EXESUF) ++fate-mxl-diag: CMD = run libavformat/tests/mxl_diag$(EXESUF) ++FATE-yes += $(if $(filter yes,$(CONFIG_MXL_DEMUXER)),fate-mxl-diag) ++ ++# Note: MXL_OPTIONS is defined explicitly (even though it matches the ++# default MXL value) to make the test's dependency on this ++# history_duration setting unambiguous and intentional. ++ ++MXL_TMP_DIR := $(TARGET_PATH)/tests/data/tmp/mxl ++MXL_OPTIONS := "{"\"urn:x-mxl:option:history_duration/v1.0\"":100000000}" ++MXL_VIDEO_FLOW_ID := 717f834b-4224-4c9b-8a64-ecb7726803b8 ++MXL_AUDIO_FLOW_ID := ff39f65b-d760-4a7c-808d-4f2778de5658 ++MXL_AUDIO_MAX_SAMPLES := 2559 ++MXL_AUDIO_SAMPLES_PER_PACKET := 512 ++MXL_SENTINEL_TIMEOUT := 4000 ++RETRY_SLEEP := 0.3 ++RETRY_LIMIT := 10 ++ ++# Use this to construct macro arguments that require "," characters ++COMMA := , ++ ++# Use this to normalize local paths ++FILTER_PWD_FROM_MXL_URI = sed "s|mxl://$$(pwd)|mxl:///path/to|g" ++ ++# Use to normalize the log prefix in "[mxl 0xXXXXXXXX]" messages ++FILTER_MXL_LOG_PREFIX = sed -E "s/(\[mxl @ 0x)[[:xdigit:]]+(\])/\1ADDR\2/g" ++ ++# Grep out normalized mxl log messages, and the final "Invalid ++# argument" or "Input/output error" message ++PROBE_LOG_FILTER = $(FILTER_PWD_FROM_MXL_URI) | $(FILTER_MXL_LOG_PREFIX) | \ ++ grep -E "^\[mxl @ 0xADDR\]|: Invalid argument$$|: Input/output error$$" ++ ++# Usage: $(call MXL_MUX_DEMUX_TEST,,,,) ++# ++# Run an MXL test in an isolated per-test temporary directory. ++# ++# The macro creates $(MXL_TMP_DIR)/, after removing any ++# previous copy of that directory, and changes to that directory. A ++# local MXL domain directory is created in the working directory at ++# ./domain, and `mux-command` is executed in the background. ++# ++# It then retries the supplied `demux-command` until it succeeds or ++# the retry limit is reached. On success it touches the local ++# ./sentinel file so the `mux-command` can perform synchronized ++# teardown. The macro waits for the muxer to exit before completing. ++# ++define MXL_MUX_DEMUX_TEST ++ ( \ ++ set -e; \ ++ export MXL_LOG_LEVEL=off; \ ++ TEST_DIR="$(MXL_TMP_DIR)/$(strip $(1))"; \ ++ rm -rf "$$TEST_DIR"; \ ++ mkdir -p "$$TEST_DIR"; \ ++ cd "$$TEST_DIR"; \ ++ rm -f sentinel; \ ++ mkdir domain; \ ++ echo $(MXL_OPTIONS) > domain/options.json; \ ++ $(2) & \ ++ MUX_PID=$$!; \ ++ trap "kill $$MUX_PID 2>/dev/null || true" INT TERM EXIT; \ ++ set +e; \ ++ RETRIES=0; \ ++ while [ $$RETRIES -lt $(RETRY_LIMIT) ]; do \ ++ sleep $(RETRY_SLEEP); \ ++ OUTFILE=$$(mktemp "$$TEST_DIR"/demux.out.XXXXXX) || exit 1; \ ++ $(3) > "$$OUTFILE" 2>&1; \ ++ STATUS=$$?; \ ++ $(FILTER_PWD_FROM_MXL_URI) < "$$OUTFILE"; \ ++ rm -f "$$OUTFILE"; \ ++ if [ $$STATUS -eq 0 ]; then \ ++ touch sentinel; \ ++ break; \ ++ else \ ++ RETRIES=$$((RETRIES+1)); \ ++ fi; \ ++ done; \ ++ set -e; \ ++ if [ $$RETRIES -ge $(RETRY_LIMIT) ]; then echo "$(4) failed"; exit 1; fi; \ ++ wait $$MUX_PID; \ ++ trap - INT TERM EXIT; \ ++ ) ++endef ++ ++# Usage: $(call MXL_PROBE,) ++# e.g. $(call MXL_PROBE,domain/.mxl-flow) ++# $(call MXL_PROBE,mxl:///domain?id=) ++define MXL_PROBE ++$(TARGET_PATH)/ffprobe -hide_banner -v verbose -f mxl -i "$(1)" 2>&1 | $(PROBE_LOG_FILTER) ++endef ++ ++fate-mxl-video-encdec: CMD = \ ++ $(call MXL_MUX_DEMUX_TEST, \ ++ mxl-video-encdec, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -v error -re \ ++ -f lavfi -i testsrc2=size=1920x1080:rate=50 -frames:v 5 -c:v v210 \ ++ -f mxl -video_flow_id $(MXL_VIDEO_FLOW_ID) \ ++ -teardown_sync_file sentinel -teardown_sync_timeout $(MXL_SENTINEL_TIMEOUT) \ ++ domain, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -v error \ ++ -f mxl -max_video_frames 5 -grain_index_init 2 \ ++ -i domain/$(MXL_VIDEO_FLOW_ID).mxl-flow \ ++ -bitexact -f framemd5 pipe:1, \ ++ demuxer) ++fate-mxl-video-encdec: REF = $(SRC_PATH)/tests/ref/fate/mxl-video-encdec ++FATE-yes += \ ++ $(if $(filter yes,$(CONFIG_MXL_DEMUXER)), \ ++ $(if $(filter yes,$(CONFIG_MXL_MUXER)),fate-mxl-video-encdec)) ++ ++fate-mxl-audio-encdec: CMD = \ ++ $(call MXL_MUX_DEMUX_TEST, \ ++ mxl-audio-encdec, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -re -v error \ ++ -f lavfi -i "anoisesrc=sample_rate=48000:nb_samples=$(MXL_AUDIO_SAMPLES_PER_PACKET):seed=0$(COMMA)aformat=sample_fmts=flt:channel_layouts=stereo$(COMMA)atrim=end_sample=$(MXL_AUDIO_MAX_SAMPLES)" \ ++ -map 0:a:0 -c:a pcm_f32le \ ++ -f mxl -audio_flow_id $(MXL_AUDIO_FLOW_ID) \ ++ -teardown_sync_file sentinel -teardown_sync_timeout $(MXL_SENTINEL_TIMEOUT) \ ++ domain, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -v error \ ++ -f mxl -max_audio_samples $(MXL_AUDIO_MAX_SAMPLES) -max_audio_samples_per_read $(MXL_AUDIO_SAMPLES_PER_PACKET) -grain_index_init 2 \ ++ -i domain/$(MXL_AUDIO_FLOW_ID).mxl-flow \ ++ -bitexact -f framemd5 pipe:1, \ ++ demuxer) ++fate-mxl-audio-encdec: REF = $(SRC_PATH)/tests/ref/fate/mxl-audio-encdec ++FATE-yes += \ ++ $(if $(filter yes,$(CONFIG_MXL_DEMUXER)), \ ++ $(if $(filter yes,$(CONFIG_MXL_MUXER)),fate-mxl-audio-encdec)) ++ ++fate-mxl-av-encdec: CMD = \ ++ $(call MXL_MUX_DEMUX_TEST, \ ++ mxl-av-encdec, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -v error -re \ ++ -f lavfi -i testsrc2=size=1920x1080:rate=50 \ ++ -f lavfi -i "anoisesrc=sample_rate=48000:nb_samples=$(MXL_AUDIO_SAMPLES_PER_PACKET):seed=0$(COMMA)aformat=sample_fmts=flt:channel_layouts=stereo$(COMMA)atrim=end_sample=$(MXL_AUDIO_MAX_SAMPLES)" \ ++ -map 0:v:0 -map 1:a:0 -frames:v 5 \ ++ -c:v v210 -c:a pcm_f32le \ ++ -f mxl -video_flow_id $(MXL_VIDEO_FLOW_ID) -audio_flow_id $(MXL_AUDIO_FLOW_ID) \ ++ -teardown_sync_file sentinel -teardown_sync_timeout $(MXL_SENTINEL_TIMEOUT) \ ++ domain, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -v error \ ++ -f mxl -max_video_frames 5 \ ++ -max_audio_samples $(MXL_AUDIO_MAX_SAMPLES) -max_audio_samples_per_read $(MXL_AUDIO_SAMPLES_PER_PACKET) \ ++ -grain_index_init 2 \ ++ -i "mxl://$$(pwd)/domain?id=$(MXL_VIDEO_FLOW_ID)&id=$(MXL_AUDIO_FLOW_ID)" \ ++ -bitexact -f framemd5 pipe:1, \ ++ demuxer) ++fate-mxl-av-encdec: REF = $(SRC_PATH)/tests/ref/fate/mxl-av-encdec ++FATE-yes += \ ++ $(if $(filter yes,$(CONFIG_MXL_DEMUXER)), \ ++ $(if $(filter yes,$(CONFIG_MXL_MUXER)),fate-mxl-av-encdec)) ++ ++fate-mxl-video-probe: CMD = \ ++ $(call MXL_MUX_DEMUX_TEST, \ ++ mxl-video-probe, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -v error -re \ ++ -f lavfi -i testsrc2=size=1920x1080:rate=50 -frames:v 5 -c:v v210 \ ++ -f mxl -video_flow_id $(MXL_VIDEO_FLOW_ID) \ ++ -teardown_sync_file sentinel \ ++ -teardown_sync_timeout $(MXL_SENTINEL_TIMEOUT) \ ++ domain, \ ++ $(TARGET_PATH)/ffprobe -hide_banner -v info \ ++ mxl://$$(pwd)/domain?id=$(MXL_VIDEO_FLOW_ID) 2>&1, \ ++ ffprobe) ++fate-mxl-video-probe: REF = $(SRC_PATH)/tests/ref/fate/mxl-video-probe ++FATE-yes += \ ++ $(if $(filter yes,$(CONFIG_MXL_DEMUXER)), \ ++ $(if $(filter yes,$(CONFIG_MXL_MUXER)),fate-mxl-video-probe)) ++ ++fate-mxl-audio-probe: CMD = \ ++ $(call MXL_MUX_DEMUX_TEST, \ ++ mxl-audio-probe, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -re -v error \ ++ -f lavfi -i "anoisesrc=sample_rate=48000:nb_samples=$(MXL_AUDIO_SAMPLES_PER_PACKET):seed=0$(COMMA)aformat=sample_fmts=flt:channel_layouts=stereo$(COMMA)atrim=end_sample=$(MXL_AUDIO_MAX_SAMPLES)" \ ++ -map 0:a:0 -c:a pcm_f32le \ ++ -f mxl -audio_flow_id $(MXL_AUDIO_FLOW_ID) \ ++ -teardown_sync_file sentinel -teardown_sync_timeout $(MXL_SENTINEL_TIMEOUT) \ ++ domain, \ ++ $(TARGET_PATH)/ffprobe -hide_banner -v info \ ++ mxl://$$(pwd)/domain?id=$(MXL_AUDIO_FLOW_ID) 2>&1, \ ++ ffprobe) ++fate-mxl-audio-probe: REF = $(SRC_PATH)/tests/ref/fate/mxl-audio-probe ++FATE-yes += \ ++ $(if $(filter yes,$(CONFIG_MXL_DEMUXER)), \ ++ $(if $(filter yes,$(CONFIG_MXL_MUXER)),fate-mxl-audio-probe)) ++ ++fate-mxl-av-probe: CMD = \ ++ $(call MXL_MUX_DEMUX_TEST, \ ++ mxl-audio-video-probe, \ ++ $(TARGET_PATH)/ffmpeg -hide_banner -v error -re \ ++ -f lavfi -i testsrc2=size=1920x1080:rate=50 \ ++ -f lavfi -i "anoisesrc=sample_rate=48000:nb_samples=$(MXL_AUDIO_SAMPLES_PER_PACKET):seed=0$(COMMA)aformat=sample_fmts=flt:channel_layouts=stereo$(COMMA)atrim=end_sample=$(MXL_AUDIO_MAX_SAMPLES)" \ ++ -map 0:v:0 -map 1:a:0 -frames:v 5 \ ++ -c:v v210 -c:a pcm_f32le \ ++ -f mxl -video_flow_id $(MXL_VIDEO_FLOW_ID) -audio_flow_id $(MXL_AUDIO_FLOW_ID) \ ++ -teardown_sync_file sentinel -teardown_sync_timeout $(MXL_SENTINEL_TIMEOUT) \ ++ domain, \ ++ $(TARGET_PATH)/ffprobe -hide_banner -v info \ ++ "mxl://$$(pwd)/domain?id=$(MXL_VIDEO_FLOW_ID)&id=$(MXL_AUDIO_FLOW_ID)" 2>&1, \ ++ ffprobe) ++fate-mxl-av-probe: REF = $(SRC_PATH)/tests/ref/fate/mxl-av-probe ++FATE-yes += \ ++ $(if $(filter yes,$(CONFIG_MXL_DEMUXER)), \ ++ $(if $(filter yes,$(CONFIG_MXL_MUXER)),fate-mxl-av-probe)) ++ ++fate-mxl-bad-loc: CMD = (\ ++ $(call MXL_PROBE,/domain/without/flow); \ ++ $(call MXL_PROBE,mxl:///domain/without/flow); \ ++ $(call MXL_PROBE,/domain/bad-flow-id.mxl-flow); \ ++ $(call MXL_PROBE,mxl:///domain?id=bad-flow-id); \ ++ $(call MXL_PROBE,/domain/bad-$(MXL_VIDEO_FLOW_ID).mxl-flow); \ ++ $(call MXL_PROBE,mxl:///domain?id=bad-$(MXL_VIDEO_FLOW_ID)); \ ++ $(call MXL_PROBE,mxl:///domain/duplicate_flows?id=$(MXL_VIDEO_FLOW_ID)&id=$(MXL_VIDEO_FLOW_ID)); \ ++ $(call MXL_PROBE,mxl://with-host/domain?id=$(MXL_VIDEO_FLOW_ID)); \ ++ true \ ++ ) ++fate-mxl-bad-loc: REF = $(SRC_PATH)/tests/ref/fate/mxl-bad-loc ++FATE-yes += $(if $(filter yes,$(CONFIG_MXL_DEMUXER)),fate-mxl-bad-loc) ++ ++fate-mxl-bad-domain: CMD = (\ ++ export MXL_LOG_LEVEL=off; \ ++ $(call MXL_PROBE,/domain/does/not/exist/$(MXL_VIDEO_FLOW_ID).mxl-flow); \ ++ $(call MXL_PROBE,mxl:///domain/does/not/exist?id=$(MXL_VIDEO_FLOW_ID)); \ ++ true \ ++ ) ++fate-mxl-bad-domain: REF = $(SRC_PATH)/tests/ref/fate/mxl-bad-domain ++FATE-yes += $(if $(filter yes,$(CONFIG_MXL_DEMUXER)),fate-mxl-bad-domain) ++ ++fate-mxl-bad-flow: CMD = (\ ++ export MXL_LOG_LEVEL=off; \ ++ TEST_DIR="$(MXL_TMP_DIR)/bad_flow"; \ ++ rm -rf "$$TEST_DIR"; \ ++ mkdir -p "$$TEST_DIR"/domain; \ ++ (cd "$$TEST_DIR"; $(call MXL_PROBE,domain/$(MXL_VIDEO_FLOW_ID).mxl-flow)); \ ++ $(call MXL_PROBE,mxl://"$$TEST_DIR"/domain?id=$(MXL_VIDEO_FLOW_ID)); \ ++ true \ ++ ) ++fate-mxl-bad-flow: REF = $(SRC_PATH)/tests/ref/fate/mxl-bad-flow ++FATE-yes += $(if $(filter yes,$(CONFIG_MXL_DEMUXER)),fate-mxl-bad-flow) +diff --git a/tests/ref/fate/mxl-audio-encdec b/tests/ref/fate/mxl-audio-encdec +new file mode 100644 +index 0000000000..54fcab1172 +--- /dev/null ++++ b/tests/ref/fate/mxl-audio-encdec +@@ -0,0 +1,14 @@ ++#format: frame checksums ++#version: 2 ++#hash: MD5 ++#tb 0: 1/48000 ++#media_type 0: audio ++#codec_id 0: pcm_s16le ++#sample_rate 0: 48000 ++#channel_layout_name 0: stereo ++#stream#, dts, pts, duration, size, hash ++0, 0, 0, 512, 2048, 53bec1b67fed459ad5cae29e678ea3b2 ++0, 512, 512, 512, 2048, d79f812809c33e70086b4fcf4fb929af ++0, 1024, 1024, 512, 2048, 0d07cdebfd31e4dc50fd606d150a8070 ++0, 1536, 1536, 512, 2048, 673b3cffcac431e42d0ceabfceae6bb1 ++0, 2048, 2048, 511, 2044, 3b4f890dc442919fe145dbe84796f30c +diff --git a/tests/ref/fate/mxl-audio-probe b/tests/ref/fate/mxl-audio-probe +new file mode 100644 +index 0000000000..4970010311 +--- /dev/null ++++ b/tests/ref/fate/mxl-audio-probe +@@ -0,0 +1,9 @@ ++Input #0, mxl, from 'mxl:///path/to/domain?id=ff39f65b-d760-4a7c-808d-4f2778de5658': ++ Duration: N/A, bitrate: N/A ++ Stream #0:0: Audio: pcm_f32le, 48000 Hz, 2 channels, flt, 3072 kb/s ++ Metadata: ++ mxl_id : ff39f65b-d760-4a7c-808d-4f2778de5658 ++ mxl_description : FFmpeg audio stream ++ mxl_label : FFmpeg audio stream ++ mxl_format : urn:x-nmos:format:audio ++ mxl_media_type : audio/float32 +diff --git a/tests/ref/fate/mxl-av-encdec b/tests/ref/fate/mxl-av-encdec +new file mode 100644 +index 0000000000..1c37877a66 +--- /dev/null ++++ b/tests/ref/fate/mxl-av-encdec +@@ -0,0 +1,24 @@ ++#format: frame checksums ++#version: 2 ++#hash: MD5 ++#tb 0: 1/50 ++#media_type 0: video ++#codec_id 0: rawvideo ++#dimensions 0: 1920x1080 ++#sar 0: 1/1 ++#tb 1: 1/48000 ++#media_type 1: audio ++#codec_id 1: pcm_s16le ++#sample_rate 1: 48000 ++#channel_layout_name 1: stereo ++#stream#, dts, pts, duration, size, hash ++0, 0, 0, 1, 8294400, 687ba6aecec048f446ee3cbe03f611b3 ++1, 0, 0, 512, 2048, 53bec1b67fed459ad5cae29e678ea3b2 ++1, 512, 512, 512, 2048, d79f812809c33e70086b4fcf4fb929af ++0, 1, 1, 1, 8294400, fa8087d706277bffe0181b862c9e8252 ++1, 1024, 1024, 512, 2048, 0d07cdebfd31e4dc50fd606d150a8070 ++1, 1536, 1536, 512, 2048, 673b3cffcac431e42d0ceabfceae6bb1 ++0, 2, 2, 1, 8294400, 19190a35fe54888aa56367a6672cb02d ++1, 2048, 2048, 511, 2044, 3b4f890dc442919fe145dbe84796f30c ++0, 3, 3, 1, 8294400, 13b4e70275b3e18080ea2411a1c7e47c ++0, 4, 4, 1, 8294400, 34c7f0e53d40f8da58a1d48510d425a8 +diff --git a/tests/ref/fate/mxl-av-probe b/tests/ref/fate/mxl-av-probe +new file mode 100644 +index 0000000000..ca09979e63 +--- /dev/null ++++ b/tests/ref/fate/mxl-av-probe +@@ -0,0 +1,17 @@ ++Input #0, mxl, from 'mxl:///path/to/domain?id=717f834b-4224-4c9b-8a64-ecb7726803b8&id=ff39f65b-d760-4a7c-808d-4f2778de5658': ++ Duration: N/A, bitrate: N/A ++ Stream #0:0: Video: v210 (v210 / 0x30313276), yuv422p10le(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 50 fps, 50 tbr, 50 tbn ++ Metadata: ++ mxl_id : 717f834b-4224-4c9b-8a64-ecb7726803b8 ++ mxl_description : FFmpeg video stream ++ mxl_label : FFmpeg video stream ++ mxl_format : urn:x-nmos:format:video ++ mxl_media_type : video/v210 ++ mxl_colorspace : BT709 ++ Stream #0:1: Audio: pcm_f32le, 48000 Hz, 2 channels, flt, 3072 kb/s ++ Metadata: ++ mxl_id : ff39f65b-d760-4a7c-808d-4f2778de5658 ++ mxl_description : FFmpeg audio stream ++ mxl_label : FFmpeg audio stream ++ mxl_format : urn:x-nmos:format:audio ++ mxl_media_type : audio/float32 +diff --git a/tests/ref/fate/mxl-bad-domain b/tests/ref/fate/mxl-bad-domain +new file mode 100644 +index 0000000000..b54a5ee7e0 +--- /dev/null ++++ b/tests/ref/fate/mxl-bad-domain +@@ -0,0 +1,8 @@ ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: /domain/does/not/exist/717f834b-4224-4c9b-8a64-ecb7726803b8.mxl-flow ++[mxl @ 0xADDR] mxlCreateInstance error ++/domain/does/not/exist/717f834b-4224-4c9b-8a64-ecb7726803b8.mxl-flow: Input/output error ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: mxl:///domain/does/not/exist?id=717f834b-4224-4c9b-8a64-ecb7726803b8 ++[mxl @ 0xADDR] mxlCreateInstance error ++mxl:///domain/does/not/exist?id=717f834b-4224-4c9b-8a64-ecb7726803b8: Input/output error +diff --git a/tests/ref/fate/mxl-bad-flow b/tests/ref/fate/mxl-bad-flow +new file mode 100644 +index 0000000000..10cef99911 +--- /dev/null ++++ b/tests/ref/fate/mxl-bad-flow +@@ -0,0 +1,8 @@ ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: domain/717f834b-4224-4c9b-8a64-ecb7726803b8.mxl-flow ++[mxl @ 0xADDR] mxlCreateFlowReader error MXL_ERR_FLOW_NOT_FOUND for flow ID 717f834b-4224-4c9b-8a64-ecb7726803b8 ++domain/717f834b-4224-4c9b-8a64-ecb7726803b8.mxl-flow: Input/output error ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: mxl:///path/to/tests/data/tmp/mxl/bad_flow/domain?id=717f834b-4224-4c9b-8a64-ecb7726803b8 ++[mxl @ 0xADDR] mxlCreateFlowReader error MXL_ERR_FLOW_NOT_FOUND for flow ID 717f834b-4224-4c9b-8a64-ecb7726803b8 ++mxl:///path/to/tests/data/tmp/mxl/bad_flow/domain?id=717f834b-4224-4c9b-8a64-ecb7726803b8: Input/output error +diff --git a/tests/ref/fate/mxl-bad-loc b/tests/ref/fate/mxl-bad-loc +new file mode 100644 +index 0000000000..47e765f3b1 +--- /dev/null ++++ b/tests/ref/fate/mxl-bad-loc +@@ -0,0 +1,43 @@ ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: /domain/without/flow ++[mxl @ 0xADDR] locator requires at least one flow ID ++[mxl @ 0xADDR] invalid locator: /domain/without/flow ++/domain/without/flow: Invalid argument ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: mxl:///domain/without/flow ++[mxl @ 0xADDR] locator requires at least one flow ID ++[mxl @ 0xADDR] invalid locator: mxl:///domain/without/flow ++mxl:///domain/without/flow: Invalid argument ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: /domain/bad-flow-id.mxl-flow ++[mxl @ 0xADDR] LOC URI flow ID is not a valid uuid: bad-flow-id ++[mxl @ 0xADDR] failed to parse resource locator: /domain/bad-flow-id.mxl-flow ++/domain/bad-flow-id.mxl-flow: Invalid argument ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: mxl:///domain?id=bad-flow-id ++[mxl @ 0xADDR] URI invalid flow ID parameter: bad-flow-id ++[mxl @ 0xADDR] LOC URI scheme found but parse failed: "mxl:///domain?id=bad-flow-id" ++[mxl @ 0xADDR] failed to parse resource locator: mxl:///domain?id=bad-flow-id ++mxl:///domain?id=bad-flow-id: Invalid argument ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: /domain/bad-717f834b-4224-4c9b-8a64-ecb7726803b8.mxl-flow ++[mxl @ 0xADDR] LOC URI flow ID is not a valid uuid: bad-717f834b-4224-4c9b-8a64-ecb7726803b8 ++[mxl @ 0xADDR] failed to parse resource locator: /domain/bad-717f834b-4224-4c9b-8a64-ecb7726803b8.mxl-flow ++/domain/bad-717f834b-4224-4c9b-8a64-ecb7726803b8.mxl-flow: Invalid argument ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: mxl:///domain?id=bad-717f834b-4224-4c9b-8a64-ecb7726803b8 ++[mxl @ 0xADDR] URI invalid flow ID parameter: bad-717f834b-4224-4c9b-8a64-ecb7726803b8 ++[mxl @ 0xADDR] LOC URI scheme found but parse failed: "mxl:///domain?id=bad-717f834b-4224-4c9b-8a64-ecb7726803b8" ++[mxl @ 0xADDR] failed to parse resource locator: mxl:///domain?id=bad-717f834b-4224-4c9b-8a64-ecb7726803b8 ++mxl:///domain?id=bad-717f834b-4224-4c9b-8a64-ecb7726803b8: Invalid argument ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: mxl:///domain/duplicate_flows?id=717f834b-4224-4c9b-8a64-ecb7726803b8&id=717f834b-4224-4c9b-8a64-ecb7726803b8 ++[mxl @ 0xADDR] URI locator flow ID not unique: 717f834b-4224-4c9b-8a64-ecb7726803b8 ++[mxl @ 0xADDR] LOC URI scheme found but parse failed: "mxl:///domain/duplicate_flows?id=717f834b-4224-4c9b-8a64-ecb7726803b8&id=717f834b-4224-4c9b-8a64-ecb7726803b8" ++[mxl @ 0xADDR] failed to parse resource locator: mxl:///domain/duplicate_flows?id=717f834b-4224-4c9b-8a64-ecb7726803b8&id=717f834b-4224-4c9b-8a64-ecb7726803b8 ++mxl:///domain/duplicate_flows?id=717f834b-4224-4c9b-8a64-ecb7726803b8&id=717f834b-4224-4c9b-8a64-ecb7726803b8: Invalid argument ++[mxl @ 0xADDR] mxl AVFormatContext: ++[mxl @ 0xADDR] url: mxl://with-host/domain?id=717f834b-4224-4c9b-8a64-ecb7726803b8 ++[mxl @ 0xADDR] LOC URI host not supported: "mxl://with-host/domain?id=717f834b-4224-4c9b-8a64-ecb7726803b8" ++[mxl @ 0xADDR] failed to parse resource locator: mxl://with-host/domain?id=717f834b-4224-4c9b-8a64-ecb7726803b8 ++mxl://with-host/domain?id=717f834b-4224-4c9b-8a64-ecb7726803b8: Invalid argument +diff --git a/tests/ref/fate/mxl-diag b/tests/ref/fate/mxl-diag +new file mode 100644 +index 0000000000..35c233d251 +--- /dev/null ++++ b/tests/ref/fate/mxl-diag +@@ -0,0 +1,82 @@ ++testing mxl_diag: mxl diagnostic server ++ ++ ++test: init fails if server path exists as a non-socket ++ ++test: init cleans up stale socket path ++ ++test: release with no active client is ignored ++ ++test: valid connect works after junk from unrelated socket ++ ++test: mxl_diag_poll returns expected codes for malformed messages ++bad size, datagram smaller than sizeof(msg.header) ++bad size, header.size smaller than sizeof(msg.header) ++bad size, header.size bigger than sizeof(msg) ++bad size, datagram size vs header.size mismatch ++bad size, truncated datagram ++bad version ++unknown type ++truncated header ++connect message size mismatch ++release message size mismatch ++ ++test: print message stream ++msg: version=1, type=4, size=64 ++audio_read: timestamp = 0, exec_dur = 1, tail_index = 2, head_index = 3, read_index = 4, read_size = 1600, mxl_status = 0 ++msg: version=1, type=3, size=64 ++video_read: timestamp = 1, exec_dur = 2, tail_index = 3, head_index = 4, read_index = 5, mxl_status = 0 ++msg: version=1, type=4, size=64 ++audio_read: timestamp = 2, exec_dur = 3, tail_index = 4, head_index = 5, read_index = 6, read_size = 1600, mxl_status = 0 ++msg: version=1, type=3, size=64 ++video_read: timestamp = 3, exec_dur = 4, tail_index = 5, head_index = 6, read_index = 7, mxl_status = 0 ++msg: version=1, type=4, size=64 ++audio_read: timestamp = 4, exec_dur = 5, tail_index = 6, head_index = 7, read_index = 8, read_size = 1600, mxl_status = 0 ++msg: version=1, type=3, size=64 ++video_read: timestamp = 5, exec_dur = 6, tail_index = 7, head_index = 8, read_index = 9, mxl_status = 0 ++msg: version=1, type=4, size=64 ++audio_read: timestamp = 6, exec_dur = 7, tail_index = 8, head_index = 9, read_index = 10, read_size = 1600, mxl_status = 0 ++msg: version=1, type=3, size=64 ++video_read: timestamp = 7, exec_dur = 8, tail_index = 9, head_index = 10, read_index = 11, mxl_status = 0 ++msg: version=1, type=4, size=64 ++audio_read: timestamp = 8, exec_dur = 9, tail_index = 10, head_index = 11, read_index = 12, read_size = 1600, mxl_status = 0 ++msg: version=1, type=3, size=64 ++video_read: timestamp = 9, exec_dur = 10, tail_index = 11, head_index = 12, read_index = 13, mxl_status = 0 ++ ++test: multiple connect attempts are ignored after the first ++client A connected ++msg: version=1, type=3, size=64 ++video_read: timestamp = 11, exec_dur = 12, tail_index = 13, head_index = 14, read_index = 15, mxl_status = 0 ++client B timed out (expected) ++msg: version=1, type=4, size=64 ++audio_read: timestamp = 12, exec_dur = 13, tail_index = 14, head_index = 15, read_index = 16, read_size = 1600, mxl_status = 0 ++client A was unaffected by client B ++client C timed out (expected) ++msg: version=1, type=3, size=64 ++video_read: timestamp = 13, exec_dur = 14, tail_index = 15, head_index = 16, read_index = 17, mxl_status = 0 ++client A was unaffected by client C ++client B still times out ++ ++test: malformed messages from active client are ignored ++msg: version=1, type=4, size=64 ++audio_read: timestamp = 22, exec_dur = 23, tail_index = 24, head_index = 25, read_index = 26, read_size = 1600, mxl_status = 0 ++bad size, message too short ++bad size, header size sanity (too small) ++bad size, header size sanity (too big) ++unknown type, truncated header ++bad version ++unknown type ++garbage message ++bad size, message too short ++bad size, header size sanity (too small) ++bad size, header size sanity (too big) ++unknown type, truncated header ++bad version ++unknown type ++garbage message ++sent 14 malformed messages from active client A ++msg: version=1, type=3, size=64 ++video_read: timestamp = 23, exec_dur = 24, tail_index = 25, head_index = 26, read_index = 27, mxl_status = 0 ++client A unaffected by malformed traffic ++ ++PASS +diff --git a/tests/ref/fate/mxl-json b/tests/ref/fate/mxl-json +new file mode 100644 +index 0000000000..5b0162c29a +--- /dev/null ++++ b/tests/ref/fate/mxl-json +@@ -0,0 +1,1041 @@ ++testing mxl_json: jsmn based MXL resource definition parser ++ ++sanity test: ++{"array_a": [0,1,2,3],"object_a": {"a": 1, "b": [0,1,2,3], "c": {"x": "y"} },"grain_rate": {"numerator": 25, "denominator": 1},"format": {"name": "video"},"media_type": "video/v210","frame_width": 1920,"array_b": [{"m": n, "o": [4,5,6]}, "p", ["q",7,{"r":8}]],"object_b": {"s": [9,"t",true], "u": {"v":{"w":10}}},"a_null": null,"a_true": true,"a_false": false,"array_c": [0, "one", {"three": "four"}],"negnums": {"a": -1.23, "b": -.123},"expnums": {"a": 1.23e2, "b": 1.23e+2, "c": 1.23e-2},"object_c": {"last": "final"},"ctrl_char": {"text":"line1\nline2"}} ++idx: type str end sz ++000: objc 000 557 016 '{"array_a": [0,1,2,3],"object_a": {"a": 1, "b": [0,1,2,3], "c": {"x": "y"} },"grain_rate": {"numerator": 25, "denominator": 1},"format": {"name": "video"},"media_type": "video/v210","frame_width": 1920,"array_b": [{"m": n, "o": [4,5,6]}, "p", ["q",7,{"r":8}]],"object_b": {"s": [9,"t",true], "u": {"v":{"w":10}}},"a_null": null,"a_true": true,"a_false": false,"array_c": [0, "one", {"three": "four"}],"negnums": {"a": -1.23, "b": -.123},"expnums": {"a": 1.23e2, "b": 1.23e+2, "c": 1.23e-2},"object_c": {"last": "final"},"ctrl_char": {"text":"line1\nline2"}}' ++001: strn 002 009 001 'array_a' ++002: arry 012 021 004 '[0,1,2,3]' ++003: prim 013 014 000 '0' ++004: prim 015 016 000 '1' ++005: prim 017 018 000 '2' ++006: prim 019 020 000 '3' ++007: strn 023 031 001 'object_a' ++008: objc 034 076 003 '{"a": 1, "b": [0,1,2,3], "c": {"x": "y"} }' ++009: strn 036 037 001 'a' ++010: prim 040 041 000 '1' ++011: strn 044 045 001 'b' ++012: arry 048 057 004 '[0,1,2,3]' ++013: prim 049 050 000 '0' ++014: prim 051 052 000 '1' ++015: prim 053 054 000 '2' ++016: prim 055 056 000 '3' ++017: strn 060 061 001 'c' ++018: objc 064 074 001 '{"x": "y"}' ++019: strn 066 067 001 'x' ++020: strn 071 072 000 'y' ++021: strn 078 088 001 'grain_rate' ++022: objc 091 126 002 '{"numerator": 25, "denominator": 1}' ++023: strn 093 102 001 'numerator' ++024: prim 105 107 000 '25' ++025: strn 110 121 001 'denominator' ++026: prim 124 125 000 '1' ++027: strn 128 134 001 'format' ++028: objc 137 154 001 '{"name": "video"}' ++029: strn 139 143 001 'name' ++030: strn 147 152 000 'video' ++031: strn 156 166 001 'media_type' ++032: strn 170 180 000 'video/v210' ++033: strn 183 194 001 'frame_width' ++034: prim 197 201 000 '1920' ++035: strn 203 210 001 'array_b' ++036: arry 213 259 003 '[{"m": n, "o": [4,5,6]}, "p", ["q",7,{"r":8}]]' ++037: objc 214 236 002 '{"m": n, "o": [4,5,6]}' ++038: strn 216 217 001 'm' ++039: prim 220 221 000 'n' ++040: strn 224 225 001 'o' ++041: arry 228 235 003 '[4,5,6]' ++042: prim 229 230 000 '4' ++043: prim 231 232 000 '5' ++044: prim 233 234 000 '6' ++045: strn 239 240 000 'p' ++046: arry 243 258 003 '["q",7,{"r":8}]' ++047: strn 245 246 000 'q' ++048: prim 248 249 000 '7' ++049: objc 250 257 001 '{"r":8}' ++050: strn 252 253 001 'r' ++051: prim 255 256 000 '8' ++052: strn 261 269 001 'object_b' ++053: objc 272 312 002 '{"s": [9,"t",true], "u": {"v":{"w":10}}}' ++054: strn 274 275 001 's' ++055: arry 278 290 003 '[9,"t",true]' ++056: prim 279 280 000 '9' ++057: strn 282 283 000 't' ++058: prim 285 289 000 'true' ++059: strn 293 294 001 'u' ++060: objc 297 311 001 '{"v":{"w":10}}' ++061: strn 299 300 001 'v' ++062: objc 302 310 001 '{"w":10}' ++063: strn 304 305 001 'w' ++064: prim 307 309 000 '10' ++065: strn 314 320 001 'a_null' ++066: prim 323 327 000 'null' ++067: strn 329 335 001 'a_true' ++068: prim 338 342 000 'true' ++069: strn 344 351 001 'a_false' ++070: prim 354 359 000 'false' ++071: strn 361 368 001 'array_c' ++072: arry 371 400 003 '[0, "one", {"three": "four"}]' ++073: prim 372 373 000 '0' ++074: strn 376 379 000 'one' ++075: objc 382 399 001 '{"three": "four"}' ++076: strn 384 389 001 'three' ++077: strn 393 397 000 'four' ++078: strn 402 409 001 'negnums' ++079: objc 412 436 002 '{"a": -1.23, "b": -.123}' ++080: strn 414 415 001 'a' ++081: prim 418 423 000 '-1.23' ++082: strn 426 427 001 'b' ++083: prim 430 435 000 '-.123' ++084: strn 438 445 001 'expnums' ++085: objc 448 489 003 '{"a": 1.23e2, "b": 1.23e+2, "c": 1.23e-2}' ++086: strn 450 451 001 'a' ++087: prim 454 460 000 '1.23e2' ++088: strn 463 464 001 'b' ++089: prim 467 474 000 '1.23e+2' ++090: strn 477 478 001 'c' ++091: prim 481 488 000 '1.23e-2' ++092: strn 491 499 001 'object_c' ++093: objc 502 519 001 '{"last": "final"}' ++094: strn 504 508 001 'last' ++095: strn 512 517 000 'final' ++096: strn 521 530 001 'ctrl_char' ++097: objc 533 556 001 '{"text":"line1\nline2"}' ++098: strn 535 539 001 'text' ++099: strn 542 554 000 'line1\nline2' ++PASS "media_type" = {"video/v210", SUCCESS} ++PASS "format.name" = {"video", SUCCESS} ++PASS "object_c.last" = {"final", SUCCESS} ++PASS "frame_width" = {1920, SUCCESS} ++PASS "grain_rate.numerator" = {25, SUCCESS} ++PASS "grain_rate.denominator" = {1, SUCCESS} ++PASS "object_a.a" = {1, SUCCESS} ++PASS "object_b.u.v.w" = {10, SUCCESS} ++PASS "negnums.a" = {-1.23, SUCCESS} ++PASS "negnums.b" = {-0.123, SUCCESS} ++PASS "expnums.a" = {123, SUCCESS} ++PASS "expnums.b" = {123, SUCCESS} ++PASS "expnums.c" = {0.0123, SUCCESS} ++PASS "ctrl_char.text" = {"line1\nline2", SUCCESS} ++PASS "a_null" = {nan, MXL_JSON_NULL} ++PASS "a_null" = {"(null)", MXL_JSON_NULL} ++PASS "a_true" = {nan, MXL_JSON_EBADTYPE} ++PASS "a_false" = {nan, MXL_JSON_EBADTYPE} ++PASS "a_false" = {"(null)", MXL_JSON_EBADTYPE} ++PASS "a_true" = {"(null)", MXL_JSON_EBADTYPE} ++PASS "format.name" = {nan, MXL_JSON_EBADTYPE} ++PASS "grain_rate.numerator" = {"(null)", MXL_JSON_EBADTYPE} ++PASS "array_a" = {nan, MXL_JSON_EBADTYPE} ++PASS "array_b" = {"(null)", MXL_JSON_EBADTYPE} ++PASS "does_not_exist" = {nan, MXL_JSON_ENOTFOUND} ++PASS "does.not_exist" = {nan, MXL_JSON_ENOTFOUND} ++PASS "blah" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "blah.yadda" = {"(null)", MXL_JSON_ENOTFOUND} ++ ++abberant input tests: ++ ++START abberant test: invalid_empty ++ ++idx: type str end sz ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: invalid_empty ++ ++START abberant test: valid_empty_object ++{} ++idx: type str end sz ++000: objc 000 002 000 '{}' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_empty_object ++ ++START abberant test: valid_empty_array ++[] ++idx: type str end sz ++000: arry 000 002 000 '[]' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_empty_array ++ ++START abberant test: valid_empty_string ++"" ++idx: type str end sz ++000: strn 001 001 000 '' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_empty_string ++ ++START abberant test: valid_single_number ++123.346 ++idx: type str end sz ++000: prim 000 007 000 '123.346' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_single_number ++ ++START abberant test: valid_single_neg_number ++-123.346 ++idx: type str end sz ++000: prim 000 008 000 '-123.346' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_single_neg_number ++ ++START abberant test: valid_single_exp_number ++1.23346e2 ++idx: type str end sz ++000: prim 000 009 000 '1.23346e2' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_single_exp_number ++ ++START abberant test: valid_single_true ++true ++idx: type str end sz ++000: prim 000 004 000 'true' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_single_true ++ ++START abberant test: valid_single_false ++false ++idx: type str end sz ++000: prim 000 005 000 'false' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_single_false ++ ++START abberant test: valid_single_null ++null ++idx: type str end sz ++000: prim 000 004 000 'null' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: valid_single_null ++ ++START abberant test: misspelled_single_null ++nul ++idx: type str end sz ++000: prim 000 003 000 'nul' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: misspelled_single_null ++ ++START abberant test: invalid_leading_plus ++{"a": +123.346} ++idx: type str end sz ++000: objc 000 015 001 '{"a": +123.346}' ++001: strn 002 003 001 'a' ++002: prim 006 014 000 '+123.346' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: invalid_leading_plus ++ ++START abberant test: invalid_exp_number ++{"a": 1.23346e} ++idx: type str end sz ++000: objc 000 015 001 '{"a": 1.23346e}' ++001: strn 002 003 001 'a' ++002: prim 006 014 000 '1.23346e' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: invalid_exp_number ++ ++START abberant test: invalid_number_sep ++{"a": 1,23346} ++idx: type str end sz ++000: objc 000 014 002 '{"a": 1,23346}' ++001: strn 002 003 001 'a' ++002: prim 006 007 000 '1' ++003: prim 008 013 000 '23346' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: invalid_number_sep ++ ++START abberant test: not_json ++this is totally wrong ++idx: type str end sz ++000: prim 000 004 000 'this' ++001: prim 005 007 000 'is' ++002: prim 008 015 000 'totally' ++003: prim 016 021 000 'wrong' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: not_json ++ ++START abberant test: missing_key_quotes ++{foo: "bar"} ++idx: type str end sz ++000: objc 000 012 001 '{foo: "bar"}' ++001: prim 001 004 001 'foo' ++002: strn 007 010 000 'bar' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: missing_key_quotes ++ ++START abberant test: single_quotes ++{'foo': 'bar'} ++idx: type str end sz ++000: objc 000 014 001 '{'foo': 'bar'}' ++001: prim 001 006 001 ''foo'' ++002: prim 008 013 000 ''bar'' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: single_quotes ++ ++START abberant test: trailing_comma_object ++{ "foo": "bar", } ++idx: type str end sz ++000: objc 000 017 001 '{ "foo": "bar", }' ++001: strn 003 006 001 'foo' ++002: strn 010 013 000 'bar' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: trailing_comma_object ++ ++START abberant test: trailing_comma_array ++[ "a", "b", ] ++idx: type str end sz ++000: arry 000 013 002 '[ "a", "b", ]' ++001: strn 003 004 000 'a' ++002: strn 008 009 000 'b' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: trailing_comma_array ++ ++START abberant test: dangling_key ++{"foo":} ++idx: type str end sz ++000: objc 000 008 001 '{"foo":}' ++001: strn 002 005 000 'foo' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: dangling_key ++ ++START abberant test: abnormal_termination ++{"foo": "bar"} what's this? ++idx: type str end sz ++000: objc 000 014 001 '{"foo": "bar"}' ++001: strn 002 005 001 'foo' ++002: strn 009 012 000 'bar' ++003: prim 015 021 000 'what's' ++004: prim 022 027 000 'this?' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: abnormal_termination ++ ++START abberant test: unescaped_ctl_char ++{ "text": "line1 ++line2" } ++idx: type str end sz ++000: objc 000 025 001 '{ "text": "line1 ++line2" }' ++001: strn 003 007 001 'text' ++002: strn 011 022 000 'line1 ++line2' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: unescaped_ctl_char ++ ++START abberant test: missing_both_braces ++"foo": "bar" ++idx: type str end sz ++000: strn 001 004 001 'foo' ++001: strn 008 011 000 'bar' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS abberant: missing_both_braces ++ ++START abberant test: missing_start_brace ++json_doc_build failed ++PASS expected build error: AVERROR_INVALIDDATA (AVERROR_INVALIDDATA) ++PASS abberant: missing_start_brace ++ ++START abberant test: missing_end_brace ++json_doc_build failed ++PASS expected build error: AVERROR_INVALIDDATA (AVERROR_INVALIDDATA) ++PASS abberant: missing_end_brace ++ ++START abberant test: misspelled_contained_null ++{"misspelled_null": nul} ++idx: type str end sz ++000: objc 000 024 001 '{"misspelled_null": nul}' ++001: strn 002 017 001 'misspelled_null' ++002: prim 020 023 000 'nul' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS "misspelled_null" = {"(null)", MXL_JSON_EBADTYPE} ++PASS "misspelled_null" = {nan, MXL_JSON_EBADTYPE} ++PASS abberant: misspelled_contained_null ++ ++START abberant test: capital_contained_null ++{"capital_null": NULL} ++idx: type str end sz ++000: objc 000 022 001 '{"capital_null": NULL}' ++001: strn 002 014 001 'capital_null' ++002: prim 017 021 000 'NULL' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS "capital_null" = {"(null)", MXL_JSON_EBADTYPE} ++PASS "capital_null" = {nan, MXL_JSON_EBADTYPE} ++PASS abberant: capital_contained_null ++ ++START abberant test: unquoted_literal ++{"what": yes } ++idx: type str end sz ++000: objc 000 014 001 '{"what": yes }' ++001: strn 002 006 001 'what' ++002: prim 009 012 000 'yes' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS "what" = {"(null)", MXL_JSON_EBADTYPE} ++PASS "what" = {nan, MXL_JSON_EBADTYPE} ++PASS abberant: unquoted_literal ++ ++START abberant test: nan_value ++{"value": NaN } ++idx: type str end sz ++000: objc 000 015 001 '{"value": NaN }' ++001: strn 002 007 001 'value' ++002: prim 010 013 000 'NaN' ++PASS "absent" = {"(null)", MXL_JSON_ENOTFOUND} ++PASS "absent" = {nan, MXL_JSON_ENOTFOUND} ++PASS "value" = {"(null)", MXL_JSON_EBADTYPE} ++PASS "value" = {nan, MXL_JSON_EBADTYPE} ++PASS abberant: nan_value ++ ++START abberant test: inf_values ++{"a":Infinity,"b":-Infinity,"c":Inf, "d":-Inf} ++idx: type str end sz ++000: objc 000 046 004 '{"a":Infinity,"b":-Infinity,"c":Inf, "d":-Inf}' ++001: strn 002 003 001 'a' ++002: prim 005 013 000 'Infinity' ++003: strn 015 016 001 'b' ++004: prim 018 027 000 '-Infinity' ++005: strn 029 030 001 'c' ++006: prim 032 035 000 'Inf' ++007: strn 038 039 001 'd' ++008: prim 041 045 000 '-Inf' ++PASS "a" = {inf, MXL_JSON_EBADTYPE} ++PASS "b" = {-inf, MXL_JSON_EBADTYPE} ++PASS "c" = {inf, MXL_JSON_EBADTYPE} ++PASS "d" = {-inf, MXL_JSON_EBADTYPE} ++PASS abberant: inf_values ++ ++START abberant test: valid_overflow_pos_number ++{"a": 1e400} ++idx: type str end sz ++000: objc 000 012 001 '{"a": 1e400}' ++001: strn 002 003 001 'a' ++002: prim 006 011 000 '1e400' ++PASS "a" = {inf, ERANGE} ++PASS abberant: valid_overflow_pos_number ++ ++START abberant test: valid_overflow_neg_number ++{"a": -1e400} ++idx: type str end sz ++000: objc 000 013 001 '{"a": -1e400}' ++001: strn 002 003 001 'a' ++002: prim 006 012 000 '-1e400' ++PASS "a" = {-inf, ERANGE} ++PASS abberant: valid_overflow_neg_number ++ ++START abberant test: valid_underflow_pos_number ++{"a": 1e-400} ++idx: type str end sz ++000: objc 000 013 001 '{"a": 1e-400}' ++001: strn 002 003 001 'a' ++002: prim 006 012 000 '1e-400' ++PASS "a" = {0, ERANGE} ++PASS abberant: valid_underflow_pos_number ++ ++START abberant test: valid_underflow_neg_number ++{"a": -1e-400} ++idx: type str end sz ++000: objc 000 014 001 '{"a": -1e-400}' ++001: strn 002 003 001 'a' ++002: prim 006 013 000 '-1e-400' ++PASS "a" = {-0, ERANGE} ++PASS abberant: valid_underflow_neg_number ++ ++non C locale number tests: ++test comma_dec_a ++{ "comma_dec_a": 3,14 } ++idx: type str end sz ++000: objc 000 023 002 '{ "comma_dec_a": 3,14 }' ++001: strn 003 014 001 'comma_dec_a' ++002: prim 017 018 000 '3' ++003: prim 019 021 000 '14' ++PASS comma_dec_a identified as malformed ++test comma_dec_b ++{ "comma_dec_b": 0,001 } ++idx: type str end sz ++000: objc 000 024 002 '{ "comma_dec_b": 0,001 }' ++001: strn 003 014 001 'comma_dec_b' ++002: prim 017 018 000 '0' ++003: prim 019 022 000 '001' ++PASS comma_dec_b identified as malformed ++test dot_thou_a ++{ "dot_thou_a": 1.234,56 } ++idx: type str end sz ++000: objc 000 026 002 '{ "dot_thou_a": 1.234,56 }' ++001: strn 003 013 001 'dot_thou_a' ++002: prim 016 021 000 '1.234' ++003: prim 022 024 000 '56' ++PASS dot_thou_a identified as malformed ++test dot_thou_b ++{ "dot_thou_b": 12.345,67 } ++idx: type str end sz ++000: objc 000 027 002 '{ "dot_thou_b": 12.345,67 }' ++001: strn 003 013 001 'dot_thou_b' ++002: prim 016 022 000 '12.345' ++003: prim 023 025 000 '67' ++PASS dot_thou_b identified as malformed ++test comma_thou_a ++{ "comma_thou_a": 1,234.56 } ++idx: type str end sz ++000: objc 000 028 002 '{ "comma_thou_a": 1,234.56 }' ++001: strn 003 015 001 'comma_thou_a' ++002: prim 018 019 000 '1' ++003: prim 020 026 000 '234.56' ++PASS comma_thou_a identified as malformed ++test comma_thou_b ++{ "comma_thou_b": 12,345.67 } ++idx: type str end sz ++000: objc 000 029 002 '{ "comma_thou_b": 12,345.67 }' ++001: strn 003 015 001 'comma_thou_b' ++002: prim 018 020 000 '12' ++003: prim 021 027 000 '345.67' ++PASS comma_thou_b identified as malformed ++test space_thou_a ++{ "space_thou_a": 1 234,56 } ++idx: type str end sz ++000: objc 000 028 002 '{ "space_thou_a": 1 234,56 }' ++001: strn 003 015 002 'space_thou_a' ++002: prim 018 019 000 '1' ++003: prim 020 023 000 '234' ++004: prim 024 026 000 '56' ++PASS space_thou_a identified as malformed ++test space_thou_b ++{ "space_thou_b": 1 234.56 } ++idx: type str end sz ++000: objc 000 028 001 '{ "space_thou_b": 1 234.56 }' ++001: strn 003 015 002 'space_thou_b' ++002: prim 018 019 000 '1' ++003: prim 020 026 000 '234.56' ++PASS space_thou_b identified as malformed ++test nbsp_thou_a ++PASS expected NOPARSE and parse did not succeed ++test nbsp_thou_b ++PASS expected NOPARSE and parse did not succeed ++test thin_thou_a ++PASS expected NOPARSE and parse did not succeed ++test thin_thou_b ++PASS expected NOPARSE and parse did not succeed ++test swiss_thou_a ++{ "swiss_thou_a": 1'234,56 } ++idx: type str end sz ++000: objc 000 028 002 '{ "swiss_thou_a": 1'234,56 }' ++001: strn 003 015 001 'swiss_thou_a' ++002: prim 018 023 000 '1'234' ++003: prim 024 026 000 '56' ++PASS swiss_thou_a identified as malformed ++test swiss_thou_b ++{ "swiss_thou_b": 1'234.56 } ++idx: type str end sz ++000: objc 000 028 001 '{ "swiss_thou_b": 1'234.56 }' ++001: strn 003 015 001 'swiss_thou_b' ++002: prim 018 026 000 '1'234.56' ++PASS swiss_thou_b identified as malformed ++test arabic_314 ++PASS expected NOPARSE and parse did not succeed ++test arabic_1234 ++PASS expected NOPARSE and parse did not succeed ++ ++real world MXL test: ++{ ++ "description": "MXL Test File", ++ "id": "5fbec3b1-1b0f-417d-9059-8b94a47197ef", ++ "tags": {}, ++ "format": "urn:x-nmos:format:video", ++ "label": "MXL Test File", ++ "parents": [], ++ "media_type": "video/v210", ++ "grain_rate": { ++ "numerator": 50, ++ "denominator": 1 ++ }, ++ "frame_width": 1920, ++ "frame_height": 1080, ++ "colorspace": "BT709", ++ "components": [ ++ { ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ } ++ ] ++} ++ ++idx: type str end sz ++000: objc 000 662 012 '{ ++ "description": "MXL Test File", ++ "id": "5fbec3b1-1b0f-417d-9059-8b94a47197ef", ++ "tags": {}, ++ "format": "urn:x-nmos:format:video", ++ "label": "MXL Test File", ++ "parents": [], ++ "media_type": "video/v210", ++ "grain_rate": { ++ "numerator": 50, ++ "denominator": 1 ++ }, ++ "frame_width": 1920, ++ "frame_height": 1080, ++ "colorspace": "BT709", ++ "components": [ ++ { ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ } ++ ] ++}' ++001: strn 005 016 001 'description' ++002: strn 020 033 000 'MXL Test File' ++003: strn 039 041 001 'id' ++004: strn 045 081 000 '5fbec3b1-1b0f-417d-9059-8b94a47197ef' ++005: strn 087 091 001 'tags' ++006: objc 094 096 000 '{}' ++007: strn 101 107 001 'format' ++008: strn 111 134 000 'urn:x-nmos:format:video' ++009: strn 140 145 001 'label' ++010: strn 149 162 000 'MXL Test File' ++011: strn 168 175 001 'parents' ++012: arry 178 180 000 '[]' ++013: strn 185 195 001 'media_type' ++014: strn 199 209 000 'video/v210' ++015: strn 215 225 001 'grain_rate' ++016: objc 228 275 002 '{ ++ "numerator": 50, ++ "denominator": 1 ++ }' ++017: strn 235 244 001 'numerator' ++018: prim 247 249 000 '50' ++019: strn 256 267 001 'denominator' ++020: prim 270 271 000 '1' ++021: strn 280 291 001 'frame_width' ++022: prim 294 298 000 '1920' ++023: strn 303 315 001 'frame_height' ++024: prim 318 322 000 '1080' ++025: strn 327 337 001 'colorspace' ++026: strn 341 346 000 'BT709' ++027: strn 352 362 001 'components' ++028: arry 365 660 003 '[ ++ { ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ } ++ ]' ++029: objc 371 462 004 '{ ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }' ++030: strn 380 384 001 'name' ++031: strn 388 389 000 'Y' ++032: strn 399 404 001 'width' ++033: prim 407 411 000 '1920' ++034: strn 420 426 001 'height' ++035: prim 429 433 000 '1080' ++036: strn 442 451 001 'bit_depth' ++037: prim 454 456 000 '10' ++038: objc 468 559 004 '{ ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }' ++039: strn 477 481 001 'name' ++040: strn 485 487 000 'Cb' ++041: strn 497 502 001 'width' ++042: prim 505 508 000 '960' ++043: strn 517 523 001 'height' ++044: prim 526 530 000 '1080' ++045: strn 539 548 001 'bit_depth' ++046: prim 551 553 000 '10' ++047: objc 565 656 004 '{ ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }' ++048: strn 574 578 001 'name' ++049: strn 582 584 000 'Cr' ++050: strn 594 599 001 'width' ++051: prim 602 605 000 '960' ++052: strn 614 620 001 'height' ++053: prim 623 627 000 '1080' ++054: strn 636 645 001 'bit_depth' ++055: prim 648 650 000 '10' ++PASS "id" = {"5fbec3b1-1b0f-417d-9059-8b94a47197ef", SUCCESS} ++PASS "label" = {"MXL Test File", SUCCESS} ++PASS "media_type" = {"video/v210", SUCCESS} ++PASS "format" = {"urn:x-nmos:format:video", SUCCESS} ++PASS "colorspace" = {"BT709", SUCCESS} ++PASS "grain_rate.numerator" = {50, SUCCESS} ++PASS "grain_rate.denominator" = {1, SUCCESS} ++PASS "frame_width" = {1920, SUCCESS} ++PASS "frame_height" = {1080, SUCCESS} ++ ++token_capacity test: ++parse with token capacity initial=2 max=8192 ++{ ++ "description": "MXL Test File", ++ "id": "5fbec3b1-1b0f-417d-9059-8b94a47197ef", ++ "tags": {}, ++ "format": "urn:x-nmos:format:video", ++ "label": "MXL Test File", ++ "parents": [], ++ "media_type": "video/v210", ++ "grain_rate": { ++ "numerator": 50, ++ "denominator": 1 ++ }, ++ "frame_width": 1920, ++ "frame_height": 1080, ++ "colorspace": "BT709", ++ "components": [ ++ { ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ } ++ ] ++} ++ ++idx: type str end sz ++000: objc 000 662 012 '{ ++ "description": "MXL Test File", ++ "id": "5fbec3b1-1b0f-417d-9059-8b94a47197ef", ++ "tags": {}, ++ "format": "urn:x-nmos:format:video", ++ "label": "MXL Test File", ++ "parents": [], ++ "media_type": "video/v210", ++ "grain_rate": { ++ "numerator": 50, ++ "denominator": 1 ++ }, ++ "frame_width": 1920, ++ "frame_height": 1080, ++ "colorspace": "BT709", ++ "components": [ ++ { ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ } ++ ] ++}' ++001: strn 005 016 001 'description' ++002: strn 020 033 000 'MXL Test File' ++003: strn 039 041 001 'id' ++004: strn 045 081 000 '5fbec3b1-1b0f-417d-9059-8b94a47197ef' ++005: strn 087 091 001 'tags' ++006: objc 094 096 000 '{}' ++007: strn 101 107 001 'format' ++008: strn 111 134 000 'urn:x-nmos:format:video' ++009: strn 140 145 001 'label' ++010: strn 149 162 000 'MXL Test File' ++011: strn 168 175 001 'parents' ++012: arry 178 180 000 '[]' ++013: strn 185 195 001 'media_type' ++014: strn 199 209 000 'video/v210' ++015: strn 215 225 001 'grain_rate' ++016: objc 228 275 002 '{ ++ "numerator": 50, ++ "denominator": 1 ++ }' ++017: strn 235 244 001 'numerator' ++018: prim 247 249 000 '50' ++019: strn 256 267 001 'denominator' ++020: prim 270 271 000 '1' ++021: strn 280 291 001 'frame_width' ++022: prim 294 298 000 '1920' ++023: strn 303 315 001 'frame_height' ++024: prim 318 322 000 '1080' ++025: strn 327 337 001 'colorspace' ++026: strn 341 346 000 'BT709' ++027: strn 352 362 001 'components' ++028: arry 365 660 003 '[ ++ { ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }, ++ { ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ } ++ ]' ++029: objc 371 462 004 '{ ++ "name": "Y", ++ "width": 1920, ++ "height": 1080, ++ "bit_depth": 10 ++ }' ++030: strn 380 384 001 'name' ++031: strn 388 389 000 'Y' ++032: strn 399 404 001 'width' ++033: prim 407 411 000 '1920' ++034: strn 420 426 001 'height' ++035: prim 429 433 000 '1080' ++036: strn 442 451 001 'bit_depth' ++037: prim 454 456 000 '10' ++038: objc 468 559 004 '{ ++ "name": "Cb", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }' ++039: strn 477 481 001 'name' ++040: strn 485 487 000 'Cb' ++041: strn 497 502 001 'width' ++042: prim 505 508 000 '960' ++043: strn 517 523 001 'height' ++044: prim 526 530 000 '1080' ++045: strn 539 548 001 'bit_depth' ++046: prim 551 553 000 '10' ++047: objc 565 656 004 '{ ++ "name": "Cr", ++ "width": 960, ++ "height": 1080, ++ "bit_depth": 10 ++ }' ++048: strn 574 578 001 'name' ++049: strn 582 584 000 'Cr' ++050: strn 594 599 001 'width' ++051: prim 602 605 000 '960' ++052: strn 614 620 001 'height' ++053: prim 623 627 000 '1080' ++054: strn 636 645 001 'bit_depth' ++055: prim 648 650 000 '10' ++PASS "id" = {"5fbec3b1-1b0f-417d-9059-8b94a47197ef", SUCCESS} ++PASS "label" = {"MXL Test File", SUCCESS} ++PASS "media_type" = {"video/v210", SUCCESS} ++PASS "format" = {"urn:x-nmos:format:video", SUCCESS} ++PASS "colorspace" = {"BT709", SUCCESS} ++PASS "grain_rate.numerator" = {50, SUCCESS} ++PASS "grain_rate.denominator" = {1, SUCCESS} ++PASS "frame_width" = {1920, SUCCESS} ++PASS "frame_height" = {1080, SUCCESS} ++PASS token capacity "low non zero initial capacity" ++parse with token capacity initial=1 max=2 ++PASS expected build error AVERROR(ENOMEM) (-12) ++PASS token capacity "exceed max capacity" ++parse with token capacity initial=2 max=2 ++PASS expected build error AVERROR(ENOMEM) (-12) ++PASS token capacity "initial equal to max" ++parse with token capacity initial=0 max=8192 ++PASS expected build error AVERROR(EINVAL) (-22) ++PASS token capacity "zero initial capacity" ++parse with token capacity initial=2 max=1 ++PASS expected build error AVERROR(EINVAL) (-22) ++PASS token capacity "initial greater than max" ++parse with token capacity initial=1 max=0 ++PASS expected build error AVERROR(EINVAL) (-22) ++PASS token capacity "zero max capacity" ++ ++limit tests: ++PASS MXL_JSON_MAX_NUMBER_CHARS test match ++ ++START limit test: number_over_conversion_limit ++{"too_long":11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111} ++idx: type str end sz ++000: objc 000 141 001 '{"too_long":11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111}' ++001: strn 002 010 001 'too_long' ++002: prim 012 140 000 '11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111' ++PASS "too_long" = {nan, MXL_JSON_UNSUPPORTED} ++PASS limit: number_over_conversion_limit ++ ++START limit test: number_under_conversion_limit ++{"not_too_long":1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111} ++idx: type str end sz ++000: objc 000 144 001 '{"not_too_long":1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111}' ++001: strn 002 014 001 'not_too_long' ++002: prim 016 143 000 '1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111' ++PASS "not_too_long" = {1.111111111111111e+126, SUCCESS} ++PASS limit: number_under_conversion_limit ++PASS MXL_JSON_MAX_DEPTH test match ++ ++START limit test: object_depth_over_limit ++{"accessible_a": 1,"too_deep":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}}},"inaccessible_b": 3} ++idx: type str end sz ++000: objc 000 148 003 '{"accessible_a": 1,"too_deep":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}}},"inaccessible_b": 3}' ++001: strn 002 014 001 'accessible_a' ++002: prim 017 018 000 '1' ++003: strn 020 028 001 'too_deep' ++004: objc 030 127 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}}}' ++005: strn 032 033 001 'a' ++006: objc 035 126 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}}' ++007: strn 037 038 001 'a' ++008: objc 040 125 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}' ++009: strn 042 043 001 'a' ++010: objc 045 124 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}' ++011: strn 047 048 001 'a' ++012: objc 050 123 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}' ++013: strn 052 053 001 'a' ++014: objc 055 122 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}' ++015: strn 057 058 001 'a' ++016: objc 060 121 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}' ++017: strn 062 063 001 'a' ++018: objc 065 120 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}' ++019: strn 067 068 001 'a' ++020: objc 070 119 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}' ++021: strn 072 073 001 'a' ++022: objc 075 118 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}' ++023: strn 077 078 001 'a' ++024: objc 080 117 001 '{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}' ++025: strn 082 083 001 'a' ++026: objc 085 116 001 '{"a":{"a":{"a":{"a":{"a":2}}}}}' ++027: strn 087 088 001 'a' ++028: objc 090 115 001 '{"a":{"a":{"a":{"a":2}}}}' ++029: strn 092 093 001 'a' ++030: objc 095 114 001 '{"a":{"a":{"a":2}}}' ++031: strn 097 098 001 'a' ++032: objc 100 113 001 '{"a":{"a":2}}' ++033: strn 102 103 001 'a' ++034: objc 105 112 001 '{"a":2}' ++035: strn 107 108 001 'a' ++036: prim 110 111 000 '2' ++037: strn 129 143 001 'inaccessible_b' ++038: prim 146 147 000 '3' ++PASS "accessible_a" = {1, SUCCESS} ++PASS "too_deep.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" = {nan, MXL_JSON_UNSUPPORTED} ++PASS "inaccessible_b" = {nan, MXL_JSON_ENOTFOUND} ++PASS limit: object_depth_over_limit ++ ++START limit test: object_depth_under_limit ++{"accessible_a": 1,"not_too_deep":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}},"accessible_b": 3} ++idx: type str end sz ++000: objc 000 144 003 '{"accessible_a": 1,"not_too_deep":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}},"accessible_b": 3}' ++001: strn 002 014 001 'accessible_a' ++002: prim 017 018 000 '1' ++003: strn 020 032 001 'not_too_deep' ++004: objc 034 125 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}}' ++005: strn 036 037 001 'a' ++006: objc 039 124 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}}' ++007: strn 041 042 001 'a' ++008: objc 044 123 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}}' ++009: strn 046 047 001 'a' ++010: objc 049 122 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}}' ++011: strn 051 052 001 'a' ++012: objc 054 121 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}}' ++013: strn 056 057 001 'a' ++014: objc 059 120 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}}' ++015: strn 061 062 001 'a' ++016: objc 064 119 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}}' ++017: strn 066 067 001 'a' ++018: objc 069 118 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}}' ++019: strn 071 072 001 'a' ++020: objc 074 117 001 '{"a":{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}}' ++021: strn 076 077 001 'a' ++022: objc 079 116 001 '{"a":{"a":{"a":{"a":{"a":{"a":2}}}}}}' ++023: strn 081 082 001 'a' ++024: objc 084 115 001 '{"a":{"a":{"a":{"a":{"a":2}}}}}' ++025: strn 086 087 001 'a' ++026: objc 089 114 001 '{"a":{"a":{"a":{"a":2}}}}' ++027: strn 091 092 001 'a' ++028: objc 094 113 001 '{"a":{"a":{"a":2}}}' ++029: strn 096 097 001 'a' ++030: objc 099 112 001 '{"a":{"a":2}}' ++031: strn 101 102 001 'a' ++032: objc 104 111 001 '{"a":2}' ++033: strn 106 107 001 'a' ++034: prim 109 110 000 '2' ++035: strn 127 139 001 'accessible_b' ++036: prim 142 143 000 '3' ++PASS "accessible_a" = {1, SUCCESS} ++PASS "not_too_deep.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" = {2, SUCCESS} ++PASS "too_deep.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" = {nan, MXL_JSON_UNSUPPORTED} ++PASS "accessible_b" = {3, SUCCESS} ++PASS limit: object_depth_under_limit ++ ++PASS +diff --git a/tests/ref/fate/mxl-loc b/tests/ref/fate/mxl-loc +new file mode 100644 +index 0000000000..43567bcbe5 +--- /dev/null ++++ b/tests/ref/fate/mxl-loc +@@ -0,0 +1,564 @@ ++testing mxl_loc: mxl resource location parser ++ ++==== test case: abs_filesystem_path ++locator: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: abs_filesystem_path PASS ++==== test case: abs_filesystem_path_root ++locator: "/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: abs_filesystem_path_root PASS ++==== test case: rel_filesystem_path ++locator: "mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: rel_filesystem_path PASS ++==== test case: rel_filesystem_path_none ++locator: "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "." ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "." ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: rel_filesystem_path_none PASS ++==== test case: rel_filesystem_path_dot ++locator: "./71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "." ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "." ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: rel_filesystem_path_dot PASS ++==== test case: rel_filesystem_path_dot_dot ++locator: "../71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: ".." ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: ".." ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: rel_filesystem_path_dot_dot PASS ++==== test case: abs_filesystem_no_flow ++locator: "/dev/shm/mxl" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_no_flow PASS ++==== test case: abs_filesystem_no_flow_root ++locator: "/" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_no_flow_root PASS ++==== test case: rel_filesystem_no_flow ++locator: "rel/shm/mxl" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "rel/shm/mxl" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "rel/shm/mxl" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: rel_filesystem_no_flow PASS ++==== test case: rel_filesystem_no_flow_pwd ++locator: "." ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "." ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "." ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: rel_filesystem_no_flow_pwd PASS ++==== test case: rel_filesystem_path_trailing_slash ++locator: "mxl/" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "mxl/" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "mxl/" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: rel_filesystem_path_trailing_slash PASS ++==== test case: rel_filesystem_path_double_sep ++locator: "mxl//71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "mxl/" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "mxl/" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: rel_filesystem_path_double_sep PASS ++==== test case: abs_filesystem_path_zero_flow ++locator: "/tests/data/tmp/mxl" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/tests/data/tmp/mxl" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/tests/data/tmp/mxl" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_path_zero_flow PASS ++==== test case: abs_filesystem_path_wrong_ext ++locator: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.txt" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.txt" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.txt" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_path_wrong_ext PASS ++==== test case: abs_filesystem_path_trailing_ext_junk ++locator: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow.tmp" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow.tmp" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow.tmp" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_path_trailing_ext_junk PASS ++==== test case: abs_filesystem_path_no_ext ++locator: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_path_no_ext PASS ++==== test case: abs_filesystem_path_upcase_id ++locator: "/dev/shm/mxl/71F000DC-2578-4A06-B4EF-3E0E2B1E46E1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71F000DC-2578-4A06-B4EF-3E0E2B1E46E1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71F000DC-2578-4A06-B4EF-3E0E2B1E46E1" ++ ] ++} ++==== test case: abs_filesystem_path_upcase_id PASS ++==== test case: abs_filesystem_path_double_sep_a ++locator: "//dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "//dev/shm/mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "//dev/shm/mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: abs_filesystem_path_double_sep_a PASS ++==== test case: abs_filesystem_path_double_sep_b ++locator: "/dev/shm//mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm//mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm//mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: abs_filesystem_path_double_sep_b PASS ++==== test case: abs_filesystem_path_double_sep_c ++locator: "/dev/shm/mxl//71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: abs_filesystem_path_double_sep_c PASS ++==== test case: abs_filesystem_directory_named_flow ++locator: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow/" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow/" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl/71f000dc-2578-4a06-b4ef-3e0e2b1e46e1.mxl-flow/" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_directory_named_flow PASS ++==== test case: uri_zero_flow ++locator: "mxl:///dev/shm/mxl" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: uri_zero_flow PASS ++==== test case: uri_one_flow ++locator: "mxl:///dev/shm/mxl?id=71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 1 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ ] ++} ++==== test case: uri_one_flow PASS ++==== test case: uri_two_flow ++locator: "mxl:///dev/shm/mxl?id=71f000dc-2578-4a06-b4ef-3e0e2b1e46e1&id=ee43ad87-1ff5-43f5-a895-9cedbcc8884a" ++expected return code: 0 ++actual return code: 0 ++expected locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 2 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ "ee43ad87-1ff5-43f5-a895-9cedbcc8884a" ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "/dev/shm/mxl" ++ nb_flow_ids: 2 ++ flow_ids: [ ++ "71f000dc-2578-4a06-b4ef-3e0e2b1e46e1" ++ "ee43ad87-1ff5-43f5-a895-9cedbcc8884a" ++ ] ++} ++==== test case: uri_two_flow PASS ++==== test case: abs_filesystem_path_bad_id ++locator: "/dev/shm/mxl/bad_id.mxl-flow" ++expected return code: -22 ++actual return code: -22 ++expected locator: ++mxl_loc { ++ domain_path: "(null)" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "(null)" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_path_bad_id PASS ++==== test case: abs_filesystem_path_missing_id ++locator: "/dev/shm/mxl/.mxl-flow" ++expected return code: -22 ++actual return code: -22 ++expected locator: ++mxl_loc { ++ domain_path: "(null)" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "(null)" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: abs_filesystem_path_missing_id PASS ++==== test case: null_locator ++locator: "(null)" ++expected return code: -22 ++actual return code: -22 ++expected locator: ++mxl_loc { ++ domain_path: "(null)" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "(null)" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: null_locator PASS ++==== test case: empty_locator ++locator: "" ++expected return code: -2 ++actual return code: -2 ++expected locator: ++mxl_loc { ++ domain_path: "(null)" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++actual locator: ++mxl_loc { ++ domain_path: "(null)" ++ nb_flow_ids: 0 ++ flow_ids: [ ++ ] ++} ++==== test case: empty_locator PASS ++ ++PASS +diff --git a/tests/ref/fate/mxl-uri b/tests/ref/fate/mxl-uri +new file mode 100644 +index 0000000000..6e854bea7f +--- /dev/null ++++ b/tests/ref/fate/mxl-uri +@@ -0,0 +1,1176 @@ ++testing mxl_uri: mxl URI parser ++ ++==== test case: zero_id ++URI: "mxl://host1.local/domain/path" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (0): ++} ++==== test case: zero_id PASS ++==== test case: one_id ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++==== test case: one_id PASS ++==== test case: two_id ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++==== test case: two_id PASS ++==== test case: three_id ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc&id=2b528ffe-c94c-454f-984a-35c98c717006" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (3): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++ [2] "2b528ffe-c94c-454f-984a-35c98c717006" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (3): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++ [2] "2b528ffe-c94c-454f-984a-35c98c717006" ++} ++==== test case: three_id PASS ++==== test case: two_id_upcase ++URI: "MXL://HOST1.LOCAL/DOMAIN/PATH?id=3645F1E2-90FC-49CB-AF9C-236C2AC124CD&id=876FC244-9C99-48A8-A461-927E43A05BDC" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "HOST1.LOCAL" ++ port: -1 ++ domain: "/DOMAIN/PATH" ++ flow_ids (2): ++ [0] "3645F1E2-90FC-49CB-AF9C-236C2AC124CD" ++ [1] "876FC244-9C99-48A8-A461-927E43A05BDC" ++} ++actual URI: ++mxl_uri { ++ host: "HOST1.LOCAL" ++ port: -1 ++ domain: "/DOMAIN/PATH" ++ flow_ids (2): ++ [0] "3645F1E2-90FC-49CB-AF9C-236C2AC124CD" ++ [1] "876FC244-9C99-48A8-A461-927E43A05BDC" ++} ++==== test case: two_id_upcase PASS ++==== test case: two_id_upcase_key ++URI: "mxl://host1.local/domain/path?ID=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++==== test case: two_id_upcase_key PASS ++==== test case: unknown_query_param ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&unknown=unknown&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++==== test case: unknown_query_param PASS ++==== test case: unknown_flag_query_param ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&empty&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++==== test case: unknown_flag_query_param PASS ++==== test case: ipv4 ++URI: "mxl://10.1.2.3:5000/domain/path?id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "10.1.2.3" ++ port: 5000 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1" ++ [1] "5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++} ++actual URI: ++mxl_uri { ++ host: "10.1.2.3" ++ port: 5000 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1" ++ [1] "5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++} ++==== test case: ipv4 PASS ++==== test case: ipv6 ++URI: "mxl://[2001:db8::2]/domain/path?id=1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92&id=3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "2001:db8::2" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92" ++ [1] "3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51" ++} ++actual URI: ++mxl_uri { ++ host: "2001:db8::2" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92" ++ [1] "3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51" ++} ++==== test case: ipv6 PASS ++==== test case: ipv6_with_port ++URI: "mxl://[2001:db8::2]:5000/domain/path?id=1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92&id=3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "2001:db8::2" ++ port: 5000 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92" ++ [1] "3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51" ++} ++actual URI: ++mxl_uri { ++ host: "2001:db8::2" ++ port: 5000 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "1e9c4c74-6c44-4f7d-b8a3-3e4f9a4e8a92" ++ [1] "3fbcf2a1-2e8a-4a4d-9c8f-0d7e4a6b7c51" ++} ++==== test case: ipv6_with_port PASS ++==== test case: valid_negative_one_port ++URI: "mxl://10.1.2.3:-1/domain/path?id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "10.1.2.3" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1" ++ [1] "5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++} ++actual URI: ++mxl_uri { ++ host: "10.1.2.3" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1" ++ [1] "5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++} ++==== test case: valid_negative_one_port PASS ++==== test case: only_unknown_query_param ++URI: "mxl://host1.local/domain/path?foo=bar&baz=qux" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (0): ++} ++==== test case: only_unknown_query_param PASS ++==== test case: local_domain_triple_slash ++URI: "mxl:///domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++actual URI: ++mxl_uri { ++ host: "" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++==== test case: local_domain_triple_slash PASS ++==== test case: local_domain_single_slash ++URI: "mxl:/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: local_domain_single_slash PASS ++==== test case: zero_port ++URI: "mxl://10.1.2.3:0/domain/path?id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: zero_port PASS ++==== test case: fragment_after_query ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd#fragment" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: fragment_after_query PASS ++==== test case: valid_at_range_limit_port ++URI: "mxl://host1.local:65535/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: 65535 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: 65535 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++==== test case: valid_at_range_limit_port PASS ++==== test case: invalid_out_of_range_port ++URI: "mxl://10.1.2.3:65536/domain/path?id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: invalid_out_of_range_port PASS ++==== test case: empty_query ++URI: "mxl://host1.local/domain/path?" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (0): ++} ++==== test case: empty_query PASS ++==== test case: leading_amp_query_param ++URI: "mxl://host1.local/domain/path?&id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++==== test case: leading_amp_query_param PASS ++==== test case: double_separator_query_param ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++==== test case: double_separator_query_param PASS ++==== test case: percent_encoded_in_other_query_value ++URI: "mxl://host1.local/domain/path?other=%26extra&id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++==== test case: percent_encoded_in_other_query_value PASS ++==== test case: percent_encoded_key ++URI: "mxl://host1.local/domain/path?%69d=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (0): ++} ++==== test case: percent_encoded_key PASS ++==== test case: trailing_amp_query_param ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++==== test case: trailing_amp_query_param PASS ++==== test case: local_domain_looks_like_port_but_is_not ++URI: "mxl:///domain/path:5000?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "" ++ port: -1 ++ domain: "/domain/path:5000" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++actual URI: ++mxl_uri { ++ host: "" ++ port: -1 ++ domain: "/domain/path:5000" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++==== test case: local_domain_looks_like_port_but_is_not PASS ++==== test case: empty_host_with_port ++URI: "mxl://:5000/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "" ++ port: 5000 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++actual URI: ++mxl_uri { ++ host: "" ++ port: 5000 ++ domain: "/domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++==== test case: empty_host_with_port PASS ++==== test case: userinfo_with_empty_host ++URI: "mxl://user@/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: userinfo_with_empty_host PASS ++==== test case: double_leading_slash_path ++URI: "mxl:////domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "" ++ port: -1 ++ domain: "//domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++actual URI: ++mxl_uri { ++ host: "" ++ port: -1 ++ domain: "//domain/path" ++ flow_ids (1): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++} ++==== test case: double_leading_slash_path PASS ++==== test case: query_without_path ++URI: "mxl://?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: query_without_path PASS ++==== test case: empty_ipv6_host ++URI: "mxl://[]/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: empty_ipv6_host PASS ++==== test case: empty_ipv6_host_with_port ++URI: "mxl://[]:5000/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: empty_ipv6_host_with_port PASS ++==== test case: empty_domain_path ++URI: "mxl://host1.local/?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: empty_domain_path PASS ++==== test case: empty_domain_path_no_query ++URI: "mxl://host1.local" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: empty_domain_path_no_query PASS ++==== test case: empty_domain_path_no_query_trailing_slash ++URI: "mxl://host1.local/" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: empty_domain_path_no_query_trailing_slash PASS ++==== test case: non_unique_flow_ids ++URI: "mxl:///host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc&id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: non_unique_flow_ids PASS ++==== test case: bad_scheme ++URI: "ftp://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: bad_scheme PASS ++==== test case: no_scheme ++URI: "/host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: no_scheme PASS ++==== test case: foo_after_scheme ++URI: "mxl:foo" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: foo_after_scheme PASS ++==== test case: null_uri ++URI: "(null)" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: null_uri PASS ++==== test case: empty_uri ++URI: "" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: empty_uri PASS ++==== test case: equal_max_scheme ++URI: "aaaa:/host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: -36 ++actual return code: -36 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: equal_max_scheme PASS ++==== test case: greater_max_scheme ++URI: "aaaaa:/host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: -36 ++actual return code: -36 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: greater_max_scheme PASS ++==== test case: empty_middle_id_query_param ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: empty_middle_id_query_param PASS ++==== test case: empty_end_id_query_param ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc&id=" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: empty_end_id_query_param PASS ++==== test case: bad_separator_query_param ++URI: "mxl://host1.local/domain/path?id=id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: bad_separator_query_param PASS ++==== test case: bad_uuid_query_param ++URI: "mxl://host1.local/domain/path?id=X645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: bad_uuid_query_param PASS ++==== test case: invalid_negative_port ++URI: "mxl://host1.local:-2/domain/path?id=645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: invalid_negative_port PASS ++==== test case: invalid_empty_port ++URI: "mxl://10.1.2.3:/domain/path?id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: invalid_empty_port PASS ++==== test case: invalid_non_integer_port ++URI: "mxl://10.1.2.3:abc/domain/path?id=8c8e8a37-4a5b-4e0a-9f0b-2e3e71e3f5e1&id=5d9d1a64-7a77-4b54-8e32-6e6e8b0b9a44" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: invalid_non_integer_port PASS ++==== test case: invalid_no_domain_path ++URI: "mxl://host1.local?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: invalid_no_domain_path PASS ++==== test case: malformed_ipv6_bracket ++URI: "mxl://[2001:db8::2/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: malformed_ipv6_bracket PASS ++==== test case: percent_encoded_in_uuid_query_value ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd%26extra" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: percent_encoded_in_uuid_query_value PASS ++==== test case: userinfo_in_authority ++URI: "mxl://user@host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: userinfo_in_authority PASS ++==== test case: userpass_in_authority ++URI: "mxl://user:pass@host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: userpass_in_authority PASS ++==== test case: multiple_at_userinfo ++URI: "mxl://a@b@host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++expected return code: -22 ++actual return code: -22 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: multiple_at_userinfo PASS ++==== test case: equal_max_len_url ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc&junk=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ++expected return code: 0 ++actual return code: 0 ++expected URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++actual URI: ++mxl_uri { ++ host: "host1.local" ++ port: -1 ++ domain: "/domain/path" ++ flow_ids (2): ++ [0] "3645f1e2-90fc-49cb-af9c-236c2ac124cd" ++ [1] "876fc244-9c99-48a8-a461-927e43a05bdc" ++} ++==== test case: equal_max_len_url PASS ++==== test case: greater_max_len_url ++URI: "mxl://host1.local/domain/path?id=3645f1e2-90fc-49cb-af9c-236c2ac124cd&id=876fc244-9c99-48a8-a461-927e43a05bdc&junk=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ++expected return code: -36 ++actual return code: -36 ++expected URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++actual URI: ++mxl_uri { ++ host: "(null)" ++ port: -1 ++ domain: "(null)" ++ flow_ids (0): ++} ++==== test case: greater_max_len_url PASS ++ ++PASS +diff --git a/tests/ref/fate/mxl-video-encdec b/tests/ref/fate/mxl-video-encdec +new file mode 100644 +index 0000000000..c94fd9fdd8 +--- /dev/null ++++ b/tests/ref/fate/mxl-video-encdec +@@ -0,0 +1,14 @@ ++#format: frame checksums ++#version: 2 ++#hash: MD5 ++#tb 0: 1/50 ++#media_type 0: video ++#codec_id 0: rawvideo ++#dimensions 0: 1920x1080 ++#sar 0: 1/1 ++#stream#, dts, pts, duration, size, hash ++0, 0, 0, 1, 8294400, 687ba6aecec048f446ee3cbe03f611b3 ++0, 1, 1, 1, 8294400, fa8087d706277bffe0181b862c9e8252 ++0, 2, 2, 1, 8294400, 19190a35fe54888aa56367a6672cb02d ++0, 3, 3, 1, 8294400, 13b4e70275b3e18080ea2411a1c7e47c ++0, 4, 4, 1, 8294400, 34c7f0e53d40f8da58a1d48510d425a8 +diff --git a/tests/ref/fate/mxl-video-probe b/tests/ref/fate/mxl-video-probe +new file mode 100644 +index 0000000000..5c38dcfbe8 +--- /dev/null ++++ b/tests/ref/fate/mxl-video-probe +@@ -0,0 +1,10 @@ ++Input #0, mxl, from 'mxl:///path/to/domain?id=717f834b-4224-4c9b-8a64-ecb7726803b8': ++ Duration: N/A, bitrate: N/A ++ Stream #0:0: Video: v210 (v210 / 0x30313276), yuv422p10le(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 50 fps, 50 tbr, 50 tbn ++ Metadata: ++ mxl_id : 717f834b-4224-4c9b-8a64-ecb7726803b8 ++ mxl_description : FFmpeg video stream ++ mxl_label : FFmpeg video stream ++ mxl_format : urn:x-nmos:format:video ++ mxl_media_type : video/v210 ++ mxl_colorspace : BT709 +-- +2.43.0 + diff --git a/deps/ffmpeg/8/bases.env b/deps/ffmpeg/8/bases.env index 67d8222c..bfec2bed 100644 --- a/deps/ffmpeg/8/bases.env +++ b/deps/ffmpeg/8/bases.env @@ -1,5 +1,5 @@ n80_commit=140fd653aed8cad774f991ba083e2d01e86420c7 -n80_tree=1b83263bd2f67f590a3434864e6b0caf24a65f20 +n80_tree=3f3cacd2a72b5a43a06bbc4cb212307bd7e1a9d6 n81_commit=9047fa1b084f76b1b4d065af2d743df1b40dfb56 -n81_tree=f0ca321865b3505b0f6c6b483bbdc14d772f95de -patch_count=10 +n81_tree=93dd33926bd56b56cab82a747ae91c39f31e84ba +patch_count=11 diff --git a/deps/ffmpeg/Dockerfile.mkpatch b/deps/ffmpeg/Dockerfile.mkpatch new file mode 100644 index 00000000..5f10ca55 --- /dev/null +++ b/deps/ffmpeg/Dockerfile.mkpatch @@ -0,0 +1,35 @@ +# Build the MXL "0011" patch on top of the avplumber FFmpeg 8.x series. +# +# Usage from the repository root: +# +# docker build -f deps/ffmpeg/Dockerfile.mkpatch \ +# -t avplumber-mxl-mkpatch:local deps/ffmpeg +# docker run --rm \ +# -v "$PWD/deps/ffmpeg/8:/out" \ +# -v "$PWD/deps/ffmpeg/8:/patches:ro" \ +# avplumber-mxl-mkpatch:local +# +# On success this overwrites: +# deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch +# +# Then refresh deps/ffmpeg/8/bases.env from deps/ffmpeg/verify.sh (it prints +# the actual tree for each base when the pinned one no longer matches). +# +# On cherry-pick conflicts the container exits with instructions; drop +# into it with `-it --entrypoint bash` on the same image to finish by hand. + +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + && rm -rf /var/lib/apt/lists/* + +COPY mkpatch-0011-mxl.sh /usr/local/bin/mkpatch-0011-mxl +COPY mkpatch-finish.sh /usr/local/bin/mkpatch-finish +COPY mkpatch-fixups /fixups +RUN chmod +x /usr/local/bin/mkpatch-0011-mxl /usr/local/bin/mkpatch-finish + +ENTRYPOINT ["/usr/local/bin/mkpatch-0011-mxl"] diff --git a/deps/ffmpeg/README.md b/deps/ffmpeg/README.md index 65eca355..7dd9c15d 100644 --- a/deps/ffmpeg/README.md +++ b/deps/ffmpeg/README.md @@ -51,6 +51,41 @@ and the filter changes) is base-independent. on NV12 CUDA frames; pixels outside the band are unchanged. Carries forward the filter from `1876208` and its FFmpeg 8.1 adaptation in `6dcda46` without changing its kernel or option defaults. The same patch applies to 8.0 and 8.1. +11. **libmxl demuxer and muxer** — [MXL](https://github.com/dmf-mxl/mxl) + shared-memory flows: demuxer, muxer, URI parser, JSON/diagnostic helpers, + FATE coverage and `--enable-libmxl` glue. Squashed from `cbcrc/FFmpeg` + branch `dmf-mxl/8.1`, pinned at `9eddb90`, plus our fixes from + `mkpatch-fixups/` (currently one: monotonic PTS across a + `reset_on_drop` reset, which otherwise aborts the process mid-stream). + Built into the shared demo image by `demos/mixer/Dockerfile` (which + also pins the MXL SDK) and exercised by `demos/mxl`. + +### Regenerating `8/0011-avformat-libmxl-demuxer-muxer.patch` + +Docker only, and no compiler needed — the container just replays git history +(works on Linux and macOS hosts): + +```bash +docker build -f deps/ffmpeg/Dockerfile.mkpatch \ + -t avplumber-mxl-mkpatch:local deps/ffmpeg +docker run --rm \ + -v "$PWD/deps/ffmpeg/8:/out" \ + -v "$PWD/deps/ffmpeg/8:/patches:ro" \ + avplumber-mxl-mkpatch:local +``` + +Everything in `mkpatch-fixups/` is `git am`-ed after the cherry-picks, so our +own fixes end up inside the squashed patch with their commit messages listed +as provenance. Retire one by deleting its file and regenerating. + +Set `MXL_REMOTE_REF`/`MXL_PIN` (and `FFMPEG_TAG`) to move to a newer fork +branch, e.g. `dmf-mxl/9.0` for a future base. Afterwards refresh +`8/bases.env`: `verify.sh` prints the actual tree per base when the pinned +one no longer matches, and `patch_count` must match the file count. + +Cherry-pick conflicts stop the container with instructions; re-run it with +`--entrypoint bash` and finish by hand (`git cherry-pick --continue`, then +`/usr/local/bin/mkpatch-finish`). ## FFmpeg 8 notes diff --git a/deps/ffmpeg/mkpatch-0011-mxl.sh b/deps/ffmpeg/mkpatch-0011-mxl.sh new file mode 100755 index 00000000..03d7b97c --- /dev/null +++ b/deps/ffmpeg/mkpatch-0011-mxl.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Generate deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch by +# replaying the MXL commits from cbcrc/FFmpeg on top of the avplumber +# series (0001-0010). +# +# Runs inside a plain Linux container (see Dockerfile.mkpatch). All state +# lives in /tmp; the resulting patch is written to /out. +# +# The fork keeps a branch per FFmpeg base (dmf-mxl/8.1, dmf-mxl/9.0, ...), +# each carrying the whole integration in a couple of commits on top of +# upstream release commits. We take the ones that touch libavformat/mxl*, +# plus any whose subject mentions mxl (the configure/allformats/Makefile +# glue), and squash them into one provenance-preserving patch so +# verify.sh keeps its "one feature per numbered patch" invariant. + +set -euo pipefail + +FFMPEG_TAG="${FFMPEG_TAG:-n8.1}" +# cbcrc's guidance-for-building-ffmpeg-with-mxl (scripts/get-src.sh) +# hosts the MXL FFmpeg work at cbcrc/FFmpeg. The dmf-mxl/8.1 branch +# forks from the same n8.1 commit that 8/bases.env pins. +MXL_REMOTE_URL="${MXL_REMOTE_URL:-https://github.com/cbcrc/FFmpeg.git}" +MXL_REMOTE_REF="${MXL_REMOTE_REF:-dmf-mxl/8.1}" +MXL_PIN="${MXL_PIN:-9eddb90ac0cf6063aaacc4fc2775f19d873500eb}" +OUT_DIR="${OUT_DIR:-/out}" +PATCH_DIR="${PATCH_DIR:-/patches}" +FIXUP_DIR="${FIXUP_DIR:-/fixups}" +WORK_DIR="${WORK_DIR:-/tmp/ffmpeg-mxl-build}" + +log() { printf '[mkpatch] %s\n' "$*"; } + +test -d "$PATCH_DIR" || { echo "expected patch input dir at $PATCH_DIR" >&2; exit 2; } +test -d "$OUT_DIR" || { echo "expected patch output dir at $OUT_DIR" >&2; exit 2; } + +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR/ffmpeg" + +cd "$WORK_DIR/ffmpeg" +git init --quiet . +git config user.name "avplumber mxl builder" +git config user.email "avplumber-mxl@local" + +# Blobless fetches keep this quick while preserving the history needed to +# compute a merge base with the fork. +log "fetching upstream $FFMPEG_TAG" +git remote add upstream https://github.com/FFmpeg/FFmpeg.git +git fetch --quiet --filter=blob:none --no-tags upstream "tag" "$FFMPEG_TAG" +git checkout --quiet -b work "$FFMPEG_TAG" + +log "applying the existing series (everything below 0011)" +# A previous 0011 in $PATCH_DIR is what we are about to replace, so skip it. +mapfile -t EXISTING < <(ls "$PATCH_DIR"/0[0-9][0-9][0-9]-*.patch | sort | grep -v '/0011-') +git am --whitespace=nowarn "${EXISTING[@]}" + +BASE_AFTER_STACK="$(git rev-parse HEAD)" + +log "fetching MXL FFmpeg fork ($MXL_REMOTE_URL @ $MXL_REMOTE_REF, pin=$MXL_PIN)" +git remote add mxl-fork "$MXL_REMOTE_URL" +git fetch --quiet --filter=blob:none --no-tags \ + mxl-fork "refs/heads/$MXL_REMOTE_REF:refs/remotes/mxl-fork/pinned" +git rev-parse --verify "$MXL_PIN^{commit}" >/dev/null + +MERGE_BASE="$(git merge-base "$MXL_PIN" "$FFMPEG_TAG" || true)" +if [ -z "$MERGE_BASE" ]; then + log "no shared history with $MXL_PIN — falling back to $FFMPEG_TAG as range base" + MERGE_BASE="$FFMPEG_TAG" +fi +log "range base: $MERGE_BASE" + +log "collecting MXL commits" +mapfile -t MXL_COMMITS < <( + { + git log --reverse --format=%H "$MERGE_BASE".."$MXL_PIN" -- 'libavformat/mxl*' + git log --reverse --format='%H %s' "$MERGE_BASE".."$MXL_PIN" \ + | awk 'tolower($0) ~ /mxl/ { print $1 }' + } | awk '!seen[$0]++' +) + +if [ "${#MXL_COMMITS[@]}" -eq 0 ]; then + echo "no MXL commits found in $MXL_REMOTE_REF; nothing to do" >&2 + exit 3 +fi + +log "picking ${#MXL_COMMITS[@]} commit(s):" +for c in "${MXL_COMMITS[@]}"; do + printf ' %s %s\n' "$c" "$(git log -1 --format=%s "$c")" +done + +log "cherry-picking onto $FFMPEG_TAG + the existing series" +# The 8.1 branch needs no conflict resolution. The fallback below stays for +# future bases (9.0 and up): prefer the version from the commit being +# picked, and if the path was removed there, remove it. We squash to one +# patch anyway, so only the end state matters. + +auto_resolve_conflicts() { + local pending + pending="$(git diff --name-only --diff-filter=U)" + if [ -z "$pending" ]; then + return 1 + fi + while IFS= read -r path; do + [ -z "$path" ] && continue + if git checkout --theirs -- "$path" 2>/dev/null; then + git add -- "$path" + else + git rm -f -- "$path" >/dev/null + fi + done <<<"$pending" + # Also handle unmerged entries that only appear in `git status` + # ("DU"/"UD"/"AA"/"DD"). + while IFS= read -r line; do + local code path2 + code="${line:0:2}" + path2="${line:3}" + case "$code" in + DU|UD|DD|AU|UA|AA|UU) + if git checkout --theirs -- "$path2" 2>/dev/null; then + git add -- "$path2" + else + git rm -f -- "$path2" >/dev/null 2>&1 || true + fi + ;; + esac + done < <(git status --porcelain=v1) + return 0 +} + +set +e +git cherry-pick --keep-redundant-commits \ + --strategy=recursive -X theirs -x "${MXL_COMMITS[@]}" +rc=$? +while [ "$rc" -ne 0 ]; do + if ! auto_resolve_conflicts; then + log "cherry-pick failed with no unresolved paths — aborting" + exit 4 + fi + if git diff --cached --quiet; then + log "empty commit after resolve — skipping" + git cherry-pick --skip + else + GIT_EDITOR=true git cherry-pick --continue + fi + rc=$? +done +set -e + +log "applying our fixes on top of the fork" +# Changes of ours that the fork has not taken yet. They are squashed into +# the same patch, so each one needs its own file here to stay reviewable — +# and dropping one is how we retire it once the fork carries the fix. +shopt -s nullglob +FIXUPS=("$FIXUP_DIR"/*.patch) +if [ "${#FIXUPS[@]}" -eq 0 ]; then + log " none in $FIXUP_DIR" +else + git am --whitespace=nowarn "${FIXUPS[@]}" +fi + +exec bash /usr/local/bin/mkpatch-finish "$BASE_AFTER_STACK" diff --git a/deps/ffmpeg/mkpatch-finish.sh b/deps/ffmpeg/mkpatch-finish.sh new file mode 100755 index 00000000..7df29aa7 --- /dev/null +++ b/deps/ffmpeg/mkpatch-finish.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Squash the MXL cherry-picks into a single provenance-preserving patch and +# write 0011-avformat-libmxl-demuxer-muxer.patch to /out. +# +# $1 = commit hash of "base after 0001-0010 applied". Passed by +# mkpatch-0011-mxl.sh after a successful cherry-pick, or by the user +# manually after resolving conflicts and running +# `git cherry-pick --continue`. + +set -eu +set -o pipefail +FFMPEG_TAG="${FFMPEG_TAG:-n8.1}" + +BASE_AFTER_STACK="${1:-}" +if [ -z "$BASE_AFTER_STACK" ]; then + # If invoked manually, recover the base by walking back over MXL commits. + BASE_AFTER_STACK="$(git log --format=%H --grep='cherry picked from' --invert-grep -1)" +fi + +OUT_DIR="${OUT_DIR:-/out}" +OUT_FILE="$OUT_DIR/0011-avformat-libmxl-demuxer-muxer.patch" + +log() { printf '[mkpatch] %s\n' "$*"; } + +# The series must apply to both n8.0 and n8.1 from one copy, so the configure +# hunks may only touch lines the two bases share. As imported, the MXL work +# anchors its `require_pkg_config libmxl` next to libmpeghdec, which exists +# only in 8.1, and carries a whitespace-only reindent of the mmal check. Move +# the require next to the mxl_* deps lines (whose neighbours are identical in +# both bases) and drop the reindent. +log "re-anchoring the configure hunks for n8.0/n8.1 portability" +awk ' + /^enabled libmxl *&& require_pkg_config libmxl/ { require = $0; next } + /^ +check_func_headers interface\/mmal\/mmal\.h "MMAL_PARAMETER_VIDEO_MAX_NUM_CALLBACKS"; }$/ { + sub(/^ +/, " "); print; next + } + /^enabled mxl_demuxer *&& prepend avformat_deps/ { + if (require == "") { print "mkpatch: libmxl require line not found" > "/dev/stderr"; exit 1 } + print require; moved = 1 + } + { print } + END { if (!moved) { print "mkpatch: mxl_demuxer prepend line not found" > "/dev/stderr"; exit 1 } } +' configure >configure.mkpatch +mv configure.mkpatch configure +chmod +x configure + +log "collecting authorship from cherry-picked commits" +AUTHOR_TRAILERS="$( + git log --format='Co-authored-by: %an <%ae>' "$BASE_AFTER_STACK"..HEAD \ + | awk '!seen[$0]++' +)" +ORIGINAL_COMMITS="$( + git log --format=' * %h %s' "$BASE_AFTER_STACK"..HEAD +)" + +log "squashing cherry-picks to a single commit" +git reset --soft "$BASE_AFTER_STACK" +git add -A + +MSG_FILE="$(mktemp)" +{ + printf 'avformat/mxl: add libmxl demuxer and muxer\n\n' + printf 'Adds the MXL demuxer, muxer, URI parser, JSON/diagnostic helpers\n' + printf 'and --enable-libmxl configure glue.\n\n' + printf 'Cherry-picked from https://github.com/cbcrc/FFmpeg (branch\n' + printf '%s) and squashed onto %s on top of the avplumber\n' "${MXL_REMOTE_REF:-dmf-mxl/8.1}" "$FFMPEG_TAG" + printf 'series 0001-0010, with the configure hunks re-anchored so the\n' + printf 'patch applies to both n8.0 and n8.1.\n\n' + printf 'Original commits:\n%s\n\n' "$ORIGINAL_COMMITS" + [ -n "$AUTHOR_TRAILERS" ] && printf '%s\n' "$AUTHOR_TRAILERS" +} >"$MSG_FILE" + +GIT_COMMITTER_NAME="avplumber mxl builder" \ +GIT_COMMITTER_EMAIL="avplumber-mxl@local" \ +git commit --quiet -F "$MSG_FILE" \ + --author="avplumber mxl builder " +rm -f "$MSG_FILE" + +log "writing patch to $OUT_FILE" +# Minimal context, like the rest of the series: it lets one copy apply to +# bases that differ only cosmetically around the touched lines. +git format-patch -1 --stdout --unified=1 HEAD >"$OUT_FILE" + +log "done." +log "next: refresh deps/ffmpeg/8/bases.env (patch_count and both trees) from" +log " deps/ffmpeg/verify.sh n8.1 " +log " deps/ffmpeg/verify.sh n8.0 " diff --git a/deps/ffmpeg/mkpatch-fixups/0001-mxldec-monotonic-pts-after-reset.patch b/deps/ffmpeg/mkpatch-fixups/0001-mxldec-monotonic-pts-after-reset.patch new file mode 100644 index 00000000..08d76c45 --- /dev/null +++ b/deps/ffmpeg/mkpatch-fixups/0001-mxldec-monotonic-pts-after-reset.patch @@ -0,0 +1,64 @@ +From 47d1860adaf7824c23ce850128276776ca452b58 Mon Sep 17 00:00:00 2001 +From: avplumber mxl builder +Date: Sun, 20 Sep 2026 15:03:30 +0000 +Subject: [PATCH] avformat/mxldec: keep pts monotonic across a reset_on_drop + reset + +reset_on_drop rebases the pts origin onto the grain the reader resumes +at. At startup that is what we want; mid-stream it drives the derived +pts back to 0 and trips the monotonic-pts av_assert0 a few lines down, +aborting the process. Any reader that falls behind the ring and hits +on_too_late=reset therefore dies instead of resynchronising. + +Rebase to the grain minus the next expected pts, so the first packet +after a reset continues one past the last one delivered while still +collapsing the gap the reset leaves behind. +--- + libavformat/mxldec.c | 24 ++++++++++++++++++++---- + 1 file changed, 20 insertions(+), 4 deletions(-) + +diff --git a/libavformat/mxldec.c b/libavformat/mxldec.c +index 404f0577fd..655ccb5152 100644 +--- a/libavformat/mxldec.c ++++ b/libavformat/mxldec.c +@@ -1278,8 +1278,18 @@ static int read_video_packet(AVFormatContext *s, AVStream *st, AVPacket *pkt, + } + + if (p->reset_on_drop) { +- // presentation timestamp is mxl_start_grain_index relative +- stream_ctx->flow.video.mxl_start_grain_index = stream_ctx->flow.video.mxl_grain_index; ++ // Presentation timestamps are mxl_start_grain_index relative. ++ // Rebase so the next packet continues one past the last one ++ // delivered: rebasing onto the resumed grain itself would send ++ // the pts back to 0 mid-stream and trip the monotonic check ++ // below. last_pts is 0 until the first grain is delivered, so ++ // the start-of-stream origin is unchanged. ++ uint64_t resume_pts = stream_ctx->flow.video.last_pts ? ++ stream_ctx->flow.video.last_pts + 1 : 0; ++ stream_ctx->flow.video.mxl_start_grain_index = ++ stream_ctx->flow.video.mxl_grain_index >= resume_pts ? ++ stream_ctx->flow.video.mxl_grain_index - resume_pts : ++ stream_ctx->flow.video.mxl_grain_index; + } + } + +@@ -1649,8 +1659,14 @@ static int read_audio_packet(AVFormatContext *s, AVStream *st, AVPacket *pkt, + } + + if (p->reset_on_drop) { +- // presentation timestamp is mxl_start_grain_index relative +- stream_ctx->flow.audio.mxl_start_sample_index = stream_ctx->flow.audio.mxl_sample_index; ++ // Same as the video path: keep the pts monotonic across a ++ // mid-stream reset instead of restarting it at 0. ++ uint64_t resume_pts = stream_ctx->flow.audio.last_pts ? ++ stream_ctx->flow.audio.last_pts + 1 : 0; ++ stream_ctx->flow.audio.mxl_start_sample_index = ++ stream_ctx->flow.audio.mxl_sample_index >= resume_pts ? ++ stream_ctx->flow.audio.mxl_sample_index - resume_pts : ++ stream_ctx->flow.audio.mxl_sample_index; + } + } + +-- +2.43.0 + diff --git a/doc/NODES.md b/doc/NODES.md index e60ffd6e..b051d344 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -364,7 +364,14 @@ FPS** (see `force_fps` node) - `dst_height` (int) - `dst_pixel_format` (string) - `flags` (list of strings) - list of possible flags: - + . + One of them must be a scaling algorithm (`SWS_LANCZOS`, + `SWS_FAST_BILINEAR`, ...), because swscale rejects modifier flags on + their own. Unset lets avcpp choose: bicubic when upscaling, area + otherwise. + +Frames that already have the requested dimensions and pixel format are +passed through untouched, saving swscale's full-frame copy. ### `resample_audio` @@ -797,6 +804,20 @@ Parameters: - `sw_format` (string) - CUDA storage, `p210le` (default) or `yuv422p10le` - `color_trc`, `color_primaries`, `colorspace`, `color_range`, `chroma_location` (string) - tags stamped on every output frame (libavutil names, e.g. `arib-std-b67`, `bt2020`, `bt2020nc`, `tv`) +### `cuda_to_v210` + +Packs CUDA 10-bit 4:2:2 frames into headerless `v210` packets with a CUDA kernel, one packet per frame, and DMAs each one into pinned memory the muxer reads directly - so a GPU graph can publish uncompressed video (an MXL flow, an SDI-style feed) without `hwdownload` and without the CPU `v210` encoder. + +The node acts as the encoder for the muxer below it, so no `enc_video` belongs in between. Geometry, frame rate and time base are taken from the nodes above at creation time, because the `output` node opens its format context then; the parameters override them, or stand in when the node above reports none. + +1 input: `av::VideoFrame` (hardware "pixel format" `cuda`, `p210le` or `yuv422p10le`), 1 output: `av::Packet` (`stride * height` bytes each) + +Parameters: +- `width`, `height` (int) - even width, default the geometry reported from above +- `stride` (int) - row pitch in bytes, default the 128-byte aligned v210 pitch +- `fps` (rational string) - stream frame rate, default the frame rate reported from above; required if nothing above reports one, because the MXL muxer derives the flow's grain rate from it +- `timebase` (rational string) - default the time base reported from above, else `1/fps` + ### `cuda_infer_yolo` Run YOLO object detection on preprocessed CUDA frames using a prebuilt TensorRT engine (`.plan` / `.engine`). diff --git a/pyplumber/node.py b/pyplumber/node.py index 3e76b8f3..cbd335ce 100644 --- a/pyplumber/node.py +++ b/pyplumber/node.py @@ -129,6 +129,10 @@ class V210ToCuda(InternalNode): TYPE = "v210_to_cuda" +class CudaToV210(InternalNode): + TYPE = "cuda_to_v210" + + class DecAudio(InternalNode): TYPE = "dec_audio" diff --git a/src/nodes/hwaccel/cuda_to_v210.cpp b/src/nodes/hwaccel/cuda_to_v210.cpp new file mode 100644 index 00000000..6c7c1ca5 --- /dev/null +++ b/src/nodes/hwaccel/cuda_to_v210.cpp @@ -0,0 +1,310 @@ +#include "v210_cuda.hpp" + +#include + +extern "C" { +#include +#include +#include +#include +#include +} + +#include +#include +#include "../../../objs/src/nodes/hwaccel/v210_pack.ptx.h" + +namespace { + +constexpr const char* kNode = "cuda_to_v210"; + +// Download buffers outlive the node: a packet can still sit in a queue, or in +// the muxer, when the graph is torn down. The pool therefore keeps its own +// reference to the CUDA device context, so cuMemFreeHost always runs against a +// live context. +struct PinnedPool { + CUcontext context = nullptr; + AVBufferRef* device_ref = nullptr; + size_t payload = 0; +}; + +void releasePinned(void* opaque, uint8_t* data) { + auto* pool = static_cast(opaque); + if (CHECK_CU(cuCtxPushCurrent(pool->context))) return; + CHECK_CU(cuMemFreeHost(data)); + CUcontext previous; + CHECK_CU(cuCtxPopCurrent(&previous)); +} + +AVBufferRef* allocPinned(void* opaque, size_t size) { + auto* pool = static_cast(opaque); + if (CHECK_CU(cuCtxPushCurrent(pool->context))) return nullptr; + AVBufferRef* buffer = nullptr; + void* data = nullptr; + if (!CHECK_CU(cuMemHostAlloc(&data, size, 0))) { + // Only the payload is written per frame; zero the AVPacket padding once. + std::memset(static_cast(data) + pool->payload, 0, size - pool->payload); + buffer = av_buffer_create(static_cast(data), size, releasePinned, opaque, 0); + if (!buffer) CHECK_CU(cuMemFreeHost(data)); + } + CUcontext previous; + CHECK_CU(cuCtxPopCurrent(&previous)); + return buffer; +} + +void destroyPinnedPool(void* opaque) { + auto* pool = static_cast(opaque); + av_buffer_unref(&pool->device_ref); + delete pool; +} + +struct PacketDeleter { + void operator()(AVPacket* packet) const { av_packet_free(&packet); } +}; + +// Everything the pack needs on the device, released in one place so that a +// failed initialization leaves nothing behind for the next attempt. +struct PackResources { + CUcontext context = nullptr; + CUstream stream = nullptr; // Borrowed: the frames arrive on it already. + CUmodule module = nullptr; + CUfunction kernel = nullptr; + CUdeviceptr packed = 0; + AVBufferPool* pool = nullptr; + // Keeps the CUDA device alive for as long as this node holds resources. + AVBufferRef* device_ref = nullptr; + + void release() { + // Buffers still referenced by packets survive this; the pool waits. + if (pool) av_buffer_pool_uninit(&pool); + if (context && !CHECK_CU(cuCtxPushCurrent(context))) { + if (stream) CHECK_CU(cuStreamSynchronize(stream)); + if (packed) CHECK_CU(cuMemFree(packed)); + if (module) CHECK_CU(cuModuleUnload(module)); + CUcontext previous; + CHECK_CU(cuCtxPopCurrent(&previous)); + } + packed = 0; + module = nullptr; + kernel = nullptr; + stream = nullptr; + context = nullptr; + if (device_ref) av_buffer_unref(&device_ref); + } + + ~PackResources() { release(); } +}; + +} // namespace + +// The mirror of v210_to_cuda: packs CUDA 10-bit 4:2:2 frames into v210 packets +// with a PTX kernel and DMAs each one straight into the buffer the muxer will +// read, which replaces both a hwdownload and the libavcodec v210 encoder. +// +// It implements IEncoder without an encoder: v210 is a memory layout, so the +// muxer only needs codec parameters and a stream rate, and those are known at +// node creation — which is when the output node opens the format context. +class CudaToV210 : public NodeSISO, public IEncoder, + public ReportsFinishByFlag { + v210cuda::Layout layout_; + av::Rational fps_, timebase_; + av::Codec codec_; + AVCodecParameters* codecpar_ = nullptr; + int semiplanar_ = 0; + PackResources gpu_; + + void initialize(const av::VideoFrame& frame) { + const AVFrame* raw = frame.raw(); + if (!raw || !raw->hw_frames_ctx) + throw Error(std::string(kNode) + ": requires CUDA frames with a hardware frames context"); + auto* frames = reinterpret_cast(raw->hw_frames_ctx->data); + if (frames->format != AV_PIX_FMT_CUDA || !v210cuda::isSupportedFormat(frames->sw_format)) + throw Error(std::string(kNode) + ": requires cuda/p210le or cuda/yuv422p10le frames, got " + + av_get_pix_fmt_name(frames->format) + "/" + av_get_pix_fmt_name(frames->sw_format)); + if (!frames->device_ref || !frames->device_ctx || !frames->device_ctx->hwctx) + throw Error(std::string(kNode) + ": frames carry no CUDA device context"); + semiplanar_ = v210cuda::isSemiplanar(frames->sw_format) ? 1 : 0; + + try { + gpu_.device_ref = av_buffer_ref(frames->device_ref); + if (!gpu_.device_ref) throw Error(std::string(kNode) + ": cannot reference the CUDA device"); + auto* device = static_cast(frames->device_ctx->hwctx); + gpu_.context = device->cuda_ctx; + // Pack on the device context's stream. Whatever produced this frame + // used it too, so the ordering needs no extra synchronization. + gpu_.stream = device->stream; + + v210cuda::CurrentContext context(gpu_.context, kNode); + v210cuda::check(cuMemAlloc(&gpu_.packed, layout_.size), kNode, "allocate packed buffer"); + // The kernel writes whole words and never the row padding, so + // zeroing once keeps that padding defined for every frame. + v210cuda::check(cuMemsetD8(gpu_.packed, 0, layout_.size), kNode, "clear packed buffer"); + + auto* pool = new PinnedPool{gpu_.context, av_buffer_ref(gpu_.device_ref), layout_.size}; + gpu_.pool = av_buffer_pool_init2(layout_.size + AV_INPUT_BUFFER_PADDING_SIZE, pool, + allocPinned, destroyPinnedPool); + if (!gpu_.pool) { + destroyPinnedPool(pool); + throw Error(std::string(kNode) + ": cannot create the download buffer pool"); + } + + const std::string image(avpl_v210_pack_ptx, avpl_v210_pack_ptx + avpl_v210_pack_ptx_len); + v210cuda::check(cuModuleLoadDataEx(&gpu_.module, image.c_str(), 0, nullptr, nullptr), + kNode, "load kernel"); + v210cuda::check(cuModuleGetFunction(&gpu_.kernel, gpu_.module, "pack_v210"), kNode, "find kernel"); + } catch (...) { + gpu_.release(); + throw; + } + } + + av::Packet pack(av::VideoFrame& frame) { + AVBufferRef* buffer = av_buffer_pool_get(gpu_.pool); + if (!buffer) throw Error(std::string(kNode) + ": cannot allocate a pinned download buffer"); + std::unique_ptr raw(av_packet_alloc()); + if (!raw) { + av_buffer_unref(&buffer); + throw Error(std::string(kNode) + ": cannot allocate a packet"); + } + // The packet owns the pinned buffer from here, including on failure. + raw->buf = buffer; + raw->data = buffer->data; + raw->size = static_cast(layout_.size); + + { + AVFrame* input = frame.raw(); + void* args[] = {&gpu_.packed, &layout_.stride, &layout_.width, &layout_.height, + &input->data[0], &input->linesize[0], + &input->data[1], &input->linesize[1], + &input->data[2], &input->linesize[2], &semiplanar_}; + const int blocks = (layout_.width + 5) / 6; + v210cuda::CurrentContext context(gpu_.context, kNode); + try { + v210cuda::check(cuLaunchKernel(gpu_.kernel, (blocks + 31) / 32, (layout_.height + 7) / 8, 1, + 32, 8, 1, 0, gpu_.stream, args, nullptr), kNode, "pack"); + // Straight into the packet's payload: the muxer's copy into the + // grain is the only time a CPU touches these bytes. + v210cuda::check(cuMemcpyDtoHAsync(raw->data, gpu_.packed, layout_.size, gpu_.stream), + kNode, "download"); + v210cuda::check(cuStreamSynchronize(gpu_.stream), kNode, "complete frame"); + } catch (...) { + // Do not recycle the buffer while a queued operation writes it. + CHECK_CU(cuStreamSynchronize(gpu_.stream)); + throw; + } + } + + raw->duration = av_rescale_q(frame.raw()->duration, frame.timeBase(), timebase_); + av::Packet packet(raw.get()); // References the pinned payload, no copy. + packet.setTimeBase(timebase_); + packet.setPts(frame.pts()); + packet.setDts(frame.pts()); // Uncompressed: no reordering, every frame a key frame. + packet.setKeyPacket(true); + packet.setComplete(true); + return packet; + } + +public: + CudaToV210(std::unique_ptr&& source, std::unique_ptr&& sink, + const v210cuda::Layout& layout, av::Rational fps, av::Rational timebase) + : NodeSISO(std::move(source), std::move(sink)), layout_(layout), fps_(fps), timebase_(timebase), + codec_(av::findEncodingCodec("v210")) { + codecpar_ = avcodec_parameters_alloc(); + if (!codecpar_) throw Error(std::string(kNode) + ": cannot allocate codec parameters"); + codecpar_->codec_type = AVMEDIA_TYPE_VIDEO; + codecpar_->codec_id = AV_CODEC_ID_V210; + codecpar_->codec_tag = MKTAG('v', '2', '1', '0'); + codecpar_->width = layout_.width; + codecpar_->height = layout_.height; + // The layout libavcodec's v210 decoder produces, which is what a reader + // of these packets gets; the packets themselves are packed, not planar. + codecpar_->format = AV_PIX_FMT_YUV422P10LE; + codecpar_->bits_per_coded_sample = 20; + codecpar_->bits_per_raw_sample = 10; + codecpar_->field_order = AV_FIELD_PROGRESSIVE; + // EOF has to reach the muxer as a packet, so this node consumes the + // marker itself instead of letting the framework swallow it. + this->auto_eof_ = false; + } + + ~CudaToV210() override { + if (codecpar_) avcodec_parameters_free(&codecpar_); + } + + av::Codec& encodingCodec() override { return codec_; } + AVCodecParameters* codecParameters() override { return codecpar_; } + + void setOutput(av::Stream& stream, av::FormatContext& octx) override { + if (!codec_.isNull() && !octx.outputFormat().codecSupported(codec_)) + throw Error(std::string(kNode) + ": codec v210 not supported by container " + + octx.outputFormat().name()); + stream.setTimeBase(timebase_); + // The MXL muxer reads the flow's grain rate off the stream, so leaving + // the frame rate unset would publish a flow nothing can consume. + stream.setAverageFrameRate(fps_); + stream.setFrameRate(fps_); + if (avcodec_parameters_copy(stream.raw()->codecpar, codecpar_) < 0) + throw Error(std::string(kNode) + ": cannot set the stream codec parameters"); + } + + void process() override { + av::VideoFrame frame = source_->get(); + if (frame.isNull()) return; // Input queue interrupted during shutdown. + if (isEofMarker(frame)) { + onEofConsumed(); + sink_->put(createEofPacket()); + markFinished(); + return; + } + if (frame.pixelFormat().get() != AV_PIX_FMT_CUDA) + throw Error(std::string(kNode) + ": requires CUDA frames, got " + frame.pixelFormat().name()); + if (frame.width() != layout_.width || frame.height() != layout_.height) + throw Error(std::string(kNode) + ": input geometry changed, expected " + + std::to_string(layout_.width) + "x" + std::to_string(layout_.height)); + if (frame.timeBase().getNumerator() <= 0 || frame.timeBase().getDenominator() <= 0) + throw Error(std::string(kNode) + ": input frame needs a positive time base"); + if (!gpu_.kernel) initialize(frame); + sink_->put(pack(frame)); + } + + static std::shared_ptr create(NodeCreationInfo& nci) { + const Parameters& params = nci.params; + auto src_edge = nci.edges.find(params["src"]); + auto dst_edge = nci.edges.find(params["dst"]); + // The muxer asks for geometry, rate and time base while the output node + // is still being created, so none of them can wait for the first frame. + // Parameters override the upstream metadata, and stand in for it when a + // node that reports none sits above. + int width = 0, height = 0; + if (params.count("width") && params.count("height")) { + width = params.at("width").get(); + height = params.at("height").get(); + } else if (auto video = src_edge->findNodeUp()) { + width = video->width(); + height = video->height(); + } else { + throw Error(std::string(kNode) + ": no upstream video format, pass width and height"); + } + av::Rational fps; + if (params.count("fps")) { + fps = v210cuda::positiveRatio(params.at("fps"), kNode); + } else if (auto rate = src_edge->findNodeUp()) { + fps = rate->frameRate(); + } + if (fps.getNumerator() <= 0 || fps.getDenominator() <= 0) + throw Error(std::string(kNode) + ": no upstream frame rate, pass fps"); + av::Rational timebase; + if (params.count("timebase")) { + timebase = v210cuda::positiveRatio(params.at("timebase"), kNode); + } else if (auto tb = src_edge->findNodeUp()) { + timebase = tb->timeBase(); + } + if (timebase.getNumerator() <= 0 || timebase.getDenominator() <= 0) + timebase = av::Rational(fps.getDenominator(), fps.getNumerator()); + const auto layout = v210cuda::makeLayout(kNode, width, height, params.value("stride", int64_t(0))); + return std::make_shared(src_edge->makeSource(), dst_edge->makeSink(), + layout, fps, timebase); + } +}; + +DECLNODE(cuda_to_v210, CudaToV210) diff --git a/src/nodes/hwaccel/v210_cuda.hpp b/src/nodes/hwaccel/v210_cuda.hpp new file mode 100644 index 00000000..2d6caf06 --- /dev/null +++ b/src/nodes/hwaccel/v210_cuda.hpp @@ -0,0 +1,88 @@ +#pragma once +// Shared by the two halves of the packed-v210 GPU bridge, v210_to_cuda and +// cuda_to_v210: both talk to the driver API directly, and both have to agree +// on what a v210 frame looks like in memory. +#include "../node_common.hpp" +#include "../../cuda.hpp" + +extern "C" { +#include +} + +#include +#include + +namespace v210cuda { + +// The node name is part of the message because a round trip runs both nodes in +// one process, and a driver error otherwise says nothing about which end failed. +inline void check(CUresult result, const char* node, const char* operation) { + if (result == CUDA_SUCCESS) return; + const char* description = nullptr; + if (cuGetErrorString) cuGetErrorString(result, &description); + throw Error(std::string(node) + ": " + operation + ": " + + (description ? description : std::to_string(result))); +} + +class CurrentContext { +public: + CurrentContext(CUcontext context, const char* node) { + check(cuCtxPushCurrent(context), node, "push context"); + } + ~CurrentContext() { + CUcontext previous; + CHECK_CU(cuCtxPopCurrent(&previous)); + } + CurrentContext(const CurrentContext&) = delete; + CurrentContext& operator=(const CurrentContext&) = delete; +}; + +// Packed v210 carries no header, so a frame is exactly stride * height bytes. +struct Layout { + int width; + int height; + int stride; + size_t size; +}; + +// Six pixels per 16 bytes, rows padded to 128 bytes: the pitch that SDI-style +// hardware and the libavcodec v210 codecs use. +inline int defaultStride(int width) { + return ((width + 47) / 48) * 128; +} + +// A non-positive stride means "use the default". The minimum is the packed +// width alone, which is what libavcodec writes before it pads the row out. +inline Layout makeLayout(const char* node, int width, int height, int64_t stride) { + if (width <= 0 || height <= 0 || width % 2 || + av_image_check_size(width, height, 0, nullptr) < 0) + throw Error(std::string(node) + ": requires valid dimensions and an even width"); + if (stride <= 0) stride = defaultStride(width); + const int64_t minimum_stride = ((int64_t(width) * 2 + 2) / 3) * 4; + if (stride < minimum_stride || stride % 4 || + stride > std::numeric_limits::max() / height) + throw Error(std::string(node) + ": stride must be a multiple of four, fit a v210 row and an AVPacket"); + return Layout{width, height, static_cast(stride), size_t(stride) * height}; +} + +// Frame rates, time bases and aspect ratios all come in as "num/den" strings, +// and a zero in either half would silently poison every timestamp downstream. +inline av::Rational positiveRatio(const std::string& value, const char* node) { + auto ratio = parseRatio(value); + if (ratio.getNumerator() <= 0 || ratio.getDenominator() <= 0) + throw Error(std::string(node) + ": ratios must be positive"); + return ratio; +} + +// The two ways a 10-bit 4:2:2 frame is stored on the GPU without resampling: +// semiplanar p210le, which keeps samples in the high bits, and planar +// yuv422p10le. Both kernels take the choice as a flag. +inline bool isSupportedFormat(AVPixelFormat format) { + return format == AV_PIX_FMT_P210LE || format == AV_PIX_FMT_YUV422P10LE; +} + +inline bool isSemiplanar(AVPixelFormat format) { + return format == AV_PIX_FMT_P210LE; +} + +} // namespace v210cuda diff --git a/src/nodes/hwaccel/v210_pack.cu b/src/nodes/hwaccel/v210_pack.cu new file mode 100644 index 00000000..4fd9507d --- /dev/null +++ b/src/nodes/hwaccel/v210_pack.cu @@ -0,0 +1,62 @@ +#include +#include + +// 0..3 and 1020..1023 are reserved for SDI sync words, so v210 excludes them. +// The libavcodec v210 encoder clamps the same way, which keeps the two packers +// byte-for-byte comparable. +static __device__ __forceinline__ uint32_t clip10(uint32_t sample) +{ + return min(max(sample, 4u), 1019u); +} + +// One thread owns a v210 block: six 4:2:2 pixels, twelve 10-bit samples, four +// little-endian words of three samples each. A block never shares a word with +// its neighbours, so the packing needs no cooperation between threads. +// +// A partial block at the right edge pads its missing samples with zero and +// leaves the words past the end of the row unwritten, which is what libavcodec +// does; the caller has already zeroed the row padding. +extern "C" __global__ void pack_v210( + uint8_t* output, int output_pitch, int width, int height, + const uint8_t* input_y, int y_pitch, const uint8_t* input_u, int u_pitch, + const uint8_t* input_v, int v_pitch, int semiplanar) +{ + const int block = blockIdx.x * blockDim.x + threadIdx.x; + const int row = blockIdx.y * blockDim.y + threadIdx.y; + if (block >= (width + 5) / 6 || row >= height) return; + + const uint16_t* y = reinterpret_cast( + input_y + static_cast(row) * y_pitch); + const uint16_t* u = reinterpret_cast( + input_u + static_cast(row) * u_pitch); + const uint16_t* v = reinterpret_cast( + input_v + static_cast(row) * v_pitch); + // p210le keeps its samples in the high bits of each 16-bit word. + const unsigned shift = semiplanar ? 6 : 0; + + uint32_t sample[12]; + for (int pair = 0; pair < 3; pair++) { + // Sample order within a block is U Y V Y per pixel pair, and the width + // is even, so one guard covers both luma samples. + const int x = block * 6 + pair * 2; + uint32_t* s = sample + pair * 4; + if (x >= width) { + s[0] = s[1] = s[2] = s[3] = 0; + continue; + } + const int chroma = semiplanar ? x : x / 2; + s[0] = clip10(u[chroma] >> shift); + s[1] = clip10(y[x] >> shift); + s[2] = clip10((semiplanar ? u[chroma + 1] : v[chroma]) >> shift); + s[3] = clip10(y[x + 1] >> shift); + } + + uint32_t* dst = reinterpret_cast( + output + static_cast(row) * output_pitch) + block * 4; + const int pixels = min(6, width - block * 6); + const int words = (pixels * 2 + 2) / 3; // three samples per word, last one partial + for (int word = 0; word < words; word++) { + dst[word] = sample[word * 3] | (sample[word * 3 + 1] << 10) | + (sample[word * 3 + 2] << 20); + } +} diff --git a/src/nodes/hwaccel/v210_to_cuda.cpp b/src/nodes/hwaccel/v210_to_cuda.cpp index d01e3ae2..5a9da91c 100644 --- a/src/nodes/hwaccel/v210_to_cuda.cpp +++ b/src/nodes/hwaccel/v210_to_cuda.cpp @@ -1,38 +1,22 @@ -#include "../node_common.hpp" -#include "../../cuda.hpp" +#include "v210_cuda.hpp" #include "../../hwaccel.hpp" extern "C" { #include -#include #include } #include -#include #include "../../../objs/src/nodes/hwaccel/v210_unpack.ptx.h" namespace { +constexpr const char* kNode = "v210_to_cuda"; + void checkCuda(CUresult result, const char* operation) { - if (result == CUDA_SUCCESS) return; - const char* description = nullptr; - if (cuGetErrorString) cuGetErrorString(result, &description); - throw Error(std::string("v210_to_cuda: ") + operation + ": " + - (description ? description : std::to_string(result))); + v210cuda::check(result, kNode, operation); } -class CurrentContext { -public: - explicit CurrentContext(CUcontext context) { checkCuda(cuCtxPushCurrent(context), "push context"); } - ~CurrentContext() { - CUcontext previous; - CHECK_CU(cuCtxPopCurrent(&previous)); - } - CurrentContext(const CurrentContext&) = delete; - CurrentContext& operator=(const CurrentContext&) = delete; -}; - struct FramePoolDeleter { void operator()(AVBufferRef* ref) const { av_buffer_unref(&ref); } }; @@ -63,10 +47,7 @@ struct UploadResources { }; av::Rational positiveRatio(const std::string& value) { - auto ratio = parseRatio(value); - if (ratio.getNumerator() <= 0 || ratio.getDenominator() <= 0) - throw Error("v210_to_cuda: ratios must be positive"); - return ratio; + return v210cuda::positiveRatio(value, kNode); } int colorOption(const Parameters& params, const char* name, int fallback, @@ -82,8 +63,7 @@ int colorOption(const Parameters& params, const char* name, int fallback, class V210ToCuda : public NodeSISO, public ReportsFinishByFlag, public IVideoFormatSource, public IFrameRateSource, public ITimeBaseSource { - int width_, height_, stride_; - size_t packed_size_; + v210cuda::Layout layout_; AVPixelFormat format_; av::Rational fps_, timebase_, aspect_; AVColorRange range_; @@ -100,15 +80,15 @@ class V210ToCuda : public NodeSISO, public ReportsFi throw Error("v210_to_cuda: requires an initialized CUDA hwaccel device"); auto* device = reinterpret_cast(device_->deviceContext()->data); gpu_.context = static_cast(device->hwctx)->cuda_ctx; - CurrentContext context(gpu_.context); + v210cuda::CurrentContext context(gpu_.context, kNode); gpu_.frames.reset(av_hwframe_ctx_alloc(device_->deviceContext())); if (!gpu_.frames) throw Error("v210_to_cuda: cannot allocate CUDA frame pool"); auto* frames = reinterpret_cast(gpu_.frames->data); frames->format = AV_PIX_FMT_CUDA; frames->sw_format = format_; - frames->width = width_; - frames->height = height_; + frames->width = layout_.width; + frames->height = layout_.height; const int result = av_hwframe_ctx_init(gpu_.frames.get()); if (result < 0) throw Error("v210_to_cuda: CUDA output format requires FFmpeg 8.1 support: " + @@ -117,8 +97,8 @@ class V210ToCuda : public NodeSISO, public ReportsFi // CU_STREAM_NON_BLOCKING is absent from the bundled dynlink declarations. constexpr unsigned kNonBlockingStream = 0x1; checkCuda(cuStreamCreate(&gpu_.stream, kNonBlockingStream), "create stream"); - checkCuda(cuMemAlloc(&gpu_.packed, packed_size_), "allocate packed buffer"); - checkCuda(cuMemHostAlloc(&gpu_.staging, packed_size_, 0), "allocate upload staging"); + checkCuda(cuMemAlloc(&gpu_.packed, layout_.size), "allocate packed buffer"); + checkCuda(cuMemHostAlloc(&gpu_.staging, layout_.size, 0), "allocate upload staging"); const std::string module(avpl_v210_unpack_ptx, avpl_v210_unpack_ptx + avpl_v210_unpack_ptx_len); checkCuda(cuModuleLoadDataEx(&gpu_.module, module.c_str(), 0, nullptr, nullptr), "load kernel"); @@ -129,7 +109,9 @@ class V210ToCuda : public NodeSISO, public ReportsFi V210ToCuda(std::unique_ptr&& source, std::unique_ptr&& sink, const Parameters& params, std::shared_ptr device) : NodeSISO(std::move(source), std::move(sink)), - width_(params.at("width").get()), height_(params.at("height").get()), + layout_(v210cuda::makeLayout(kNode, params.at("width").get(), + params.at("height").get(), + params.value("stride", int64_t(0)))), fps_(positiveRatio(params.at("fps"))), timebase_(params.count("timebase") ? positiveRatio(params.at("timebase")) : av::Rational(fps_.getDenominator(), fps_.getNumerator())), @@ -140,21 +122,9 @@ class V210ToCuda : public NodeSISO, public ReportsFi transfer_(static_cast(colorOption(params, "color_trc", AVCOL_TRC_UNSPECIFIED, av_color_transfer_from_name))), chroma_(static_cast(colorOption(params, "chroma_location", AVCHROMA_LOC_UNSPECIFIED, av_chroma_location_from_name))), device_(std::move(device)) { - if (width_ <= 0 || height_ <= 0 || width_ % 2 || - av_image_check_size(width_, height_, 0, nullptr) < 0) - throw Error("v210_to_cuda: requires valid dimensions and an even width"); - const int64_t minimum_stride = ((int64_t(width_) * 2 + 2) / 3) * 4; - const int64_t stride = params.value("stride", ((int64_t(width_) + 47) / 48) * 128); - if (stride < minimum_stride || stride % 4 || - stride > std::numeric_limits::max() / height_) - throw Error("v210_to_cuda: stride must be a multiple of four, fit a v210 row and an AVPacket"); - stride_ = static_cast(stride); - packed_size_ = size_t(stride_) * height_; - const auto format = params.value("sw_format", std::string("p210le")); - if (format != "p210le" && format != "yuv422p10le") + format_ = av_get_pix_fmt(params.value("sw_format", std::string("p210le")).c_str()); + if (!v210cuda::isSupportedFormat(format_)) throw Error("v210_to_cuda: sw_format must be p210le or yuv422p10le"); - format_ = av_get_pix_fmt(format.c_str()); - if (format_ == AV_PIX_FMT_NONE) throw Error("v210_to_cuda: output pixel format unavailable"); } void process() override { @@ -165,7 +135,7 @@ class V210ToCuda : public NodeSISO, public ReportsFi markFinished(); return; } - if (packet.size() != packed_size_ || !packet.data()) + if (packet.size() != layout_.size || !packet.data()) throw Error("v210_to_cuda: expected exactly one stride * height packed frame per packet"); if (!packet.pts().isValid() || packet.timeBase().getNumerator() <= 0 || packet.timeBase().getDenominator() <= 0) @@ -173,21 +143,21 @@ class V210ToCuda : public NodeSISO, public ReportsFi av::VideoFrame output; { - CurrentContext context(gpu_.context); + v210cuda::CurrentContext context(gpu_.context, kNode); const int result = av_hwframe_get_buffer(gpu_.frames.get(), output.raw(), 0); if (result < 0) throw Error("v210_to_cuda: cannot allocate output: " + av::error2string(result)); // Copy bytes only. Unpacking is entirely on the GPU; staging allows // arbitrary AVPacket/MXL host buffers without registering their pages. - std::memcpy(gpu_.staging, packet.data(), packed_size_); + std::memcpy(gpu_.staging, packet.data(), layout_.size); auto* frame = output.raw(); - int semiplanar = format_ == AV_PIX_FMT_P210LE; - void* args[] = {&gpu_.packed, &stride_, &width_, &height_, + int semiplanar = v210cuda::isSemiplanar(format_); + void* args[] = {&gpu_.packed, &layout_.stride, &layout_.width, &layout_.height, &frame->data[0], &frame->linesize[0], &frame->data[1], &frame->linesize[1], &frame->data[2], &frame->linesize[2], &semiplanar}; try { - checkCuda(cuMemcpyHtoDAsync(gpu_.packed, gpu_.staging, packed_size_, gpu_.stream), "upload"); - checkCuda(cuLaunchKernel(gpu_.kernel, (width_ / 2 + 31) / 32, (height_ + 7) / 8, 1, + checkCuda(cuMemcpyHtoDAsync(gpu_.packed, gpu_.staging, layout_.size, gpu_.stream), "upload"); + checkCuda(cuLaunchKernel(gpu_.kernel, (layout_.width / 2 + 31) / 32, (layout_.height + 7) / 8, 1, 32, 8, 1, 0, gpu_.stream, args, nullptr), "unpack"); checkCuda(cuStreamSynchronize(gpu_.stream), "complete frame"); } catch (...) { @@ -211,8 +181,8 @@ class V210ToCuda : public NodeSISO, public ReportsFi sink_->put(output); } - int width() override { return width_; } - int height() override { return height_; } + int width() override { return layout_.width; } + int height() override { return layout_.height; } av::PixelFormat pixelFormat() override { return av::PixelFormat(AV_PIX_FMT_CUDA); } av::PixelFormat realPixelFormat() override { return av::PixelFormat(format_); } av::Rational frameRate() override { return fps_; } diff --git a/src/nodes/input.cpp b/src/nodes/input.cpp index ede4a759..eaaa2234 100644 --- a/src/nodes/input.cpp +++ b/src/nodes/input.cpp @@ -174,6 +174,12 @@ class StreamInput: public NodeSingleOutput, public IStreamsInput, pu } setTimeout(initial_timeout); ictx_.openInput(params["url"], opts, ifmt); + if (opts.count() > 0) { + for (auto &entry : opts) { + logstream << "WARNING: input option not consumed by libavformat: " + << entry.key() << "=" << entry.value(); + } + } ictx_.findStreamInfo(); logstream << "Opened URL " << params["url"] << " . Streams:"; for (unsigned i=0; i, public IStreamsInput, } ictx_.openInput(params["url"], opts, ifmt); + if (opts.count() > 0) { + for (auto &entry : opts) { + logstream << "WARNING: input option not consumed by libavformat: " + << entry.key() << "=" << entry.value(); + } + } ictx_.findStreamInfo(); logstream << "Opened URL " << params["url"] << " . Streams:"; for (unsigned i=0; i, publi //av::Rational timebase_ = {0, 1}; //av::Rational frame_rate_ = {0, 1}; int32_t sws_flags_; + bool passthrough_ = false; bool sourceChanged(const av::VideoFrame &frame) { return src_params_ != VideoParameters(frame); } void createRescaler() { + const int dst_width = (dst_params_.width > 0) ? dst_params_.width : src_params_.width; + const int dst_height = (dst_params_.height > 0) ? dst_params_.height : src_params_.height; + // Converting a frame to the geometry and format it already has still + // costs swscale a full-frame copy, which at 1080p59.94 10-bit 4:2:2 is + // 1 ms and 8 MB per frame. Pass such frames on untouched: edges carry + // reference-counted frames, so nothing downstream can tell. + passthrough_ = (dst_width == src_params_.width) && (dst_height == src_params_.height) && + (dst_params_.pixel_format == src_params_.pixel_format); + if (passthrough_) { + rescaler_.reset(); + return; + } + // No flags means av::SwsFlagAuto: avcpp then picks bicubic when + // upscaling and area otherwise. A flags list must therefore name a + // scaling algorithm, because swscale rejects modifier flags alone. rescaler_ = make_unique( - (dst_params_.width > 0) ? dst_params_.width : src_params_.width, - (dst_params_.height > 0) ? dst_params_.height : src_params_.height, - dst_params_.pixel_format, - src_params_.width, src_params_.height, src_params_.pixel_format); + dst_width, dst_height, dst_params_.pixel_format, + src_params_.width, src_params_.height, src_params_.pixel_format, + sws_flags_); } public: virtual void process() { @@ -39,6 +54,10 @@ class DynamicVideoScaler: public NodeSISO, publi src_params_ = VideoParameters(in_frame); createRescaler(); } + if (passthrough_) { + this->sink_->put(in_frame); + return; + } //av::VideoFrame out_frame; //rescaler_->rescale(out_frame, in_frame); av::VideoFrame out_frame = rescaler_->rescale(in_frame, av::throws());