From e6957ce51f16a6d24ab9315a4d16e0653f8101d7 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Wed, 9 Sep 2026 15:06:37 +0200 Subject: [PATCH 01/19] Add the MXL demuxer/muxer patch to the FFmpeg 8.x series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8/0011-avformat-libmxl-demuxer-muxer.patch, squashed from the two MXL commits on cbcrc/FFmpeg branch dmf-mxl/8.1 (pinned at 9eddb90). That branch forks from the same n8.1 commit deps/ffmpeg/8/bases.env pins, so both picks apply without conflicts — unlike the earlier 7.1.5-based version of this patch, which force-fitted an 8.x codebase onto 7.1.5 with -X theirs and dragged fork test scaffolding along. The configure hunks are re-anchored so one copy serves both bases: the fork puts its `require_pkg_config libmxl` next to libmpeghdec, which only exists in 8.1, and carries a whitespace-only reindent of the mmal check. The generator moves the require next to the mxl_* deps lines and drops the reindent; verify.sh now reproduces the pinned tree for both n8.0 and n8.1 with patch_count=11. The generator lives at deps/ffmpeg/Dockerfile.mkpatch and needs no compiler — it replays git history only, so it runs on macOS hosts too. MXL_REMOTE_REF/MXL_PIN/FFMPEG_TAG move it to a future base (the fork already carries a dmf-mxl/9.0 branch). Co-Authored-By: Claude Opus 5 --- .../0011-avformat-libmxl-demuxer-muxer.patch | 14047 ++++++++++++++++ deps/ffmpeg/8/bases.env | 4 +- deps/ffmpeg/Dockerfile.mkpatch | 34 + deps/ffmpeg/README.md | 29 + deps/ffmpeg/mkpatch-0011-mxl.sh | 146 + deps/ffmpeg/mkpatch-finish.sh | 87 + 6 files changed, 14345 insertions(+), 2 deletions(-) create mode 100644 deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch create mode 100644 deps/ffmpeg/Dockerfile.mkpatch create mode 100755 deps/ffmpeg/mkpatch-0011-mxl.sh create mode 100755 deps/ffmpeg/mkpatch-finish.sh 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..1debb052 --- /dev/null +++ b/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch @@ -0,0 +1,14047 @@ +From 7b03b2fa7bd85259cd3c0c7392e69a2d04a5597f Mon Sep 17 00:00:00 2001 +From: avplumber mxl builder +Date: Wed, 16 Sep 2026 19:47:16 +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: + * ec72cbaefc ffmpeg 8.1 port + * 196ba57d71 Apply FFmpeg 8 MXL integration to FFmpeg 8.1 + +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 | 1974 +++++++++++++++++++++++++++++++ + 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, 13693 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 82cdbd27ac..12698a2d27 100755 +--- a/configure ++++ b/configure +@@ -2066,2 +2066,3 @@ EXTERNAL_LIBRARY_LIST=" + libmp3lame ++ libmxl + libmysofa +@@ -3853,2 +3854,4 @@ libxvid_encoder_deps="libxvid" + libzvbi_teletext_decoder_deps="libzvbi" ++mxl_muxer_deps="libmxl swresample" ++mxl_demuxer_deps="libmxl swresample" + vapoursynth_demuxer_deps="vapoursynth" +@@ -7474,2 +7477,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..404f0577fd +--- /dev/null ++++ b/libavformat/mxldec.c +@@ -0,0 +1,1974 @@ ++/* ++ * 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 timestamp is mxl_start_grain_index relative ++ stream_ctx->flow.video.mxl_start_grain_index = 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) { ++ // presentation timestamp is mxl_start_grain_index relative ++ stream_ctx->flow.audio.mxl_start_sample_index = 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..4c4cfd75 100644 --- a/deps/ffmpeg/8/bases.env +++ b/deps/ffmpeg/8/bases.env @@ -1,5 +1,5 @@ n80_commit=140fd653aed8cad774f991ba083e2d01e86420c7 n80_tree=1b83263bd2f67f590a3434864e6b0caf24a65f20 n81_commit=9047fa1b084f76b1b4d065af2d743df1b40dfb56 -n81_tree=f0ca321865b3505b0f6c6b483bbdc14d772f95de -patch_count=10 +n81_tree=e281422964996db696f00c63ef0b287cf94e07de +patch_count=11 diff --git a/deps/ffmpeg/Dockerfile.mkpatch b/deps/ffmpeg/Dockerfile.mkpatch new file mode 100644 index 00000000..ef962352 --- /dev/null +++ b/deps/ffmpeg/Dockerfile.mkpatch @@ -0,0 +1,34 @@ +# 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 +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..23442806 100644 --- a/deps/ffmpeg/README.md +++ b/deps/ffmpeg/README.md @@ -51,6 +51,35 @@ 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`), which forks from the same + n8.1 commit `8/bases.env` pins. Consumed by `demos/mixer/Dockerfile` for + `demos/mxl`; the MXL SDK itself is pinned in that Dockerfile. + +### 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 +``` + +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..cc333604 --- /dev/null +++ b/deps/ffmpeg/mkpatch-0011-mxl.sh @@ -0,0 +1,146 @@ +#!/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}" +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 + +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 " From 466e56b9ec6089cf4b1d20c511119adf280a5e47 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Wed, 9 Sep 2026 15:06:57 +0200 Subject: [PATCH 02/19] Add MXL round-trip demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds demos/mxl/, a demo that publishes into and reads back from an MXL shared-memory domain through avplumber's generic input/output nodes (format="mxl"), with no MXL-specific node types. The Dockerfile mirrors demos/mixer/Dockerfile: bases on nvidia/cuda:11.7.1-devel-ubuntu22.04, installs gcc-13 (required by the MXL SDK) alongside the distro gcc-11, brings up vcpkg + Rust 1.88.0, builds libmxl v1.1.0-beta-1, applies the FFmpeg patch series, and configures FFmpeg with the CUDA composition suite plus --enable-libmxl --enable-demuxer=mxl --enable-muxer=mxl --enable-protocol=mxl. avplumber's python_module is built with HAVE_CUDA=1 HAVE_NVCC=1 so the same image also runs mixer-class workloads on real NVIDIA hosts. The demo runs without a GPU too — CUDA init fails silently, MXL round-trip works. mxl_demo.py builds two graphs in one process: writer: lavfi testsrc -> decode -> rescale(yuv422p10le) -> AssumeVideoFormat -> enc(v210) -> mux -> Output(format="mxl") reader: Input(format="mxl") -> demux -> dec(v210) -> rescale(yuv420p) -> AssumeVideoFormat -> enc(mpeg4) -> mux -> fragmented mp4 Verified end-to-end on colima (aarch64, no GPU): the reader produces 28k+ mpeg4 packets of the testsrc pattern in a 10-second run. The fragmented-mp4 output stays playable on SIGINT. Known gaps (documented in demos/mxl/README.md): * options={"blocking":"1"} does not currently reach the MXL demuxer's private AVOptions through avplumber's input node; worked around with auto_restart:"group" on the reader input. * Reader runs at wall-clock max — a RealtimeVideoFrame node between decode and encode would cap it to the source frame rate. Co-Authored-By: Claude Opus 4.7 --- demos/README.md | 3 + demos/mxl/Dockerfile | 218 +++++++++++++++++++++++++ demos/mxl/README.md | 123 ++++++++++++++ demos/mxl/mxl_demo.py | 299 ++++++++++++++++++++++++++++++++++ demos/mxl/requirements.txt | 2 + demos/mxl/test-media/.gitkeep | 0 6 files changed, 645 insertions(+) create mode 100644 demos/mxl/Dockerfile create mode 100644 demos/mxl/README.md create mode 100644 demos/mxl/mxl_demo.py create mode 100644 demos/mxl/requirements.txt create mode 100644 demos/mxl/test-media/.gitkeep diff --git a/demos/README.md b/demos/README.md index 80af485d..beead22a 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 a file into an MXL shared-memory + domain and read it back. Linux-only, no GPU required, but depends on the + pending `0008-avformat-libmxl-demuxer-muxer.patch`. diff --git a/demos/mxl/Dockerfile b/demos/mxl/Dockerfile new file mode 100644 index 00000000..2ff2a7d7 --- /dev/null +++ b/demos/mxl/Dockerfile @@ -0,0 +1,218 @@ +# MXL demo build image (NVIDIA-enabled). +# +# Mirrors ../mixer/Dockerfile: builds patched FFmpeg on top of an +# NVIDIA CUDA base image and produces avplumber's python_module with +# CUDA support. Adds libmxl SDK and the 0008 MXL patch so the same +# image can also run the MXL demo. Once this proves stable it can +# fold into demos/mixer/Dockerfile itself. + +FROM nvidia/cuda:11.7.1-devel-ubuntu22.04 + +ARG DEBIAN_FRONTEND=noninteractive +ARG AVPLUMBER_REVISION=workspace +ARG FFMPEG_TAG=n7.1.5 +ARG MXL_TAG=v1.1.0-beta-1 +ARG NV_CODEC_HEADERS_TAG=n12.1.14.0 + +# gcc-13 is required by the MXL SDK (C++20 features + spdlog headers). +# Ubuntu 22.04 default is gcc-11, so pull gcc-13 from the ubuntu +# toolchain PPA and expose it under a distinct name. FFmpeg and +# avplumber keep using the distro's default 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/* + +# --- nv-codec-headers (headers-only, required by 0002 CUDA suite) ------- +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 + +# Ubuntu 22.04 ships cmake 3.22 but MXL requires >= 3.24. Upgrade via +# pip — installs a modern cmake wheel to /usr/local/bin ahead of the +# distro's /usr/bin/cmake so both MXL and FFmpeg pick it up. +RUN python3 -m pip install --no-cache-dir --upgrade 'cmake>=3.28' + +# --- Rust (needed by MXL's ada-url dep pulled through vcpkg) ------------ +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 (MXL'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 +# still use the distro gcc-11 that the CUDA suite is validated with. +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 + +# --- Patched FFmpeg (full 8-patch stack, with CUDA + MXL) --------------- +COPY deps/ffmpeg-patches /build/deps/ffmpeg-patches + +RUN git clone --quiet --branch "${FFMPEG_TAG}" --depth 1 \ + https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg \ + && git -C /tmp/ffmpeg config user.name "mxl-demo builder" \ + && git -C /tmp/ffmpeg config user.email "mxl-demo@local" \ + && git -C /tmp/ffmpeg am /build/deps/ffmpeg-patches/*.patch \ + # 0008 was cherry-picked with -X theirs and pulled in a couple of + # test scaffolding lines that reference files not present in + # n7.1.5. Strip them so `make` succeeds. + && sed -i '/tests\/fate\/ogg-\(opus\|flac\|vorbis\)\.mak/d' /tmp/ffmpeg/tests/Makefile \ + && sed -i '/^# uri test if demuxer is enabled$/,+1d' /tmp/ffmpeg/tests/fate/mxl.mak + +RUN cd /tmp/ffmpeg \ + && PKG_CONFIG_PATH=/usr/local/lib/pkgconfig \ + ./configure \ + --prefix=/usr/local \ + --libdir=/usr/local/lib \ + --incdir=/usr/local/include \ + --pkgconfigdir=/usr/local/lib/pkgconfig \ + --enable-gpl \ + --enable-nonfree \ + --enable-shared \ + --disable-static \ + --enable-cuda \ + --enable-cuda-nvcc \ + --enable-cuvid \ + --enable-ffnvcodec \ + --enable-nvdec \ + --enable-nvenc \ + --disable-libnpp \ + --enable-libmxl \ + --enable-demuxer=mxl \ + --enable-muxer=mxl \ + --enable-protocol=mxl \ + --disable-doc \ + --disable-htmlpages \ + --disable-manpages \ + --disable-podpages \ + --disable-txtpages \ + --extra-cflags=-I/usr/local/cuda/include \ + --extra-ldflags=-L/usr/local/cuda/lib64 \ + --extra-ldexeflags=-Wl,--disable-new-dtags,-rpath,/usr/local/lib \ + --nvcc=/usr/local/cuda/bin/nvcc \ + --nvccflags='-gencode arch=compute_70,code=compute_70 -O2' \ + && make -j"${BUILD_JOBS:-2}" \ + && make install \ + && ldconfig \ + && rm -rf /tmp/ffmpeg + +# Assertions: both the CUDA composition suite AND MXL must be present. +RUN /usr/local/bin/ffmpeg -hide_banner -filters | grep -q ' overlay_many_cuda ' \ + && /usr/local/bin/ffmpeg -hide_banner -filters \ + | awk '$2 == "transition_cuda" && $1 ~ /C/ { found = 1 } END { exit !found }' \ + && /usr/local/bin/ffmpeg -hide_banner -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 }' + +# --- avplumber (with CUDA) --------------------------------------------- +WORKDIR /build + +COPY deps /build/deps +COPY Makefile generate_node_list /build/ +COPY src /build/src +COPY pyplumber /build/pyplumber +COPY avpmixer /build/avpmixer + +RUN git init --quiet /build \ + && git -C /build config user.name "mxl-demo builder" \ + && git -C /build config user.email "mxl-demo@local" \ + && git -C /build commit --quiet --allow-empty -m "workspace ${AVPLUMBER_REVISION}" + +RUN make -j"${BUILD_JOBS:-2}" python_module \ + BUILD_TYPE=Release \ + HAVE_CUDA=1 \ + HAVE_DRM=0 \ + HAVE_GL=0 \ + HAVE_NVOF_FRUC=0 \ + HAVE_NVCC=1 \ + HAVE_NVJPEG=0 \ + HAVE_TENSORRT=0 \ + NEURAL_NET=0 \ + CUDA_MODULE_IMAGE_FORMAT=ptx \ + NVCC=/usr/local/cuda/bin/nvcc \ + CUDA_ROOT=/usr/local/cuda \ + PKG_CONFIG_PATH=/usr/local/lib/pkgconfig \ + CXXFLAGS+=' -I/usr/local/include -I/usr/local/cuda/include' \ + LFLAGS+=' -Wl,--disable-new-dtags -L/usr/local/lib -Wl,-rpath,/usr/local/lib -L/usr/local/cuda/lib64 -Wl,-rpath,/usr/local/cuda/lib64' + +RUN test -f /build/_avplumber"$(python3-config --extension-suffix)" \ + && ldd /build/_avplumber"$(python3-config --extension-suffix)" \ + | grep -q '/usr/local/lib/libavfilter' \ + && LD_LIBRARY_PATH=/usr/local/lib:/usr/local/cuda/lib64 \ + PYTHONPATH=/build \ + python3 -c 'import pyplumber' + +COPY demos/mxl /build/demos/mxl + +RUN if [ -f /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} +ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video +ENV NVIDIA_VISIBLE_DEVICES=all +ENV PYTHONPATH=/build +ENV PYTHONUNBUFFERED=1 + +ENTRYPOINT ["python3", "/build/demos/mxl/mxl_demo.py"] diff --git a/demos/mxl/README.md b/demos/mxl/README.md new file mode 100644 index 00000000..549a4082 --- /dev/null +++ b/demos/mxl/README.md @@ -0,0 +1,123 @@ +# MXL demo + +End-to-end proof that avplumber can publish and consume flows on the +[MXL](https://github.com/dmf-mxl/mxl) shared-memory transport, using +avplumber's generic `input` / `output` nodes with `format="mxl"` — no +MXL-specific node types are introduced. + +Two graphs run in one process: + +* **writer** — `lavfi testsrc` → decode → rescale to yuv422p10le → + encode as `v210` (10-bit 4:2:2 uncompressed, per SMPTE ST 2110) → + mux → `output(format="mxl", url="/dev/shm/mxl")` (the MXL FFmpeg + muxer takes a plain filesystem path). +* **reader** — `input(format="mxl", url="mxl:///dev/shm/mxl?id=")` + → demux → decode v210 → rescale to yuv420p → encode as mpeg4 → mux + → fragmented mp4. + +Verified end-to-end on Docker Desktop (aarch64) producing 28k mpeg4 +packets of the testsrc pattern in a 10-second run. + +## Status + +NVIDIA support is temporarily disabled in this image. The Dockerfile +applies only patches `0001`, `0005`, `0006`, `0008` from +`deps/ffmpeg-patches/`; `0002` (CUDA composition suite), `0003` (NPP +CUDA13 compat), `0004` (NVDEC intra), and `0007` (NDI) are held back +until the mxl path is folded into `demos/mixer/Dockerfile` alongside +CUDA. + +## Requirements + +* Linux x86_64 or aarch64 (MXL SDK is Linux-only — no macOS support). +* Docker with enough tmpfs at `/dev/shm` (default is fine for the demo). +* The 8-patch stack applied to `n7.1.5`. `deps/ffmpeg-patches/verify.sh` + confirms the tree hash; the MXL patch (`0008-...`) is generated + from `cbcrc/FFmpeg` via `deps/ffmpeg-patches/Dockerfile.mkpatch` + — see that directory's README. + +## Build + +From the repository root (after `git submodule update --init --recursive`): + +```sh +docker build -f demos/mxl/Dockerfile -t avplumber-mxl:local . +``` + +The build: + +1. Bootstraps `microsoft/vcpkg` and Rust 1.88.0. +2. Builds and installs the MXL SDK (`dmf-mxl/mxl` @ `v1.1.0-beta-1`) + into `/usr/local` — libmxl is statically linked against spdlog, so + its `.pc` file is scrubbed of the private `Requires` before FFmpeg + configure. +3. Applies the reduced patch stack to FFmpeg `n7.1.5`, strips a + handful of fork-side test scaffolding that references files not + present in `n7.1.5` (`tests/fate/ogg-*.mak`, duplicated + `fate-mxl-uri` rule), and configures with `--enable-libmxl + --enable-demuxer=mxl --enable-muxer=mxl --enable-protocol=mxl`. +4. Builds `pyplumber` with `HAVE_CUDA=0 NEURAL_NET=0` (`-j2` to stay + inside Docker Desktop's memory ceiling on Apple Silicon). + +Verified inside the image: + +```sh +ffmpeg -hide_banner -demuxers | grep mxl # D mxl Media eXchange Layer +ffmpeg -hide_banner -muxers | grep mxl # E mxl Media eXchange Layer +python3 -c 'import pyplumber' # OK +``` + +## Run + +The demo needs a shared `/dev/shm/mxl` domain directory with an +`options.json` file. On a single host use `--ipc=host`: + +```sh +mkdir -p /dev/shm/mxl +echo '{"urn:x-mxl:option:history_duration/v1.0": 100000000}' \ + > /dev/shm/mxl/options.json + +docker run --rm --ipc=host \ + -v /dev/shm/mxl:/dev/shm/mxl \ + -v "$PWD/demos/mxl/test-media:/media" \ + -e AVP_OUTPUT=/media/out.mp4 \ + avplumber-mxl:local +``` + +Defaults to publishing an `lavfi testsrc` pattern; override with +`-e AVP_INPUT=/media/your-file.mp4` for a real file (played back +looped and realtime-paced through `InputRec`). + +Runs both graphs in one process. Ctrl-C to stop — the fragmented mp4 +stays playable even on interrupt because `movflags=frag_keyframe+ +empty_moov+default_base_moof` is set on the reader-side output. + +Split writer and reader across two containers by passing +`--writer-only` / `--reader-only` and sharing the flow UUID via +`AVP_MXL_VIDEO_ID`. + +## Codec choices + +MXL flows carry uncompressed frames. The muxer registered by +`0008-*.patch` insists on `v210` (10-bit 4:2:2 packed) for video — +`rawvideo` is rejected at header write. The reader re-encodes to +`mpeg4` (rather than H.264) because the NVIDIA-off image does not +link `libx264`. + +## Known gaps + +* Passing `blocking=1` to the MXL demuxer through avplumber's `options` + dict does not currently reach the demuxer's private AVOptions + — the reader survives EAGAIN via `auto_restart:"group"` instead. + Root-cause is in the order of format assignment vs `openInput` in + `src/nodes/input.cpp`; direct `ffmpeg -blocking 1` on the CLI works. +* The demo runs the reader at wall-clock max (~2800 fps into mpeg4 + at 320×240) because we didn't wire in a realtime pacer on the + reader side. Adding a `RealtimeVideoFrame` node between decode and + encode would cap the reader to the source frame rate. + +## References + +* MXL SDK: +* MXL FFmpeg fork (source for the 0008 patch): +* Reference build guidance: diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py new file mode 100644 index 00000000..195a3780 --- /dev/null +++ b/demos/mxl/mxl_demo.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""MXL round-trip demo. + +Builds two avplumber graphs in one process: + +* writer: file -> demux -> decode -> rawvideo encode -> mxl mux -> mxl:// URL +* reader: mxl:// URL -> demux -> decode -> re-encode -> file + +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, + Input, + InputRec, + Mux, + Output, +) + + +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).", + ) + return p.parse_args() + + +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 encode -> mxl output.""" + 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", + "auto_restart": "off", + })) + avp.addNode(DecVideo({ + "src": "w_vpkt", + "dst": "w_vframe", + "group": "w_in", + "name": "w_dec", + "auto_restart": "off", + })) + # v210 requires 10-bit 4:2:2 planar. Convert explicitly so the + # encoder's metadata chain is unambiguous. + avp.addNode(RescaleVideo({ + "src": "w_vframe", + "dst": "w_vscaled", + "group": "w_in", + "name": "w_scale", + "dst_pixel_format": "yuv422p10le", + "auto_restart": "off", + })) + avp.addNode(AssumeVideoFormat({ + "src": "w_vscaled", + "dst": "w_vscaled_assumed", + "group": "w_in", + "width": 320, + "height": 240, + "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", + })) + 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", + "auto_restart": "off", + })) + + +def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: + """mxl:// -> demux -> decode -> rescale -> mpeg4 encode -> file.""" + from pyplumber.node import RescaleVideo, AssumeVideoFormat + + # The MXL demuxer returns EAGAIN when the writer hasn't published + # a new grain yet, which avplumber's Input node treats as fatal. + # `auto_restart:"group"` restarts the reader chain when that + # happens; the group's own auto-restart handling reopens the edge + # cleanly. `blocking=1` should make av_read_frame block instead, + # but passing it via the `options` dict does not yet reach the + # demuxer's private AVOptions (see TODO in README). + 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", + }, + })) + avp.addNode(Demux({ + "src": "r_in", + "routing": {"v:0": "r_vpkt"}, + "group": "r_in", + "auto_restart": "off", + })) + avp.addNode(DecVideo({ + "src": "r_vpkt", + "dst": "r_vframe", + "group": "r_in", + "name": "r_dec", + "auto_restart": "off", + })) + # mpeg4 needs yuv420p — rescale from the 10-bit 4:2:2 flow. + avp.addNode(RescaleVideo({ + "src": "r_vframe", + "dst": "r_vscaled", + "group": "r_in", + "name": "r_scale", + "dst_pixel_format": "yuv420p", + "auto_restart": "off", + })) + avp.addNode(AssumeVideoFormat({ + "src": "r_vscaled", + "dst": "r_vscaled_assumed", + "group": "r_in", + "width": 320, + "height": 240, + "pixel_format": "yuv420p", + "real_pixel_format": "yuv420p", + "auto_restart": "off", + })) + avp.addNode(EncVideo({ + "src": "r_vscaled_assumed", + "dst": "r_venc", + "group": "r_in", + "name": "r_enc", + "codec": "mpeg4", + "auto_restart": "off", + })) + avp.addNode(Mux({ + "src": ["r_venc"], + "dst": "r_mux", + "group": "r_in", + })) + 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. + "options": {"movflags": "frag_keyframe+empty_moov+default_base_moof"}, + "group": "r_in", + "auto_restart": "off", + })) + + +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() + + 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() + + 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 From 66fffc83e333214c3398f859d130102e8e4639b2 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Wed, 9 Sep 2026 15:32:26 +0200 Subject: [PATCH 03/19] Fold the MXL Dockerfile into the shared mixer image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes demos/mxl/Dockerfile and merges its libmxl SDK build, vcpkg / Rust / gcc-13 setup, cmake upgrade, MXL FFmpeg configure flags, and MXL demuxer/muxer assertions into demos/mixer/Dockerfile. The mixer image is now a superset of what the mixer, playlist, replay, and mxl demos need — one image, one build. FFmpeg comes from deps/ffmpeg/apply.sh like the other demo images. The 7.1.5-era tests/Makefile and fate/mxl.mak cleanups are gone: the 8.1 patch carries only MXL files plus configure/Makefile/allformats glue, so there is no fork scaffolding left to strip. Also COPYs demos/mxl into the image so the MXL demo can be run through the shared tag by overriding the entrypoint: docker run --rm --ipc=host --entrypoint python3 \ -v /dev/shm/mxl:/dev/shm/mxl \ -v "$PWD/demos/mxl/test-media:/media" \ avplumber-mixer:local /build/demos/mxl/mxl_demo.py demos/mxl/README.md is retargeted at the FFmpeg 8.x series (patch 8/0011, deps/ffmpeg/verify.sh, the dmf-mxl/8.1 fork branch) and notes that the end-to-end run predates the 8.1 migration. It also records where this is heading: the demuxer emits one packed v210 frame per grain, which is exactly what the v210_to_cuda node consumes, so the read path can drop swscale and the CPU v210 decoder entirely. Co-Authored-By: Claude Opus 5 --- demos/README.md | 6 +- demos/mixer/Dockerfile | 78 ++++++++++++++- demos/mxl/Dockerfile | 218 ----------------------------------------- demos/mxl/README.md | 71 ++++++++------ 4 files changed, 119 insertions(+), 254 deletions(-) delete mode 100644 demos/mxl/Dockerfile diff --git a/demos/README.md b/demos/README.md index beead22a..7f4de71c 100644 --- a/demos/README.md +++ b/demos/README.md @@ -89,7 +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 a file into an MXL shared-memory - domain and read it back. Linux-only, no GPU required, but depends on the - pending `0008-avformat-libmxl-demuxer-muxer.patch`. +- [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..7215f900 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-beta-1 +# 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/Dockerfile b/demos/mxl/Dockerfile deleted file mode 100644 index 2ff2a7d7..00000000 --- a/demos/mxl/Dockerfile +++ /dev/null @@ -1,218 +0,0 @@ -# MXL demo build image (NVIDIA-enabled). -# -# Mirrors ../mixer/Dockerfile: builds patched FFmpeg on top of an -# NVIDIA CUDA base image and produces avplumber's python_module with -# CUDA support. Adds libmxl SDK and the 0008 MXL patch so the same -# image can also run the MXL demo. Once this proves stable it can -# fold into demos/mixer/Dockerfile itself. - -FROM nvidia/cuda:11.7.1-devel-ubuntu22.04 - -ARG DEBIAN_FRONTEND=noninteractive -ARG AVPLUMBER_REVISION=workspace -ARG FFMPEG_TAG=n7.1.5 -ARG MXL_TAG=v1.1.0-beta-1 -ARG NV_CODEC_HEADERS_TAG=n12.1.14.0 - -# gcc-13 is required by the MXL SDK (C++20 features + spdlog headers). -# Ubuntu 22.04 default is gcc-11, so pull gcc-13 from the ubuntu -# toolchain PPA and expose it under a distinct name. FFmpeg and -# avplumber keep using the distro's default 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/* - -# --- nv-codec-headers (headers-only, required by 0002 CUDA suite) ------- -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 - -# Ubuntu 22.04 ships cmake 3.22 but MXL requires >= 3.24. Upgrade via -# pip — installs a modern cmake wheel to /usr/local/bin ahead of the -# distro's /usr/bin/cmake so both MXL and FFmpeg pick it up. -RUN python3 -m pip install --no-cache-dir --upgrade 'cmake>=3.28' - -# --- Rust (needed by MXL's ada-url dep pulled through vcpkg) ------------ -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 (MXL'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 -# still use the distro gcc-11 that the CUDA suite is validated with. -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 - -# --- Patched FFmpeg (full 8-patch stack, with CUDA + MXL) --------------- -COPY deps/ffmpeg-patches /build/deps/ffmpeg-patches - -RUN git clone --quiet --branch "${FFMPEG_TAG}" --depth 1 \ - https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg \ - && git -C /tmp/ffmpeg config user.name "mxl-demo builder" \ - && git -C /tmp/ffmpeg config user.email "mxl-demo@local" \ - && git -C /tmp/ffmpeg am /build/deps/ffmpeg-patches/*.patch \ - # 0008 was cherry-picked with -X theirs and pulled in a couple of - # test scaffolding lines that reference files not present in - # n7.1.5. Strip them so `make` succeeds. - && sed -i '/tests\/fate\/ogg-\(opus\|flac\|vorbis\)\.mak/d' /tmp/ffmpeg/tests/Makefile \ - && sed -i '/^# uri test if demuxer is enabled$/,+1d' /tmp/ffmpeg/tests/fate/mxl.mak - -RUN cd /tmp/ffmpeg \ - && PKG_CONFIG_PATH=/usr/local/lib/pkgconfig \ - ./configure \ - --prefix=/usr/local \ - --libdir=/usr/local/lib \ - --incdir=/usr/local/include \ - --pkgconfigdir=/usr/local/lib/pkgconfig \ - --enable-gpl \ - --enable-nonfree \ - --enable-shared \ - --disable-static \ - --enable-cuda \ - --enable-cuda-nvcc \ - --enable-cuvid \ - --enable-ffnvcodec \ - --enable-nvdec \ - --enable-nvenc \ - --disable-libnpp \ - --enable-libmxl \ - --enable-demuxer=mxl \ - --enable-muxer=mxl \ - --enable-protocol=mxl \ - --disable-doc \ - --disable-htmlpages \ - --disable-manpages \ - --disable-podpages \ - --disable-txtpages \ - --extra-cflags=-I/usr/local/cuda/include \ - --extra-ldflags=-L/usr/local/cuda/lib64 \ - --extra-ldexeflags=-Wl,--disable-new-dtags,-rpath,/usr/local/lib \ - --nvcc=/usr/local/cuda/bin/nvcc \ - --nvccflags='-gencode arch=compute_70,code=compute_70 -O2' \ - && make -j"${BUILD_JOBS:-2}" \ - && make install \ - && ldconfig \ - && rm -rf /tmp/ffmpeg - -# Assertions: both the CUDA composition suite AND MXL must be present. -RUN /usr/local/bin/ffmpeg -hide_banner -filters | grep -q ' overlay_many_cuda ' \ - && /usr/local/bin/ffmpeg -hide_banner -filters \ - | awk '$2 == "transition_cuda" && $1 ~ /C/ { found = 1 } END { exit !found }' \ - && /usr/local/bin/ffmpeg -hide_banner -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 }' - -# --- avplumber (with CUDA) --------------------------------------------- -WORKDIR /build - -COPY deps /build/deps -COPY Makefile generate_node_list /build/ -COPY src /build/src -COPY pyplumber /build/pyplumber -COPY avpmixer /build/avpmixer - -RUN git init --quiet /build \ - && git -C /build config user.name "mxl-demo builder" \ - && git -C /build config user.email "mxl-demo@local" \ - && git -C /build commit --quiet --allow-empty -m "workspace ${AVPLUMBER_REVISION}" - -RUN make -j"${BUILD_JOBS:-2}" python_module \ - BUILD_TYPE=Release \ - HAVE_CUDA=1 \ - HAVE_DRM=0 \ - HAVE_GL=0 \ - HAVE_NVOF_FRUC=0 \ - HAVE_NVCC=1 \ - HAVE_NVJPEG=0 \ - HAVE_TENSORRT=0 \ - NEURAL_NET=0 \ - CUDA_MODULE_IMAGE_FORMAT=ptx \ - NVCC=/usr/local/cuda/bin/nvcc \ - CUDA_ROOT=/usr/local/cuda \ - PKG_CONFIG_PATH=/usr/local/lib/pkgconfig \ - CXXFLAGS+=' -I/usr/local/include -I/usr/local/cuda/include' \ - LFLAGS+=' -Wl,--disable-new-dtags -L/usr/local/lib -Wl,-rpath,/usr/local/lib -L/usr/local/cuda/lib64 -Wl,-rpath,/usr/local/cuda/lib64' - -RUN test -f /build/_avplumber"$(python3-config --extension-suffix)" \ - && ldd /build/_avplumber"$(python3-config --extension-suffix)" \ - | grep -q '/usr/local/lib/libavfilter' \ - && LD_LIBRARY_PATH=/usr/local/lib:/usr/local/cuda/lib64 \ - PYTHONPATH=/build \ - python3 -c 'import pyplumber' - -COPY demos/mxl /build/demos/mxl - -RUN if [ -f /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} -ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video -ENV NVIDIA_VISIBLE_DEVICES=all -ENV PYTHONPATH=/build -ENV PYTHONUNBUFFERED=1 - -ENTRYPOINT ["python3", "/build/demos/mxl/mxl_demo.py"] diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 549a4082..7ba71f5e 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -16,48 +16,49 @@ Two graphs run in one process: → fragmented mp4. Verified end-to-end on Docker Desktop (aarch64) producing 28k mpeg4 -packets of the testsrc pattern in a 10-second run. - -## Status - -NVIDIA support is temporarily disabled in this image. The Dockerfile -applies only patches `0001`, `0005`, `0006`, `0008` from -`deps/ffmpeg-patches/`; `0002` (CUDA composition suite), `0003` (NPP -CUDA13 compat), `0004` (NVDEC intra), and `0007` (NDI) are held back -until the mxl path is folded into `demos/mixer/Dockerfile` alongside -CUDA. +packets of the testsrc pattern in a 10-second run — that run was on the +earlier FFmpeg 7.1.5 build of this patch stack; the 8.1 series has not +been re-run yet. ## Requirements -* Linux x86_64 or aarch64 (MXL SDK is Linux-only — no macOS support). +* Linux x86_64 (MXL SDK is Linux-only — no macOS support). * Docker with enough tmpfs at `/dev/shm` (default is fine for the demo). -* The 8-patch stack applied to `n7.1.5`. `deps/ffmpeg-patches/verify.sh` - confirms the tree hash; the MXL patch (`0008-...`) is generated - from `cbcrc/FFmpeg` via `deps/ffmpeg-patches/Dockerfile.mkpatch` - — see that directory's README. +* NVIDIA GPU + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) + are optional for the MXL demo itself (CUDA init fails silently at + runtime without them), but the shared mixer image is built with + CUDA support and other demos need it. +* The FFmpeg 8.x series applied to `n8.1` (or `n8.0`). + `deps/ffmpeg/verify.sh n8.1 ` confirms the tree hash; the + MXL patch (`8/0011-...`) is generated from `cbcrc/FFmpeg` branch + `dmf-mxl/8.1` via `deps/ffmpeg/Dockerfile.mkpatch` — see + `deps/ffmpeg/README.md`. ## Build -From the repository root (after `git submodule update --init --recursive`): +This demo shares its runtime image with `demos/mixer/`. Build once +from the repository root (after `git submodule update --init --recursive`): ```sh -docker build -f demos/mxl/Dockerfile -t avplumber-mxl:local . +docker build -f demos/mixer/Dockerfile -t avplumber-mixer:local . ``` -The build: +The image build: -1. Bootstraps `microsoft/vcpkg` and Rust 1.88.0. -2. Builds and installs the MXL SDK (`dmf-mxl/mxl` @ `v1.1.0-beta-1`) +1. Installs distro `gcc-11` plus `gcc-13` from + `ppa:ubuntu-toolchain-r/test` (the MXL SDK needs C++20; FFmpeg and + avplumber keep using gcc-11). +2. Bootstraps `microsoft/vcpkg` and Rust 1.88.0. +3. Builds and installs the MXL SDK (`dmf-mxl/mxl` @ `v1.1.0`) into `/usr/local` — libmxl is statically linked against spdlog, so its `.pc` file is scrubbed of the private `Requires` before FFmpeg configure. -3. Applies the reduced patch stack to FFmpeg `n7.1.5`, strips a - handful of fork-side test scaffolding that references files not - present in `n7.1.5` (`tests/fate/ogg-*.mak`, duplicated - `fate-mxl-uri` rule), and configures with `--enable-libmxl - --enable-demuxer=mxl --enable-muxer=mxl --enable-protocol=mxl`. -4. Builds `pyplumber` with `HAVE_CUDA=0 NEURAL_NET=0` (`-j2` to stay - inside Docker Desktop's memory ceiling on Apple Silicon). +4. Applies the eleven-patch FFmpeg 8.x series (`deps/ffmpeg/apply.sh`) + to FFmpeg `n8.1` and configures with CUDA (`--enable-cuda + --enable-cuda-nvcc --enable-cuvid --enable-nvdec --enable-nvenc`) + plus MXL (`--enable-libmxl --enable-demuxer=mxl --enable-muxer=mxl + --enable-protocol=mxl`). +5. Builds `pyplumber` with `HAVE_CUDA=1 HAVE_NVCC=1`. Verified inside the image: @@ -78,12 +79,16 @@ echo '{"urn:x-mxl:option:history_duration/v1.0": 100000000}' \ > /dev/shm/mxl/options.json docker run --rm --ipc=host \ + --entrypoint python3 \ -v /dev/shm/mxl:/dev/shm/mxl \ -v "$PWD/demos/mxl/test-media:/media" \ -e AVP_OUTPUT=/media/out.mp4 \ - avplumber-mxl:local + avplumber-mixer:local /build/demos/mxl/mxl_demo.py ``` +Add `--gpus all` if you're on an NVIDIA host and want CUDA +initialization to succeed (the MXL demo itself doesn't need it). + Defaults to publishing an `lavfi testsrc` pattern; override with `-e AVP_INPUT=/media/your-file.mp4` for a real file (played back looped and realtime-paced through `InputRec`). @@ -99,7 +104,7 @@ Split writer and reader across two containers by passing ## Codec choices MXL flows carry uncompressed frames. The muxer registered by -`0008-*.patch` insists on `v210` (10-bit 4:2:2 packed) for video — +`8/0011-*.patch` insists on `v210` (10-bit 4:2:2 packed) for video — `rawvideo` is rejected at header write. The reader re-encodes to `mpeg4` (rather than H.264) because the NVIDIA-off image does not link `libx264`. @@ -115,9 +120,15 @@ link `libx264`. at 320×240) because we didn't wire in a realtime pacer on the reader side. Adding a `RealtimeVideoFrame` node between decode and encode would cap the reader to the source frame rate. +* Both sides still go through swscale and the CPU `v210` codec. The + demuxer hands out one packed frame per grain, which is exactly what + the `v210_to_cuda` node consumes, so the read path can become + `input(mxl) -> demux -> v210_to_cuda` with a single pinned upload and + no CPU colour conversion. Nothing packs CUDA frames back to v210 yet, + so the write path keeps the CPU encoder. ## References * MXL SDK: -* MXL FFmpeg fork (source for the 0008 patch): +* MXL FFmpeg fork (source for the 0011 patch, branch `dmf-mxl/8.1`): * Reference build guidance: From 8f4e7cd87946eaee25becd1bafd2775b602f01f6 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Sun, 13 Sep 2026 02:29:49 +0200 Subject: [PATCH 04/19] Bump MXL SDK from v1.1.0-beta-1 to v1.1.0 v1.1.0 was cut on 2026-09-09, 125 commits ahead of the beta pin we shipped. Notable changes in that window: audio RMA samples are packed in channel-major order, fabrics/OFI got batch grain drain and configurable CQ depth, the audio testsrc no longer busy-spins at 100% CPU, and Rust deps were bumped to fix RUSTSEC-2026-0204. Verified end-to-end: MXL demuxer/muxer still register in FFmpeg, the CUDA composition suite still passes assertions, and the mxl_demo round-trip publishes into and reads back from the v1.1.0 SDK, producing 16,753 mpeg4 packets in a short run. Co-Authored-By: Claude Opus 4.7 --- demos/mixer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/mixer/Dockerfile b/demos/mixer/Dockerfile index 7215f900..8b76f7a2 100644 --- a/demos/mixer/Dockerfile +++ b/demos/mixer/Dockerfile @@ -4,7 +4,7 @@ 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-beta-1 +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 From 22b07a4645e70f70a522c1dc8d086427280ac132 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Mon, 14 Sep 2026 10:41:24 +0200 Subject: [PATCH 05/19] Warn on input options not consumed by libavformat `input` and `input_rec` forward the JSON `options` dictionary to `avformat_open_input`, which strips out any AVOptions it recognizes and leaves unrecognized entries behind. Nothing was inspecting those leftovers, so misnamed options or ones targeting the wrong scope were silently ignored. Log each remaining entry as a warning. Verified against the MXL round-trip demo that `blocking=0/1` and `grain_index_init=head` are consumed by the MXL demuxer as advertised (`blocking=0` produces the expected EAGAIN + auto_restart storm, `blocking=1` matches the auto default for video-only). Drop the "known gap" bullet in `demos/mxl/README.md` and the matching inline comment in `mxl_demo.py`. Co-Authored-By: Claude Opus 4.7 --- demos/mxl/README.md | 5 ----- demos/mxl/mxl_demo.py | 12 +++++------- src/nodes/input.cpp | 6 ++++++ src/nodes/input_rec.cpp | 6 ++++++ 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 7ba71f5e..0ec0cd37 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -111,11 +111,6 @@ link `libx264`. ## Known gaps -* Passing `blocking=1` to the MXL demuxer through avplumber's `options` - dict does not currently reach the demuxer's private AVOptions - — the reader survives EAGAIN via `auto_restart:"group"` instead. - Root-cause is in the order of format assignment vs `openInput` in - `src/nodes/input.cpp`; direct `ffmpeg -blocking 1` on the CLI works. * The demo runs the reader at wall-clock max (~2800 fps into mpeg4 at 320×240) because we didn't wire in a realtime pacer on the reader side. Adding a `RealtimeVideoFrame` node between decode and diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py index 195a3780..362f2814 100644 --- a/demos/mxl/mxl_demo.py +++ b/demos/mxl/mxl_demo.py @@ -185,13 +185,11 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: """mxl:// -> demux -> decode -> rescale -> mpeg4 encode -> file.""" from pyplumber.node import RescaleVideo, AssumeVideoFormat - # The MXL demuxer returns EAGAIN when the writer hasn't published - # a new grain yet, which avplumber's Input node treats as fatal. - # `auto_restart:"group"` restarts the reader chain when that - # happens; the group's own auto-restart handling reopens the edge - # cleanly. `blocking=1` should make av_read_frame block instead, - # but passing it via the `options` dict does not yet reach the - # demuxer's private AVOptions (see TODO in README). + # `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", 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 Date: Thu, 17 Sep 2026 12:38:09 +0200 Subject: [PATCH 06/19] Read MXL flows straight onto the GPU, zero-copy The MXL demuxer hands out one packed v210 frame per grain, which is exactly what v210_to_cuda consumes, so the reader no longer needs the CPU v210 decoder or swscale: the grain is staged once through pinned memory, unpacked to CUDA p210 by the PTX kernel, and finished by scale_cuda + h264_nvenc. NVENC also drops the mpeg4 fallback the image needed for lack of libx264. Selected by --gpu-unpack, defaulting to auto: on when /dev/nvidiactl is mapped in, CPU path otherwise, so the GPU-less runs the demo was verified with keep working. Also enable the demuxer's zero_copy by default, which points each AVPacket at the MXL ring in /dev/shm instead of copying the grain out. Nothing holds a reference on the grain, so the packet queue is planned down to one frame and the documented history_duration goes from 100 ms (~2 grains) to 1 s; --no-zero-copy trades the memcpy back for immunity. The geometry is now a CLI contract (--width/--height/--fps) that the writer rescales to, since packed v210 carries no dimensions and the reader derives the row stride from the width. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 92 ++++++++++++++----- demos/mxl/mxl_demo.py | 209 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 254 insertions(+), 47 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 0ec0cd37..d9a89704 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -12,22 +12,33 @@ Two graphs run in one process: mux → `output(format="mxl", url="/dev/shm/mxl")` (the MXL FFmpeg muxer takes a plain filesystem path). * **reader** — `input(format="mxl", url="mxl:///dev/shm/mxl?id=")` - → demux → decode v210 → rescale to yuv420p → encode as mpeg4 → mux - → fragmented mp4. + → demux → v210 unpack → encode → fragmented mp4. The unpack step has + two implementations, picked by `--gpu-unpack` (default `auto`): + * **GPU** (a CUDA device is present) — `v210_to_cuda` copies each + grain once into pinned memory and unpacks it to a CUDA `p210le` + frame with a PTX kernel, then `scale_cuda` and `h264_nvenc` finish + the file on the GPU. No CPU codec, no swscale. + * **CPU** (no device) — the libavcodec `v210` decoder plus swscale to + yuv420p and `mpeg4`. + +Grains are taken **zero-copy** by default: the demuxer's `zero_copy=1` +points each AVPacket straight at the MXL ring buffer in `/dev/shm` +instead of copying it out. See "Zero-copy grains" below. Verified end-to-end on Docker Desktop (aarch64) producing 28k mpeg4 packets of the testsrc pattern in a 10-second run — that run was on the -earlier FFmpeg 7.1.5 build of this patch stack; the 8.1 series has not -been re-run yet. +earlier FFmpeg 7.1.5 build of this patch stack with the CPU reader and +copied grains. Neither the 8.1 series nor the GPU/zero-copy paths have +been run yet. ## Requirements * Linux x86_64 (MXL SDK is Linux-only — no macOS support). * Docker with enough tmpfs at `/dev/shm` (default is fine for the demo). * NVIDIA GPU + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) - are optional for the MXL demo itself (CUDA init fails silently at - runtime without them), but the shared mixer image is built with - CUDA support and other demos need it. + are optional: with them (`--gpus all`) the reader unpacks on the GPU + and encodes with NVENC; without them it falls back to the CPU v210 + decoder. Other demos in the shared image do require CUDA. * The FFmpeg 8.x series applied to `n8.1` (or `n8.0`). `deps/ffmpeg/verify.sh n8.1 ` confirms the tree hash; the MXL patch (`8/0011-...`) is generated from `cbcrc/FFmpeg` branch @@ -75,10 +86,12 @@ The demo needs a shared `/dev/shm/mxl` domain directory with an ```sh mkdir -p /dev/shm/mxl -echo '{"urn:x-mxl:option:history_duration/v1.0": 100000000}' \ +# 1 s of history. The 100 ms the MXL samples use is only ~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 \ +docker run --rm --ipc=host --gpus all \ --entrypoint python3 \ -v /dev/shm/mxl:/dev/shm/mxl \ -v "$PWD/demos/mxl/test-media:/media" \ @@ -86,8 +99,12 @@ docker run --rm --ipc=host \ avplumber-mixer:local /build/demos/mxl/mxl_demo.py ``` -Add `--gpus all` if you're on an NVIDIA host and want CUDA -initialization to succeed (the MXL demo itself doesn't need it). +Drop `--gpus all` to force the CPU reader path (`--gpu-unpack` prints +which one it picked). The flow geometry defaults to the built-in +testsrc's 320×240p25; pass `--width/--height/--fps` (or `AVP_WIDTH`, +`AVP_HEIGHT`, `AVP_FPS`) to change it — the writer rescales to match, +because packed v210 carries no dimensions and the reader derives the +row stride from `--width`. Defaults to publishing an `lavfi testsrc` pattern; override with `-e AVP_INPUT=/media/your-file.mp4` for a real file (played back @@ -105,22 +122,49 @@ Split writer and reader across two containers by passing MXL flows carry uncompressed frames. The muxer registered by `8/0011-*.patch` insists on `v210` (10-bit 4:2:2 packed) for video — -`rawvideo` is rejected at header write. The reader re-encodes to -`mpeg4` (rather than H.264) because the NVIDIA-off image does not -link `libx264`. +`rawvideo` is rejected at header write. The GPU reader path encodes +H.264 with NVENC; the CPU fallback uses `mpeg4` (rather than H.264) +because the image does not link `libx264`. + +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. + +## Zero-copy grains + +`zero_copy=1` (default; `--no-zero-copy` opts out) makes the demuxer +wrap the grain payload in an `AVBufferRef` pointing into the shared +memory ring rather than `memcpy`-ing it into a fresh packet. The +release callback in the patch is a no-op — **nothing holds a reference +on the grain** — so the bytes are valid only until the writer laps that +slot in the ring. Two things keep that safe here: + +* `queue.plan_capacity r_vpkt 1` bounds the packet queue to a single + in-flight grain, so a packet cannot sit and age behind others. +* `history_duration` in `options.json` sizes the ring; 1 s (25 grains at + 25 fps) gives a large margin over that one queued packet. + +A reader that stalls for longer than the ring depth will silently see +overwritten pixels rather than an error, which is why the FFmpeg option +is marked experimental. `--no-zero-copy` trades one memcpy per frame +for immunity. + +Note that zero-copy removes the shm→packet copy only. `v210_to_cuda` +still stages each grain through pinned host memory on its way to the +GPU; making that leg copy-free would mean `cudaHostRegister`-ing the MXL +ring, which is a change to the node in `src/`. ## Known gaps -* The demo runs the reader at wall-clock max (~2800 fps into mpeg4 - at 320×240) because we didn't wire in a realtime pacer on the - reader side. Adding a `RealtimeVideoFrame` node between decode and - encode would cap the reader to the source frame rate. -* Both sides still go through swscale and the CPU `v210` codec. The - demuxer hands out one packed frame per grain, which is exactly what - the `v210_to_cuda` node consumes, so the read path can become - `input(mxl) -> demux -> v210_to_cuda` with a single pinned upload and - no CPU colour conversion. Nothing packs CUDA frames back to v210 yet, - so the write path keeps the CPU encoder. +* The demo runs the reader at wall-clock max because we didn't wire in + a realtime pacer on the reader side. Adding a `RealtimeVideoFrame` + node before the encoder would cap it to the flow frame rate. Running + uncapped is harmless in itself — `blocking=1` parks the reader on the + writer's head — it just spends CPU on `blocking` waits. +* The write path still goes through swscale and the CPU `v210` encoder, + because nothing packs CUDA frames back to v210 yet. The read path no + longer does (with `--gpu-unpack`). ## References diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py index 362f2814..ffb10901 100644 --- a/demos/mxl/mxl_demo.py +++ b/demos/mxl/mxl_demo.py @@ -3,8 +3,12 @@ Builds two avplumber graphs in one process: -* writer: file -> demux -> decode -> rawvideo encode -> mxl mux -> mxl:// URL -* reader: mxl:// URL -> demux -> decode -> re-encode -> file +* 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 @@ -33,6 +37,10 @@ Mux, Output, ) +from avpmixer.inputs import v210_row_stride + +# Name of the CUDA device the GPU reader path initializes and shares. +_HWACCEL = "@gpu" def _parse_args() -> argparse.Namespace: @@ -78,7 +86,67 @@ def _parse_args() -> argparse.Namespace: action="store_true", help="Skip the reader side (publish only).", ) - return p.parse_args() + 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( + "--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( + "--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.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" + 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 _writer_url(domain: str) -> str: @@ -141,14 +209,19 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "group": "w_in", "name": "w_scale", "dst_pixel_format": "yuv422p10le", + # Force the declared geometry: 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. + "dst_width": args.width, + "dst_height": args.height, "auto_restart": "off", })) avp.addNode(AssumeVideoFormat({ "src": "w_vscaled", "dst": "w_vscaled_assumed", "group": "w_in", - "width": 320, - "height": 240, + "width": args.width, + "height": args.height, "pixel_format": "yuv422p10le", "real_pixel_format": "yuv422p10le", "auto_restart": "off", @@ -182,9 +255,22 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: - """mxl:// -> demux -> decode -> rescale -> mpeg4 encode -> file.""" + """mxl:// -> demux -> v210 unpack -> 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. + """ from pyplumber.node import RescaleVideo, AssumeVideoFormat + 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. @@ -201,6 +287,7 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "options": { "blocking": "1", "grain_index_init": "head", + "zero_copy": "1" if args.zero_copy else "0", }, })) avp.addNode(Demux({ @@ -209,6 +296,93 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "group": "r_in", "auto_restart": "off", })) + if args.gpu_unpack: + _build_gpu_reader_tail(avp, args) + else: + _build_cpu_reader_tail(avp, args, RescaleVideo, AssumeVideoFormat) + avp.addNode(Mux({ + "src": ["r_venc"], + "dst": "r_mux", + "group": "r_in", + })) + 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. + "options": {"movflags": "frag_keyframe+empty_moov+default_base_moof"}, + "group": "r_in", + "auto_restart": "off", + })) + + +def _build_gpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: + """r_vpkt -> v210_to_cuda -> scale_cuda -> NVENC -> r_venc. + + 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. NVENC also + sidesteps the CPU path's mpeg4 fallback (the image has no libx264). + """ + from pyplumber.node import AssumeVideoFormat, FilterVideo, 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), + "timebase": "1/90000", + "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", + })) + # NVENC only takes 4:2:2 10-bit on Blackwell-class hardware, so + # convert on the GPU to 8-bit 4:2:0 for the demo's output file. + avp.addNode(FilterVideo({ + "src": "r_vcuda", + "dst": "r_vnv12", + "group": "r_in", + "name": "r_filter", + "graph": "scale_cuda=format=nv12", + "hwaccel": _HWACCEL, + "auto_restart": "off", + })) + avp.addNode(AssumeVideoFormat({ + "src": "r_vnv12", + "dst": "r_vassumed", + "group": "r_in", + "width": args.width, + "height": args.height, + "pixel_format": "cuda", + "real_pixel_format": "nv12", + "auto_restart": "off", + })) + avp.addNode(EncVideo({ + "src": "r_vassumed", + "dst": "r_venc", + "group": "r_in", + "name": "r_enc", + "codec": "h264_nvenc", + "hwaccel": _HWACCEL, + "options": {"preset": "p4", "profile": "high", "bf": 0}, + "auto_restart": "off", + })) + + +def _build_cpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, + RescaleVideo, AssumeVideoFormat) -> None: + """r_vpkt -> CPU v210 decode -> swscale -> mpeg4 -> r_venc.""" avp.addNode(DecVideo({ "src": "r_vpkt", "dst": "r_vframe", @@ -229,8 +403,8 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "src": "r_vscaled", "dst": "r_vscaled_assumed", "group": "r_in", - "width": 320, - "height": 240, + "width": args.width, + "height": args.height, "pixel_format": "yuv420p", "real_pixel_format": "yuv420p", "auto_restart": "off", @@ -243,21 +417,6 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "codec": "mpeg4", "auto_restart": "off", })) - avp.addNode(Mux({ - "src": ["r_venc"], - "dst": "r_mux", - "group": "r_in", - })) - 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. - "options": {"movflags": "frag_keyframe+empty_moov+default_base_moof"}, - "group": "r_in", - "auto_restart": "off", - })) def main() -> int: @@ -274,6 +433,10 @@ def main() -> int: if not args.reader_only: _build_writer(avp, args) if not args.writer_only: + if args.gpu_unpack: + avp.executeCommandsFromString( + 'hwaccel.init { "name": "%s", "type": "cuda" }' % _HWACCEL + ) _build_reader(avp, args) if not args.reader_only: From f04fcacbba12003a31ecc06009a99a9f4327e313 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Thu, 17 Sep 2026 13:39:02 +0200 Subject: [PATCH 07/19] demos/mxl: pace the writer, survive a kill, verify on real hardware Three fixes found while running the MXL round-trip on an RTX 4000 Ada with FFmpeg 8.1: The MXL muxer ignores PTS and increments its own grain index per packet, so an unpaced lavfi source published ~2300 grains/s, ran the flow into the future and left every reader "too late". Insert realtime(set_pts) + force_fps ahead of the v210 encoder. The demuxer picks its grain index during avformat_open_input's probe, i.e. seconds before the group starts, with CUDA and NVENC init in between; by then it is behind the ring tail and the EAGAIN is fatal to input.cpp. on_too_late=reset re-derives it, and reset_on_drop rebases the timestamps so the output does not open with a multi-second hole. The reader's mp4 was frequently 28 bytes: fragmented mp4 only helps if the data leaves the muxer's 256 KiB avio buffer, which a low-bitrate run killed after 25 s never fills. Add flush_packets=1, plus a one-second NVENC GOP so fragments are cut at a useful rate. README records what was actually verified on the hardware and the SIGINT teardown hang, which reproduces on the CPU path too and so is not from this wiring. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 75 ++++++++++++++++++++++++++++++++++++------- demos/mxl/mxl_demo.py | 57 ++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index d9a89704..0661a2c0 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -25,11 +25,14 @@ Grains are taken **zero-copy** by default: the demuxer's `zero_copy=1` points each AVPacket straight at the MXL ring buffer in `/dev/shm` instead of copying it out. See "Zero-copy grains" below. -Verified end-to-end on Docker Desktop (aarch64) producing 28k mpeg4 -packets of the testsrc pattern in a 10-second run — that run was on the -earlier FFmpeg 7.1.5 build of this patch stack with the CPU reader and -copied grains. Neither the 8.1 series nor the GPU/zero-copy paths have -been run yet. +Verified end-to-end on an x86_64 Fedora host with an RTX 4000 Ada +(driver 615.71, Docker + NVIDIA Container Toolkit), on the FFmpeg 8.1 +build of this patch stack (`n8.1-12-g93aafbb`, mxl demuxer and muxer +registered). Runs covered: GPU zero-copy, GPU with `--no-zero-copy` and +`--gpu-unpack off`, against both an `lavfi testsrc` and a looped file, +at 320×240p25 and 640×480p25. All of them ran without node failures, +started at PTS 0, reported 25.0 fps (25.06 on the NVENC path) in the +container header, and decoded back to the source pattern. ## Requirements @@ -110,9 +113,14 @@ Defaults to publishing an `lavfi testsrc` pattern; override with `-e AVP_INPUT=/media/your-file.mp4` for a real file (played back looped and realtime-paced through `InputRec`). -Runs both graphs in one process. Ctrl-C to stop — the fragmented mp4 -stays playable even on interrupt because `movflags=frag_keyframe+ -empty_moov+default_base_moof` is set on the reader-side output. +Runs both graphs in one process. Stop it with `docker kill` (see Known +gaps — Ctrl-C hangs in teardown). The output stays playable anyway: +the reader-side 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 out only when it fills, so a low-bitrate run that is killed +rather than closed leaves a 28-byte file containing just the `ftyp` +box. Split writer and reader across two containers by passing `--writer-only` / `--reader-only` and sharing the flow UUID via @@ -126,11 +134,42 @@ MXL flows carry uncompressed frames. The muxer registered by H.264 with NVENC; the CPU fallback uses `mpeg4` (rather than H.264) because the image does not link `libx264`. +NVENC is given `g=` — one keyframe per second instead of its +default 250-frame GOP — because `frag_keyframe` cuts a fragment per +keyframe, and that is what makes the growing file 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 entirely: it +takes its own grain index from `mxlGetCurrentIndex` at header write and +increments it once per packet. So the *writer* must be paced, or grain +N lands in shared memory long before wall-clock N/fps. An unpaced lavfi +source published ~2300 grains/s here, which ran the flow seconds into +the future and collapsed the ring's usable history to milliseconds — +every reader was then "too late". Hence `realtime(set_pts=1)` followed +by `force_fps` between the writer's decoder and the v210 encoder. + +Three demuxer options matter on the reader: + +* `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 already reads a + grain, so the index is chosen when the node is *created*, several + seconds before the group starts (CUDA and NVENC init sit in between). + By then it is behind the ring tail. Without `reset` the demuxer + returns `EAGAIN`, which `input.cpp` treats as fatal, and the node + fails with "Resource temporarily unavailable". +* `reset_on_drop=1` — resetting the index leaves a hole where the + skipped grains would have been (a 5.8 s leading PTS gap, and a + container frame rate of 19.4 fps instead of 25). This rebases the + timestamps after the reset. + ## Zero-copy grains `zero_copy=1` (default; `--no-zero-copy` opts out) makes the demuxer @@ -157,11 +196,25 @@ ring, which is a change to the node in `src/`. ## Known gaps -* The demo runs the reader at wall-clock max because we didn't wire in - a realtime pacer on the reader side. Adding a `RealtimeVideoFrame` - node before the encoder would cap it to the flow frame rate. Running +* The reader is not paced: it runs at wall-clock max. Adding a + `realtime` node (as the writer has) before the encoder would cap it + to the flow frame rate. Running uncapped is harmless in itself — `blocking=1` parks the reader on the writer's head — it just spends CPU on `blocking` waits. +* **Ctrl-C does not shut the demo down.** SIGINT leaves the process + stuck in teardown: every node thread is alive in a timed poll and the + main thread waits in `futex_do_wait`. This reproduces on the GPU and + CPU reader paths alike and predates this demo's reader wiring, so it + looks like a framework-level stop-ordering issue rather than anything + MXL-specific. Use `docker kill` / SIGKILL meanwhile. +* Restarting the reader group has the same shape of problem: it parks at + "Stopping node r_unpack ...". `auto_restart` is therefore `off` on + every reader node except `r_input`. +* With a looped file source, each 5 s wraparound logs `EventLoop + negative time to wait, resyncing` on the writer and a couple of + dropped frames in `force_fps`, and the reader sees a burst of "too + early" waits. Output is unaffected, but a live flow would rather have + a seamless looper. * The write path still goes through swscale and the CPU `v210` encoder, because nothing packs CUDA frames back to v210 yet. The read path no longer does (with `--gpu-unpack`). diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py index ffb10901..5db42e82 100644 --- a/demos/mxl/mxl_demo.py +++ b/demos/mxl/mxl_demo.py @@ -32,10 +32,12 @@ Demux, DecVideo, EncVideo, + ForceFPS, Input, InputRec, Mux, Output, + Realtime, ) from avpmixer.inputs import v210_row_stride @@ -149,6 +151,12 @@ def _fps_ratio(fps: str) -> str: return fps if "/" in fps else f"{fps}/1" +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 _writer_url(domain: str) -> str: """MXL muxer wants a plain filesystem path (AVFMT_NOFILE).""" return domain @@ -201,10 +209,32 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "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. + 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", + })) # v210 requires 10-bit 4:2:2 planar. Convert explicitly so the # encoder's metadata chain is unambiguous. avp.addNode(RescaleVideo({ - "src": "w_vframe", + "src": "w_vpaced", "dst": "w_vscaled", "group": "w_in", "name": "w_scale", @@ -287,6 +317,16 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "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", }, })) @@ -311,7 +351,14 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "format": "mp4", # Fragmented MP4 so the file stays playable even if the demo # is interrupted before the mp4 trailer is written. - "options": {"movflags": "frag_keyframe+empty_moov+default_base_moof"}, + # 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", "auto_restart": "off", })) @@ -375,7 +422,11 @@ def _build_gpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace) - "name": "r_enc", "codec": "h264_nvenc", "hwaccel": _HWACCEL, - "options": {"preset": "p4", "profile": "high", "bf": 0}, + # 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", })) From 3ebd2424148d5ae4c584f25953b93a1ab642d69b Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Thu, 17 Sep 2026 14:05:53 +0200 Subject: [PATCH 08/19] docs: tighten the MXL demo and patch-series notes Trim the MXL demo README to the facts a reader needs: fold the duplicated build recipe into what is MXL-specific about the shared image, merge the two teardown-hang gaps into one, and drop the in-image verification snippet the Dockerfile already asserts. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 293 ++++++++++++++++++------------------------ deps/ffmpeg/README.md | 6 +- 2 files changed, 127 insertions(+), 172 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 0661a2c0..579b687b 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -1,96 +1,74 @@ # MXL demo -End-to-end proof that avplumber can publish and consume flows on the -[MXL](https://github.com/dmf-mxl/mxl) shared-memory transport, using +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 are introduced. +MXL-specific node types. Two graphs run in one process: -* **writer** — `lavfi testsrc` → decode → rescale to yuv422p10le → - encode as `v210` (10-bit 4:2:2 uncompressed, per SMPTE ST 2110) → - mux → `output(format="mxl", url="/dev/shm/mxl")` (the MXL FFmpeg - muxer takes a plain filesystem path). +* **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. * **reader** — `input(format="mxl", url="mxl:///dev/shm/mxl?id=")` - → demux → v210 unpack → encode → fragmented mp4. The unpack step has - two implementations, picked by `--gpu-unpack` (default `auto`): - * **GPU** (a CUDA device is present) — `v210_to_cuda` copies each - grain once into pinned memory and unpacks it to a CUDA `p210le` - frame with a PTX kernel, then `scale_cuda` and `h264_nvenc` finish - the file on the GPU. No CPU codec, no swscale. - * **CPU** (no device) — the libavcodec `v210` decoder plus swscale to - yuv420p and `mpeg4`. - -Grains are taken **zero-copy** by default: the demuxer's `zero_copy=1` -points each AVPacket straight at the MXL ring buffer in `/dev/shm` -instead of copying it out. See "Zero-copy grains" below. - -Verified end-to-end on an x86_64 Fedora host with an RTX 4000 Ada -(driver 615.71, Docker + NVIDIA Container Toolkit), on the FFmpeg 8.1 -build of this patch stack (`n8.1-12-g93aafbb`, mxl demuxer and muxer -registered). Runs covered: GPU zero-copy, GPU with `--no-zero-copy` and -`--gpu-unpack off`, against both an `lavfi testsrc` and a looped file, -at 320×240p25 and 640×480p25. All of them ran without node failures, -started at PTS 0, reported 25.0 fps (25.06 on the NVENC path) in the -container header, and decoded back to the source pattern. + → 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`. + +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. ## Requirements -* Linux x86_64 (MXL SDK is Linux-only — no macOS support). -* Docker with enough tmpfs at `/dev/shm` (default is fine for the demo). +* 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: with them (`--gpus all`) the reader unpacks on the GPU - and encodes with NVENC; without them it falls back to the CPU v210 - decoder. Other demos in the shared image do require CUDA. -* The FFmpeg 8.x series applied to `n8.1` (or `n8.0`). - `deps/ffmpeg/verify.sh n8.1 ` confirms the tree hash; the - MXL patch (`8/0011-...`) is generated from `cbcrc/FFmpeg` branch - `dmf-mxl/8.1` via `deps/ffmpeg/Dockerfile.mkpatch` — see - `deps/ffmpeg/README.md`. + 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 -This demo shares its runtime image with `demos/mixer/`. Build once -from the repository root (after `git submodule update --init --recursive`): +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 . ``` -The image build: - -1. Installs distro `gcc-11` plus `gcc-13` from - `ppa:ubuntu-toolchain-r/test` (the MXL SDK needs C++20; FFmpeg and - avplumber keep using gcc-11). -2. Bootstraps `microsoft/vcpkg` and Rust 1.88.0. -3. Builds and installs the MXL SDK (`dmf-mxl/mxl` @ `v1.1.0`) - into `/usr/local` — libmxl is statically linked against spdlog, so - its `.pc` file is scrubbed of the private `Requires` before FFmpeg - configure. -4. Applies the eleven-patch FFmpeg 8.x series (`deps/ffmpeg/apply.sh`) - to FFmpeg `n8.1` and configures with CUDA (`--enable-cuda - --enable-cuda-nvcc --enable-cuvid --enable-nvdec --enable-nvenc`) - plus MXL (`--enable-libmxl --enable-demuxer=mxl --enable-muxer=mxl - --enable-protocol=mxl`). -5. Builds `pyplumber` with `HAVE_CUDA=1 HAVE_NVCC=1`. - -Verified inside the image: - -```sh -ffmpeg -hide_banner -demuxers | grep mxl # D mxl Media eXchange Layer -ffmpeg -hide_banner -muxers | grep mxl # E mxl Media eXchange Layer -python3 -c 'import pyplumber' # OK -``` +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 shared `/dev/shm/mxl` domain directory with an -`options.json` file. On a single host use `--ipc=host`: +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 only ~2 grains at -# 25 fps, which leaves zero-copy readers no slack at all. +# 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 @@ -102,125 +80,102 @@ docker run --rm --ipc=host --gpus all \ avplumber-mixer:local /build/demos/mxl/mxl_demo.py ``` -Drop `--gpus all` to force the CPU reader path (`--gpu-unpack` prints -which one it picked). The flow geometry defaults to the built-in -testsrc's 320×240p25; pass `--width/--height/--fps` (or `AVP_WIDTH`, -`AVP_HEIGHT`, `AVP_FPS`) to change it — the writer rescales to match, -because packed v210 carries no dimensions and the reader derives the -row stride from `--width`. - -Defaults to publishing an `lavfi testsrc` pattern; override with -`-e AVP_INPUT=/media/your-file.mp4` for a real file (played back -looped and realtime-paced through `InputRec`). - -Runs both graphs in one process. Stop it with `docker kill` (see Known -gaps — Ctrl-C hangs in teardown). The output stays playable anyway: -the reader-side 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 out only when it fills, so a low-bitrate run that is killed -rather than closed leaves a 28-byte file containing just the `ftyp` -box. - -Split writer and reader across two containers by passing -`--writer-only` / `--reader-only` and sharing the flow UUID via -`AVP_MXL_VIDEO_ID`. +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. + +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. ## Codec choices -MXL flows carry uncompressed frames. The muxer registered by -`8/0011-*.patch` insists on `v210` (10-bit 4:2:2 packed) for video — -`rawvideo` is rejected at header write. The GPU reader path encodes -H.264 with NVENC; the CPU fallback uses `mpeg4` (rather than H.264) -because the image does not link `libx264`. +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 is given `g=` — one keyframe per second instead of its -default 250-frame GOP — because `frag_keyframe` cuts a fragment per -keyframe, and that is what makes the growing file playable a second in -rather than ten. +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. +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 entirely: it -takes its own grain index from `mxlGetCurrentIndex` at header write and -increments it once per packet. So the *writer* must be paced, or grain -N lands in shared memory long before wall-clock N/fps. An unpaced lavfi -source published ~2300 grains/s here, which ran the flow seconds into -the future and collapsed the ring's usable history to milliseconds — -every reader was then "too late". Hence `realtime(set_pts=1)` followed -by `force_fps` between the writer's decoder and the v210 encoder. - -Three demuxer options matter on the reader: - -* `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 already reads a - grain, so the index is chosen when the node is *created*, several - seconds before the group starts (CUDA and NVENC init sit in between). - By then it is behind the ring tail. Without `reset` the demuxer - returns `EAGAIN`, which `input.cpp` treats as fatal, and the node - fails with "Resource temporarily unavailable". -* `reset_on_drop=1` — resetting the index leaves a hole where the - skipped grains would have been (a 5.8 s leading PTS gap, and a - container frame rate of 19.4 fps instead of 25). This rebases the - timestamps after the reset. +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). ## Zero-copy grains -`zero_copy=1` (default; `--no-zero-copy` opts out) makes the demuxer -wrap the grain payload in an `AVBufferRef` pointing into the shared -memory ring rather than `memcpy`-ing it into a fresh packet. The -release callback in the patch is a no-op — **nothing holds a reference -on the grain** — so the bytes are valid only until the writer laps that -slot in the ring. Two things keep that safe here: - -* `queue.plan_capacity r_vpkt 1` bounds the packet queue to a single - in-flight grain, so a packet cannot sit and age behind others. -* `history_duration` in `options.json` sizes the ring; 1 s (25 grains at - 25 fps) gives a large margin over that one queued packet. - -A reader that stalls for longer than the ring depth will silently see +`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. `--no-zero-copy` trades one memcpy per frame -for immunity. +is marked experimental. -Note that zero-copy removes the shm→packet copy only. `v210_to_cuda` -still stages each grain through pinned host memory on its way to the -GPU; making that leg copy-free would mean `cudaHostRegister`-ing the MXL -ring, which is a change to the node in `src/`. +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 -* The reader is not paced: it runs at wall-clock max. Adding a - `realtime` node (as the writer has) before the encoder would cap it - to the flow frame rate. Running - uncapped is harmless in itself — `blocking=1` parks the reader on the - writer's head — it just spends CPU on `blocking` waits. -* **Ctrl-C does not shut the demo down.** SIGINT leaves the process - stuck in teardown: every node thread is alive in a timed poll and the - main thread waits in `futex_do_wait`. This reproduces on the GPU and - CPU reader paths alike and predates this demo's reader wiring, so it - looks like a framework-level stop-ordering issue rather than anything - MXL-specific. Use `docker kill` / SIGKILL meanwhile. -* Restarting the reader group has the same shape of problem: it parks at - "Stopping node r_unpack ...". `auto_restart` is therefore `off` on - every reader node except `r_input`. -* With a looped file source, each 5 s wraparound logs `EventLoop - negative time to wait, resyncing` on the writer and a couple of - dropped frames in `force_fps`, and the reader sees a burst of "too - early" waits. Output is unaffected, but a live flow would rather have - a seamless looper. -* The write path still goes through swscale and the CPU `v210` encoder, - because nothing packs CUDA frames back to v210 yet. The read path no - longer does (with `--gpu-unpack`). +* **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. +* The write path still goes through swscale and the CPU `v210` encoder; + nothing packs CUDA frames back to v210 yet. ## References * MXL SDK: -* MXL FFmpeg fork (source for the 0011 patch, branch `dmf-mxl/8.1`): +* MXL FFmpeg fork (source for patch 0011, branch `dmf-mxl/8.1`): * Reference build guidance: diff --git a/deps/ffmpeg/README.md b/deps/ffmpeg/README.md index 23442806..e02e3d1b 100644 --- a/deps/ffmpeg/README.md +++ b/deps/ffmpeg/README.md @@ -54,9 +54,9 @@ and the filter changes) is base-independent. 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`), which forks from the same - n8.1 commit `8/bases.env` pins. Consumed by `demos/mixer/Dockerfile` for - `demos/mxl`; the MXL SDK itself is pinned in that Dockerfile. + branch `dmf-mxl/8.1`, pinned at `9eddb90`. 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` From 131a9f86d48a5887ca01722c616039fd896bf724 Mon Sep 17 00:00:00 2001 From: Jan Pietek Date: Fri, 18 Sep 2026 16:25:04 +0200 Subject: [PATCH 09/19] MXL on develop: patch 0010, pyplumber.mixer import, README merge leftovers Co-Authored-By: Claude Fable 5.1 --- demos/mxl/README.md | 4 ++-- demos/mxl/mxl_demo.py | 2 +- deps/ffmpeg/8/bases.env | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 579b687b..5b306870 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -39,7 +39,7 @@ pattern. 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` — + `8/0010-*` is generated from `cbcrc/FFmpeg` branch `dmf-mxl/8.1` — see [`deps/ffmpeg/README.md`](../../deps/ffmpeg/README.md). ## Build @@ -177,5 +177,5 @@ mean `cudaHostRegister`-ing the MXL ring, i.e. a change to the node in ## References * MXL SDK: -* MXL FFmpeg fork (source for patch 0011, branch `dmf-mxl/8.1`): +* MXL FFmpeg fork (source for patch 0010, branch `dmf-mxl/8.1`): * Reference build guidance: diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py index 5db42e82..c8b45a93 100644 --- a/demos/mxl/mxl_demo.py +++ b/demos/mxl/mxl_demo.py @@ -39,7 +39,7 @@ Output, Realtime, ) -from avpmixer.inputs import v210_row_stride +from pyplumber.mixer.inputs import v210_row_stride # Name of the CUDA device the GPU reader path initializes and shares. _HWACCEL = "@gpu" diff --git a/deps/ffmpeg/8/bases.env b/deps/ffmpeg/8/bases.env index 4c4cfd75..dd13e314 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=9710194f9e826260dbe3b12521ed7fd81f18b8db n81_commit=9047fa1b084f76b1b4d065af2d743df1b40dfb56 -n81_tree=e281422964996db696f00c63ef0b287cf94e07de +n81_tree=739eaf7a86756dc83e179ce8957d98c6d2afd76c patch_count=11 From 75f78598a65010621f2eb02ff83e4ad6ee2a311d Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Sun, 20 Sep 2026 16:30:03 +0200 Subject: [PATCH 10/19] demos/mxl: measure throughput, and document the reset assert --bench-seconds samples enqueued_total on the writer and reader encoder edges once a second and prints a summary; polling that counter keeps the measurement off the media path, unlike a wiretap callback. It exits with os._exit because graceful teardown hangs. --writer-pace off drops realtime + force_fps to find the writer's ceiling. Running it against the CPU reader exposed an abort: with reset_on_drop=1 a mid-stream "too late" reset sets mxl_start_grain_index to the current grain, so the derived PTS drops back to 0 and the monotonic-PTS av_assert0 in mxldec.c:1393 kills the process. Documented for now, not fixed. Numbers on 1080p59.94 smptehdbars in the README. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 39 ++++++++++++ demos/mxl/mxl_demo.py | 144 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 166 insertions(+), 17 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 5b306870..b3918120 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -90,6 +90,12 @@ 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. + 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` @@ -98,6 +104,33 @@ 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`: + +| case | writer | reader | CPU | NVENC | +|---|---|---|---|---| +| GPU unpack, zero-copy | 59.94 fps | 59.94 fps | 0.36 cores | 5% | +| GPU unpack, `--no-zero-copy` | 59.94 fps | 59.94 fps | 0.38 cores | 5% | +| CPU unpack, zero-copy | 59.94 fps | 59.94 fps | 0.70 cores | — | +| `--writer-pace off`, GPU, zero-copy | 327 fps | 327 fps | 1.52 cores | 26% | +| `--writer-pace off`, GPU, `--no-zero-copy` | 328 fps | 328 fps | 1.62 cores | 27% | +| `--writer-pace off`, `--writer-only` | 351 fps | — | 1.23 cores | — | + +Realtime 1080p59.94 is not demanding: a tenth of one core per side, and +the GPU is idle at 2% SM. The ceiling is the writer — swscale to +yuv422p10le plus the CPU `v210` encoder reach ~350 fps (1.9 GB/s into +`/dev/shm`) on their own, and attaching the GPU reader costs ~7% of that +for ~0.3 extra cores. Zero-copy is worth ~0.1 cores at 327 fps +(~0.02 at 59.94): real but small next to the unpack itself, which the GPU +path removes (0.70 → 0.36 cores). + +`--writer-pace off --gpu-unpack off` aborts after a few seconds — the +CPU reader falls behind, resets, and trips the demuxer assert described +under [Timing and grain indices](#timing-and-grain-indices). + ## Codec choices MXL flows carry uncompressed frames, and the muxer insists on `v210` for @@ -136,6 +169,12 @@ Reader-side demuxer options: * `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). + It is only safe for the reset at startup: the rebase sets + `mxl_start_grain_index` to the current grain, so a *mid-stream* reset + drives the derived PTS back to 0 and trips the monotonic-PTS + `av_assert0` at `mxldec.c:1393`, aborting the process. Reproducible in + seconds with `--writer-pace off --gpu-unpack off`, where the reader + cannot keep up with the flood. ## Zero-copy grains diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py index c8b45a93..23e92ab2 100644 --- a/demos/mxl/mxl_demo.py +++ b/demos/mxl/mxl_demo.py @@ -116,6 +116,33 @@ def _parse_args() -> argparse.Namespace: "falls back to the CPU v210 decoder and mpeg4." ), ) + 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", @@ -215,26 +242,30 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: # 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. - 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", - })) + 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. avp.addNode(RescaleVideo({ - "src": "w_vpaced", + "src": paced_src, "dst": "w_vscaled", "group": "w_in", "name": "w_scale", @@ -470,6 +501,82 @@ def _build_cpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, })) +def _edge_counter(avp: pyplumber.AVPlumber, name: str): + """Total-enqueued reader for an edge, or None if it does not exist. + + Edges materialize with their producing node, so this is called after + the groups have started. + """ + try: + edge = avp.getEdge(name, "packet") + except Exception as exc: # edge missing: node failed + print(f"bench: no edge {name}: {exc}") + return None + return lambda: edge.enqueued_total + + +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, c) for n, c in ( + ("writer", None if args.reader_only else _edge_counter(avp, "w_venc")), + ("reader", None if args.writer_only else _edge_counter(avp, "r_venc")), + ) 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} + 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 + 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'}") + 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") + # 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: @@ -499,6 +606,9 @@ def main() -> int: time.sleep(1.0) avp.group("r_in").startNodes() + if args.bench_seconds: + return _bench(avp, args) + try: while True: time.sleep(1) From 9208b54b91c12c0d5d58ff2a49e07bc7248cff52 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Sun, 20 Sep 2026 20:58:16 +0200 Subject: [PATCH 11/19] deps/ffmpeg: keep MXL pts monotonic across a reset_on_drop reset The fork's demuxer rebases the pts origin onto the grain the reader resumes at. At startup that is correct; mid-stream it drives the derived pts back to 0 and trips the monotonic-pts av_assert0 a few lines below, aborting the process. Rebase so the first packet after a reset continues one past the last one delivered instead, on both the video and audio paths. Reproducible in seconds with demos/mxl and `--writer-pace off --gpu-unpack off`, where the reader cannot keep up with the flood and on_too_late=reset fires repeatedly. The change is carried in mkpatch-fixups/ and git am-ed by mkpatch-0010-mxl.sh after the cherry-picks, so it lands inside the squashed patch 0010 with its commit message listed as provenance, and retiring it once the fork carries a fix is one file deletion plus a regeneration. Both base trees in 8/bases.env refreshed accordingly (verify.sh passes for n8.0 and n8.1). Co-Authored-By: Claude Opus 5 --- .../0011-avformat-libmxl-demuxer-muxer.patch | 44 +++++++++---- deps/ffmpeg/8/bases.env | 4 +- deps/ffmpeg/Dockerfile.mkpatch | 1 + deps/ffmpeg/README.md | 12 +++- deps/ffmpeg/mkpatch-0011-mxl.sh | 13 ++++ ...001-mxldec-monotonic-pts-after-reset.patch | 64 +++++++++++++++++++ 6 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 deps/ffmpeg/mkpatch-fixups/0001-mxldec-monotonic-pts-after-reset.patch diff --git a/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch b/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch index 1debb052..cc27c1a3 100644 --- a/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch +++ b/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch @@ -1,6 +1,6 @@ -From 7b03b2fa7bd85259cd3c0c7392e69a2d04a5597f Mon Sep 17 00:00:00 2001 +From 3e74ea3f9d721d2cc1013c99a95235e49176d4d7 Mon Sep 17 00:00:00 2001 From: avplumber mxl builder -Date: Wed, 16 Sep 2026 19:47:16 +0000 +Date: Sun, 20 Sep 2026 15:04:43 +0000 Subject: [PATCH] avformat/mxl: add libmxl demuxer and muxer Adds the MXL demuxer, muxer, URI parser, JSON/diagnostic helpers @@ -8,13 +8,15 @@ 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 +series 0001-0009, with the configure hunks re-anchored so the patch applies to both n8.0 and n8.1. Original commits: - * ec72cbaefc ffmpeg 8.1 port - * 196ba57d71 Apply FFmpeg 8 MXL integration to FFmpeg 8.1 + * b099cc6224 avformat/mxldec: keep pts monotonic across a reset_on_drop reset + * ff4066273e ffmpeg 8.1 port + * 8eda88cfc1 Apply FFmpeg 8 MXL integration to FFmpeg 8.1 +Co-authored-by: avplumber mxl builder Co-authored-by: jpt Co-authored-by: jpt --- @@ -37,7 +39,7 @@ Co-authored-by: jpt libavformat/mxl_status.h | 59 + libavformat/mxl_uri.c | 304 +++++ libavformat/mxl_uri.h | 118 ++ - libavformat/mxldec.c | 1974 +++++++++++++++++++++++++++++++ + libavformat/mxldec.c | 1990 +++++++++++++++++++++++++++++++ libavformat/mxlenc.c | 893 ++++++++++++++ libavformat/tests/mxl_diag.c | 1471 +++++++++++++++++++++++ libavformat/tests/mxl_json.c | 1076 +++++++++++++++++ @@ -58,7 +60,7 @@ Co-authored-by: jpt tests/ref/fate/mxl-uri | 1176 ++++++++++++++++++ tests/ref/fate/mxl-video-encdec | 14 + tests/ref/fate/mxl-video-probe | 10 + - 40 files changed, 13693 insertions(+) + 40 files changed, 13709 insertions(+) create mode 100644 libavformat/jsmn.c create mode 100644 libavformat/jsmn.h create mode 100644 libavformat/mxl_common.h @@ -3248,10 +3250,10 @@ index 0000000000..ce0e67131a +#endif /* AVFORMAT_MXL_URI_H */ diff --git a/libavformat/mxldec.c b/libavformat/mxldec.c new file mode 100644 -index 0000000000..404f0577fd +index 0000000000..655ccb5152 --- /dev/null +++ b/libavformat/mxldec.c -@@ -0,0 +1,1974 @@ +@@ -0,0 +1,1990 @@ +/* + * MXL demuxer for Media eXchange Layer flows + * @@ -4532,8 +4534,18 @@ index 0000000000..404f0577fd + } + + 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; + } + } + @@ -4903,8 +4915,14 @@ index 0000000000..404f0577fd + } + + 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; + } + } + diff --git a/deps/ffmpeg/8/bases.env b/deps/ffmpeg/8/bases.env index dd13e314..77045b02 100644 --- a/deps/ffmpeg/8/bases.env +++ b/deps/ffmpeg/8/bases.env @@ -1,5 +1,5 @@ n80_commit=140fd653aed8cad774f991ba083e2d01e86420c7 -n80_tree=9710194f9e826260dbe3b12521ed7fd81f18b8db +n80_tree=9fbd0cf64609767ff664ccea7cb3b3cff906a851 n81_commit=9047fa1b084f76b1b4d065af2d743df1b40dfb56 -n81_tree=739eaf7a86756dc83e179ce8957d98c6d2afd76c +n81_tree=7573ce4137633f6f624d11a63ffc653e88b9c80f patch_count=11 diff --git a/deps/ffmpeg/Dockerfile.mkpatch b/deps/ffmpeg/Dockerfile.mkpatch index ef962352..5f10ca55 100644 --- a/deps/ffmpeg/Dockerfile.mkpatch +++ b/deps/ffmpeg/Dockerfile.mkpatch @@ -29,6 +29,7 @@ RUN apt-get update \ 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 e02e3d1b..7dd9c15d 100644 --- a/deps/ffmpeg/README.md +++ b/deps/ffmpeg/README.md @@ -54,9 +54,11 @@ and the filter changes) is base-independent. 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`. Built into the shared demo - image by `demos/mixer/Dockerfile` (which also pins the MXL SDK) and - exercised by `demos/mxl`. + 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` @@ -72,6 +74,10 @@ docker run --rm \ 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 diff --git a/deps/ffmpeg/mkpatch-0011-mxl.sh b/deps/ffmpeg/mkpatch-0011-mxl.sh index cc333604..03d7b97c 100755 --- a/deps/ffmpeg/mkpatch-0011-mxl.sh +++ b/deps/ffmpeg/mkpatch-0011-mxl.sh @@ -24,6 +24,7 @@ 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' "$*"; } @@ -143,4 +144,16 @@ while [ "$rc" -ne 0 ]; do 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-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 + From c7af92ddadf2c0c2d4f75ff56a84dc5003d44cb2 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Sun, 20 Sep 2026 23:16:28 +0200 Subject: [PATCH 12/19] demos/mxl: re-measure throughput on an idle host, drop the abort caveat The earlier table was taken while an unrelated container was using ~2 of the 4 vCPU, so every ceiling in it was understated: the writer alone now reaches 567 fps rather than 351, and with the GPU reader attached both sides sustain 507 fps. Realtime figures barely move (the target was met either way) but drop to 0.27 cores. The unpaced CPU-unpack case no longer aborts now that patch 0010 keeps the pts monotonic across a reset, so it is a row in the table instead of a warning, and it shows what the CPU reader actually costs: 192 fps against the writer's 419, with all four cores busy. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 50 ++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index b3918120..2c792e9d 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -110,26 +110,24 @@ the `ftyp` box. (4 vCPU of an EPYC 9474F, RTX 4000 Ada), 60 s per case after a 10 s warmup, via `--bench-seconds`: -| case | writer | reader | CPU | NVENC | -|---|---|---|---|---| -| GPU unpack, zero-copy | 59.94 fps | 59.94 fps | 0.36 cores | 5% | -| GPU unpack, `--no-zero-copy` | 59.94 fps | 59.94 fps | 0.38 cores | 5% | -| CPU unpack, zero-copy | 59.94 fps | 59.94 fps | 0.70 cores | — | -| `--writer-pace off`, GPU, zero-copy | 327 fps | 327 fps | 1.52 cores | 26% | -| `--writer-pace off`, GPU, `--no-zero-copy` | 328 fps | 328 fps | 1.62 cores | 27% | -| `--writer-pace off`, `--writer-only` | 351 fps | — | 1.23 cores | — | - -Realtime 1080p59.94 is not demanding: a tenth of one core per side, and -the GPU is idle at 2% SM. The ceiling is the writer — swscale to -yuv422p10le plus the CPU `v210` encoder reach ~350 fps (1.9 GB/s into -`/dev/shm`) on their own, and attaching the GPU reader costs ~7% of that -for ~0.3 extra cores. Zero-copy is worth ~0.1 cores at 327 fps -(~0.02 at 59.94): real but small next to the unpack itself, which the GPU -path removes (0.70 → 0.36 cores). - -`--writer-pace off --gpu-unpack off` aborts after a few seconds — the -CPU reader falls behind, resets, and trips the demuxer assert described -under [Timing and grain indices](#timing-and-grain-indices). +| 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 | — | — | + +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 unpack itself matters more, halving realtime CPU (0.51 → 0.27 +cores) and, unpaced, capping the CPU reader at 192 fps against the +writer's 419 while saturating all four cores. ## Codec choices @@ -169,12 +167,12 @@ Reader-side demuxer options: * `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). - It is only safe for the reset at startup: the rebase sets - `mxl_start_grain_index` to the current grain, so a *mid-stream* reset - drives the derived PTS back to 0 and trips the monotonic-PTS - `av_assert0` at `mxldec.c:1393`, aborting the process. Reproducible in - seconds with `--writer-pace off --gpu-unpack off`, where the reader - cannot keep up with the flood. + 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 `0010` now continues from the last delivered + PTS instead (see `deps/ffmpeg/mkpatch-fixups/`). ## Zero-copy grains From b52a8f349110894f11f9adb371759a647265865e Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Mon, 21 Sep 2026 12:54:51 +0200 Subject: [PATCH 13/19] rescale_video: apply the flags parameter, pass matching frames through The node parsed "flags" into sws_flags_ and then built av::VideoRescaler without it, so every graph in the tree scaled with avcpp's default pick (bicubic when upscaling, area otherwise) no matter what it asked for. Graphs that request SWS_LANCZOS -- examples/complicated_transcoder, examples/from_dmabuf_hwdownload, pyplumber/examples/simple-node -- now get it, and pay for it where they actually resize. While there: converting a frame to the geometry and format it already has still costs swscale a full-frame copy, 1.0 ms and 8 MB per frame at 1080p 10-bit 4:2:2. Hand such frames straight to the sink instead. Edges carry reference-counted frames and no software node in src/ writes pixels in place, so nothing downstream can tell. Co-Authored-By: Claude Opus 5 --- doc/NODES.md | 9 ++++++++- src/nodes/rescale_video.cpp | 27 +++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/doc/NODES.md b/doc/NODES.md index e60ffd6e..f8dea1ba 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` diff --git a/src/nodes/rescale_video.cpp b/src/nodes/rescale_video.cpp index 9830daab..85357b64 100644 --- a/src/nodes/rescale_video.cpp +++ b/src/nodes/rescale_video.cpp @@ -13,15 +13,30 @@ class DynamicVideoScaler: public NodeSISO, 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()); From 4f428b9d78db4a615ab890fe8c1886bd497eddeb Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Mon, 21 Sep 2026 12:55:06 +0200 Subject: [PATCH 14/19] demos/mxl: per-node CPU accounting, and reader tails that skip the encoder Measuring "the MXL reader" with the full tail mostly measured mpeg4, which is 3.58 of the reader's 5.9 ms of CPU per frame. Add --reader-tail {demux,unpack,encode} to drop grains at either end of the unpack, and a per-node CPU table taken from /proc/self/task: every node runs in a thread named after it, so the profiler is free and stays off the media path. Add --sws-flags to pick the swscale algorithm for the two rescale nodes, now that rescale_video honours it. Fix the bench edge counters, which is what made --reader-tail unpack fail on the GPU path: startNodes() only queues the group's state change, so getEdge() ran while the nodes were still being created and *created* r_vcuda as a Packet edge, after which the real node failed forever. Wait for the edge in the stats JSON, which never creates one. What the numbers say, in README: MXL transport is not the cost centre (publish is one memcpy at 0.27 ms/frame, zero-copy read is 0.07 against 0.38), v210_to_cuda costs as much host CPU as the libavcodec v210 decoder it replaces and wins only by skipping swscale and the encoder, and unpaced the writer is the limit until the output encoder enters and pins one core. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 82 +++++++++++++- demos/mxl/mxl_demo.py | 254 +++++++++++++++++++++++++++++++++++------- 2 files changed, 294 insertions(+), 42 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 2c792e9d..f8cd0de2 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -96,6 +96,20 @@ 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, +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. `--sws-flags` picks the swscale algorithm for the writer's and the +CPU reader's `rescale_video` nodes (`fast_bilinear`, `neighbor`, ...). + 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` @@ -119,15 +133,74 @@ warmup, via `--bench-seconds`: | `--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 | — | — | 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 unpack itself matters more, halving realtime CPU (0.51 → 0.27 -cores) and, unpaced, capping the CPU reader at 192 fps against the -writer's 419 while saturating all four cores. +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. + +## 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. ## Codec choices @@ -187,6 +260,9 @@ 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 diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py index 23e92ab2..fb36ebba 100644 --- a/demos/mxl/mxl_demo.py +++ b/demos/mxl/mxl_demo.py @@ -105,6 +105,29 @@ def _parse_args() -> argparse.Namespace: 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", "unpack", "demux"), + default=os.environ.get("AVP_READER_TAIL", "encode"), + help=( + "What the reader does with each grain. 'encode' (default) " + "writes --output. The two measurement tails drop the data " + "instead: 'unpack' after the v210 unpack, 'demux' straight out " + "of the demuxer. They isolate the MXL read and the unpack 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-unpack", choices=("auto", "on", "off"), @@ -184,6 +207,17 @@ def _fps_rounded(fps: str) -> int: return max(1, round(float(num) / float(den))) +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 @@ -227,6 +261,7 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "src": "w_in", "routing": {"v:0": "w_vpkt"}, "group": "w_in", + "name": "w_demux", "auto_restart": "off", })) avp.addNode(DecVideo({ @@ -263,8 +298,9 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: else: paced_src = "w_vframe" # v210 requires 10-bit 4:2:2 planar. Convert explicitly so the - # encoder's metadata chain is unambiguous. - avp.addNode(RescaleVideo({ + # encoder's metadata chain is unambiguous. This up-convert is the + # writer's single most expensive step, so it takes --sws-flags. + scale_params = { "src": paced_src, "dst": "w_vscaled", "group": "w_in", @@ -276,11 +312,15 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "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)) 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", @@ -299,6 +339,7 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "src": ["w_venc"], "dst": "w_mux", "group": "w_in", + "name": "w_mux", })) mxl_options: dict[str, str] = {} if args.video_flow_id: @@ -311,6 +352,7 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "format": "mxl", "options": mxl_options, "group": "w_in", + "name": "w_output", "auto_restart": "off", })) @@ -320,9 +362,11 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: 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. + decoder plus swscale feed the mpeg4 encoder. `--reader-tail` cuts the + chain short after the demuxer or the unpack, for measuring those in + isolation. """ - from pyplumber.node import RescaleVideo, AssumeVideoFormat + from pyplumber.node import NullSink if args.zero_copy: # Zero-copy hands out AVPackets pointing straight into the MXL @@ -365,16 +409,36 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "src": "r_in", "routing": {"v:0": "r_vpkt"}, "group": "r_in", + "name": "r_demux", "auto_restart": "off", })) + if args.reader_tail == "demux": + avp.addNode(NullSink({ + "src": "r_vpkt", + "group": "r_in", + "name": "r_sink", + "auto_restart": "off", + })) + return + unpacked = (_build_gpu_unpack(avp, args) if args.gpu_unpack + else _build_cpu_unpack(avp, args)) + if args.reader_tail == "unpack": + avp.addNode(NullSink({ + "src": unpacked, + "group": "r_in", + "name": "r_sink", + "auto_restart": "off", + })) + return if args.gpu_unpack: - _build_gpu_reader_tail(avp, args) + _build_gpu_encode_tail(avp, args, unpacked) else: - _build_cpu_reader_tail(avp, args, RescaleVideo, AssumeVideoFormat) + _build_cpu_encode_tail(avp, args, unpacked) avp.addNode(Mux({ "src": ["r_venc"], "dst": "r_mux", "group": "r_in", + "name": "r_mux", })) avp.addNode(Output({ "src": "r_mux", @@ -391,20 +455,20 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "flush_packets": "1", }, "group": "r_in", + "name": "r_output", "auto_restart": "off", })) -def _build_gpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: - """r_vpkt -> v210_to_cuda -> scale_cuda -> NVENC -> r_venc. +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. NVENC also - sidesteps the CPU path's mpeg4 fallback (the image has no libx264). + the PTX kernel, and never touches host memory again. """ - from pyplumber.node import AssumeVideoFormat, FilterVideo, V210ToCuda + from pyplumber.node import V210ToCuda avp.addNode(V210ToCuda({ "src": "r_vpkt", @@ -425,10 +489,22 @@ def _build_gpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace) - "color_range": "tv", "auto_restart": "off", })) + return "r_vcuda" + + +def _build_gpu_encode_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, + src: str) -> None: + """CUDA frames -> scale_cuda -> NVENC -> r_venc. + + NVENC sidesteps the CPU path's mpeg4 fallback (the image has no + libx264) and keeps the frames on the GPU end to end. + """ + from pyplumber.node import AssumeVideoFormat, FilterVideo + # NVENC only takes 4:2:2 10-bit on Blackwell-class hardware, so # convert on the GPU to 8-bit 4:2:0 for the demo's output file. avp.addNode(FilterVideo({ - "src": "r_vcuda", + "src": src, "dst": "r_vnv12", "group": "r_in", "name": "r_filter", @@ -440,6 +516,7 @@ def _build_gpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace) - "src": "r_vnv12", "dst": "r_vassumed", "group": "r_in", + "name": "r_assume", "width": args.width, "height": args.height, "pixel_format": "cuda", @@ -462,9 +539,8 @@ def _build_gpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace) - })) -def _build_cpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, - RescaleVideo, AssumeVideoFormat) -> None: - """r_vpkt -> CPU v210 decode -> swscale -> mpeg4 -> r_venc.""" +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", @@ -472,19 +548,33 @@ def _build_cpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, "name": "r_dec", "auto_restart": "off", })) - # mpeg4 needs yuv420p — rescale from the 10-bit 4:2:2 flow. - avp.addNode(RescaleVideo({ - "src": "r_vframe", + return "r_vframe" + + +def _build_cpu_encode_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, + src: str) -> None: + """yuv422p10le frames -> swscale -> mpeg4 -> r_venc.""" + from pyplumber.node import RescaleVideo, AssumeVideoFormat + + # mpeg4 needs yuv420p — rescale from the 10-bit 4:2:2 flow. This + # conversion is the reader's most expensive step after the encoder + # itself, so it takes --sws-flags. + scale_params = { + "src": src, "dst": "r_vscaled", "group": "r_in", "name": "r_scale", "dst_pixel_format": "yuv420p", "auto_restart": "off", - })) + } + if flags := _sws_flags(args.sws_flags): + scale_params["flags"] = flags + avp.addNode(RescaleVideo(scale_params)) avp.addNode(AssumeVideoFormat({ "src": "r_vscaled", "dst": "r_vscaled_assumed", "group": "r_in", + "name": "r_assume", "width": args.width, "height": args.height, "pixel_format": "yuv420p", @@ -501,20 +591,89 @@ def _build_cpu_reader_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, })) -def _edge_counter(avp: pyplumber.AVPlumber, name: str): - """Total-enqueued reader for an edge, or None if it does not exist. +def _edge_counter(avp: pyplumber.AVPlumber, name: str, timeout: float = 30.0): + """Total-enqueued reader for an edge, or None if it never appears. - Edges materialize with their producing node, so this is called after - the groups have started. + `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. """ - try: - edge = avp.getEdge(name, "packet") - except Exception as exc: # edge missing: node failed - print(f"bench: no edge {name}: {exc}") - return None + 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 == "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. @@ -523,26 +682,32 @@ def _bench(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> int: which would take the GIL per packet. """ target = _fps_rounded(args.fps) - counters = [(n, c) for n, c in ( - ("writer", None if args.reader_only else _edge_counter(avp, "w_venc")), - ("reader", None if args.writer_only else _edge_counter(avp, "r_venc")), + 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} + 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} + 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: + for name, _, counter in counters: total = counter() fps = (total - prev[name]) / dt prev[name] = total @@ -553,6 +718,10 @@ def _bench(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> int: 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) @@ -560,8 +729,10 @@ def _bench(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> int: 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'}") - for name, counter in counters: + f"zero_copy {'on' if args.zero_copy else 'off'}, " + f"reader_tail {args.reader_tail}, " + f"sws_flags {args.sws_flags or 'default'}") + for name, _, counter in counters: got = samples[name] if not got: continue @@ -571,6 +742,10 @@ def _bench(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> int: 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() @@ -591,7 +766,8 @@ def main() -> int: if not args.reader_only: _build_writer(avp, args) if not args.writer_only: - if args.gpu_unpack: + # The demux-only tail never unpacks, so it needs no CUDA device. + if args.gpu_unpack and args.reader_tail != "demux": avp.executeCommandsFromString( 'hwaccel.init { "name": "%s", "type": "cuda" }' % _HWACCEL ) From 4896108081ffc26a6f40f1cbfde882d1167edd6b Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Mon, 21 Sep 2026 13:39:48 +0200 Subject: [PATCH 15/19] demos/mxl: scale and convert on the GPU, and price the PCIe round trip The two rescale_video nodes are the largest per-frame CPU cost after the output encoder, so add --gpu-scale {writer,reader,both} to hand them to scale_cuda instead (hwupload,scale_cuda,hwdownload, with --cuda-interp as its --sws-flags). What that actually saves depends on how many times the frame crosses PCIe, so add --reader-encoder {mpeg4,nvenc} to decouple the output encoder from --gpu-unpack: the same conversion can then be timed with an upload, a download, both, or neither. --reader-tail gains "scale", which drops the frame right after the conversion. Reorganise the reader around that: _build_convert and _build_encoder replace the two whole-tail builders, each tail exits through one _drop, and hwaccel.init now runs before both graphs are built, gated on whether any chosen node actually wants the device. v210_to_cuda stamps the frame period rather than 90 kHz when mpeg4 is the output, because mpeg4 rejects any timebase denominator above 65535. What the measurements say, in README: the host CPU cost of a GPU conversion is 0.04 ms plus every transferred byte at ~19 GB/s -- unpinned frames are staged through the driver's own pinned buffer, and the conversion itself adds nothing measurable, which nvidia-smi dmon corroborates byte for byte. So the GPU wins outright where it is handed a frame it does not have to move (0.04 and 0.22 against swscale's 1.6-1.7 ms/frame) and wins less than it looks like where it pays both transfers (0.61, a third of which mpeg4 takes back on frames arriving cold from DMA). A resize is the clear case: lanczos to 720p costs 0.36 ms/frame on the GPU against 3.18 in swscale, and a third of what swscale's *area* resize costs. Both paths agree to SSIM 0.9996. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 108 ++++++++++++- demos/mxl/mxl_demo.py | 359 ++++++++++++++++++++++++++++-------------- 2 files changed, 339 insertions(+), 128 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index f8cd0de2..a7dc830b 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -21,6 +21,11 @@ Two graphs run in one process: * **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 @@ -104,11 +109,20 @@ 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, -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. `--sws-flags` picks the swscale algorithm for the writer's and the -CPU reader's `rescale_video` nodes (`fast_bilinear`, `neighbor`, ...). +`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). Stop the demo with `docker kill` — Ctrl-C hangs in teardown (see [Known gaps](#known-gaps)). The output stays playable regardless: the @@ -134,6 +148,7 @@ warmup, via `--bench-seconds`: | `--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 | 7% | — | 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` @@ -202,6 +217,81 @@ 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 | + +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, 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. + ## Codec choices MXL flows carry uncompressed frames, and the muxer insists on `v210` for @@ -284,8 +374,12 @@ mean `cudaHostRegister`-ing the MXL ring, i.e. a change to the node in 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. -* The write path still goes through swscale and the CPU `v210` encoder; - nothing packs CUDA frames back to v210 yet. +* The write path always ends in the CPU `v210` encoder; nothing packs CUDA + frames back to v210 yet. `--gpu-scale writer` therefore has to download + every converted frame, which is 0.44 of its 0.61 ms/frame. A + `cuda_to_v210` node — the mirror of `v210_to_cuda`, packing straight + into a grain-sized buffer — would remove that download and the 0.3 ms + encoder with it, leaving a writer that spends almost nothing per frame. ## References diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py index fb36ebba..b4dbc664 100644 --- a/demos/mxl/mxl_demo.py +++ b/demos/mxl/mxl_demo.py @@ -107,14 +107,16 @@ def _parse_args() -> argparse.Namespace: ) p.add_argument( "--reader-tail", - choices=("encode", "unpack", "demux"), + 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 two measurement tails drop the data " - "instead: 'unpack' after the v210 unpack, 'demux' straight out " - "of the demuxer. They isolate the MXL read and the unpack from " - "the output encoder, which otherwise dominates the CPU path." + "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( @@ -128,6 +130,27 @@ def _parse_args() -> argparse.Namespace: "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( + "--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"), @@ -139,6 +162,18 @@ def _parse_args() -> argparse.Namespace: "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"), @@ -183,6 +218,8 @@ def _parse_args() -> argparse.Namespace: 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 @@ -201,12 +238,61 @@ def _fps_ratio(fps: str) -> str: 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"): + 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: @@ -299,23 +385,40 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: 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. - scale_params = { - "src": paced_src, - "dst": "w_vscaled", - "group": "w_in", - "name": "w_scale", - "dst_pixel_format": "yuv422p10le", - # Force the declared geometry: 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. - "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)) + # 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", + "graph": _cuda_convert_graph(args, "yuv422p10le", + upload=True, download=True, + 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)) avp.addNode(AssumeVideoFormat({ "src": "w_vscaled", "dst": "w_vscaled_assumed", @@ -357,17 +460,28 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: })) +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 -> encode -> file. + """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. `--reader-tail` cuts the - chain short after the demuxer or the unpack, for measuring those in - isolation. + 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. """ - from pyplumber.node import NullSink - 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 @@ -413,27 +527,18 @@ def _build_reader(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "auto_restart": "off", })) if args.reader_tail == "demux": - avp.addNode(NullSink({ - "src": "r_vpkt", - "group": "r_in", - "name": "r_sink", - "auto_restart": "off", - })) + _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": - avp.addNode(NullSink({ - "src": unpacked, - "group": "r_in", - "name": "r_sink", - "auto_restart": "off", - })) + _drop(avp, unpacked) return - if args.gpu_unpack: - _build_gpu_encode_tail(avp, args, unpacked) - else: - _build_cpu_encode_tail(avp, args, unpacked) + 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", @@ -480,7 +585,11 @@ def _build_gpu_unpack(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> str "height": args.height, "stride": v210_row_stride(args.width), "fps": _fps_ratio(args.fps), - "timebase": "1/90000", + # 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", @@ -492,53 +601,6 @@ def _build_gpu_unpack(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> str return "r_vcuda" -def _build_gpu_encode_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, - src: str) -> None: - """CUDA frames -> scale_cuda -> NVENC -> r_venc. - - NVENC sidesteps the CPU path's mpeg4 fallback (the image has no - libx264) and keeps the frames on the GPU end to end. - """ - from pyplumber.node import AssumeVideoFormat, FilterVideo - - # NVENC only takes 4:2:2 10-bit on Blackwell-class hardware, so - # convert on the GPU to 8-bit 4:2:0 for the demo's output file. - avp.addNode(FilterVideo({ - "src": src, - "dst": "r_vnv12", - "group": "r_in", - "name": "r_filter", - "graph": "scale_cuda=format=nv12", - "hwaccel": _HWACCEL, - "auto_restart": "off", - })) - avp.addNode(AssumeVideoFormat({ - "src": "r_vnv12", - "dst": "r_vassumed", - "group": "r_in", - "name": "r_assume", - "width": args.width, - "height": args.height, - "pixel_format": "cuda", - "real_pixel_format": "nv12", - "auto_restart": "off", - })) - avp.addNode(EncVideo({ - "src": "r_vassumed", - "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 _build_cpu_unpack(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> str: """r_vpkt -> libavcodec v210 decoder -> r_vframe (yuv422p10le).""" avp.addNode(DecVideo({ @@ -551,42 +613,91 @@ def _build_cpu_unpack(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> str return "r_vframe" -def _build_cpu_encode_tail(avp: pyplumber.AVPlumber, args: argparse.Namespace, - src: str) -> None: - """yuv422p10le frames -> swscale -> mpeg4 -> r_venc.""" - from pyplumber.node import RescaleVideo, AssumeVideoFormat +def _build_convert(avp: pyplumber.AVPlumber, args: argparse.Namespace, + src: str) -> str: + """Bring the unpacked frames to what the output encoder takes. - # mpeg4 needs yuv420p — rescale from the 10-bit 4:2:2 flow. This - # conversion is the reader's most expensive step after the encoder - # itself, so it takes --sws-flags. - scale_params = { - "src": src, - "dst": "r_vscaled", - "group": "r_in", - "name": "r_scale", - "dst_pixel_format": "yuv420p", - "auto_restart": "off", - } - if flags := _sws_flags(args.sws_flags): - scale_params["flags"] = flags - avp.addNode(RescaleVideo(scale_params)) + 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_vscaled", - "dst": "r_vscaled_assumed", + "src": "r_vconv", + "dst": "r_vassumed", "group": "r_in", "name": "r_assume", "width": args.width, "height": args.height, - "pixel_format": "yuv420p", - "real_pixel_format": "yuv420p", + "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": "r_vscaled_assumed", + "src": src, "dst": "r_venc", "group": "r_in", "name": "r_enc", - "codec": "mpeg4", + "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", })) @@ -617,6 +728,8 @@ 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" @@ -731,6 +844,9 @@ def _bench(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> int: 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"cuda_interp {args.cuda_interp or 'default'}, " f"sws_flags {args.sws_flags or 'default'}") for name, _, counter in counters: got = samples[name] @@ -763,14 +879,15 @@ def main() -> int: 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: - # The demux-only tail never unpacks, so it needs no CUDA device. - if args.gpu_unpack and args.reader_tail != "demux": - avp.executeCommandsFromString( - 'hwaccel.init { "name": "%s", "type": "cuda" }' % _HWACCEL - ) _build_reader(avp, args) if not args.reader_only: From 6ab5df9a8f2d0730abda6e432b18609d2138e417 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Mon, 21 Sep 2026 15:13:20 +0200 Subject: [PATCH 16/19] hwaccel: pack CUDA 10-bit 4:2:2 frames straight into v210 A GPU graph publishing uncompressed video had to come back to the host twice: hwdownload after the conversion, then the libavcodec v210 encoder, together 0.73 of the MXL writer's 1.15 ms of CPU per frame. Add cuda_to_v210, the mirror of v210_to_cuda: a kernel packs the CUDA frame into v210 and the bytes leave the device by DMA into pinned memory that the muxer reads directly, so nothing on the host touches a pixel. The node is the encoder as far as the muxer below it is concerned. It implements IEncoder with a hand-built AVCodecParameters rather than a libavcodec context (as nvjpeg_enc does), which means geometry, frame rate and time base have to be answerable at node creation -- output opens its whole format context then -- so they come from the nodes above via IVideoFormatSource / IFrameRateSource / ITimeBaseSource, with parameters to override or stand in. The MXL muxer takes the flow's grain rate from the stream's frame rate, hence a hard error when nothing above reports one. auto_eof_ is off so the EOF marker is forwarded as a packet. Packets are handed out of an AVBufferPool of cuMemHostAlloc'd buffers that cuMemcpyDtoHAsync writes into, and the pool holds its own reference on the CUDA device context so buffers may outlive the node. The pack runs on the device context's stream, which is also whatever produced the frame used, so no extra synchronization is needed beyond waiting for the copy. Everything both v210 nodes share now lives in v210_cuda.hpp -- the error check, the context guard, the geometry/stride layout, the format predicates -- and v210_to_cuda is refactored onto it instead of keeping a second copy. The output is bit-exact: grains from the kernel are byte-for-byte what the libavcodec v210 encoder produces at 1920x1080, 1280x720 and 1918x1080, the last two exercising rows that end in a partial 6-pixel block and all three the padding to a 128-byte row pitch. Co-Authored-By: Claude Opus 5 --- Makefile | 2 + doc/NODES.md | 14 ++ pyplumber/node.py | 4 + src/nodes/hwaccel/cuda_to_v210.cpp | 310 +++++++++++++++++++++++++++++ src/nodes/hwaccel/v210_cuda.hpp | 88 ++++++++ src/nodes/hwaccel/v210_pack.cu | 62 ++++++ src/nodes/hwaccel/v210_to_cuda.cpp | 80 +++----- 7 files changed, 505 insertions(+), 55 deletions(-) create mode 100644 src/nodes/hwaccel/cuda_to_v210.cpp create mode 100644 src/nodes/hwaccel/v210_cuda.hpp create mode 100644 src/nodes/hwaccel/v210_pack.cu 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/doc/NODES.md b/doc/NODES.md index f8dea1ba..b051d344 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -804,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_; } From 2facce6d6b22ebef78197c4e6972cf47467991ba Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Mon, 21 Sep 2026 15:13:45 +0200 Subject: [PATCH 17/19] demos/mxl: pack on the GPU, and find MXL's copy at the bottom Add --writer-pack {auto,cpu,gpu}: gpu drops the writer's hwdownload and its v210 encoder for a single cuda_to_v210, which needs the conversion on the GPU too, so it requires --gpu-scale writer|both and auto turns it on exactly when that holds. What the measurements say, in README: the conversion node loses precisely its download (0.62 -> 0.21 ms/frame, the rest being the 3.1 MB upload at ~19 GB/s) and the pack costs the fixed 0.04 alone, because the 5.5 MB leaves the device by DMA and the CPU pays nothing per byte. The writer falls from 1.15 to 0.59 ms of CPU per frame, the paced writer process from 0.09 to 0.05 cores, and an all-GPU round trip from 0.17 to 0.09. Unpaced the writer's peak nearly doubles, 855 -> 1583 fps on the same 1.2-1.3 cores at 82% SM. That moves the bottom of the write path somewhere new. w_output -- the muxer's memcpy into the opened grain -- gets more expensive (0.23 -> 0.34 ms/frame) reading bytes no CPU has touched, and at 1583 fps it is 8.8 GB/s into /dev/shm and the writer's actual cap: the first time in this demo that MXL itself limits anything. Removing that copy means having the muxer hand out the grain's address before the packet is built, which is a change to patch 0010, so it goes to Known gaps next to the read side's cudaHostRegister note. Also correct the SM figure on the unpaced --gpu-scale writer row, which had the paced --gpu-scale both dmon reading (7%) rather than its own; the case re-measures at 51% on an idle GPU, same 855 fps. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 89 ++++++++++++++++++++++++++++++++++++----- demos/mxl/mxl_demo.py | 93 +++++++++++++++++++++++++++++++++---------- 2 files changed, 151 insertions(+), 31 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index a7dc830b..5d29bdf7 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -10,7 +10,9 @@ 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. + 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: @@ -34,7 +36,9 @@ Verified end-to-end on x86_64 Fedora with an RTX 4000 Ada (driver `--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. +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 @@ -124,6 +128,12 @@ 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` @@ -148,7 +158,9 @@ warmup, via `--bench-seconds`: | `--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 | 7% | — | +| `--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` @@ -160,6 +172,13 @@ 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 @@ -264,6 +283,7 @@ from swscale. Paced 1080p59.94 round trips, total process CPU: | 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 @@ -292,6 +312,52 @@ 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, 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 @@ -374,12 +440,17 @@ mean `cudaHostRegister`-ing the MXL ring, i.e. a change to the node in 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. -* The write path always ends in the CPU `v210` encoder; nothing packs CUDA - frames back to v210 yet. `--gpu-scale writer` therefore has to download - every converted frame, which is 0.44 of its 0.61 ms/frame. A - `cuda_to_v210` node — the mirror of `v210_to_cuda`, packing straight - into a grain-sized buffer — would remove that download and the 0.3 ms - encoder with it, leaving a writer that spends almost nothing per frame. +* `--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 `0010`, and it mirrors the `cudaHostRegister` + note under [Zero-copy grains](#zero-copy-grains) on the read side. ## References diff --git a/demos/mxl/mxl_demo.py b/demos/mxl/mxl_demo.py index b4dbc664..ffe40b76 100644 --- a/demos/mxl/mxl_demo.py +++ b/demos/mxl/mxl_demo.py @@ -142,6 +142,18 @@ def _parse_args() -> argparse.Namespace: "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"), @@ -213,6 +225,13 @@ def _parse_args() -> argparse.Namespace: ), ) 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)") @@ -280,7 +299,8 @@ def _cuda_convert_graph(args: argparse.Namespace, dst_format: str, *, 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"): + 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 @@ -315,7 +335,12 @@ def _reader_url(domain: str, video_id: str) -> str: def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: - """source -> demux -> decode -> rescale -> v210 encode -> mxl output.""" + """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:"): @@ -399,8 +424,11 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: "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=True, + upload=True, + download=args.writer_pack != "gpu", width=args.width, height=args.height), "hwaccel": _HWACCEL, "auto_restart": "off", @@ -419,25 +447,45 @@ def _build_writer(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> None: if flags := _sws_flags(args.sws_flags): scale_params["flags"] = flags avp.addNode(RescaleVideo(scale_params)) - 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", - })) + 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", @@ -846,6 +894,7 @@ def _bench(avp: pyplumber.AVPlumber, args: argparse.Namespace) -> int: 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: From 29272712fb5168575aa592a03bdcb35596c5b923 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Mon, 21 Sep 2026 15:23:20 +0200 Subject: [PATCH 18/19] deps/ffmpeg: regenerate the MXL patch as 0011, re-verify both bases band_blur_cuda took 0010 on develop, so the MXL patch is 0011 here -- the file, the mkpatch scripts and the series list move with it in the commit that adds them. Regenerate the patch against 0001-0010 instead of leaving its hunks anchored a line off what they now apply to: nothing in the FFmpeg change moves, only the provenance line, the squash's local commit ids and the configure offsets band blur shifts by one. Refresh 8/bases.env for the combined series -- patch_count 11 and the trees it actually produces, which verify.sh reproduces for both bases: FFmpeg n8.1 patch series verified: 93dd33926bd56b56cab82a747ae91c39f31e84ba FFmpeg n8.0 patch series verified: 3f3cacd2a72b5a43a06bbc4cb212307bd7e1a9d6 Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 8 ++++---- .../8/0011-avformat-libmxl-demuxer-muxer.patch | 18 +++++++++--------- deps/ffmpeg/8/bases.env | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 5d29bdf7..4500afb1 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -48,7 +48,7 @@ pattern. `--gpu-scale` and `--writer-pack gpu` were checked separately at 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/0010-*` is generated from `cbcrc/FFmpeg` branch `dmf-mxl/8.1` — + `8/0011-*` is generated from `cbcrc/FFmpeg` branch `dmf-mxl/8.1` — see [`deps/ffmpeg/README.md`](../../deps/ffmpeg/README.md). ## Build @@ -400,7 +400,7 @@ Reader-side demuxer options: 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 `0010` now continues from the last delivered + up with the flood. Patch `0011` now continues from the last delivered PTS instead (see `deps/ffmpeg/mkpatch-fixups/`). ## Zero-copy grains @@ -449,11 +449,11 @@ mean `cudaHostRegister`-ing the MXL ring, i.e. a change to the node in 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 `0010`, and it mirrors the `cudaHostRegister` + 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 0010, branch `dmf-mxl/8.1`): +* MXL FFmpeg fork (source for patch 0011, branch `dmf-mxl/8.1`): * Reference build guidance: diff --git a/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch b/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch index cc27c1a3..d2ac069c 100644 --- a/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch +++ b/deps/ffmpeg/8/0011-avformat-libmxl-demuxer-muxer.patch @@ -1,6 +1,6 @@ -From 3e74ea3f9d721d2cc1013c99a95235e49176d4d7 Mon Sep 17 00:00:00 2001 +From b3e1c3d9516d3d5af85f66f7a4aee3ab1d80862f Mon Sep 17 00:00:00 2001 From: avplumber mxl builder -Date: Sun, 20 Sep 2026 15:04:43 +0000 +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 @@ -8,13 +8,13 @@ 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-0009, with the configure hunks re-anchored so the +series 0001-0010, with the configure hunks re-anchored so the patch applies to both n8.0 and n8.1. Original commits: - * b099cc6224 avformat/mxldec: keep pts monotonic across a reset_on_drop reset - * ff4066273e ffmpeg 8.1 port - * 8eda88cfc1 Apply FFmpeg 8 MXL integration to FFmpeg 8.1 + * 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 @@ -99,19 +99,19 @@ Co-authored-by: jpt create mode 100644 tests/ref/fate/mxl-video-probe diff --git a/configure b/configure -index 82cdbd27ac..12698a2d27 100755 +index 8f3a2df56f..25a484e6b6 100755 --- a/configure +++ b/configure @@ -2066,2 +2066,3 @@ EXTERNAL_LIBRARY_LIST=" libmp3lame + libmxl libmysofa -@@ -3853,2 +3854,4 @@ libxvid_encoder_deps="libxvid" +@@ -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" -@@ -7474,2 +7477,5 @@ enabled mmal && { check_lib mmal interface/mmal/mmal.h mmal_port_co +@@ -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" diff --git a/deps/ffmpeg/8/bases.env b/deps/ffmpeg/8/bases.env index 77045b02..bfec2bed 100644 --- a/deps/ffmpeg/8/bases.env +++ b/deps/ffmpeg/8/bases.env @@ -1,5 +1,5 @@ n80_commit=140fd653aed8cad774f991ba083e2d01e86420c7 -n80_tree=9fbd0cf64609767ff664ccea7cb3b3cff906a851 +n80_tree=3f3cacd2a72b5a43a06bbc4cb212307bd7e1a9d6 n81_commit=9047fa1b084f76b1b4d065af2d743df1b40dfb56 -n81_tree=7573ce4137633f6f624d11a63ffc653e88b9c80f +n81_tree=93dd33926bd56b56cab82a747ae91c39f31e84ba patch_count=11 From 42c215fe9581f8f74a3814f3e532f21d0cc0e499 Mon Sep 17 00:00:00 2001 From: Mateusz Starzak Date: Mon, 21 Sep 2026 16:54:55 +0200 Subject: [PATCH 19/19] demos/mxl: keep the measurement scripts that produced the tables Every number in demos/mxl/README.md came from an ad-hoc wrapper around mxl_demo.py --bench-seconds that lived only on the test host, so the tables were reproducible in principle and not in practice. Collect them under demos/mxl/bench/ as one harness: bench.sh runs a single case (fresh MXL domain, nvidia-smi and cgroup samplers, log triage, ffprobe of the output, one digest instead of a log), cases.sh holds the case lists as named groups, and bench/README.md maps each group to the table it produced. Two checks are not an fps number and stay separate. pack_bitexact.sh publishes the same converted frames with the CPU and the GPU v210 pack, dumps the grains with ffmpeg -c copy and compares them byte for byte at 1920x1080, 1280x720 and 1918x1080, exiting nonzero on a difference so it can gate. pixel_compare.sh covers the rest of "Conversion on the GPU": PSNR/SSIM between the two writer conversions, nvidia-smi dmon for the SM and PCIe columns, and which formats this build's scale_cuda accepts. The scripts reach the demo two ways, because measuring and developing want different things. --image starts a container per case, which is the only mode whose cgroup holds nothing but the run and the only one that can drop the GPU (--no-gpu); --exec uses a container that is already up, for a /build tree you keep rebuilding, where per-node CPU is still exact (the demo reads its own /proc/self/task) but whole-container CPU is not. Anything the harness does not recognise goes to the demo, so its flags work unchanged and beat the group defaults. Verified on the NVIDIA host in both modes: all 54 cases of pack, swsflags, srcfmt, tails, throughput and conversion exit 0 with only the benign "setting edge more than once" triage entry; smoke 11/11; nodecpu, zerocopy, pack_bitexact.sh and all three pixel_compare.sh sections reproduce the documented per-node ms/frame, IDENTICAL grains, and the PSNR/SSIM and dmon figures. Co-Authored-By: Claude Opus 5 --- demos/mxl/README.md | 22 +-- demos/mxl/bench/README.md | 132 ++++++++++++++++ demos/mxl/bench/bench.sh | 67 ++++++++ demos/mxl/bench/cases.sh | 202 ++++++++++++++++++++++++ demos/mxl/bench/lib.sh | 258 +++++++++++++++++++++++++++++++ demos/mxl/bench/pack_bitexact.sh | 103 ++++++++++++ demos/mxl/bench/pixel_compare.sh | 121 +++++++++++++++ 7 files changed, 896 insertions(+), 9 deletions(-) create mode 100644 demos/mxl/bench/README.md create mode 100755 demos/mxl/bench/bench.sh create mode 100755 demos/mxl/bench/cases.sh create mode 100644 demos/mxl/bench/lib.sh create mode 100755 demos/mxl/bench/pack_bitexact.sh create mode 100755 demos/mxl/bench/pixel_compare.sh diff --git a/demos/mxl/README.md b/demos/mxl/README.md index 4500afb1..8a8d8ff1 100644 --- a/demos/mxl/README.md +++ b/demos/mxl/README.md @@ -146,7 +146,9 @@ the `ftyp` box. 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`: +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 | |---|---|---|---|---|---| @@ -306,10 +308,11 @@ 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, 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. +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 @@ -350,10 +353,11 @@ 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, 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: +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. 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