From 7ed0e70b0a0636bb1b6e6c4ffcba6f8ee5a0822d Mon Sep 17 00:00:00 2001 From: Raymond Lucke Date: Tue, 30 Jun 2026 09:54:44 -0700 Subject: [PATCH] Add moq5 (libmoq) as an opt-in publishing backend Route the batch, live stdin/SRT, and live-object publish paths through the moq5 service tier when -DOPENMOQ_USE_LIBMOQ_PUBLISHER=ON, alongside the existing picoquic transport. A compile-time gate keeps the default path unchanged while the integration matures; an injected TransportFactory always forces the legacy path regardless of the gate. The moq5 path translates moqxr publish plans into media-sender objects with demand-aware subscription gating, bounded WOULD_BLOCK backpressure handling, CMSF SAP metadata propagation, and a graceful transport drain (moq_endpoint_drain) that flushes queued stream bytes before teardown so a slow or in-flight object is not truncated. Package coalesced progressive-MP4 media as bounded, keyframe-aligned CMAF objects: one group per track with a per-GOP object, long GOPs split into capped continuation objects, rather than a single whole-track payload -- keeping each fragment's sample table within the receiver's CMAF validation limits while preserving group-start SAP types. CMake gains sibling moq5/picoquic/picotls detection, OpenSSL pinning for the picotls build, and WebTransport adapter enablement. Adds translation and packaging tests and documents the backend selection in docs/build.md and the README. --- .gitignore | 3 + CMakeLists.txt | 154 +- README.md | 25 + docs/build.md | 25 + docs/publisher-api.md | 58 +- include/openmoq/publisher/cli_options.h | 2 +- include/openmoq/publisher/cmaf_segmenter.h | 3 +- include/openmoq/publisher/cmsf_packager.h | 2 + include/openmoq/publisher/live_object.h | 32 + include/openmoq/publisher/moq_draft.h | 2 +- include/openmoq/publisher/publisher_api.h | 19 +- .../publisher/transport/libmoq_publisher.h | 265 +++ src/cli_options.cpp | 17 +- src/cmaf_segmenter.cpp | 205 ++- src/cmsf_packager.cpp | 2 + src/live_srt_ingest.cpp | 6 +- src/publisher_api.cpp | 183 +++ src/transport/libmoq_publisher.cpp | 1434 +++++++++++++++++ tests/cmaf_segmenter_test.cpp | 151 ++ tests/libmoq_translation_test.cpp | 736 +++++++++ tests/publisher_api_test.cpp | 29 + 21 files changed, 3292 insertions(+), 61 deletions(-) create mode 100644 include/openmoq/publisher/transport/libmoq_publisher.h create mode 100644 src/transport/libmoq_publisher.cpp create mode 100644 tests/libmoq_translation_test.cpp diff --git a/.gitignore b/.gitignore index 3a0da55..5f5261e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ run-trace*.log openmoq-publisher-trace.csv /.claude /third_party + +# Stray dtrace-generated probe header (build artifact) +.tmp.dprobes.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f485d32..9adbc79 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ endif() project(openmoq_publisher VERSION 0.1.0 DESCRIPTION "OpenMOQ contribution project: C++20 publisher for fragmented MP4 to CMSF/MOQT" - LANGUAGES CXX) + LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -18,6 +18,11 @@ option(OPENMOQ_BUILD_TESTS "Build OpenMOQ publisher tests" ON) option(OPENMOQ_ENABLE_PICOQUIC "Enable picoquic transport integration when picoquic is available" ON) option(OPENMOQ_RUN_PICOQUIC_SMOKE_TESTS "Build and run picoquic loopback smoke tests" OFF) option(OPENMOQ_ENABLE_SRT "Enable libsrt ingest support when libsrt is available" ON) +# Temporary migration gate: route the production Publisher through libmoq instead +# of the legacy MoqtSession path. Requires libmoq to be available. OFF keeps the +# old/moxygen publish path as the default until the migration is accepted; the +# libmoq integration code still builds and is tested whenever libmoq is present. +option(OPENMOQ_USE_LIBMOQ_PUBLISHER "Use libmoq as the production publish backend (requires libmoq)" OFF) set(OPENMOQ_HAS_SRT OFF) if(OPENMOQ_ENABLE_SRT) @@ -30,18 +35,59 @@ if(OPENMOQ_ENABLE_SRT) endif() endif() -set(_OPENMOQ_THIRDPARTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/third_party") -if(NOT EXISTS "${_OPENMOQ_THIRDPARTY_ROOT}") - set(_OPENMOQ_THIRDPARTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/thirdparty") -endif() +# Resolve default picoquic/picotls source dirs, preferring (in order): an +# existing sibling checkout next to moqxr (../picoquic, ../picotls), then +# third_party/, then thirdparty/. The first candidate that contains a +# CMakeLists.txt wins; otherwise fall back to the third_party path. +function(_openmoq_pick_dep_default out_var name) + foreach(_candidate + "${CMAKE_CURRENT_SOURCE_DIR}/../${name}" + "${CMAKE_CURRENT_SOURCE_DIR}/third_party/${name}" + "${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/${name}") + if(EXISTS "${_candidate}/CMakeLists.txt") + get_filename_component(_abs "${_candidate}" ABSOLUTE) + set(${out_var} "${_abs}" PARENT_SCOPE) + return() + endif() + endforeach() + set(${out_var} "${CMAKE_CURRENT_SOURCE_DIR}/third_party/${name}" PARENT_SCOPE) +endfunction() -set(_OPENMOQ_DEFAULT_PICOQUIC_SOURCE_DIR "${_OPENMOQ_THIRDPARTY_ROOT}/picoquic") -set(_OPENMOQ_DEFAULT_PICOTLS_SOURCE_DIR "${_OPENMOQ_THIRDPARTY_ROOT}/picotls") +_openmoq_pick_dep_default(_OPENMOQ_DEFAULT_PICOQUIC_SOURCE_DIR picoquic) +_openmoq_pick_dep_default(_OPENMOQ_DEFAULT_PICOTLS_SOURCE_DIR picotls) set(OPENMOQ_PICOQUIC_SOURCE_DIR "${_OPENMOQ_DEFAULT_PICOQUIC_SOURCE_DIR}" CACHE PATH - "Path to a picoquic source checkout (defaults to third_party/ or thirdparty/)") + "Path to a picoquic source checkout (defaults to ../picoquic, then third_party/, then thirdparty/)") set(OPENMOQ_PICOTLS_SOURCE_DIR "${_OPENMOQ_DEFAULT_PICOTLS_SOURCE_DIR}" CACHE PATH - "Path to a picotls source checkout (defaults to third_party/ or thirdparty/)") + "Path to a picotls source checkout (defaults to ../picotls, then third_party/, then thirdparty/)") + +# OpenSSL coherency: the picotls checkout here uses 1.1.x-only APIs +# (EVP_PKEY_*_tls_encodedpoint), so pin ONE OpenSSL 1.1 install for both headers +# AND libraries. Without this, CMake's FindOpenSSL on macOS can mix +# GStreamer.framework headers with a Homebrew openssl@3 libcrypto, which lacks +# those symbols -> undefined-symbol link failures in libpicotls-openssl. +set(OPENMOQ_OPENSSL_ROOT_DIR "" CACHE PATH + "OpenSSL prefix for picoquic/picotls/libmoq (auto: Homebrew openssl@1.1 if found)") +if(NOT OPENMOQ_OPENSSL_ROOT_DIR) + foreach(_ssl_prefix + "/opt/homebrew/opt/openssl@1.1" + "/usr/local/opt/openssl@1.1") + if(EXISTS "${_ssl_prefix}/include/openssl/ssl.h") + set(OPENMOQ_OPENSSL_ROOT_DIR "${_ssl_prefix}" CACHE PATH "" FORCE) + break() + endif() + endforeach() +endif() +if(OPENMOQ_OPENSSL_ROOT_DIR) + # Cache + FORCE so the picoquic/picotls subtree's find_package(OpenSSL) and + # libmoq all resolve the same prefix; FIND_FRAMEWORK LAST keeps Homebrew + # headers ahead of /Library/Frameworks/*. + set(OPENSSL_ROOT_DIR "${OPENMOQ_OPENSSL_ROOT_DIR}" CACHE PATH "" FORCE) + set(CMAKE_FIND_FRAMEWORK LAST) + message(STATUS "OpenMOQ: pinning OpenSSL to ${OPENMOQ_OPENSSL_ROOT_DIR}") +else() + message(STATUS "OpenMOQ: no pinned OpenSSL found; relying on CMake default FindOpenSSL") +endif() set(OPENMOQ_HAS_PICOQUIC OFF) @@ -142,6 +188,52 @@ if(OPENMOQ_ENABLE_PICOQUIC AND EXISTS "${OPENMOQ_PICOQUIC_SOURCE_DIR}/CMakeLists endif() endif() +# -- libmoq integration ------------------------------------------------- +# Build moqxr against the sibling libmoq service tier (moq::service). This is +# added AFTER picoquic above on purpose: libmoq's find_package(Picoquic) uses +# "already-defined target" as its first resolution step, so it reuses the +# picoquic-core target moqxr already created instead of add_subdirectory'ing +# picoquic a second time (which would collide on target names). By default moqxr +# still publishes via its own (legacy) transport; the Publisher only routes +# through libmoq when -DOPENMOQ_USE_LIBMOQ_PUBLISHER=ON. Linking libmoq always +# validates that the service tier builds and links into this project. +set(OPENMOQ_LIBMOQ_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../libmoq" CACHE PATH + "Path to the sibling libmoq source checkout") + +set(OPENMOQ_HAS_LIBMOQ OFF) +if(EXISTS "${OPENMOQ_LIBMOQ_SOURCE_DIR}/CMakeLists.txt") + # Service tier (endpoint + media sender/receiver) and its prerequisites. + set(MOQ_BUILD_SERVICE ON CACHE BOOL "" FORCE) + set(MOQ_BUILD_MSF ON CACHE BOOL "" FORCE) + set(MOQ_BUILD_MEDIA_OBJECT ON CACHE BOOL "" FORCE) # required by MOQ_BUILD_SERVICE + # Keep libmoq's own test/sim suite out of moqxr's CTest run. + set(MOQ_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(MOQ_BUILD_SIM OFF CACHE BOOL "" FORCE) + + if(OPENMOQ_HAS_PICOQUIC) + # Usable raw QUIC + WebTransport: enable the picoquic adapter + threaded + # helper AND the picoquic WebTransport adapter + managed helper (the test + # relay is WebTransport-only). Map moqxr's picoquic/picotls inputs onto + # libmoq's FindPicoquic hints; picohttp-core (built here with BUILD_HTTP) + # satisfies the pico-WT HTTP/3 requirement. + set(MOQ_BUILD_ADAPTER_PICOQUIC ON CACHE BOOL "" FORCE) + set(MOQ_BUILD_PQ_THREADED ON CACHE BOOL "" FORCE) + set(MOQ_BUILD_ADAPTER_PICO_WT ON CACHE BOOL "" FORCE) + set(MOQ_BUILD_PICO_WT_MANAGED ON CACHE BOOL "" FORCE) + set(MOQ_PICOQUIC_SOURCE_DIR "${OPENMOQ_PICOQUIC_SOURCE_DIR}" CACHE PATH "" FORCE) + if(NOT MOQ_PICOTLS_PREFIX) + set(MOQ_PICOTLS_PREFIX "${OPENMOQ_PICOTLS_SOURCE_DIR}" CACHE PATH "" FORCE) + endif() + else() + message(STATUS "libmoq: picoquic not available; building core/service tier without the raw QUIC adapter") + endif() + + add_subdirectory("${OPENMOQ_LIBMOQ_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/libmoq") + set(OPENMOQ_HAS_LIBMOQ ON) +else() + message(STATUS "libmoq source not found at ${OPENMOQ_LIBMOQ_SOURCE_DIR}; building without libmoq integration") +endif() + add_library(openmoq_publisher_lib STATIC src/cli_options.cpp src/cmaf_segmenter.cpp @@ -151,6 +243,7 @@ add_library(openmoq_publisher_lib STATIC src/moq_draft.cpp src/mp4_box.cpp src/publisher_api.cpp + src/transport/libmoq_publisher.cpp src/transport/moqt_control_messages.cpp src/transport/moqt_session.cpp src/transport/picoquic_client.cpp @@ -193,6 +286,31 @@ if(OPENMOQ_HAS_SRT) target_link_libraries(openmoq_publisher_lib PRIVATE ${OPENMOQ_SRT_LIBRARY}) endif() +# OPENMOQ_HAS_LIBMOQ = libmoq source/service tier is available and links. +# OPENMOQ_ENABLE_LIBMOQ_PUBLISHER = the production Publisher routes through libmoq +# (only when libmoq is available AND the gate is ON). +set(OPENMOQ_ENABLE_LIBMOQ_PUBLISHER OFF) +if(OPENMOQ_HAS_LIBMOQ AND TARGET moq::service) + target_compile_definitions(openmoq_publisher_lib PRIVATE OPENMOQ_HAS_LIBMOQ=1) + target_link_libraries(openmoq_publisher_lib PRIVATE moq::service) + if(OPENMOQ_USE_LIBMOQ_PUBLISHER) + set(OPENMOQ_ENABLE_LIBMOQ_PUBLISHER ON) + target_compile_definitions(openmoq_publisher_lib PRIVATE OPENMOQ_ENABLE_LIBMOQ_PUBLISHER=1) + endif() +elseif(OPENMOQ_USE_LIBMOQ_PUBLISHER) + message(WARNING + "OPENMOQ_USE_LIBMOQ_PUBLISHER=ON but libmoq is not available; " + "falling back to the legacy MoqtSession publish backend.") +endif() + +# Migration-gate status (temporary): availability vs. selected backend. +message(STATUS "OpenMOQ: libmoq available .......... ${OPENMOQ_HAS_LIBMOQ}") +if(OPENMOQ_ENABLE_LIBMOQ_PUBLISHER) + message(STATUS "OpenMOQ: publish backend .......... libmoq (OPENMOQ_USE_LIBMOQ_PUBLISHER=ON)") +else() + message(STATUS "OpenMOQ: publish backend .......... legacy MoqtSession (set -DOPENMOQ_USE_LIBMOQ_PUBLISHER=ON for libmoq)") +endif() + if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") target_compile_options(openmoq_publisher_lib PRIVATE -Wall -Wextra -Wpedantic) elseif(MSVC) @@ -244,6 +362,15 @@ if(OPENMOQ_BUILD_TESTS) tests/publisher_api_test.cpp ) target_link_libraries(openmoq-publisher-api-tests PRIVATE openmoq_publisher_lib) + if(OPENMOQ_HAS_LIBMOQ) + target_compile_definitions(openmoq-publisher-api-tests PRIVATE OPENMOQ_HAS_LIBMOQ=1) + endif() + if(OPENMOQ_ENABLE_LIBMOQ_PUBLISHER) + # Only when libmoq is the selected backend does the non-injected route go + # through libmoq (so the test can assert its metadata gate). With the gate + # off, non-injected publishing stays on the MoqtSession path. + target_compile_definitions(openmoq-publisher-api-tests PRIVATE OPENMOQ_ENABLE_LIBMOQ_PUBLISHER=1) + endif() add_test(NAME openmoq-publisher-api-tests COMMAND openmoq-publisher-api-tests) add_executable(openmoq-publisher-control-message-tests @@ -252,6 +379,15 @@ if(OPENMOQ_BUILD_TESTS) target_link_libraries(openmoq-publisher-control-message-tests PRIVATE openmoq_publisher_lib) add_test(NAME openmoq-publisher-control-message-tests COMMAND openmoq-publisher-control-message-tests) + if(OPENMOQ_HAS_LIBMOQ) + add_executable(openmoq-publisher-libmoq-translation-tests + tests/libmoq_translation_test.cpp + ) + target_compile_definitions(openmoq-publisher-libmoq-translation-tests PRIVATE OPENMOQ_HAS_LIBMOQ=1) + target_link_libraries(openmoq-publisher-libmoq-translation-tests PRIVATE openmoq_publisher_lib moq::service) + add_test(NAME openmoq-publisher-libmoq-translation-tests COMMAND openmoq-publisher-libmoq-translation-tests) + endif() + if(OPENMOQ_HAS_PICOQUIC AND OPENMOQ_RUN_PICOQUIC_SMOKE_TESTS) add_executable(openmoq-publisher-picoquic-smoke-tests tests/picoquic_smoke_test.cpp diff --git a/README.md b/README.md index 7f258c9..1a23a45 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,31 @@ It turns MP4 input into CMSF-style publishable objects, builds draft-aware MOQT - Emits generated objects and catalog metadata to disk for inspection. - Supports draft-aware MOQT framing for drafts 14, 16, and 18. - Publishes over Raw QUIC or WebTransport when picoquic and picotls are available. +- Optionally publishes through the [**moq5**](https://github.com/openmoq/moq5) Media-over-QUIC library (drafts 16 and 18). + +## Publishing via moq5 + +`moqxr` can publish through [**moq5**](https://github.com/openmoq/moq5), the C11 +Media-over-QUIC reference library, as an opt-in backend alongside the built-in +picoquic transport. When enabled, the batch, live (stdin and SRT), and +live-object publish paths are routed through moq5's service tier, which owns +catalog publication, CMSF/CMAF object validation, demand-aware subscription +gating, bounded backpressure handling, and a graceful transport drain that +flushes queued media to the wire before the connection is torn down. + +Media is packaged into bounded, keyframe-aligned CMAF objects so that coalesced +publishing produces per-GOP objects rather than a single whole-track payload. + +Enable the moq5 backend at configure time (the moq5 library must be available +alongside the publisher): + +```bash +cmake -S . -B build -DOPENMOQ_USE_LIBMOQ_PUBLISHER=ON +``` + +The default build keeps the existing transport path; the backend is selectable +while the integration matures. See [docs/build.md](docs/build.md) for the +configuration details. ## Quick Start diff --git a/docs/build.md b/docs/build.md index dea8e9a..2c55f61 100644 --- a/docs/build.md +++ b/docs/build.md @@ -80,6 +80,31 @@ GitHub Actions workflows set `OPENSSL_ROOT_DIR` automatically from the runner's - `-DOPENMOQ_PICOTLS_SOURCE_DIR=/path/to/picotls` - `-DOPENSSL_ROOT_DIR=/path/to/openssl` - `-DOPENMOQ_RUN_PICOQUIC_SMOKE_TESTS=ON|OFF` +- `-DOPENMOQ_USE_LIBMOQ_PUBLISHER=ON|OFF` (default `OFF`) + +### Publish backend (temporary migration gate) + +moqxr is migrating its publish path from the legacy MoqtSession/moxygen-style +transport onto the sibling **libmoq** service tier. While the migration is being +reviewed, the backend is selectable: + +- **libmoq available** — the libmoq integration code (translation, drivers, + tests) builds and is validated whenever a sibling `../libmoq` is present; this + is independent of which backend is *selected*. +- **`-DOPENMOQ_USE_LIBMOQ_PUBLISHER=ON`** — the production `Publisher` routes + batch, live stdin, live SRT, and `LiveObjectSource` publishing through libmoq. +- **default (`OFF`)** — publishing stays on the legacy MoqtSession path. + +Configure-time output reports both, e.g.: + +``` +-- OpenMOQ: libmoq available .......... ON +-- OpenMOQ: publish backend .......... legacy MoqtSession (set -DOPENMOQ_USE_LIBMOQ_PUBLISHER=ON for libmoq) +``` + +This gate is **temporary** — it will be removed once the libmoq publish path is +accepted as the default. An injected `TransportFactory` always forces the legacy +path regardless of this option. ## Release Builds diff --git a/docs/publisher-api.md b/docs/publisher-api.md index 49590b8..a6f1ced 100644 --- a/docs/publisher-api.md +++ b/docs/publisher-api.md @@ -173,19 +173,44 @@ if (!status.ok) { Applications that already produce MoQ objects directly can bypass fragmented MP4 ingest with `publish_live_objects(...)`. +When built against libmoq (the default service-tier path), each `LiveTrack` must +declare real media metadata so the libmoq media sender can author the catalog and +package objects. Required: `media_type` and `codec`; video tracks add +`width`/`height`, audio tracks add `sample_rate`/`channel_count`. `packaging` +selects RAW vs CMAF object framing. `bitrate` is optional (a media-type default is +used when omitted). + +`init_data` (codec/decoder configuration) is **optional** — supply it only when +the codec or container needs out-of-band decoder config: a CMAF init segment, or +codecs whose parameter sets are not carried in-band (H.264/HEVC SPS/PPS/VPS, AAC +AudioSpecificConfig, ...). A RAW track whose codec carries its parameters in-band +may omit it. + ```cpp std::vector objects = { { - .track_name = "events", + .track_name = "video", .group_id = 0, .object_id = 0, - .payload = {'h', 'e', 'l', 'l', 'o'}, + .media_time_us = 0, + .payload = encoded_access_unit, }, }; std::size_t next = 0; openmoq::publisher::LiveObjectSource source; -source.tracks = {{.track_name = "events"}}; +source.tracks = { + openmoq::publisher::LiveTrack{ + .track_name = "video", + .media_type = openmoq::publisher::LiveMediaType::kVideo, + .packaging = openmoq::publisher::LivePackaging::kRaw, // or kCmaf + .codec = "av01", + .init_data = decoder_config, // SPS/PPS, AV1 config, CMAF init segment, ... + .bitrate = 1500000, + .width = 1280, + .height = 720, + }, +}; source.next_object = [&]() -> std::optional { if (next >= objects.size()) { return std::nullopt; @@ -196,9 +221,30 @@ source.next_object = [&]() -> std::optional { const auto status = publisher.publish_live_objects(source, endpoint, tls); ``` -Each `LiveObject` supplies the target track, group/object IDs, optional media -timing, and the payload bytes to send. The fragmented MP4 `publish_live(...)` -API remains the default live publishing path. +Each `LiveObject` supplies the target track, group/object IDs, media timing, and +the payload bytes to send. `object_id == 0` starts a group (and is treated as a +sync point); `final_in_subgroup && subgroup_contains_group_largest` closes the +group. + +**Demand gating (lazy relays).** When built against libmoq, the publish path waits +for at least one downstream media subscriber before producing media — a lazy relay +forwards a SUBSCRIBE only when a player subscribes. Until then nothing is written +(batch/objects/stdin do not consume their source; live SRT drops fragments to stay +bounded). If no subscriber appears within `PublisherConfig::subscriber_timeout`, +the call fails with `timed out waiting for media subscriber` instead of hanging. + +Calling `disconnect()` from another thread stops a running `publish_live_objects` +(or live stdin/SRT) publish promptly; the driver loop breaks, the endpoint is +interrupted, and the call returns success. For stdin specifically, cancellation is +observed once the current blocking read returns. + +> **Legacy note:** bare `LiveTrack{.track_name = ...}` entries with no media +> metadata (a generic "events"-style object track) are **legacy-only**. They are +> rejected on the libmoq path with a clear error; to publish such tracks you must +> inject a custom `TransportFactory`, which forces the older MoqtSession transport. + +The fragmented MP4 `publish_live(...)` API remains the default live publishing +path for media ingest. ## 10. ALPN Override Behavior diff --git a/include/openmoq/publisher/cli_options.h b/include/openmoq/publisher/cli_options.h index b8efb9d..ea7943f 100644 --- a/include/openmoq/publisher/cli_options.h +++ b/include/openmoq/publisher/cli_options.h @@ -34,7 +34,7 @@ struct CliOptions { std::optional endpoint; transport::TransportKind transport = transport::TransportKind::kRawQuic; transport::TlsConfig tls; - DraftVersion draft_version = DraftVersion::kDraft14; + DraftVersion draft_version = DraftVersion::kDraft16; std::string track_namespace = "media"; bool endpoint_alpn_overridden = false; bool forward = false; diff --git a/include/openmoq/publisher/cmaf_segmenter.h b/include/openmoq/publisher/cmaf_segmenter.h index 8c287cb..d82837a 100644 --- a/include/openmoq/publisher/cmaf_segmenter.h +++ b/include/openmoq/publisher/cmaf_segmenter.h @@ -24,7 +24,8 @@ struct MediaFragment { std::uint64_t start_time_us = 0; std::uint64_t duration_us = 0; std::uint64_t earliest_presentation_time_us = 0; - std::uint8_t sap_type = 0; + std::uint8_t sap_type = 0; // concrete CMSF SAP type 0..3 when has_sap_type + bool has_sap_type = false; // true: sap_type was computed (vs unset/unknown) bool is_video_keyframe = false; // True if this is a video track IDR/keyframe fragment std::uint64_t creation_time_us = 0; // Wall-clock time when fragment was created (for queue delay measurement) PayloadBuffer payload; diff --git a/include/openmoq/publisher/cmsf_packager.h b/include/openmoq/publisher/cmsf_packager.h index 2a4ebc5..fb62ec1 100644 --- a/include/openmoq/publisher/cmsf_packager.h +++ b/include/openmoq/publisher/cmsf_packager.h @@ -24,6 +24,8 @@ struct CmsfObject { std::size_t object_id = 0; std::uint64_t media_time_us = 0; std::uint64_t media_duration_us = 0; + std::uint8_t sap_type = 0; // concrete CMSF SAP type 0..3 when has_sap_type + bool has_sap_type = false; // true: sap_type was computed (carried from MediaFragment) ByteSpan payload; std::vector owned_payload; }; diff --git a/include/openmoq/publisher/live_object.h b/include/openmoq/publisher/live_object.h index fcf1226..ca665e1 100644 --- a/include/openmoq/publisher/live_object.h +++ b/include/openmoq/publisher/live_object.h @@ -8,8 +8,40 @@ namespace openmoq::publisher { +// Media classification for a LiveTrack. kUnset marks a legacy "bare" track that +// carries only a name + opaque payloads -- such tracks cannot be published via +// the libmoq service tier (which requires real media metadata) and remain +// legacy-only (MoqtSession path). +enum class LiveMediaType { + kUnset, + kVideo, + kAudio, +}; + +enum class LivePackaging { + kRaw, + kCmaf, +}; + struct LiveTrack { std::string track_name; + + // Optional media metadata. Required for libmoq-backed publish_live_objects; + // omitting it (kUnset) keeps a track legacy-only. Defaults preserve + // source-compatibility of existing `{.track_name = ...}` initializers. + LiveMediaType media_type = LiveMediaType::kUnset; + LivePackaging packaging = LivePackaging::kRaw; + std::string codec; + std::vector init_data; // codec/decoder config (CMAF init, SPS/PPS, ASC, ...) + std::uint64_t bitrate = 0; // max bitrate (bits/s); 0 => libmoq default + + // Video. + std::uint32_t width = 0; + std::uint32_t height = 0; + + // Audio. + std::uint32_t sample_rate = 0; + std::uint32_t channel_count = 0; }; struct LiveObject { diff --git a/include/openmoq/publisher/moq_draft.h b/include/openmoq/publisher/moq_draft.h index 8fda7d3..32b87d0 100644 --- a/include/openmoq/publisher/moq_draft.h +++ b/include/openmoq/publisher/moq_draft.h @@ -12,7 +12,7 @@ enum class DraftVersion { }; struct DraftProfile { - DraftVersion version = DraftVersion::kDraft14; + DraftVersion version = DraftVersion::kDraft16; std::string subscribe_namespace_label; std::string track_alias_label; std::string object_status_label; diff --git a/include/openmoq/publisher/publisher_api.h b/include/openmoq/publisher/publisher_api.h index 89abf73..8904e8e 100644 --- a/include/openmoq/publisher/publisher_api.h +++ b/include/openmoq/publisher/publisher_api.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -19,8 +20,15 @@ namespace openmoq::publisher { +namespace transport { +// Defined in transport/libmoq_publisher.h (libmoq builds only). Held here by +// shared_ptr so disconnect() can interrupt an in-flight libmoq live publish; the +// member stays null when libmoq is not compiled in. +struct LibmoqLiveHandle; +} // namespace transport + struct PublisherConfig { - DraftVersion draft_version = DraftVersion::kDraft14; + DraftVersion draft_version = DraftVersion::kDraft16; std::string track_namespace = "media"; bool forward = false; bool publish_catalog = false; @@ -148,8 +156,17 @@ class Publisher { PublisherConfig config_; TransportFactory transport_factory_; + // True when the caller injected a custom TransportFactory (e.g. a test + // mock). When false and libmoq is available, batch publishing prefers the + // libmoq service-tier path over the local MoqtSession transport. + bool transport_factory_injected_ = false; mutable std::mutex state_mutex_; mutable std::shared_ptr active_session_; + // Shared handle for an in-progress libmoq-backed live publish (cancel flag + + // the live endpoint). disconnect() calls request_cancel() on it so a running + // driver stops promptly AND its blocking wait is interrupted (the libmoq + // paths do not use active_session_). Guarded by state_mutex_. + mutable std::shared_ptr libmoq_live_; mutable StatsSnapshot stats_; }; diff --git a/include/openmoq/publisher/transport/libmoq_publisher.h b/include/openmoq/publisher/transport/libmoq_publisher.h new file mode 100644 index 0000000..7482294 --- /dev/null +++ b/include/openmoq/publisher/transport/libmoq_publisher.h @@ -0,0 +1,265 @@ +#pragma once + +// libmoq-backed batch publish path. Only compiled when moqxr is built against +// the sibling libmoq service tier (OPENMOQ_HAS_LIBMOQ). The translation helpers +// below are pure and network-free so they can be unit-tested without a relay; +// the driver function drives a real moq_endpoint_t / moq_media_sender_t. +#ifdef OPENMOQ_HAS_LIBMOQ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include // moq_sap_type_t / MOQ_SAP_* +#include +#include +#include + +#include "openmoq/publisher/cmsf_packager.h" +#include "openmoq/publisher/live_srt_ingest.h" +#include "openmoq/publisher/publisher_api.h" +#include "openmoq/publisher/transport/publisher_transport.h" + +namespace openmoq::publisher::transport { + +// -- Translation (pure; unit-tested without a network) ------------------- +// +// One libmoq media track per PublishPlan media track. Owns the backing +// storage (name, codec, init segment, ...) so cfg() can hand libmoq a +// borrowing moq_media_track_cfg_t that points into this object. The cfg is +// only valid while the owning LibmoqTrackTranslation is alive and unmoved. + +struct LibmoqTrackTranslation { + std::string name; + moq_media_type_t media_type = MOQ_MEDIA_TYPE_VIDEO; + moq_media_packaging_t packaging = MOQ_MEDIA_PACKAGING_CMAF; + std::string codec; + std::uint32_t timescale = 0; // 0 => microseconds (libmoq default) + std::vector init_data; // track-specific CMAF init segment + bool is_live = false; // batch publish is VOD + std::uint32_t width = 0; + std::uint32_t height = 0; + std::uint64_t framerate_millis = 0; + std::uint32_t samplerate = 0; + std::string channel_config; + std::uint64_t bitrate = 0; // max bitrate (bits/s); MSF-01 requires > 0 + + moq_media_track_cfg_t cfg() const; +}; + +// One libmoq media send-object per PublishPlan media object. The typed timing +// and grouping fields are set here; the caller fills object().payload with a +// freshly created moq_rcbuf_t from this object's payload bytes before write(). + +struct LibmoqObjectTranslation { + std::string track_name; + std::size_t group_id = 0; + std::size_t object_id = 0; + bool starts_group = false; + bool ends_group = false; + bool is_sync = false; + bool has_sap_type = false; // true: moqxr declares a concrete CMSF SAP type + moq_sap_type_t sap_type = MOQ_SAP_NONE; // concrete 0..3; needed so a coalesced/ + // live group-start passes libmoq's §3.4 rule + std::uint64_t decode_time_us = 0; + std::uint64_t presentation_time_us = 0; + std::vector payload; // full CMAF fragment bytes + + moq_media_send_object_t object() const; // payload left NULL for the caller +}; + +struct LibmoqPlanTranslation { + std::vector tracks; + std::vector objects; // media objects only +}; + +// Translate a *materialized* PublishPlan into libmoq track + object configs. +// +// moqxr's locally generated catalog object (kInitialization "catalog") and its +// generated timeline objects (kMetadata) are intentionally dropped: the libmoq +// media sender owns catalog publication and derives it from the configured +// tracks. Per-track init segments are taken from plan.track_initializations and +// carried into each track's init_data. +LibmoqPlanTranslation translate_plan_for_libmoq(const PublishPlan& plan); + +// -- Live (stdin) translation (pure; unit-tested without a network) ------ +// +// The live path streams fragments as they arrive rather than from a fully +// materialized plan, so the per-track config and per-object mapping are exposed +// directly. Tracks are marked isLive; init_data is the track-specific CMAF init +// segment built from the ftyp+moov header. + +LibmoqTrackTranslation make_libmoq_live_track(const TrackDescription& track, + const std::vector& init_segment); + +// Map one live MediaFragment (with its group_id/object_id already assigned by +// the caller's keyframe-grouping) into a libmoq send-object. ends_group is left +// false: in a live stream a group is closed by the next group-start object (and +// the final group by end_track), so boundaries are not known ahead of time. +LibmoqObjectTranslation make_libmoq_live_object(const MediaFragment& fragment); + +// -- LiveObjectSource translation (pure; unit-tested without a network) --- +// +// A LiveTrack carries enough media metadata for libmoq when it declares a +// media_type and codec (and, for audio, sample_rate + channel_count). Bare +// legacy tracks (media_type kUnset) do not qualify. +bool live_track_has_media_metadata(const LiveTrack& track); + +// Translate a LiveTrack into a libmoq track config (RAW or CMAF packaging). +LibmoqTrackTranslation make_libmoq_live_object_track(const LiveTrack& track); + +// Translate a LiveObject into a libmoq send-object. starts_group/is_sync key off +// object_id==0; ends_group = final_in_subgroup && subgroup_contains_group_largest; +// timing comes from media_time_us. +LibmoqObjectTranslation make_libmoq_live_source_object(const LiveObject& object); + +// Build a libmoq endpoint URL from an EndpointConfig: +// raw QUIC -> moqt://host:port/path +// WebTransport -> https://host:port/path +std::string libmoq_endpoint_url(const EndpointConfig& endpoint); + +// -- Driver (batch publish over a real endpoint) ------------------------- + +struct LibmoqPublishStats { + std::uint64_t bytes_published = 0; + std::uint64_t objects_published = 0; + std::uint64_t groups_published = 0; +}; + +// Connect an endpoint, attach a media sender, add the plan's tracks, write its +// media objects, end each finite track, then tear everything down cleanly. +// draft_version must be draft-16 or draft-18; the endpoint requests it exactly. +TransportStatus publish_plan_via_libmoq(const PublishPlan& materialized_plan, + const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + LibmoqPublishStats& out_stats); + +// Shared handle for an in-flight libmoq live publish. Publisher owns it; the +// driver registers its endpoint (set_endpoint) once connected and clears it +// before teardown. disconnect() on another thread calls request_cancel(), which +// sets the cancel flag AND interrupts the live endpoint so a blocking libmoq +// wait returns IMMEDIATELY (rather than sitting until subscriber_timeout). The +// driver observes the cancel flag in its loops and tears down cleanly, returning +// success -- a cancel is a deliberate stop, matching the legacy MoqtSession +// close() semantics. A cancel set before the driver connects short-circuits +// without connecting. NOTE: stdin's blocking read still means cancel is observed +// once the current read returns; object/SRT/readiness waits stop promptly. +struct LibmoqLiveHandle { + std::atomic cancel{false}; + std::mutex ep_mutex; + moq_endpoint_t* ep = nullptr; // valid only between set_endpoint(ep)/(nullptr) + + bool cancelled() const { return cancel.load(); } + + void set_endpoint(moq_endpoint_t* e) { + std::lock_guard lock(ep_mutex); + ep = e; + } + + void request_cancel() { + cancel.store(true); + std::lock_guard lock(ep_mutex); + if (ep != nullptr) { + moq_endpoint_set_interrupted(ep, true); + } + } +}; + +// Readiness-wait primitive, factored out so cancellation-during-readiness is +// unit-testable without a network: the ops are injected. Polls is_ready() with +// fatal/timeout/closed/cancel exits; wait(step_us) blocks up to step_us and +// returns a moq_result_t (e.g. MOQ_ERR_CLOSED). With the endpoint interrupt +// latch set by request_cancel(), the real wait() returns at once, so the loop +// observes the cancel flag promptly. +enum class LibmoqReadyOutcome { kReady, kCancelled, kFatal, kTimeout, kClosed }; +struct LibmoqReadyOps { + std::function is_ready; + std::function is_fatal; + std::function wait; +}; +LibmoqReadyOutcome libmoq_wait_ready(std::atomic* cancel, + std::uint64_t timeout_us, std::uint64_t step_us, + const LibmoqReadyOps& ops); + +// Demand-wait primitive: block until a downstream subscriber exists (lazy relays +// forward a SUBSCRIBE only when a player subscribes). Factored out like +// libmoq_wait_ready so it is unit-testable without a network. Polls +// has_subscriber() with fatal/closed/timeout/cancel exits; wait(step_us) blocks +// up to step_us OR until the demand callback wakes it. has_subscriber() is the +// authoritative check (the callback only nudges the wait). +enum class LibmoqDemandOutcome { kSubscriber, kCancelled, kFatal, kTimeout, kClosed }; +struct LibmoqDemandOps { + std::function has_subscriber; + std::function is_fatal; + std::function is_closed; + std::function wait; +}; +LibmoqDemandOutcome libmoq_wait_demand(std::atomic* cancel, + std::uint64_t timeout_us, std::uint64_t step_us, + const LibmoqDemandOps& ops); + +// Bounded retry for a blocking sender op (write / end_track), so a stalled send +// queue (e.g. the subscriber left, or the queue never drains) can no longer hang +// the publish. attempt() returns a moq_result_t: MOQ_OK -> kOk; a non-WOULD_BLOCK +// error -> kError (the code is written to *out_rc); MOQ_ERR_WOULD_BLOCK -> re-check +// cancel/fatal/closed/(optional)demand/timeout, then wait(step_us) and retry. +// Factored out for network-free unit tests. timeout_us bounds the total retry +// (PublisherConfig::subscriber_timeout); has_demand is optional (batch media +// writes set it so a departed subscriber yields kNoDemand). +enum class LibmoqRetryOutcome { kOk, kCancelled, kFatal, kClosed, kTimeout, kNoDemand, kError }; +struct LibmoqRetryOps { + std::function attempt; + std::function is_fatal; + std::function is_closed; + std::function has_demand; // optional; when set and false -> kNoDemand + std::function wait; +}; +LibmoqRetryOutcome libmoq_retry_blocking(std::atomic* cancel, + std::uint64_t timeout_us, std::uint64_t step_us, + const LibmoqRetryOps& ops, int* out_rc); + +// Live stdin publish over libmoq: read ftyp+moov for track discovery, attach a +// media sender, add the discovered tracks, then stream moof+mdat fragments as +// send-objects until stdin EOF, ending each track on the way out. (SRT and +// LiveObjectSource are handled by their own drivers below.) +TransportStatus publish_live_stdin_via_libmoq(std::istream& input, + const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + LibmoqPublishStats& out_stats, + LibmoqLiveHandle* live = nullptr); + +// LiveObjectSource publish over libmoq: add the source's (metadata-bearing) +// tracks, wait for readiness, then pull objects from source.next_object() until +// it returns nullopt, mapping each through make_libmoq_live_source_object(). +// Objects for undeclared tracks are rejected. Callers should validate that every +// track satisfies live_track_has_media_metadata() before invoking this. +TransportStatus publish_live_objects_via_libmoq(const LiveObjectSource& source, + const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + LibmoqPublishStats& out_stats, + LibmoqLiveHandle* live = nullptr); + +// Live SRT publish over libmoq: start a LiveSrtIngestManager for the configured +// callers, attach a media sender, add the bootstrap-discovered tracks, then +// drain the manager's MediaFragment callback queue into send-objects until the +// source ends, ending each track on the way out. The manager is always stopped +// and joined on every exit path after a successful start(). (LiveObjectSource is +// handled by its own driver above.) +TransportStatus publish_live_srt_via_libmoq(std::vector srt_callers, + const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + LibmoqPublishStats& out_stats, + LibmoqLiveHandle* live = nullptr); + +} // namespace openmoq::publisher::transport + +#endif // OPENMOQ_HAS_LIBMOQ diff --git a/src/cli_options.cpp b/src/cli_options.cpp index e1d5748..a38537b 100644 --- a/src/cli_options.cpp +++ b/src/cli_options.cpp @@ -15,20 +15,21 @@ InputSource parse_input_source(std::string_view value) { } DraftVersion parse_draft(std::string_view value) { - if (value == "14") { - return DraftVersion::kDraft14; - } if (value == "16") { return DraftVersion::kDraft16; } - if (value == "17") { - return DraftVersion::kDraft17; - } if (value == "18") { return DraftVersion::kDraft18; } + // Draft-14 and draft-17 are no longer user-selectable; only draft-16 and + // draft-18 are supported going forward. + if (value == "14" || value == "17") { + throw std::runtime_error( + "draft " + std::string(value) + + " is no longer supported; only draft 16 and 18 are available"); + } - throw std::runtime_error("unsupported draft value: expected 14, 16, 17, or 18"); + throw std::runtime_error("unsupported draft value: expected 16 or 18"); } transport::TransportKind parse_transport_kind(std::string_view value) { @@ -240,7 +241,7 @@ CliOptions parse_cli_options(int argc, char** argv) { std::string build_usage(const char* argv0) { return std::string("Usage: ") + argv0 + " --input [--live-source auto|stdin|srt] [--srt-config ]" - " [--transport raw|webtransport] [--draft 14|16|17|18] [--namespace ] [--forward 0|1] [--timeout ]" + " [--transport raw|webtransport] [--draft 16|18] [--namespace ] [--forward 0|1] [--timeout ]" " [--publish-catalog] [--sap] [--msf-timeline] [--coalesce-cmaf-chunks] [--paced] [--loop] [--dump-plan] [--emit-dir ]" " [--endpoint host:port|moqt://host:port/path|https://host:port/path] [--alpn value] [--sni value]" " [--cert file] [--key file] [--ca file] [--insecure]"; diff --git a/src/cmaf_segmenter.cpp b/src/cmaf_segmenter.cpp index 99c47f5..fc90161 100644 --- a/src/cmaf_segmenter.cpp +++ b/src/cmaf_segmenter.cpp @@ -606,11 +606,14 @@ std::uint32_t sample_flags_for(const TrackRemuxInfo& track, std::size_t sample_i return 0x02000000U; } -std::vector build_trun_box(const TrackRemuxInfo& track, std::uint32_t data_offset) { +std::vector build_trun_range_box(const TrackRemuxInfo& track, + std::size_t first, std::size_t count, + std::uint32_t data_offset) { std::vector trun_payload; - append_be32(trun_payload, static_cast(track.sample_sizes.size())); + append_be32(trun_payload, static_cast(count)); append_be32(trun_payload, data_offset); - for (std::size_t index = 0; index < track.sample_sizes.size(); ++index) { + for (std::size_t k = 0; k < count; ++k) { + const std::size_t index = first + k; append_be32(trun_payload, track.sample_durations[index]); append_be32(trun_payload, track.sample_sizes[index]); append_be32(trun_payload, sample_flags_for(track, index)); @@ -662,12 +665,21 @@ std::vector build_sample_object(std::uint32_t track_id, return concat_boxes({moof, mdat}); } -std::vector build_remuxed_fragment(const TrackRemuxInfo& track, - std::size_t sequence, - std::span bytes) { +// Build one CMAF fragment (moof+mdat) covering samples [first, first+count) of a +// progressive track, with tfdt = base_decode_time. Used by bounded per-GOP +// coalescing: each fragment carries a subrange of the track's samples (one GOP, +// or a capped slice of a long GOP) rather than the whole track. +std::vector build_remuxed_fragment_range(const TrackRemuxInfo& track, + std::size_t first, std::size_t count, + std::uint64_t base_decode_time, + std::size_t sequence, + std::span bytes) { std::vector mdat_payload; - mdat_payload.reserve(std::accumulate(track.sample_sizes.begin(), track.sample_sizes.end(), std::size_t{0})); - for (std::size_t index = 0; index < track.sample_sizes.size(); ++index) { + std::size_t total = 0; + for (std::size_t k = 0; k < count; ++k) total += track.sample_sizes[first + k]; + mdat_payload.reserve(total); + for (std::size_t k = 0; k < count; ++k) { + const std::size_t index = first + k; const ByteSpan sample_span{.offset = static_cast(track.sample_offsets[index]), .size = track.sample_sizes[index]}; const auto sample_bytes = slice_bytes(bytes, sample_span); @@ -683,15 +695,15 @@ std::vector build_remuxed_fragment(const TrackRemuxInfo& track, const std::vector tfhd = make_full_box("tfhd", 0, 0x020000, tfhd_payload); std::vector tfdt_payload; - append_be32(tfdt_payload, 0); + append_be32(tfdt_payload, static_cast(base_decode_time)); const std::vector tfdt = make_full_box("tfdt", 0, 0, tfdt_payload); - const std::vector placeholder_trun = build_trun_box(track, 0); + const std::vector placeholder_trun = build_trun_range_box(track, first, count, 0); const std::vector placeholder_traf = make_box("traf", concat_boxes({tfhd, tfdt, placeholder_trun})); const std::vector placeholder_moof = make_box("moof", concat_boxes({mfhd, placeholder_traf})); const std::uint32_t data_offset = static_cast(placeholder_moof.size() + 8); - const std::vector trun = build_trun_box(track, data_offset); + const std::vector trun = build_trun_range_box(track, first, count, data_offset); const std::vector traf = make_box("traf", concat_boxes({tfhd, tfdt, trun})); const std::vector moof = make_box("moof", concat_boxes({mfhd, traf})); const std::vector mdat = make_box("mdat", mdat_payload); @@ -699,13 +711,6 @@ std::vector build_remuxed_fragment(const TrackRemuxInfo& track, return concat_boxes({moof, mdat}); } -std::uint64_t remux_fragment_duration_us(const TrackRemuxInfo& track) { - return scale_to_us(std::accumulate(track.sample_durations.begin(), - track.sample_durations.end(), - std::uint64_t{0}), - track.timescale); -} - std::uint8_t sap_type_from_flags(std::string_view handler_type, std::uint32_t sample_flags) { if (handler_type != "vide") { return 1; @@ -834,6 +839,123 @@ std::vector parse_fragment_samples(const Mp4Box& moof, return samples; } +// Cap on samples packed into a single coalesced CMAF object. Kept comfortably +// below libmoq's CMAF validator scratch buffer (512 samples per trun) so a long +// GOP is split across continuation objects within its group rather than +// producing one oversized fragment that the validator rejects. +constexpr std::size_t kMaxSamplesPerCoalescedObject = 120; + +// Decode-time (microseconds) at which each video GOP begins, used to align audio +// group boundaries to the video group timeline. +std::vector video_group_start_times_us(const TrackRemuxInfo& video) { + std::vector starts; + std::uint64_t decode_time = 0; + for (std::size_t i = 0; i < video.sample_sizes.size(); ++i) { + if (i == 0 || video.sync_samples[i]) { + starts.push_back(scale_to_us(decode_time, video.timescale)); + } + decode_time += video.sample_durations[i]; + } + return starts; +} + +// Bounded coalescing for one progressive track. Video groups break at sync +// samples (each GOP -> one group whose first object is a SAP); audio/other +// groups break at the video group boundaries (or form a single group when there +// is no video reference). Each group is then chunked into objects of at most +// kMaxSamplesPerCoalescedObject samples. The track forms ONE MoQ group (like +// the split path: group_id = track_index), so the relay forwards a single +// long-lived subgroup rather than a burst of short-lived per-GOP groups -- the +// latter does not survive a lazy relay's subscribe/forward window. GOP/segment +// boundaries become OBJECT boundaries within that group: object 0 begins with +// the track's first keyframe (the group-start SAP); each later video object +// that begins a GOP is also a SAP, and continuation objects (a long GOP split +// to stay under the validator buffer) are non-SAP. This replaces the previous +// whole-track object, which produced one trun over every sample and tripped +// libmoq's 512-sample CMAF validator. +void append_coalesced_fragments(SegmentedMp4& segmented, + const TrackRemuxInfo& track, + std::size_t track_index, + const std::vector& video_group_starts_us, + std::span bytes) { + const std::size_t n = track.sample_sizes.size(); + if (n == 0) { + return; + } + const bool is_video = track.description.handler_type == "vide"; + + // Prefix sum of sample durations -> decode time (timescale units) per sample. + std::vector decode_time(n + 1, 0); + for (std::size_t i = 0; i < n; ++i) { + decode_time[i + 1] = decode_time[i] + track.sample_durations[i]; + } + + // GOP/segment boundaries: video breaks at sync samples; audio/other breaks + // at the video GOP timeline (each becomes an object boundary, not a group). + std::vector gop_starts; + if (is_video) { + for (std::size_t i = 0; i < n; ++i) { + if (i == 0 || track.sync_samples[i]) { + gop_starts.push_back(i); + } + } + } else if (!video_group_starts_us.empty()) { + std::size_t vg = 0; + for (std::size_t i = 0; i < n; ++i) { + const std::uint64_t t_us = scale_to_us(decode_time[i], track.timescale); + bool start = (i == 0); + while (vg + 1 < video_group_starts_us.size() && + t_us >= video_group_starts_us[vg + 1]) { + ++vg; + start = true; + } + if (start) { + gop_starts.push_back(i); + } + } + } + if (gop_starts.empty() || gop_starts.front() != 0) { + gop_starts.insert(gop_starts.begin(), 0); + } + + std::size_t object_id = 0; + for (std::size_t g = 0; g < gop_starts.size(); ++g) { + const std::size_t gop_begin = gop_starts[g]; + const std::size_t gop_end = (g + 1 < gop_starts.size()) ? gop_starts[g + 1] : n; + + for (std::size_t c0 = gop_begin; c0 < gop_end; c0 += kMaxSamplesPerCoalescedObject) { + const std::size_t c1 = std::min(c0 + kMaxSamplesPerCoalescedObject, gop_end); + const std::size_t count = c1 - c0; + const std::uint64_t base_decode_time = decode_time[c0]; + const std::uint64_t duration = decode_time[c1] - decode_time[c0]; + + // A video object that begins a GOP (c0 == gop_begin) starts with the + // keyframe -> SAP type 2; a continuation chunk of a long GOP is + // non-SAP. Every audio object is a SAP (type 1). object 0 is the + // group-start and is always a SAP, satisfying CMSF Section 3.4. + const std::uint8_t sap = is_video + ? static_cast(c0 == gop_begin ? 2 : 0) + : static_cast(1); + + segmented.fragments.push_back({ + .group_id = track_index, + .object_id = object_id, + .track_name = track.description.track_name, + .start_time_us = scale_to_us(base_decode_time, track.timescale), + .duration_us = scale_to_us(duration, track.timescale), + .earliest_presentation_time_us = + presentation_time_us(base_decode_time, track.composition_offsets[c0], track.timescale), + .sap_type = sap, + .has_sap_type = true, + .payload = {.span = {}, + .owned_bytes = build_remuxed_fragment_range(track, c0, count, + base_decode_time, object_id, bytes)}, + }); + ++object_id; + } + } +} + } // namespace SegmentedMp4 segment_for_cmaf(const ParsedMp4& parsed_mp4, CmafObjectMode object_mode) { @@ -892,6 +1014,7 @@ SegmentedMp4 segment_for_cmaf(const ParsedMp4& parsed_mp4, CmafObjectMode object .earliest_presentation_time_us = presentation_time_us(sample.decode_time, sample.composition_offset, track_it->timescale), .sap_type = sap_type_from_flags(track_it->handler_type, sample.flags), + .has_sap_type = true, .payload = {.span = {}, .owned_bytes = build_sample_object(track_it->track_id, group_id * 1000 + sample_index, @@ -911,6 +1034,7 @@ SegmentedMp4 segment_for_cmaf(const ParsedMp4& parsed_mp4, CmafObjectMode object .duration_us = timing.duration_us, .earliest_presentation_time_us = timing.earliest_presentation_time_us, .sap_type = timing.sap_type, + .has_sap_type = true, .payload = {.span = {.offset = moofs[index]->span.offset, .size = moofs[index]->span.size + mdats[index]->span.size}, .owned_bytes = {}}, @@ -924,6 +1048,28 @@ SegmentedMp4 segment_for_cmaf(const ParsedMp4& parsed_mp4, CmafObjectMode object build_fragmented_init_segment(*ftyp, *moov, parsed_mp4.tracks, parsed_mp4.bytes); segmented.initialization_segment.span = {}; + // For bounded coalescing, audio group boundaries follow the video GOP + // timeline, so derive the video group start times before segmenting tracks. + std::vector video_group_starts_us; + if (object_mode != CmafObjectMode::kSplit) { + std::size_t scan_index = 0; + for (const auto& child : moov->children) { + if (child.type != "trak") { + continue; + } + if (scan_index >= parsed_mp4.tracks.size()) { + break; + } + if (parsed_mp4.tracks[scan_index].handler_type == "vide") { + const TrackRemuxInfo video_info = + parse_track_remux_info(child, parsed_mp4.tracks[scan_index], parsed_mp4.bytes); + video_group_starts_us = video_group_start_times_us(video_info); + break; + } + ++scan_index; + } + } + std::size_t track_index = 0; for (const auto& child : moov->children) { if (child.type != "trak") { @@ -955,6 +1101,7 @@ SegmentedMp4 segment_for_cmaf(const ParsedMp4& parsed_mp4, CmafObjectMode object .earliest_presentation_time_us = presentation_time_us(sample.decode_time, sample.composition_offset, track_info.timescale), .sap_type = sap_type_from_flags(track_info.description.handler_type, sample.flags), + .has_sap_type = true, .payload = {.span = {}, .owned_bytes = build_sample_object(track_info.description.track_id, track_index * 1000 + sample_index, @@ -964,21 +1111,12 @@ SegmentedMp4 segment_for_cmaf(const ParsedMp4& parsed_mp4, CmafObjectMode object decode_time += sample.duration; } } else { - segmented.fragments.push_back({ - .group_id = track_index, - .object_id = 0, - .track_name = track_info.description.track_name, - .start_time_us = 0, - .duration_us = remux_fragment_duration_us(track_info), - .earliest_presentation_time_us = track_info.composition_offsets.empty() - ? 0 - : scale_to_us(static_cast(std::max(track_info.composition_offsets.front(), 0)), - track_info.timescale), - .sap_type = static_cast(track_info.description.handler_type == "vide" - ? (track_info.sync_samples.empty() || track_info.sync_samples.front() ? 2 : 0) - : 1), - .payload = {.span = {}, .owned_bytes = build_remuxed_fragment(track_info, track_index, parsed_mp4.bytes)}, - }); + // Bounded keyframe/GOP coalescing: one group per track (like split), + // with each GOP (video) or aligned span (audio) a separate object, + // chunked to stay under the 512-sample validator -- never a single + // whole-track fragment. + append_coalesced_fragments(segmented, track_info, track_index, video_group_starts_us, + parsed_mp4.bytes); } ++track_index; } @@ -1145,6 +1283,7 @@ MediaFragment build_live_fragment(std::span moof_bytes, .duration_us = scale_to_us(duration, track_desc->timescale), .earliest_presentation_time_us = scale_to_us(earliest_presentation_time, track_desc->timescale), .sap_type = sap_type, + .has_sap_type = true, // moqxr computed a concrete SAP type above .is_video_keyframe = is_video_keyframe, .payload = {.span = {}, .owned_bytes = std::move(payload)}, }; diff --git a/src/cmsf_packager.cpp b/src/cmsf_packager.cpp index 5b7fbd3..f53b2b5 100644 --- a/src/cmsf_packager.cpp +++ b/src/cmsf_packager.cpp @@ -577,6 +577,8 @@ PublishPlan build_publish_plan(const SegmentedMp4& segmented_mp4, .object_id = fragment.object_id, .media_time_us = fragment.start_time_us, .media_duration_us = fragment.duration_us, + .sap_type = fragment.sap_type, // carry CMSF SAP type into the object + .has_sap_type = fragment.has_sap_type, .payload = fragment.payload.span, .owned_payload = fragment.payload.owned_bytes, }); diff --git a/src/live_srt_ingest.cpp b/src/live_srt_ingest.cpp index 42731b4..cb99361 100644 --- a/src/live_srt_ingest.cpp +++ b/src/live_srt_ingest.cpp @@ -1433,7 +1433,11 @@ MediaFragment build_fragment_from_sample(const LiveSrtCallerRuntimeConfig& confi fragment.start_time_us = pts_us; fragment.duration_us = duration_us; fragment.earliest_presentation_time_us = pts_us; - fragment.sap_type = sample.is_video ? (sample.keyframe ? 1 : 2) : 1; + // Concrete CMSF SAP type, matching the segmenter convention: video keyframe = 2, + // non-key video = 0 (NONE -- a P/B frame is NOT a SAP; declaring it as one would + // corrupt SAP timelines / drop-recovery), audio = 1. + fragment.sap_type = sample.is_video ? (sample.keyframe ? 2 : 0) : 1; + fragment.has_sap_type = true; // concrete SAP type computed above fragment.is_video_keyframe = sample.is_video && sample.keyframe; fragment.creation_time_us = static_cast( std::chrono::duration_cast( diff --git a/src/publisher_api.cpp b/src/publisher_api.cpp index ffd04d8..6c258ab 100644 --- a/src/publisher_api.cpp +++ b/src/publisher_api.cpp @@ -5,6 +5,9 @@ #include "openmoq/publisher/transport/moqt_session.h" #include "openmoq/publisher/transport/picoquic_client.h" #include "openmoq/publisher/transport/webtransport_client.h" +#ifdef OPENMOQ_HAS_LIBMOQ +#include "openmoq/publisher/transport/libmoq_publisher.h" +#endif #include #include @@ -76,6 +79,7 @@ struct Publisher::ActiveSession { Publisher::Publisher(PublisherConfig config, TransportFactory transport_factory) : config_(std::move(config)), transport_factory_(std::move(transport_factory)) { + transport_factory_injected_ = static_cast(transport_factory_); if (!transport_factory_) { transport_factory_ = default_transport_factory(); } @@ -127,6 +131,43 @@ transport::TransportStatus Publisher::publish(const PreparedPublish& prepared, const transport::EndpointConfig& endpoint, const transport::TlsConfig& tls, bool endpoint_alpn_overridden) const { +#ifdef OPENMOQ_ENABLE_LIBMOQ_PUBLISHER + // Batch publishing prefers the libmoq service tier unless a custom + // TransportFactory was injected (tests still exercise the local path). + // The live paths (stdin, SRT, LiveObjectSource) route to libmoq in their + // own methods below; the MoqtSession transport now only serves injected- + // factory callers and builds without libmoq. + if (!transport_factory_injected_) { + const transport::EndpointConfig resolved_endpoint = + resolve_endpoint(endpoint, endpoint_alpn_overridden); + const PublishPlan materialized = + materialize_publish_plan(prepared.plan, prepared.input_bytes); + transport::LibmoqPublishStats libmoq_stats; + const transport::TransportStatus status = transport::publish_plan_via_libmoq( + materialized, config_, resolved_endpoint, tls, libmoq_stats); + if (!status.ok) { + std::lock_guard lock(state_mutex_); + stats_.active = false; + stats_.connected = false; + stats_.publishing_live = false; + stats_.last_error = status.message; + return status; + } + std::lock_guard lock(state_mutex_); + stats_.active = false; + stats_.connected = true; + stats_.publishing_live = false; + stats_.transport = resolved_endpoint.transport; + stats_.host = resolved_endpoint.host; + stats_.port = resolved_endpoint.port; + stats_.path = resolved_endpoint.path; + stats_.bytes_published = libmoq_stats.bytes_published; + stats_.objects_published = libmoq_stats.objects_published; + stats_.groups_published = libmoq_stats.groups_published; + stats_.last_error.clear(); + return status; + } +#endif if (!transport_factory_) { return transport::TransportStatus::failure("publisher transport factory is not configured"); } @@ -204,6 +245,94 @@ transport::TransportStatus Publisher::publish_live(const LiveIngestConfig& inges const transport::EndpointConfig& endpoint, const transport::TlsConfig& tls, bool endpoint_alpn_overridden) const { +#ifdef OPENMOQ_ENABLE_LIBMOQ_PUBLISHER + // Live stdin and SRT publishing prefer the libmoq service tier unless a + // custom TransportFactory was injected. (publish_live_objects routes to + // libmoq in its own method.) + if (!transport_factory_injected_ && ingest.use_stdin && ingest.srt_callers.empty()) { + if (stdin_input == nullptr) { + return transport::TransportStatus::failure("live stdin publish requires an input stream"); + } + const transport::EndpointConfig resolved_endpoint = + resolve_endpoint(endpoint, endpoint_alpn_overridden); + auto live = std::make_shared(); + { + std::lock_guard lock(state_mutex_); + libmoq_live_ = live; + } + transport::LibmoqPublishStats libmoq_stats; + const transport::TransportStatus status = transport::publish_live_stdin_via_libmoq( + *stdin_input, config_, resolved_endpoint, tls, libmoq_stats, live.get()); + std::lock_guard lock(state_mutex_); + if (libmoq_live_ == live) { + libmoq_live_.reset(); + } + stats_.active = false; + stats_.connected = status.ok; + stats_.publishing_live = false; + stats_.transport = resolved_endpoint.transport; + stats_.host = resolved_endpoint.host; + stats_.port = resolved_endpoint.port; + stats_.path = resolved_endpoint.path; + stats_.bytes_published = libmoq_stats.bytes_published; + stats_.objects_published = libmoq_stats.objects_published; + stats_.groups_published = libmoq_stats.groups_published; + stats_.last_error = status.ok ? std::string() : status.message; + return status; + } + // SRT-only live publishing also prefers the libmoq service tier. Mixed + // stdin+SRT does not match either branch and falls through to the old path + // below, which rejects it (consistent with existing validation). + if (!transport_factory_injected_ && !ingest.use_stdin && !ingest.srt_callers.empty()) { + std::vector runtime_callers; + runtime_callers.reserve(ingest.srt_callers.size()); + for (const auto& caller : ingest.srt_callers) { + LiveSrtCallerRuntimeConfig rc; + rc.id = caller.id; + rc.endpoint = caller.endpoint; + rc.fragment_on_keyframe = caller.fragment_on_keyframe; + rc.empty_moov = caller.empty_moov; + rc.default_base_moof = caller.default_base_moof; + rc.separate_moof_per_track = caller.separate_moof_per_track; + rc.target_fragment_duration_ms = caller.target_fragment_duration_ms; + rc.latency_ms = caller.latency_ms; + rc.auto_detect_program = caller.auto_detect_program; + rc.program_number = caller.program_number.value_or(0); + rc.has_program_number = caller.program_number.has_value(); + rc.video_pid = caller.video_pid.value_or(0); + rc.has_video_pid = caller.video_pid.has_value(); + rc.audio_pid = caller.audio_pid.value_or(0); + rc.has_audio_pid = caller.audio_pid.has_value(); + runtime_callers.push_back(std::move(rc)); + } + const transport::EndpointConfig resolved_endpoint = + resolve_endpoint(endpoint, endpoint_alpn_overridden); + auto live = std::make_shared(); + { + std::lock_guard lock(state_mutex_); + libmoq_live_ = live; + } + transport::LibmoqPublishStats libmoq_stats; + const transport::TransportStatus status = transport::publish_live_srt_via_libmoq( + std::move(runtime_callers), config_, resolved_endpoint, tls, libmoq_stats, live.get()); + std::lock_guard lock(state_mutex_); + if (libmoq_live_ == live) { + libmoq_live_.reset(); + } + stats_.active = false; + stats_.connected = status.ok; + stats_.publishing_live = false; + stats_.transport = resolved_endpoint.transport; + stats_.host = resolved_endpoint.host; + stats_.port = resolved_endpoint.port; + stats_.path = resolved_endpoint.path; + stats_.bytes_published = libmoq_stats.bytes_published; + stats_.objects_published = libmoq_stats.objects_published; + stats_.groups_published = libmoq_stats.groups_published; + stats_.last_error = status.ok ? std::string() : status.message; + return status; + } +#endif if (!transport_factory_) { return transport::TransportStatus::failure("publisher transport factory is not configured"); } @@ -276,6 +405,50 @@ transport::TransportStatus Publisher::publish_live_objects(const LiveObjectSourc const transport::EndpointConfig& endpoint, const transport::TlsConfig& tls, bool endpoint_alpn_overridden) const { +#ifdef OPENMOQ_ENABLE_LIBMOQ_PUBLISHER + // publish_live_objects prefers the libmoq service tier unless a custom + // TransportFactory was injected (legacy MoqtSession tests). libmoq requires + // real media metadata per track; a bare/legacy LiveTrack fails with a clear + // message rather than being faked as a generic media track. + if (!transport_factory_injected_) { + for (const auto& track : source.tracks) { + if (!transport::live_track_has_media_metadata(track)) { + return transport::TransportStatus::failure( + "libmoq-backed publish_live_objects requires media metadata on LiveTrack '" + + track.track_name + + "' (set media_type, codec, and -- for audio -- sample_rate/channel_count); " + "bare object tracks are legacy-only: inject a TransportFactory to use the " + "MoqtSession path"); + } + } + const transport::EndpointConfig resolved_endpoint = + resolve_endpoint(endpoint, endpoint_alpn_overridden); + auto live = std::make_shared(); + { + std::lock_guard lock(state_mutex_); + libmoq_live_ = live; + } + transport::LibmoqPublishStats libmoq_stats; + const transport::TransportStatus status = transport::publish_live_objects_via_libmoq( + source, config_, resolved_endpoint, tls, libmoq_stats, live.get()); + std::lock_guard lock(state_mutex_); + if (libmoq_live_ == live) { + libmoq_live_.reset(); + } + stats_.active = false; + stats_.connected = status.ok; + stats_.publishing_live = false; + stats_.transport = resolved_endpoint.transport; + stats_.host = resolved_endpoint.host; + stats_.port = resolved_endpoint.port; + stats_.path = resolved_endpoint.path; + stats_.bytes_published = libmoq_stats.bytes_published; + stats_.objects_published = libmoq_stats.objects_published; + stats_.groups_published = libmoq_stats.groups_published; + stats_.last_error = status.ok ? std::string() : status.message; + return status; + } +#endif if (!transport_factory_) { return transport::TransportStatus::failure("publisher transport factory is not configured"); } @@ -323,10 +496,20 @@ transport::TransportStatus Publisher::publish_live_objects(const LiveObjectSourc transport::TransportStatus Publisher::disconnect(std::uint64_t application_error_code) const { std::shared_ptr active; + std::shared_ptr live; { std::lock_guard lock(state_mutex_); active = active_session_; + live = libmoq_live_; + } + // A libmoq-backed live publish does not use active_session_; request_cancel() + // sets its cancel flag AND interrupts the live endpoint so a blocking wait + // returns immediately (not just at the next poll). +#ifdef OPENMOQ_HAS_LIBMOQ + if (live) { + live->request_cancel(); } +#endif if (!active || !active->session) { std::lock_guard lock(state_mutex_); stats_.active = false; diff --git a/src/transport/libmoq_publisher.cpp b/src/transport/libmoq_publisher.cpp new file mode 100644 index 0000000..7728783 --- /dev/null +++ b/src/transport/libmoq_publisher.cpp @@ -0,0 +1,1434 @@ +#include "openmoq/publisher/transport/libmoq_publisher.h" + +#ifdef OPENMOQ_HAS_LIBMOQ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace openmoq::publisher::transport { + +namespace { + +moq_bytes_t bytes_of(const std::string& s) { + return moq_bytes_t{reinterpret_cast(s.data()), s.size()}; +} + +moq_bytes_t bytes_of(const std::vector& v) { + return moq_bytes_t{v.data(), v.size()}; +} + +// Real media tracks carry an MP4 "vide"/"soun" handler. moqxr's synthetic +// catalog ("meta"/packaging "catalog") and generated timeline tracks +// (packaging "mediatimeline"/"eventtimeline") use the "meta" handler and must +// NOT be configured as libmoq media tracks -- libmoq owns catalog publication +// and derives any timeline tracks itself. +bool is_real_media_track(const TrackDescription& td) { + return td.handler_type == "vide" || td.handler_type == "soun"; +} + +moq_media_type_t media_type_from_track(const TrackDescription& td) { + if (td.handler_type == "soun") { + return MOQ_MEDIA_TYPE_AUDIO; + } + if (td.handler_type == "vide") { + return MOQ_MEDIA_TYPE_VIDEO; + } + // Fall back on shape: audio tracks carry a sample rate and no geometry. + if (td.sample_rate != 0 && td.width == 0 && td.height == 0) { + return MOQ_MEDIA_TYPE_AUDIO; + } + return MOQ_MEDIA_TYPE_VIDEO; +} + +// Shared track config builder for the batch and live paths. moqxr's +// TrackDescription carries no bitrate, but MSF-01 5.2.22 requires a non-zero +// maxBitrate per audio/video track, so a bitrate_hint (0 = unknown) is supplied +// and a media-type default fills in when it is unavailable. +LibmoqTrackTranslation make_track_translation(const TrackDescription& td, + std::vector init_data, + std::uint64_t bitrate_hint, + bool is_live) { + LibmoqTrackTranslation t; + t.name = !td.track_name.empty() ? td.track_name + : ("track" + std::to_string(td.track_id)); + t.media_type = media_type_from_track(td); + t.packaging = MOQ_MEDIA_PACKAGING_CMAF; + t.codec = td.codec; + t.timescale = 0; // object times are already microseconds + t.is_live = is_live; + t.width = td.width; + t.height = td.height; + t.framerate_millis = static_cast(td.frame_rate * 1000.0 + 0.5); + if (t.media_type == MOQ_MEDIA_TYPE_AUDIO) { + t.samplerate = td.sample_rate != 0 ? td.sample_rate : 48000; + t.channel_config = + td.channel_count != 0 ? std::to_string(td.channel_count) : std::string("2"); + } + t.bitrate = bitrate_hint; + if (t.bitrate == 0) { + t.bitrate = t.media_type == MOQ_MEDIA_TYPE_AUDIO ? 128000ull : 2000000ull; + } + t.init_data = std::move(init_data); + return t; +} + +} // namespace + +moq_media_track_cfg_t LibmoqTrackTranslation::cfg() const { + moq_media_track_cfg_t c; + moq_media_track_cfg_init(&c); + c.name = bytes_of(name); + c.media_type = media_type; + c.packaging = packaging; + c.codec = bytes_of(codec); + c.timescale = timescale; + if (!init_data.empty()) { + c.init_data = bytes_of(init_data); + } + c.is_live = is_live; + c.width = width; + c.height = height; + c.framerate_millis = framerate_millis; + c.samplerate = samplerate; + if (!channel_config.empty()) { + c.channel_config = bytes_of(channel_config); + } + c.bitrate = bitrate; + return c; +} + +moq_media_send_object_t LibmoqObjectTranslation::object() const { + moq_media_send_object_t o; + std::memset(&o, 0, sizeof(o)); + o.struct_size = sizeof(o); + o.payload = nullptr; // caller fills with a freshly created rcbuf + o.properties = nullptr; // CMAF: timing rides in the fragment; no extra block + o.is_sync = is_sync; + o.starts_group = starts_group; + o.ends_group = ends_group; + o.has_sap_type = has_sap_type; // declared SAP lets a coalesced/live group-start + o.sap_type = sap_type; // pass libmoq's §3.4 group-start rule + o.decode_time_us = decode_time_us; + o.presentation_time_us = presentation_time_us; + return o; +} + +namespace { +// Map moqxr's uint8 SAP type (0..3) onto the libmoq enum, declaring only the +// concrete values moqxr actually computed -- never fabricating one. A value +// outside 0..3 (e.g. an unknown/unset sentinel) leaves has_sap_type=false, so +// libmoq falls back to its sync-sample check. A declared 0 (NONE) at a group +// start is left as-is and will be (correctly) rejected by libmoq's §3.4 rule. +void apply_sap(LibmoqObjectTranslation& o, bool src_has, std::uint8_t value) { + o.has_sap_type = false; + o.sap_type = MOQ_SAP_NONE; + if (!src_has) { + return; + } + switch (value) { + case 0: o.sap_type = MOQ_SAP_NONE; o.has_sap_type = true; break; + case 1: o.sap_type = MOQ_SAP_TYPE_1; o.has_sap_type = true; break; + case 2: o.sap_type = MOQ_SAP_TYPE_2; o.has_sap_type = true; break; + case 3: o.sap_type = MOQ_SAP_TYPE_3; o.has_sap_type = true; break; + default: break; // unknown/out-of-range: do not declare + } +} +} // namespace + +LibmoqPlanTranslation translate_plan_for_libmoq(const PublishPlan& plan) { + LibmoqPlanTranslation out; + + // moqxr's TrackDescription carries no bitrate, but MSF-01 5.2.22 requires a + // non-zero maxBitrate per audio/video track. Derive an estimate from the + // observed media payload (bytes*8 / duration) and fall back when unknown. + struct Accum { + std::uint64_t bytes = 0; + std::uint64_t duration_us = 0; + }; + std::unordered_map accum; + + // Highest media object_id per (track, group), for ends_group. + std::map, std::size_t> last_object_in_group; + + for (const auto& obj : plan.objects) { + if (obj.kind != CmsfObjectKind::kMedia) { + continue; + } + const auto key = std::make_pair(obj.track_name, obj.group_id); + auto it = last_object_in_group.find(key); + if (it == last_object_in_group.end() || obj.object_id > it->second) { + last_object_in_group[key] = obj.object_id; + } + const std::size_t bytes = + !obj.owned_payload.empty() ? obj.owned_payload.size() : obj.payload.size; + Accum& a = accum[obj.track_name]; + a.bytes += static_cast(bytes); + a.duration_us += obj.media_duration_us; + } + + out.tracks.reserve(plan.tracks.size()); + for (const auto& td : plan.tracks) { + if (!is_real_media_track(td)) { + continue; // skip catalog + mediatimeline/eventtimeline synthetic tracks + } + const std::string name = + !td.track_name.empty() ? td.track_name : ("track" + std::to_string(td.track_id)); + std::vector init_data; + for (const auto& init : plan.track_initializations) { + if (init.track_name == name) { + init_data = init.init_segment; + break; + } + } + std::uint64_t bitrate = 0; + const auto a = accum.find(name); + if (a != accum.end() && a->second.duration_us > 0) { + bitrate = (a->second.bytes * 8ull * 1000000ull) / a->second.duration_us; + } + out.tracks.push_back( + make_track_translation(td, std::move(init_data), bitrate, /*is_live=*/false)); + } + + for (const auto& obj : plan.objects) { + if (obj.kind != CmsfObjectKind::kMedia) { + continue; // skip catalog (kInitialization) + timelines (kMetadata) + } + LibmoqObjectTranslation o; + o.track_name = obj.track_name; + o.group_id = obj.group_id; + o.object_id = obj.object_id; + o.starts_group = obj.object_id == 0; + const auto it = last_object_in_group.find({obj.track_name, obj.group_id}); + o.ends_group = it != last_object_in_group.end() && obj.object_id == it->second; + o.is_sync = o.starts_group; // group starts are SAPs; no finer metadata yet + apply_sap(o, obj.has_sap_type, obj.sap_type); // carry SAP into the send object + o.decode_time_us = obj.media_time_us; + o.presentation_time_us = obj.media_time_us; + if (!obj.owned_payload.empty()) { + o.payload = obj.owned_payload; + } + out.objects.push_back(std::move(o)); + } + + return out; +} + +std::string libmoq_endpoint_url(const EndpointConfig& endpoint) { + const bool wt = endpoint.transport == TransportKind::kWebTransport; + std::string path = endpoint.path.empty() ? std::string("/") : endpoint.path; + if (path.front() != '/') { + path.insert(path.begin(), '/'); + } + return (wt ? std::string("https://") : std::string("moqt://")) + endpoint.host + ":" + + std::to_string(endpoint.port) + path; +} + +LibmoqTrackTranslation make_libmoq_live_track(const TrackDescription& track, + const std::vector& init_segment) { + // Live tracks are isLive; no upfront stats, so the bitrate falls back to a + // media-type default inside make_track_translation (hint = 0). + return make_track_translation(track, init_segment, /*bitrate_hint=*/0, /*is_live=*/true); +} + +LibmoqObjectTranslation make_libmoq_live_object(const MediaFragment& fragment) { + LibmoqObjectTranslation o; + o.track_name = fragment.track_name; + o.group_id = fragment.group_id; + o.object_id = fragment.object_id; + o.starts_group = fragment.object_id == 0; + o.ends_group = false; // streaming: closed by the next group start / end_track + // Live fragments carry richer sync metadata than the batch plan: a video + // keyframe or a *declared* SAP type marks a random-access point, as does any + // group start (group boundaries are driven by video keyframes here). Only a + // declared SAP counts -- a stale/undeclared sap_type must not imply sync. + o.is_sync = o.starts_group || fragment.is_video_keyframe || + (fragment.has_sap_type && fragment.sap_type != 0); + apply_sap(o, fragment.has_sap_type, fragment.sap_type); // carry SAP into the send object + o.decode_time_us = fragment.start_time_us; + o.presentation_time_us = fragment.earliest_presentation_time_us != 0 + ? fragment.earliest_presentation_time_us + : fragment.start_time_us; + o.payload = fragment.payload.owned_bytes; + return o; +} + +bool live_track_has_media_metadata(const LiveTrack& track) { + if (track.track_name.empty()) { + return false; + } + if (track.media_type == LiveMediaType::kUnset) { + return false; // bare legacy track: name + opaque payloads only + } + if (track.codec.empty()) { + return false; // MSF-01 5.2.18: codec required for audio/video + } + if (track.media_type == LiveMediaType::kAudio && + (track.sample_rate == 0 || track.channel_count == 0)) { + return false; // MSF-01 5.2.28/5.2.29: audio needs samplerate + channels + } + return true; +} + +LibmoqTrackTranslation make_libmoq_live_object_track(const LiveTrack& track) { + LibmoqTrackTranslation t; + t.name = track.track_name; + t.media_type = + track.media_type == LiveMediaType::kAudio ? MOQ_MEDIA_TYPE_AUDIO : MOQ_MEDIA_TYPE_VIDEO; + t.packaging = + track.packaging == LivePackaging::kCmaf ? MOQ_MEDIA_PACKAGING_CMAF : MOQ_MEDIA_PACKAGING_RAW; + t.codec = track.codec; + t.timescale = 0; // object times are microseconds + t.init_data = track.init_data; + t.is_live = true; + t.width = track.width; + t.height = track.height; + t.framerate_millis = 0; // not carried by LiveTrack + if (t.media_type == MOQ_MEDIA_TYPE_AUDIO) { + t.samplerate = track.sample_rate; + t.channel_config = + track.channel_count != 0 ? std::to_string(track.channel_count) : std::string("2"); + } + t.bitrate = track.bitrate != 0 + ? track.bitrate + : (t.media_type == MOQ_MEDIA_TYPE_AUDIO ? 128000ull : 2000000ull); + return t; +} + +LibmoqObjectTranslation make_libmoq_live_source_object(const LiveObject& object) { + LibmoqObjectTranslation o; + o.track_name = object.track_name; + o.group_id = object.group_id; + o.object_id = object.object_id; + o.starts_group = object.object_id == 0; + o.ends_group = object.final_in_subgroup && object.subgroup_contains_group_largest; + o.is_sync = object.object_id == 0; // group starts are SAPs for now + o.decode_time_us = object.media_time_us; + o.presentation_time_us = object.media_time_us; + o.payload = object.payload; + return o; +} + +namespace { + +bool cancelled(const std::atomic* cancel) { + return cancel != nullptr && cancel->load(); +} + +// Records subscriber-demand events from the sender's network thread. The +// media-sender demand callbacks are non-reentrant and signal-only, so they just +// bump a generation counter + notify the CV; the app thread re-checks the +// authoritative moq_media_sender_has_media_subscriber() query after a wake. +struct DemandMonitor { + std::mutex mutex; + std::condition_variable cv; + + void notify() { + { + std::lock_guard lock(mutex); + } + cv.notify_all(); + } +}; + +void on_demand_joined(void* ctx, moq_media_sender_t* /*sender*/, + moq_media_track_t* /*track*/, size_t /*active*/) { + if (ctx != nullptr) { + static_cast(ctx)->notify(); + } +} + +void on_demand_left(void* ctx, moq_media_sender_t* /*sender*/, + moq_media_track_t* /*track*/, size_t /*active*/) { + if (ctx != nullptr) { + static_cast(ctx)->notify(); + } +} + +TransportStatus teardown(moq_media_sender_t* sender, + moq_endpoint_t* ep, + const std::string& error) { + if (sender) { + moq_media_sender_destroy(sender); + } + if (ep) { + moq_endpoint_stop(ep); + moq_endpoint_destroy(ep); + } + return error.empty() ? TransportStatus::success() : TransportStatus::failure(error); +} + +// Tear down a live publish, first unregistering the endpoint from the shared +// handle (under its mutex) so a concurrent disconnect() cannot touch the +// endpoint while it is being destroyed. +TransportStatus live_teardown(LibmoqLiveHandle* live, moq_media_sender_t* sender, + moq_endpoint_t* ep, const std::string& error) { + if (live != nullptr) { + live->set_endpoint(nullptr); + } + return teardown(sender, ep, error); +} + +// Clean stop on a disconnect()-initiated cancel: publish the stats gathered so +// far and tear down without the strict end_track/drain error checks. Returns +// success -- cancellation is a deliberate stop, matching the legacy MoqtSession +// close(). The endpoint interrupt latch was already set by request_cancel(). +TransportStatus cancel_teardown(LibmoqLiveHandle* live, moq_media_sender_t* sender, + moq_endpoint_t* ep, const LibmoqPublishStats& stats, + LibmoqPublishStats& out_stats) { + out_stats = stats; + return live_teardown(live, sender, ep, ""); +} + +bool draft_to_version(DraftVersion draft, moq_version_t* out) { + switch (draft) { + case DraftVersion::kDraft16: + *out = MOQ_VERSION_DRAFT_16; + return true; + case DraftVersion::kDraft18: + *out = MOQ_VERSION_DRAFT_18; + return true; + default: + return false; + } +} + +// Connect a managed endpoint and attach a media sender for the configured +// namespace, requesting the configured draft exactly. `live` selects the +// backpressure preset (live: drop-to-keyframe; batch: lossless block). On +// success *out_ep and *out_sender are set and the caller owns teardown; on +// failure everything is torn down and a failure status returned. The backing +// strings live for the whole call, which spans connect() and attach() -- both +// copy what they retain (the handshake/verifier run after connect returns). +// +// This does NOT wait for readiness: callers must add their tracks first, then +// wait_ready(), so the initial retained catalog is built from the configured +// tracks rather than published empty and then republished post-add. +TransportStatus connect_and_attach(const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + bool live, + DemandMonitor* demand, + moq_endpoint_t** out_ep, + moq_media_sender_t** out_sender) { + *out_ep = nullptr; + *out_sender = nullptr; + + moq_version_t version; + if (!draft_to_version(config.draft_version, &version)) { + return TransportStatus::failure( + "libmoq publish supports only draft 16 and draft 18"); + } + + const std::string url = libmoq_endpoint_url(endpoint); + const std::string sni = endpoint.sni; + const std::string ca = tls.ca_path; + const std::string wt_path = endpoint.path; + const std::string ns_str = config.track_namespace; + + moq_endpoint_cfg_t ep_cfg; + moq_endpoint_cfg_init(&ep_cfg); + ep_cfg.url = moq_bytes_t{reinterpret_cast(url.data()), url.size()}; + ep_cfg.protocol = endpoint.transport == TransportKind::kWebTransport + ? MOQ_TRANSPORT_PROTOCOL_WEBTRANSPORT + : MOQ_TRANSPORT_PROTOCOL_RAW_QUIC; + moq_version_t versions[1] = {version}; + ep_cfg.versions.struct_size = sizeof(ep_cfg.versions); + ep_cfg.versions.policy = MOQ_VERSION_POLICY_EXACT; // request the configured draft exactly + ep_cfg.versions.versions = versions; + ep_cfg.versions.version_count = 1; + if (!sni.empty()) { + ep_cfg.sni = moq_bytes_t{reinterpret_cast(sni.data()), sni.size()}; + } + if (!ca.empty()) { + ep_cfg.ca_file = moq_bytes_t{reinterpret_cast(ca.data()), ca.size()}; + } + ep_cfg.insecure_skip_verify = tls.insecure_skip_verify; + if (endpoint.transport == TransportKind::kWebTransport && !wt_path.empty()) { + ep_cfg.wt_path = + moq_bytes_t{reinterpret_cast(wt_path.data()), wt_path.size()}; + } + + moq_endpoint_t* ep = nullptr; + moq_result_t rc = moq_endpoint_connect(&ep_cfg, &ep); + if (rc != MOQ_OK || ep == nullptr) { + return TransportStatus::failure(std::string("endpoint connect failed: ") + + moq_strerror(rc)); + } + + moq_bytes_t ns_part{reinterpret_cast(ns_str.data()), ns_str.size()}; + moq_namespace_t ns{&ns_part, 1}; + + moq_media_sender_cfg_t s_cfg; + if (live) { + moq_media_sender_cfg_init_live(&s_cfg); // never block the encoder + } else { + moq_media_sender_cfg_init_lossless(&s_cfg); // batch/VOD: never drop on our own + } + s_cfg.endpoint = nullptr; // attach mode borrows the endpoint + s_cfg.namespace_ = ns; + if (demand != nullptr) { + // Demand-visibility callbacks (network-thread, signal-only) so the app + // thread can wait for a real subscriber instead of blindly streaming. + moq_media_sender_callbacks_init(&s_cfg.callbacks); + s_cfg.callbacks.ctx = demand; + s_cfg.callbacks.on_subscriber_joined = on_demand_joined; + s_cfg.callbacks.on_subscriber_left = on_demand_left; + } + + moq_media_sender_t* sender = nullptr; + rc = moq_media_sender_attach(ep, &s_cfg, &sender); + if (rc != MOQ_OK || sender == nullptr) { + return teardown(nullptr, ep, + std::string("media sender attach failed: ") + moq_strerror(rc)); + } + + *out_ep = ep; + *out_sender = sender; + return TransportStatus::success(); +} + +// Wait for namespace acceptance + catalog publication. Call AFTER tracks are +// added so the initial catalog carries them. Builds the real libmoq ops and +// delegates to the unit-testable libmoq_wait_ready() primitive. A cancel during +// the wait returns kCancelled promptly: request_cancel() set the endpoint +// interrupt latch, so the underlying moq_endpoint_wait() returns at once. +LibmoqReadyOutcome wait_ready(moq_endpoint_t* ep, moq_media_sender_t* sender, + std::atomic* cancel, std::uint64_t timeout_us) { + LibmoqReadyOps ops; + ops.is_ready = [sender] { return moq_media_sender_is_ready(sender); }; + ops.is_fatal = [sender, ep] { + return moq_media_sender_is_fatal(sender) || moq_endpoint_is_fatal(ep); + }; + ops.wait = [ep](std::uint64_t step) { return static_cast(moq_endpoint_wait(ep, step)); }; + return libmoq_wait_ready(cancel, timeout_us, /*step_us=*/100000, ops); +} + +// Map a non-ready readiness outcome to a teardown. Precondition: outcome is not +// kReady (and the caller handles kCancelled itself, which needs the stats). +TransportStatus ready_failure_teardown(LibmoqLiveHandle* live, moq_media_sender_t* sender, + moq_endpoint_t* ep, LibmoqReadyOutcome outcome) { + switch (outcome) { + case LibmoqReadyOutcome::kFatal: + return live_teardown(live, sender, ep, "endpoint/sender became fatal before readiness"); + case LibmoqReadyOutcome::kTimeout: + return live_teardown(live, sender, ep, "timed out waiting for media sender readiness"); + case LibmoqReadyOutcome::kClosed: + return live_teardown(live, sender, ep, "endpoint closed before readiness"); + case LibmoqReadyOutcome::kReady: + case LibmoqReadyOutcome::kCancelled: + break; + } + return live_teardown(live, sender, ep, "media sender readiness failed"); +} + +// Wait for at least one downstream media subscriber. Builds the real libmoq ops +// (the demand callback nudges the CV; has_media_subscriber() is authoritative) +// and delegates to the unit-testable libmoq_wait_demand() primitive. Bounded by +// timeout_us (PublisherConfig::subscriber_timeout) so it never hangs. +LibmoqDemandOutcome wait_for_media_subscriber(moq_endpoint_t* ep, moq_media_sender_t* sender, + DemandMonitor* demand, std::atomic* cancel, + std::uint64_t timeout_us) { + LibmoqDemandOps ops; + ops.has_subscriber = [sender] { return moq_media_sender_has_media_subscriber(sender); }; + ops.is_fatal = [sender, ep] { + return moq_media_sender_is_fatal(sender) || moq_endpoint_is_fatal(ep); + }; + ops.is_closed = [sender, ep] { + return moq_media_sender_is_closed(sender) || moq_endpoint_is_closed(ep); + }; + ops.wait = [demand](std::uint64_t step) { + std::unique_lock lock(demand->mutex); + demand->cv.wait_for(lock, std::chrono::microseconds(step)); + }; + return libmoq_wait_demand(cancel, timeout_us, /*step_us=*/100000, ops); +} + +// Map a non-subscriber demand outcome to a teardown (caller handles kSubscriber +// and kCancelled, which needs the stats). +TransportStatus demand_failure_teardown(LibmoqLiveHandle* live, moq_media_sender_t* sender, + moq_endpoint_t* ep, LibmoqDemandOutcome outcome) { + switch (outcome) { + case LibmoqDemandOutcome::kFatal: + return live_teardown(live, sender, ep, + "endpoint/sender became fatal before a media subscriber"); + case LibmoqDemandOutcome::kTimeout: + return live_teardown(live, sender, ep, "timed out waiting for media subscriber"); + case LibmoqDemandOutcome::kClosed: + return live_teardown(live, sender, ep, "endpoint closed before a media subscriber"); + case LibmoqDemandOutcome::kSubscriber: + case LibmoqDemandOutcome::kCancelled: + break; + } + return live_teardown(live, sender, ep, "media subscriber wait failed"); +} + +// Bounded retry of moq_media_sender_write(): builds real ops and delegates to +// libmoq_retry_blocking. check_demand=true (batch media) bails kNoDemand if the +// subscriber leaves mid-write. Caller owns the payload rcbuf on any non-kOk. +LibmoqRetryOutcome retry_write(moq_media_sender_t* sender, moq_endpoint_t* ep, + moq_media_track_t* track, const moq_media_send_object_t* obj, + std::atomic* cancel, bool check_demand, + std::uint64_t timeout_us, int* out_rc) { + LibmoqRetryOps ops; + ops.attempt = [sender, track, obj] { + return static_cast(moq_media_sender_write(sender, track, obj)); + }; + ops.is_fatal = [sender, ep] { + return moq_media_sender_is_fatal(sender) || moq_endpoint_is_fatal(ep); + }; + ops.is_closed = [sender, ep] { + return moq_media_sender_is_closed(sender) || moq_endpoint_is_closed(ep); + }; + if (check_demand) { + ops.has_demand = [sender] { return moq_media_sender_has_media_subscriber(sender); }; + } + ops.wait = [ep](std::uint64_t step) { moq_endpoint_wait(ep, step); }; + return libmoq_retry_blocking(cancel, timeout_us, /*step_us=*/50000, ops, out_rc); +} + +// Bounded retry of moq_media_sender_end_track(). No demand check: end_track +// completes locally even with no subscriber, so a WOULD_BLOCK here means the +// send queue is momentarily full -- bound it on cancel/fatal/closed/timeout. +LibmoqRetryOutcome retry_end_track(moq_media_sender_t* sender, moq_endpoint_t* ep, + moq_media_track_t* track, std::atomic* cancel, + std::uint64_t timeout_us, int* out_rc) { + LibmoqRetryOps ops; + ops.attempt = [sender, track] { + return static_cast(moq_media_sender_end_track(sender, track)); + }; + ops.is_fatal = [sender, ep] { + return moq_media_sender_is_fatal(sender) || moq_endpoint_is_fatal(ep); + }; + ops.is_closed = [sender, ep] { + return moq_media_sender_is_closed(sender) || moq_endpoint_is_closed(ep); + }; + ops.wait = [ep](std::uint64_t step) { moq_endpoint_wait(ep, step); }; + return libmoq_retry_blocking(cancel, timeout_us, /*step_us=*/50000, ops, out_rc); +} + +// Map a non-OK (and non-cancelled) retry outcome to a teardown with a clear +// reason. `what` names the op (e.g. "media write", "end_track"). +TransportStatus retry_failure_teardown(LibmoqLiveHandle* live, moq_media_sender_t* sender, + moq_endpoint_t* ep, LibmoqRetryOutcome outcome, int rc, + const char* what) { + switch (outcome) { + case LibmoqRetryOutcome::kFatal: + return live_teardown(live, sender, ep, + std::string("endpoint/sender became fatal during ") + what); + case LibmoqRetryOutcome::kClosed: + return live_teardown(live, sender, ep, std::string("endpoint closed during ") + what); + case LibmoqRetryOutcome::kTimeout: + return live_teardown(live, sender, ep, + std::string("timed out waiting for media send queue capacity (") + + what + ")"); + case LibmoqRetryOutcome::kNoDemand: + return live_teardown(live, sender, ep, "media subscriber disappeared during publish"); + case LibmoqRetryOutcome::kError: + return live_teardown(live, sender, ep, + std::string(what) + " failed: " + moq_strerror(rc)); + case LibmoqRetryOutcome::kOk: + case LibmoqRetryOutcome::kCancelled: + break; + } + return live_teardown(live, sender, ep, std::string(what) + " failed"); +} + +// Wait for the sender's queue to drain to the wire (bounded by timeout_us; 0 = +// wait indefinitely until drained or fatal). Returns early on cancel. +void drain_sender(moq_endpoint_t* ep, moq_media_sender_t* sender, std::uint64_t timeout_us, + const std::atomic* cancel = nullptr) { + std::uint64_t waited = 0; + for (;;) { + moq_media_sender_stats_t ms; + if (moq_media_sender_get_stats(sender, &ms, sizeof(ms)) == MOQ_OK && + ms.objects_queued == 0 && ms.objects_sent >= ms.objects_written) { + break; + } + if (cancelled(cancel)) { + break; + } + if (moq_media_sender_is_fatal(sender) || moq_endpoint_is_fatal(ep)) { + break; + } + if (timeout_us != 0 && waited >= timeout_us) { + break; + } + moq_endpoint_wait(ep, 100000); + waited += 100000; + } +} + +// Write one translated live object: create the payload rcbuf, hand it to the +// sender (ownership transfers on MOQ_OK), and on success bump the stats. On a +// non-OK return the caller's ref is released here and the result code is +// returned -- under the live drop policy WOULD_BLOCK is an expected non-fatal +// drop, so the caller only fails on sender/endpoint fatal states. +moq_result_t write_live_object(moq_media_sender_t* sender, moq_media_track_t* track, + const moq_alloc_t* alloc, + const LibmoqObjectTranslation& translated, + LibmoqPublishStats& stats, + std::set>& groups) { + moq_rcbuf_t* buf = nullptr; + moq_result_t rc = + moq_rcbuf_create(alloc, translated.payload.data(), translated.payload.size(), &buf); + if (rc != MOQ_OK || buf == nullptr) { + return rc != MOQ_OK ? rc : MOQ_ERR_NOMEM; + } + moq_media_send_object_t so = translated.object(); + so.payload = buf; + rc = moq_media_sender_write(sender, track, &so); + if (rc == MOQ_OK) { + stats.bytes_published += static_cast(translated.payload.size()); + stats.objects_published += 1; + groups.emplace(translated.track_name, translated.group_id); + return MOQ_OK; + } + moq_rcbuf_decref(buf); // no ownership transfer on a non-OK write + return rc; +} + +} // namespace + +LibmoqReadyOutcome libmoq_wait_ready(std::atomic* cancel, + std::uint64_t timeout_us, std::uint64_t step_us, + const LibmoqReadyOps& ops) { + std::uint64_t waited = 0; + while (!ops.is_ready()) { + if (cancel != nullptr && cancel->load()) { + return LibmoqReadyOutcome::kCancelled; + } + if (ops.is_fatal()) { + return LibmoqReadyOutcome::kFatal; + } + if (timeout_us != 0 && waited >= timeout_us) { + return LibmoqReadyOutcome::kTimeout; + } + if (ops.wait(step_us) == MOQ_ERR_CLOSED) { + return LibmoqReadyOutcome::kClosed; + } + waited += step_us; + } + return LibmoqReadyOutcome::kReady; +} + +LibmoqDemandOutcome libmoq_wait_demand(std::atomic* cancel, + std::uint64_t timeout_us, std::uint64_t step_us, + const LibmoqDemandOps& ops) { + std::uint64_t waited = 0; + for (;;) { + if (ops.has_subscriber()) { // authoritative: return as soon as demand exists + return LibmoqDemandOutcome::kSubscriber; + } + if (cancel != nullptr && cancel->load()) { + return LibmoqDemandOutcome::kCancelled; + } + if (ops.is_fatal()) { + return LibmoqDemandOutcome::kFatal; + } + if (ops.is_closed()) { + return LibmoqDemandOutcome::kClosed; + } + if (timeout_us != 0 && waited >= timeout_us) { + return LibmoqDemandOutcome::kTimeout; + } + ops.wait(step_us); // woken by the demand callback or after step_us + waited += step_us; + } +} + +LibmoqRetryOutcome libmoq_retry_blocking(std::atomic* cancel, + std::uint64_t timeout_us, std::uint64_t step_us, + const LibmoqRetryOps& ops, int* out_rc) { + std::uint64_t waited = 0; + for (;;) { + const int rc = ops.attempt(); + if (out_rc != nullptr) { + *out_rc = rc; + } + if (rc == MOQ_OK) { + return LibmoqRetryOutcome::kOk; + } + if (rc != MOQ_ERR_WOULD_BLOCK) { + return LibmoqRetryOutcome::kError; // a genuine failure, not backpressure + } + // WOULD_BLOCK: decide whether to keep retrying or bail with a clear reason. + if (cancel != nullptr && cancel->load()) { + return LibmoqRetryOutcome::kCancelled; + } + if (ops.is_fatal()) { + return LibmoqRetryOutcome::kFatal; + } + if (ops.is_closed()) { + return LibmoqRetryOutcome::kClosed; + } + if (ops.has_demand && !ops.has_demand()) { + return LibmoqRetryOutcome::kNoDemand; + } + if (timeout_us != 0 && waited >= timeout_us) { + return LibmoqRetryOutcome::kTimeout; + } + ops.wait(step_us); + waited += step_us; + } +} + +TransportStatus publish_plan_via_libmoq(const PublishPlan& materialized_plan, + const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + LibmoqPublishStats& out_stats) { + DemandMonitor demand; + moq_endpoint_t* ep = nullptr; + moq_media_sender_t* sender = nullptr; + const TransportStatus setup = + connect_and_attach(config, endpoint, tls, /*live=*/false, &demand, &ep, &sender); + if (!setup.ok) { + return setup; + } + + const std::uint64_t timeout_us = + static_cast(config.subscriber_timeout.count()) * 1000000ull; + moq_result_t rc = MOQ_OK; + + const LibmoqPlanTranslation tr = translate_plan_for_libmoq(materialized_plan); + + std::unordered_map handles; + for (const auto& t : tr.tracks) { + moq_media_track_cfg_t c = t.cfg(); + moq_media_track_t* h = nullptr; + rc = moq_media_sender_add_track(sender, &c, &h); + if (rc != MOQ_OK || h == nullptr) { + return teardown(sender, ep, + "add_track failed for '" + t.name + "': " + moq_strerror(rc)); + } + handles[t.name] = h; + } + + // Tracks are configured: now wait for the initial catalog to publish. + const LibmoqReadyOutcome ready = wait_ready(ep, sender, /*cancel=*/nullptr, timeout_us); + if (ready != LibmoqReadyOutcome::kReady) { + return ready_failure_teardown(/*live=*/nullptr, sender, ep, ready); + } + + // Wait for a real subscriber before writing: against a lazy relay the + // lossless preset would otherwise fill the queue and block here forever. + const LibmoqDemandOutcome demand_out = + wait_for_media_subscriber(ep, sender, &demand, /*cancel=*/nullptr, timeout_us); + if (demand_out != LibmoqDemandOutcome::kSubscriber) { + return demand_failure_teardown(/*live=*/nullptr, sender, ep, demand_out); + } + + const moq_alloc_t* alloc = moq_alloc_default(); + LibmoqPublishStats stats; + std::set> groups; + for (const auto& o : tr.objects) { + const auto hit = handles.find(o.track_name); + if (hit == handles.end()) { + continue; // media object for a track that was not configured + } + moq_rcbuf_t* buf = nullptr; + rc = moq_rcbuf_create(alloc, o.payload.data(), o.payload.size(), &buf); + if (rc != MOQ_OK || buf == nullptr) { + return teardown(sender, ep, "payload buffer allocation failed"); + } + moq_media_send_object_t so = o.object(); + so.payload = buf; + // Bounded retry: if the subscriber leaves or the queue never drains, bail + // with a clear reason instead of spinning forever on WOULD_BLOCK. + int wrc = MOQ_OK; + const LibmoqRetryOutcome wout = retry_write(sender, ep, hit->second, &so, + /*cancel=*/nullptr, /*check_demand=*/true, + timeout_us, &wrc); + if (wout != LibmoqRetryOutcome::kOk) { + moq_rcbuf_decref(buf); // no ownership transfer on a non-OK write + return retry_failure_teardown(/*live=*/nullptr, sender, ep, wout, wrc, "media write"); + } + // kOk transfers the buf ref to the sender; do not decref. + stats.bytes_published += static_cast(o.payload.size()); + stats.objects_published += 1; + groups.emplace(o.track_name, o.group_id); + } + stats.groups_published = static_cast(groups.size()); + + for (const auto& t : tr.tracks) { + const auto hit = handles.find(t.name); + if (hit == handles.end()) { + continue; + } + int erc = MOQ_OK; + const LibmoqRetryOutcome eout = + retry_end_track(sender, ep, hit->second, /*cancel=*/nullptr, timeout_us, &erc); + if (eout != LibmoqRetryOutcome::kOk) { + return retry_failure_teardown(/*live=*/nullptr, sender, ep, eout, erc, "end_track"); + } + } + + // Let the queued objects drain to the wire before tearing down. + drain_sender(ep, sender, timeout_us); + + // drain_sender() only confirms the SENDER queue is empty (objects emitted to + // the session) -- not that the transport flushed them. moq_endpoint_drain() + // blocks (bounded by timeout_us) until libmoq/picoquic has flushed the local + // stream bytes + FIN from its send queues, so teardown does not truncate a + // slow/in-flight object (it does not wait for peer consumption or full ACK). + // Best-effort before teardown: a timeout (MOQ_DONE), interrupt, or a backend + // that cannot prove a flush (MOQ_ERR_UNSUPPORTED) all fall through to the stop + // below -- the publish already succeeded once the objects were written. + moq_endpoint_drain(ep, timeout_us); + + out_stats = stats; + return teardown(sender, ep, ""); +} + +TransportStatus publish_live_stdin_via_libmoq(std::istream& input, + const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + LibmoqPublishStats& out_stats, + LibmoqLiveHandle* live) { + std::atomic* cancel = (live != nullptr) ? &live->cancel : nullptr; + if (cancelled(cancel)) { + return TransportStatus::success(); // cancelled before connecting: clean stop + } + + // Phase 1: read ftyp + moov from stdin for track discovery (mirrors the + // MoqtSession live path so the same fragmentation/grouping is reused). + StreamingMp4Reader reader; + std::vector ftyp_bytes; + std::vector moov_bytes; + while (ftyp_bytes.empty() || moov_bytes.empty()) { + const std::size_t bytes_read = reader.read_from(input); + if (bytes_read == 0 && ftyp_bytes.empty()) { + return TransportStatus::failure("stdin EOF before ftyp box"); + } + if (bytes_read == 0 && moov_bytes.empty()) { + return TransportStatus::failure("stdin EOF before moov box"); + } + while (auto box = reader.next_box()) { + if (box->type == "ftyp") { + ftyp_bytes = std::move(box->bytes); + } else if (box->type == "moov") { + moov_bytes = std::move(box->bytes); + break; + } + } + } + + std::vector init_segment; + init_segment.reserve(ftyp_bytes.size() + moov_bytes.size()); + init_segment.insert(init_segment.end(), ftyp_bytes.begin(), ftyp_bytes.end()); + init_segment.insert(init_segment.end(), moov_bytes.begin(), moov_bytes.end()); + + const std::vector init_boxes = parse_mp4_boxes(init_segment); + const std::vector tracks = extract_tracks(init_boxes, init_segment); + if (tracks.empty()) { + return TransportStatus::failure("no tracks found in moov box"); + } + + // Per-track CMAF init segments for catalog/init_data (libmoq owns the + // catalog itself; we only use the per-track init segments here). + const LiveCatalog live_catalog = build_live_catalog(tracks, init_segment, /*is_live=*/true); + auto init_for_track = [&](const std::string& name) -> std::vector { + for (const auto& init : live_catalog.track_initializations) { + if (init.track_name == name) { + return init.init_segment; + } + } + return init_segment; // fall back to the whole ftyp+moov header + }; + + DemandMonitor demand; + moq_endpoint_t* ep = nullptr; + moq_media_sender_t* sender = nullptr; + const TransportStatus setup = + connect_and_attach(config, endpoint, tls, /*live=*/true, &demand, &ep, &sender); + if (!setup.ok) { + return setup; + } + // Register the endpoint so disconnect() can interrupt it (cleared by every + // *_teardown below before the endpoint is destroyed). + if (live != nullptr) { + live->set_endpoint(ep); + } + + const std::uint64_t timeout_us = + static_cast(config.subscriber_timeout.count()) * 1000000ull; + + std::unordered_map handles; + for (const auto& td : tracks) { + if (!is_real_media_track(td)) { + continue; // defensive: stdin moov yields only media tracks + } + const LibmoqTrackTranslation t = make_libmoq_live_track(td, init_for_track(td.track_name)); + moq_media_track_cfg_t c = t.cfg(); + moq_media_track_t* h = nullptr; + const moq_result_t rc = moq_media_sender_add_track(sender, &c, &h); + if (rc != MOQ_OK || h == nullptr) { + return live_teardown(live, sender, ep, + "add_track failed for '" + t.name + "': " + moq_strerror(rc)); + } + handles[t.name] = h; + } + + // Tracks are configured: wait for the initial catalog before streaming. + const LibmoqReadyOutcome ready = wait_ready(ep, sender, cancel, timeout_us); + if (ready == LibmoqReadyOutcome::kCancelled) { + return cancel_teardown(live, sender, ep, LibmoqPublishStats{}, out_stats); + } + if (ready != LibmoqReadyOutcome::kReady) { + return ready_failure_teardown(live, sender, ep, ready); + } + + // Wait for a real subscriber before consuming stdin: a lazy relay forwards a + // SUBSCRIBE only when a player subscribes. Until then we do NOT read stdin + // (ffmpeg blocks on the full pipe), so no live fragments are produced and + // dropped with nobody watching. + const LibmoqDemandOutcome demand_out = + wait_for_media_subscriber(ep, sender, &demand, cancel, timeout_us); + if (demand_out == LibmoqDemandOutcome::kCancelled) { + return cancel_teardown(live, sender, ep, LibmoqPublishStats{}, out_stats); + } + if (demand_out != LibmoqDemandOutcome::kSubscriber) { + return demand_failure_teardown(live, sender, ep, demand_out); + } + + // Phase 2: stream moof+mdat fragments until stdin EOF. libmoq owns the + // network thread, so we read and write on this thread; the sender enqueues + // and drains on its own thread. Grouping mirrors the MoqtSession live path: + // a new shared group begins at each video keyframe; object ids reset per + // group, per track. + const moq_alloc_t* alloc = moq_alloc_default(); + LibmoqPublishStats stats; + std::set> groups; + std::vector pending_moof; + std::size_t shared_group_id = 0; + std::map object_id_in_group; + bool first_keyframe_seen = false; + + for (;;) { + if (cancelled(cancel)) { + stats.groups_published = static_cast(groups.size()); + return cancel_teardown(live, sender, ep, stats, out_stats); + } + const std::size_t bytes_read = reader.read_from(input); + while (auto box = reader.next_box()) { + if (box->type == "moof") { + pending_moof = std::move(box->bytes); + continue; + } + if (box->type != "mdat") { + continue; // skip styp/free/etc. + } + if (pending_moof.empty()) { + continue; // mdat without a preceding moof + } + + MediaFragment fragment; + try { + fragment = build_live_fragment(pending_moof, box->bytes, tracks, 0); + } catch (const std::exception&) { + pending_moof.clear(); + continue; + } + pending_moof.clear(); + + if (fragment.is_video_keyframe) { + if (first_keyframe_seen) { + ++shared_group_id; + } + first_keyframe_seen = true; + object_id_in_group.clear(); + } + if (!first_keyframe_seen) { + continue; // drop fragments before the first keyframe (no IDR) + } + fragment.group_id = shared_group_id; + fragment.object_id = object_id_in_group[fragment.track_name]++; + + const auto hit = handles.find(fragment.track_name); + if (hit == handles.end()) { + continue; // fragment for an undiscovered track + } + + const LibmoqObjectTranslation translated = make_libmoq_live_object(fragment); + const moq_result_t wrc = + write_live_object(sender, hit->second, alloc, translated, stats, groups); + // Only WOULD_BLOCK is a tolerated live drop (drop-to-keyframe policy); + // any other write error (INVAL/NOMEM/WRONG_STATE/CLOSED/...) is fatal. + if (wrc != MOQ_OK && wrc != MOQ_ERR_WOULD_BLOCK) { + return live_teardown(live, sender, ep, + std::string("media write failed: ") + moq_strerror(wrc)); + } + } + + if (bytes_read == 0) { + break; // stdin EOF + } + } + + stats.groups_published = static_cast(groups.size()); + + for (const auto& entry : handles) { + int erc = MOQ_OK; + const LibmoqRetryOutcome eout = + retry_end_track(sender, ep, entry.second, cancel, timeout_us, &erc); + if (eout == LibmoqRetryOutcome::kCancelled) { + return cancel_teardown(live, sender, ep, stats, out_stats); + } + if (eout != LibmoqRetryOutcome::kOk) { + return retry_failure_teardown(live, sender, ep, eout, erc, "end_track"); + } + } + + drain_sender(ep, sender, timeout_us, cancel); + + out_stats = stats; + return live_teardown(live, sender, ep, ""); +} + +TransportStatus publish_live_srt_via_libmoq(std::vector srt_callers, + const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + LibmoqPublishStats& out_stats, + LibmoqLiveHandle* live) { + std::atomic* cancel = (live != nullptr) ? &live->cancel : nullptr; + if (srt_callers.empty()) { + return TransportStatus::failure("no SRT callers configured"); + } + if (cancelled(cancel)) { + return TransportStatus::success(); // cancelled before starting: clean stop + } + + // Fragment queue filled by the SRT ingest worker threads (via the sink) and + // drained on this thread into the media sender. + struct LiveQueue { + std::mutex mutex; + std::condition_variable cv; + std::deque fragments; + bool eof = false; + }; + auto queue = std::make_shared(); + std::atomic stop_requested{false}; + + LiveSrtIngestManager manager( + std::move(srt_callers), + [queue](MediaFragment&& fragment) { + { + std::lock_guard lock(queue->mutex); + queue->fragments.push_back(std::move(fragment)); + } + queue->cv.notify_one(); + }, + stop_requested); + + const TransportStatus start = manager.start(); + if (!start.ok) { + return start; // start() failed: nothing was spawned, nothing to join + } + + const std::vector& tracks = manager.bootstrap().tracks; + if (tracks.empty()) { + stop_requested = true; + manager.join(); + return TransportStatus::failure("no live tracks available from SRT source"); + } + + // Background thread: when the source ends (all workers join), mark EOF so the + // drain loop can finish. Spawned only after a successful start with tracks. + std::thread join_thread([&manager, queue]() { + manager.join(); + { + std::lock_guard lock(queue->mutex); + queue->eof = true; + } + queue->cv.notify_all(); + }); + + // From here, every exit path must stop the source and join the worker/join + // threads. This guard runs at scope exit -- after any ep/sender teardown + // below -- so the SRT threads are always stopped and joined. + struct SrtGuard { + std::atomic& stop; + std::thread& join_thread; + ~SrtGuard() { + stop = true; + if (join_thread.joinable()) { + join_thread.join(); + } + } + } srt_guard{stop_requested, join_thread}; + + // Per-track CMAF init segments derived from the synthetic init segment. + const std::vector synthetic_init = + LiveSrtIngestManager::build_synthetic_init_segment(tracks); + const LiveCatalog live_catalog = build_live_catalog(tracks, synthetic_init, /*is_live=*/true); + auto init_for_track = [&](const std::string& name) -> std::vector { + for (const auto& init : live_catalog.track_initializations) { + if (init.track_name == name) { + return init.init_segment; + } + } + return synthetic_init; + }; + + DemandMonitor demand; + moq_endpoint_t* ep = nullptr; + moq_media_sender_t* sender = nullptr; + const TransportStatus setup = + connect_and_attach(config, endpoint, tls, /*live=*/true, &demand, &ep, &sender); + if (!setup.ok) { + return setup; // srt_guard stops + joins the manager + } + if (live != nullptr) { + live->set_endpoint(ep); // let disconnect() interrupt this endpoint + } + + const std::uint64_t timeout_us = + static_cast(config.subscriber_timeout.count()) * 1000000ull; + + std::unordered_map handles; + for (const auto& td : tracks) { + if (!is_real_media_track(td)) { + continue; // skip any synthetic/metadata track in the bootstrap + } + const LibmoqTrackTranslation t = make_libmoq_live_track(td, init_for_track(td.track_name)); + moq_media_track_cfg_t c = t.cfg(); + moq_media_track_t* h = nullptr; + const moq_result_t rc = moq_media_sender_add_track(sender, &c, &h); + if (rc != MOQ_OK || h == nullptr) { + return live_teardown(live, sender, ep, + "add_track failed for '" + t.name + "': " + moq_strerror(rc)); + } + handles[t.name] = h; + } + + // Tracks are configured: wait for the initial catalog before streaming. + const LibmoqReadyOutcome ready = wait_ready(ep, sender, cancel, timeout_us); + if (ready == LibmoqReadyOutcome::kCancelled) { + return cancel_teardown(live, sender, ep, LibmoqPublishStats{}, out_stats); + } + if (ready != LibmoqReadyOutcome::kReady) { + return ready_failure_teardown(live, sender, ep, ready); + } + + // Drain the fragment queue until the source ends. The SRT manager assigns + // each fragment's group_id/object_id, so make_libmoq_live_object maps them + // directly (no keyframe-grouping needed here, unlike the stdin path). + const moq_alloc_t* alloc = moq_alloc_default(); + LibmoqPublishStats stats; + std::set> groups; + bool fatal = false; + std::string fatal_msg; + for (;;) { + if (cancelled(cancel)) { + stats.groups_published = static_cast(groups.size()); + return cancel_teardown(live, sender, ep, stats, out_stats); + } + if (moq_media_sender_is_fatal(sender) || moq_endpoint_is_fatal(ep)) { + fatal = true; + fatal_msg = "endpoint/sender became fatal"; + break; + } + MediaFragment fragment; + bool have = false; + { + std::unique_lock lock(queue->mutex); + queue->cv.wait_for(lock, std::chrono::milliseconds(50), [&queue] { + return !queue->fragments.empty() || queue->eof; + }); + if (!queue->fragments.empty()) { + fragment = std::move(queue->fragments.front()); + queue->fragments.pop_front(); + have = true; + } else if (queue->eof) { + break; // source ended and queue drained + } + } + if (!have) { + continue; // timeout/spurious wake -- re-check fatal and queue + } + + const auto hit = handles.find(fragment.track_name); + if (hit == handles.end()) { + continue; // fragment for an untracked stream + } + // Lazy relay: with no media subscriber, DROP this live fragment instead + // of buffering it. We still pop every fragment above, so the SRT queue + // stays bounded while we wait for a player to subscribe; once demand + // exists we start writing the live edge. + if (!moq_media_sender_has_media_subscriber(sender)) { + continue; + } + const LibmoqObjectTranslation translated = make_libmoq_live_object(fragment); + const moq_result_t wrc = + write_live_object(sender, hit->second, alloc, translated, stats, groups); + // Only WOULD_BLOCK is a tolerated live drop (drop-to-keyframe policy); + // any other write error (INVAL/NOMEM/WRONG_STATE/CLOSED/...) is fatal. + if (wrc != MOQ_OK && wrc != MOQ_ERR_WOULD_BLOCK) { + fatal = true; + fatal_msg = std::string("media write failed: ") + moq_strerror(wrc); + break; + } + } + + if (fatal) { + return live_teardown(live, sender, ep, fatal_msg); + } + + stats.groups_published = static_cast(groups.size()); + + for (const auto& entry : handles) { + int erc = MOQ_OK; + const LibmoqRetryOutcome eout = + retry_end_track(sender, ep, entry.second, cancel, timeout_us, &erc); + if (eout == LibmoqRetryOutcome::kCancelled) { + return cancel_teardown(live, sender, ep, stats, out_stats); + } + if (eout != LibmoqRetryOutcome::kOk) { + return retry_failure_teardown(live, sender, ep, eout, erc, "end_track"); + } + } + + drain_sender(ep, sender, timeout_us, cancel); + + out_stats = stats; + return live_teardown(live, sender, ep, ""); +} + +TransportStatus publish_live_objects_via_libmoq(const LiveObjectSource& source, + const PublisherConfig& config, + const EndpointConfig& endpoint, + const TlsConfig& tls, + LibmoqPublishStats& out_stats, + LibmoqLiveHandle* live) { + std::atomic* cancel = (live != nullptr) ? &live->cancel : nullptr; + if (source.tracks.empty()) { + return TransportStatus::failure("live object source has no tracks"); + } + if (!source.next_object) { + return TransportStatus::failure("live object source has no object reader"); + } + if (cancelled(cancel)) { + return TransportStatus::success(); // cancelled before connecting: clean stop + } + + DemandMonitor demand; + moq_endpoint_t* ep = nullptr; + moq_media_sender_t* sender = nullptr; + const TransportStatus setup = + connect_and_attach(config, endpoint, tls, /*live=*/true, &demand, &ep, &sender); + if (!setup.ok) { + return setup; + } + if (live != nullptr) { + live->set_endpoint(ep); // let disconnect() interrupt this endpoint + } + + const std::uint64_t timeout_us = + static_cast(config.subscriber_timeout.count()) * 1000000ull; + + std::unordered_map handles; + for (const auto& track : source.tracks) { + const LibmoqTrackTranslation t = make_libmoq_live_object_track(track); + moq_media_track_cfg_t c = t.cfg(); + moq_media_track_t* h = nullptr; + const moq_result_t rc = moq_media_sender_add_track(sender, &c, &h); + if (rc != MOQ_OK || h == nullptr) { + return live_teardown(live, sender, ep, + "add_track failed for '" + t.name + "': " + moq_strerror(rc)); + } + handles[t.name] = h; + } + + // Tracks are configured: wait for the initial catalog before writing. + const LibmoqReadyOutcome ready = wait_ready(ep, sender, cancel, timeout_us); + if (ready == LibmoqReadyOutcome::kCancelled) { + return cancel_teardown(live, sender, ep, LibmoqPublishStats{}, out_stats); + } + if (ready != LibmoqReadyOutcome::kReady) { + return ready_failure_teardown(live, sender, ep, ready); + } + + // Wait for a real subscriber before pulling from the source: a lazy relay + // forwards a SUBSCRIBE only when a player subscribes, so without this the + // app's next_object() would be consumed (and its objects dropped) with + // nobody watching. + const LibmoqDemandOutcome demand_out = + wait_for_media_subscriber(ep, sender, &demand, cancel, timeout_us); + if (demand_out == LibmoqDemandOutcome::kCancelled) { + return cancel_teardown(live, sender, ep, LibmoqPublishStats{}, out_stats); + } + if (demand_out != LibmoqDemandOutcome::kSubscriber) { + return demand_failure_teardown(live, sender, ep, demand_out); + } + + const moq_alloc_t* alloc = moq_alloc_default(); + LibmoqPublishStats stats; + std::set> groups; + for (;;) { + if (cancelled(cancel)) { + stats.groups_published = static_cast(groups.size()); + return cancel_teardown(live, sender, ep, stats, out_stats); + } + std::optional next = source.next_object(); + if (!next.has_value()) { + break; // source exhausted + } + const LiveObject& object = *next; + const auto hit = handles.find(object.track_name); + if (hit == handles.end()) { + return live_teardown(live, sender, ep, + "live object references undeclared track '" + object.track_name + + "'"); + } + const LibmoqObjectTranslation translated = make_libmoq_live_source_object(object); + const moq_result_t wrc = + write_live_object(sender, hit->second, alloc, translated, stats, groups); + // Only WOULD_BLOCK is a tolerated live drop (drop-to-keyframe policy); + // any other write error is fatal. + if (wrc != MOQ_OK && wrc != MOQ_ERR_WOULD_BLOCK) { + return live_teardown(live, sender, ep, + std::string("media write failed: ") + moq_strerror(wrc)); + } + } + + stats.groups_published = static_cast(groups.size()); + + for (const auto& entry : handles) { + int erc = MOQ_OK; + const LibmoqRetryOutcome eout = + retry_end_track(sender, ep, entry.second, cancel, timeout_us, &erc); + if (eout == LibmoqRetryOutcome::kCancelled) { + return cancel_teardown(live, sender, ep, stats, out_stats); + } + if (eout != LibmoqRetryOutcome::kOk) { + return retry_failure_teardown(live, sender, ep, eout, erc, "end_track"); + } + } + + drain_sender(ep, sender, timeout_us, cancel); + + out_stats = stats; + return live_teardown(live, sender, ep, ""); +} + +} // namespace openmoq::publisher::transport + +#endif // OPENMOQ_HAS_LIBMOQ diff --git a/tests/cmaf_segmenter_test.cpp b/tests/cmaf_segmenter_test.cpp index f92bb39..191d3e0 100644 --- a/tests/cmaf_segmenter_test.cpp +++ b/tests/cmaf_segmenter_test.cpp @@ -191,6 +191,98 @@ std::vector make_progressive_test_mp4() { return file; } +// Progressive (non-fragmented) MP4 with `sample_count` video samples (each +// `sample_size` bytes, all in one chunk) and sync samples at the given 1-based +// indices. Exercises bounded per-GOP coalescing: multiple keyframes -> multiple +// groups; a long run between keyframes -> capped continuation objects. Mirrors +// make_progressive_test_mp4's box layout so the stco patch offset formula holds. +std::vector make_progressive_gops_mp4(std::uint32_t sample_count, + const std::vector& sync_numbers, + std::uint32_t sample_size = 2) { + auto put = [](std::vector& v, std::uint32_t x) { + const auto b = be32_bytes(x); + v.insert(v.end(), b.begin(), b.end()); + }; + const auto ftyp = make_box("ftyp", {'i', 's', 'o', '6', 0, 0, 0, 1, 'i', 's', 'o', '6', 'c', 'm', 'f', 'c'}); + const auto tkhd = make_full_box("tkhd", {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}); + const auto mdhd = make_full_box("mdhd", + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 232, 0, 0, 7, 208, 0, 0, 0, 0}); + const auto hdlr = make_full_box("hdlr", {0, 0, 0, 0, 'v', 'i', 'd', 'e', 0, 0, 0, 0}); + auto visual_header = std::vector(70, 0); + visual_header[24] = 0x01; + visual_header[25] = 0x40; + visual_header[26] = 0x00; + visual_header[27] = 0xf0; + const auto sample_entry = make_box("avc1", concat({visual_header, make_box("avcC", {1, 100, 0, 12, 0xff})})); + const auto stsd = make_full_box("stsd", concat({std::vector{0, 0, 0, 1}, sample_entry})); + + std::vector stts_payload; // one run: sample_count samples, delta 1000 + put(stts_payload, 1); + put(stts_payload, sample_count); + put(stts_payload, 1000); + const auto stts = make_full_box("stts", stts_payload); + + std::vector stsc_payload; // all samples in one chunk + put(stsc_payload, 1); // entry_count + put(stsc_payload, 1); // first_chunk + put(stsc_payload, sample_count); // samples_per_chunk + put(stsc_payload, 1); // sample_description_index + const auto stsc = make_full_box("stsc", stsc_payload); + + std::vector stsz_payload; // explicit per-sample sizes + put(stsz_payload, 0); // sample_size 0 -> per-sample table + put(stsz_payload, sample_count); + for (std::uint32_t i = 0; i < sample_count; ++i) { + put(stsz_payload, sample_size); + } + const auto stsz = make_full_box("stsz", stsz_payload); + + auto stco = make_full_box("stco", concat({be32_bytes(1), be32_bytes(0)})); // one chunk, patched below + + std::vector stss_payload; // sync sample numbers (1-based) + put(stss_payload, static_cast(sync_numbers.size())); + for (const std::uint32_t n : sync_numbers) { + put(stss_payload, n); + } + const auto stss = make_full_box("stss", stss_payload); + + const auto stbl = make_box("stbl", concat({stsd, stts, stsc, stsz, stco, stss})); + const auto minf = make_box("minf", stbl); + const auto mdia = make_box("mdia", concat({mdhd, hdlr, minf})); + const auto trak = make_box("trak", concat({tkhd, mdia})); + const auto moov = make_box("moov", trak); + const std::vector mdat_box = + make_box("mdat", std::vector(static_cast(sample_count) * sample_size, 0x41)); + + std::vector file = concat({ftyp, moov, mdat_box}); + const std::uint32_t mdat_payload_offset = static_cast(ftyp.size() + moov.size() + 8); + const std::size_t stco_payload_offset = + ftyp.size() + 8 + tkhd.size() + 8 + mdhd.size() + hdlr.size() + 8 + 8 + stsd.size() + stts.size() + + stsc.size() + stsz.size() + 16; + patch_be32(file, stco_payload_offset, mdat_payload_offset); + return file; +} + +// Largest trun sample_count across all fragments in a CMAF object's bytes. +// Scans for the trun FourCC (synthetic mdat payloads never contain it), reading +// the sample_count field 8 bytes past the type (after the 4-byte version/flags). +std::uint32_t max_trun_sample_count(const std::vector& fragment) { + std::uint32_t mx = 0; + for (std::size_t i = 0; i + 12 <= fragment.size(); ++i) { + if (fragment[i] == 't' && fragment[i + 1] == 'r' && fragment[i + 2] == 'u' && fragment[i + 3] == 'n') { + const std::size_t p = i + 8; + const std::uint32_t sc = (static_cast(fragment[p]) << 24) | + (static_cast(fragment[p + 1]) << 16) | + (static_cast(fragment[p + 2]) << 8) | + static_cast(fragment[p + 3]); + if (sc > mx) { + mx = sc; + } + } + } + return mx; +} + std::vector make_multitrack_init_mp4() { const auto ftyp = make_box("ftyp", {'i', 's', 'o', '6', 0, 0, 0, 1, 'i', 's', 'o', '6', 'c', 'm', 'f', 'c'}); @@ -553,6 +645,65 @@ int main() { ok &= expect(!remuxed_plan.track_initializations.front().init_segment.empty(), "expected remuxed standalone init segment"); + // Bounded per-GOP coalescing: a multi-keyframe progressive MP4 must become + // multiple media objects (one group per GOP), never a single whole-track + // object, and no fragment may exceed libmoq's 512-sample CMAF validator. + { + // 8 samples, keyframes at sample 1 and sample 5 -> two GOPs of 4 samples. + const auto gops_bytes = make_progressive_gops_mp4(8, {1, 5}); + ParsedMp4 gops{.bytes = gops_bytes, .top_level_boxes = parse_mp4_boxes(gops_bytes), .tracks = {}}; + gops.tracks = extract_tracks(gops.top_level_boxes, gops.bytes); + + const auto coalesced = segment_for_cmaf(gops, CmafObjectMode::kCoalesced); + const auto split = segment_for_cmaf(gops, CmafObjectMode::kSplit); + + ok &= expect(split.fragments.size() == 8, "expected split mode to emit one object per sample"); + ok &= expect(coalesced.fragments.size() == 2, + "expected coalesced multi-GOP MP4 to emit one object per GOP, not one whole-track object"); + ok &= expect(coalesced.fragments.size() > 1, + "expected coalesced mode to never emit a single whole-track object"); + + bool groups_ok = true; + bool sap_ok = true; + bool bound_ok = true; + for (std::size_t i = 0; i < coalesced.fragments.size(); ++i) { + const auto& frag = coalesced.fragments[i]; + // One group per track (track_index 0), each GOP a sequential object. + groups_ok = groups_ok && frag.group_id == 0 && frag.object_id == i; + // Each GOP-start video object carries declared SAP type 2. + sap_ok = sap_ok && frag.has_sap_type && frag.sap_type == 2; + const std::uint32_t trun_samples = max_trun_sample_count(frag.payload.owned_bytes); + bound_ok = bound_ok && trun_samples == 4 && trun_samples < 512; + } + ok &= expect(groups_ok, "expected one group per track with one sequential object per GOP"); + ok &= expect(sap_ok, "expected each GOP-start video object to declare SAP type 2"); + ok &= expect(bound_ok, "expected each per-GOP fragment to carry exactly its 4 samples (<512)"); + + // A long single GOP must be split into capped continuation objects rather + // than one oversized trun -- this is the >512-sample validator guard. + const auto long_bytes = make_progressive_gops_mp4(520, {1}); + ParsedMp4 long_gop{.bytes = long_bytes, .top_level_boxes = parse_mp4_boxes(long_bytes), .tracks = {}}; + long_gop.tracks = extract_tracks(long_gop.top_level_boxes, long_gop.bytes); + const auto long_coalesced = segment_for_cmaf(long_gop, CmafObjectMode::kCoalesced); + + ok &= expect(long_coalesced.fragments.size() > 1, + "expected a 520-sample GOP to be chunked into multiple capped objects"); + bool long_bound_ok = !long_coalesced.fragments.empty(); + bool single_group_ok = true; + for (std::size_t i = 0; i < long_coalesced.fragments.size(); ++i) { + const auto& frag = long_coalesced.fragments[i]; + single_group_ok = single_group_ok && frag.group_id == 0 && frag.object_id == i; + const std::uint32_t trun_samples = max_trun_sample_count(frag.payload.owned_bytes); + long_bound_ok = long_bound_ok && trun_samples > 0 && trun_samples < 512; + } + ok &= expect(long_bound_ok, "expected every chunked fragment trun to stay below the 512-sample validator"); + ok &= expect(single_group_ok, "expected a long GOP to stay one group with sequential continuation objects"); + ok &= expect(long_coalesced.fragments.front().sap_type == 2, + "expected the long GOP's first object to keep SAP type 2"); + ok &= expect(long_coalesced.fragments.back().sap_type == 0, + "expected long GOP continuation objects to be non-SAP"); + } + const auto multitrack_init_bytes = make_multitrack_init_mp4(); const SegmentedMp4 multitrack_segmented{ .initialization_segment = {.span = {}, .owned_bytes = multitrack_init_bytes}, diff --git a/tests/libmoq_translation_test.cpp b/tests/libmoq_translation_test.cpp new file mode 100644 index 0000000..cba9b17 --- /dev/null +++ b/tests/libmoq_translation_test.cpp @@ -0,0 +1,736 @@ +// Unit coverage for translating a PublishPlan into libmoq track/object configs. +// Pure translation only -- no endpoint, no sender, no network. + +#include "openmoq/publisher/transport/libmoq_publisher.h" + +#include "openmoq/publisher/cmsf_packager.h" +#include "openmoq/publisher/mp4_box.h" +#include "openmoq/publisher/moq_draft.h" +#include "openmoq/publisher/transport/publisher_transport.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace openmoq::publisher; +using namespace openmoq::publisher::transport; + +bool expect(bool condition, const std::string& message) { + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + return false; + } + return true; +} + +TrackDescription make_video_track() { + TrackDescription td; + td.track_id = 1; + td.handler_type = "vide"; + td.codec = "avc1.64001f"; + td.track_name = "video"; + td.packaging = "cmaf"; + td.timescale = 90000; + td.width = 1920; + td.height = 1080; + td.frame_rate = 30.0; + return td; +} + +TrackDescription make_audio_track() { + TrackDescription td; + td.track_id = 2; + td.handler_type = "soun"; + td.codec = "mp4a.40.2"; + td.track_name = "audio"; + td.packaging = "cmaf"; + td.timescale = 48000; + td.sample_rate = 48000; + td.channel_count = 2; + return td; +} + +TrackDescription make_catalog_track() { + TrackDescription td; + td.track_id = 0; + td.handler_type = "meta"; + td.codec = "catalog"; + td.sample_entry_type = "catalog"; + td.track_name = "catalog"; + td.packaging = "catalog"; + return td; +} + +TrackDescription make_mediatimeline_track() { + TrackDescription td; + td.track_id = 10; + td.handler_type = "meta"; + td.sample_entry_type = "mediatimeline"; + td.track_name = "video.timeline"; + td.packaging = "mediatimeline"; + td.mime_type = "application/json"; + return td; +} + +TrackDescription make_eventtimeline_track() { + TrackDescription td; + td.track_id = 11; + td.handler_type = "meta"; + td.sample_entry_type = "eventtimeline"; + td.track_name = "video.sap"; + td.packaging = "eventtimeline"; + td.event_type = "org.ietf.moq.cmsf.sap"; + td.mime_type = "application/json"; + return td; +} + +CmsfObject media_object(const std::string& track, std::size_t group, std::size_t object, + std::uint64_t time_us, std::vector bytes) { + CmsfObject o; + o.kind = CmsfObjectKind::kMedia; + o.track_name = track; + o.group_id = group; + o.object_id = object; + o.media_time_us = time_us; + o.media_duration_us = 33333; + o.owned_payload = std::move(bytes); + return o; +} + +PublishPlan make_plan() { + PublishPlan plan; + plan.draft = draft_profile(DraftVersion::kDraft16); + // Mirrors build_publish_plan: a synthetic "catalog" track at the front and + // generated timeline tracks alongside the real media tracks. Only the + // "vide"/"soun" tracks should survive translation. + plan.tracks = { + make_catalog_track(), + make_video_track(), + make_audio_track(), + make_mediatimeline_track(), + make_eventtimeline_track(), + }; + plan.track_initializations = { + TrackInitialization{.track_name = "video", .codec_payload = {}, .init_segment = {1, 2, 3, 4}}, + TrackInitialization{.track_name = "audio", .codec_payload = {}, .init_segment = {9, 8, 7}}, + }; + + // A catalog object (kInitialization) and a timeline object (kMetadata) that + // MUST be dropped by the translation. + CmsfObject catalog; + catalog.kind = CmsfObjectKind::kInitialization; + catalog.track_name = "catalog"; + catalog.owned_payload = {'{', '}'}; + CmsfObject timeline; + timeline.kind = CmsfObjectKind::kMetadata; + timeline.track_name = "video"; + timeline.owned_payload = {0x00}; + + plan.objects = { + catalog, + media_object("video", 0, 0, 0, {0xaa, 0xbb}), + media_object("video", 0, 1, 33333, {0xcc}), + media_object("video", 1, 0, 1000000, {0xdd, 0xee, 0xff}), + timeline, + media_object("audio", 0, 0, 0, {0x11, 0x22}), + }; + return plan; +} + +} // namespace + +int main() { + bool ok = true; + + const PublishPlan plan = make_plan(); + const LibmoqPlanTranslation tr = translate_plan_for_libmoq(plan); + + // -- Tracks -------------------------------------------------------------- + // Only the real "vide"/"soun" media tracks are configured; the synthetic + // catalog and timeline tracks (handler "meta") are dropped -- libmoq owns + // catalog publication. + ok &= expect(tr.tracks.size() == 2, + "expected only the two media tracks (catalog + timelines skipped)"); + for (const auto& t : tr.tracks) { + ok &= expect(t.name != "catalog" && t.name != "video.timeline" && t.name != "video.sap", + "expected no synthetic catalog/timeline track to be configured as media"); + } + if (tr.tracks.size() == 2) { + const LibmoqTrackTranslation& v = tr.tracks[0]; + ok &= expect(v.name == "video", "expected first track named 'video'"); + ok &= expect(v.media_type == MOQ_MEDIA_TYPE_VIDEO, "expected video media type"); + ok &= expect(v.packaging == MOQ_MEDIA_PACKAGING_CMAF, "expected CMAF packaging for video"); + ok &= expect(v.codec == "avc1.64001f", "expected video codec carried through"); + ok &= expect(v.init_data == std::vector({1, 2, 3, 4}), + "expected video init segment used as init_data"); + ok &= expect(v.width == 1920 && v.height == 1080, "expected video geometry carried"); + ok &= expect(v.framerate_millis == 30000, "expected framerate millis = fps*1000"); + ok &= expect(!v.is_live, "expected batch tracks to be VOD (is_live=false)"); + ok &= expect(v.bitrate > 0, "expected a non-zero derived bitrate (MSF-01 5.2.22)"); + + const LibmoqTrackTranslation& a = tr.tracks[1]; + ok &= expect(a.name == "audio", "expected second track named 'audio'"); + ok &= expect(a.media_type == MOQ_MEDIA_TYPE_AUDIO, "expected audio media type"); + ok &= expect(a.samplerate == 48000, "expected audio samplerate carried"); + ok &= expect(a.channel_config == "2", "expected audio channel config from channel count"); + ok &= expect(a.init_data == std::vector({9, 8, 7}), + "expected audio init segment used as init_data"); + ok &= expect(a.bitrate > 0, "expected a non-zero derived audio bitrate"); + + // Borrowing cfg() points into the owning translation object. + const moq_media_track_cfg_t c = v.cfg(); + ok &= expect(c.name.len == v.name.size() && + c.name.data == reinterpret_cast(v.name.data()), + "expected cfg().name to borrow the track name"); + ok &= expect(c.codec.len == v.codec.size(), "expected cfg().codec length to match"); + ok &= expect(c.init_data.len == v.init_data.size() && c.init_data.data == v.init_data.data(), + "expected cfg().init_data to borrow the init segment"); + ok &= expect(c.media_type == MOQ_MEDIA_TYPE_VIDEO && c.packaging == MOQ_MEDIA_PACKAGING_CMAF, + "expected cfg() to carry media type and packaging"); + ok &= expect(c.bitrate == v.bitrate, "expected cfg() bitrate to match"); + } + + // -- Objects (catalog + timeline dropped) -------------------------------- + ok &= expect(tr.objects.size() == 4, "expected four media objects (catalog + timeline skipped)"); + if (tr.objects.size() == 4) { + const LibmoqObjectTranslation& v0 = tr.objects[0]; // video g0 o0 + ok &= expect(v0.track_name == "video" && v0.group_id == 0 && v0.object_id == 0, + "expected first object to be video group 0 object 0"); + ok &= expect(v0.starts_group, "expected object_id==0 to start a group"); + ok &= expect(v0.is_sync, "expected a group start to be a sync point"); + ok &= expect(!v0.ends_group, "expected group 0 not to end at object 0 (object 1 follows)"); + ok &= expect(v0.decode_time_us == 0 && v0.presentation_time_us == 0, + "expected timing from media_time_us"); + + const LibmoqObjectTranslation& v1 = tr.objects[1]; // video g0 o1 + ok &= expect(!v1.starts_group, "expected object_id!=0 not to start a group"); + ok &= expect(v1.ends_group, "expected the last object in group 0 to end it"); + ok &= expect(!v1.is_sync, "expected a non-group-start not to be a sync point"); + ok &= expect(v1.presentation_time_us == 33333, "expected presentation time from media_time_us"); + + const LibmoqObjectTranslation& v2 = tr.objects[2]; // video g1 o0 + ok &= expect(v2.group_id == 1 && v2.starts_group && v2.ends_group, + "expected a single-object group to both start and end"); + + const LibmoqObjectTranslation& a0 = tr.objects[3]; // audio g0 o0 + ok &= expect(a0.track_name == "audio" && a0.starts_group && a0.ends_group, + "expected the lone audio object to start and end its group"); + + // object() builds the send-object with typed fields and a NULL payload. + const moq_media_send_object_t so = v0.object(); + ok &= expect(so.struct_size == sizeof(so), "expected object() to stamp struct_size"); + ok &= expect(so.payload == nullptr, "expected object() to leave payload for the caller"); + ok &= expect(so.properties == nullptr, "expected CMAF object to carry no extra property block"); + ok &= expect(so.is_sync && so.starts_group && !so.ends_group, + "expected object() to carry the grouping flags"); + } + + // -- Live track translation ---------------------------------------------- + { + const std::vector vinit = {1, 2, 3, 4}; + const LibmoqTrackTranslation v = make_libmoq_live_track(make_video_track(), vinit); + ok &= expect(v.name == "video", "expected live video track name"); + ok &= expect(v.media_type == MOQ_MEDIA_TYPE_VIDEO, "expected live video media type"); + ok &= expect(v.packaging == MOQ_MEDIA_PACKAGING_CMAF, "expected live CMAF packaging"); + ok &= expect(v.codec == "avc1.64001f", "expected live video codec carried"); + ok &= expect(v.init_data == vinit, "expected live track init segment used as init_data"); + ok &= expect(v.is_live, "expected live tracks to be isLive=true"); + ok &= expect(v.bitrate > 0, "expected a non-zero fallback bitrate for live video"); + + const std::vector ainit = {9, 8, 7}; + const LibmoqTrackTranslation a = make_libmoq_live_track(make_audio_track(), ainit); + ok &= expect(a.media_type == MOQ_MEDIA_TYPE_AUDIO, "expected live audio media type"); + ok &= expect(a.samplerate == 48000, "expected live audio samplerate carried"); + ok &= expect(a.channel_config == "2", "expected live audio channel config"); + ok &= expect(a.is_live, "expected live audio track to be isLive=true"); + ok &= expect(a.bitrate > 0, "expected a non-zero fallback bitrate for live audio"); + } + + // -- Live object (MediaFragment) translation ------------------------------ + { + // A video keyframe at group start. + MediaFragment kf; + kf.group_id = 5; + kf.object_id = 0; + kf.track_name = "video"; + kf.start_time_us = 100000; + kf.earliest_presentation_time_us = 110000; + kf.is_video_keyframe = true; + kf.sap_type = 2; // moqxr computed SAP type 2 for the keyframe + kf.has_sap_type = true; + kf.payload.owned_bytes = {0xde, 0xad, 0xbe, 0xef}; + const LibmoqObjectTranslation o = make_libmoq_live_object(kf); + ok &= expect(o.track_name == "video" && o.group_id == 5 && o.object_id == 0, + "expected live object identity carried from fragment"); + ok &= expect(o.starts_group, "expected object_id==0 to start a group"); + ok &= expect(o.is_sync, "expected a video keyframe to be a sync point"); + ok &= expect(!o.ends_group, "expected live objects never to set ends_group"); + ok &= expect(o.decode_time_us == 100000, "expected decode time from fragment start time"); + ok &= expect(o.presentation_time_us == 110000, + "expected presentation time from earliest presentation time"); + ok &= expect(o.payload == std::vector({0xde, 0xad, 0xbe, 0xef}), + "expected payload from the fragment's owned bytes"); + ok &= expect(o.has_sap_type && o.sap_type == MOQ_SAP_TYPE_2, + "expected live fragment SAP type 2 carried into the translation"); + const moq_media_send_object_t so = o.object(); + ok &= expect(so.payload == nullptr, "expected object() to leave payload for the caller"); + ok &= expect(so.is_sync && so.starts_group && !so.ends_group, + "expected object() to carry live grouping flags"); + ok &= expect(so.has_sap_type && so.sap_type == MOQ_SAP_TYPE_2, + "expected object() to declare the SAP type to libmoq (§3.4 group start)"); + + // A fragment with no computed SAP must NOT declare one (no fabrication). + MediaFragment undecl = kf; + undecl.has_sap_type = false; + const moq_media_send_object_t uso = make_libmoq_live_object(undecl).object(); + ok &= expect(!uso.has_sap_type, "expected no SAP declaration when moqxr did not compute one"); + + // A mid-group non-keyframe video fragment is not a sync point. + MediaFragment p; + p.group_id = 5; + p.object_id = 1; + p.track_name = "video"; + p.start_time_us = 133000; + p.is_video_keyframe = false; + p.payload.owned_bytes = {0x01}; + const LibmoqObjectTranslation po = make_libmoq_live_object(p); + ok &= expect(!po.starts_group, "expected object_id!=0 not to start a group"); + ok &= expect(!po.is_sync, "expected a mid-group non-keyframe not to be a sync point"); + ok &= expect(po.presentation_time_us == 133000, + "expected presentation time to fall back to start time when EPT is 0"); + + // A *declared* SAP type marks a sync point even mid-group. + MediaFragment sap; + sap.group_id = 5; + sap.object_id = 2; + sap.track_name = "audio"; + sap.sap_type = 1; + sap.has_sap_type = true; + sap.payload.owned_bytes = {0x02}; + const LibmoqObjectTranslation sapo = make_libmoq_live_object(sap); + ok &= expect(sapo.is_sync, "expected a declared SAP type to be a sync point"); + ok &= expect(sapo.has_sap_type && sapo.sap_type == MOQ_SAP_TYPE_1, + "expected the declared SAP type carried through"); + + // A non-key video fragment declares NONE (type 0), not a SAP -- and a + // mid-group NONE is not a sync point. + MediaFragment nonkey; + nonkey.group_id = 5; + nonkey.object_id = 3; + nonkey.track_name = "video"; + nonkey.is_video_keyframe = false; + nonkey.sap_type = 0; // NONE, as the SRT/segmenter paths now declare for P/B + nonkey.has_sap_type = true; + nonkey.payload.owned_bytes = {0x03}; + const LibmoqObjectTranslation nko = make_libmoq_live_object(nonkey); + ok &= expect(nko.has_sap_type && nko.sap_type == MOQ_SAP_NONE, + "expected non-key video to declare MOQ_SAP_NONE, not a SAP type"); + ok &= expect(!nko.is_sync, "expected a mid-group NONE not to be a sync point"); + ok &= expect(nko.object().has_sap_type && nko.object().sap_type == MOQ_SAP_NONE, + "expected object() to carry the NONE declaration"); + + // A stale nonzero sap_type with has_sap_type=false must NOT declare a SAP + // and must NOT imply a sync point. + MediaFragment stale; + stale.group_id = 5; + stale.object_id = 4; + stale.track_name = "video"; + stale.is_video_keyframe = false; + stale.sap_type = 2; // stale/garbage value... + stale.has_sap_type = false; // ...but not actually declared + stale.payload.owned_bytes = {0x04}; + const LibmoqObjectTranslation sto = make_libmoq_live_object(stale); + ok &= expect(!sto.has_sap_type, "expected no SAP declaration when has_sap_type is false"); + ok &= expect(!sto.object().has_sap_type, "expected object() to declare no SAP for stale value"); + ok &= expect(!sto.is_sync, "expected stale sap_type not to imply a sync point"); + } + + // -- Batch: CMAF SAP type preserved CmsfObject -> moq_media_send_object --- + { + PublishPlan plan; + plan.draft = draft_profile(DraftVersion::kDraft16); + plan.tracks = {make_video_track()}; + CmsfObject mo; + mo.kind = CmsfObjectKind::kMedia; + mo.track_name = "video"; + mo.group_id = 0; + mo.object_id = 0; + mo.sap_type = 2; // moqxr computed SAP type 2 for the coalesced group start + mo.has_sap_type = true; + mo.owned_payload = {0x11, 0x22}; + plan.objects = {mo}; + + const LibmoqPlanTranslation tr = translate_plan_for_libmoq(plan); + ok &= expect(tr.objects.size() == 1, "expected one translated media object"); + if (tr.objects.size() == 1) { + const LibmoqObjectTranslation& o = tr.objects[0]; + ok &= expect(o.starts_group && o.has_sap_type && o.sap_type == MOQ_SAP_TYPE_2, + "expected batch CmsfObject SAP type 2 carried into the translation"); + const moq_media_send_object_t so = o.object(); + ok &= expect(so.has_sap_type && so.sap_type == MOQ_SAP_TYPE_2, + "expected object() to declare the batch SAP type to libmoq (§3.4)"); + } + } + + // -- SRT bootstrap -> live track configs; SRT fragment -> live object ----- + { + // The SRT ingest manager bootstraps real media TrackDescriptions and a + // synthetic init segment; both flow through the same live helpers. + const std::vector bootstrap_tracks = {make_video_track(), + make_audio_track()}; + const std::vector synthetic_init = + LiveSrtIngestManager::build_synthetic_init_segment(bootstrap_tracks); + ok &= expect(!synthetic_init.empty(), + "expected a non-empty synthetic init segment from bootstrap tracks"); + + const LibmoqTrackTranslation v = make_libmoq_live_track(bootstrap_tracks[0], synthetic_init); + ok &= expect(v.media_type == MOQ_MEDIA_TYPE_VIDEO && v.packaging == MOQ_MEDIA_PACKAGING_CMAF, + "expected SRT bootstrap video track to map to a CMAF media track"); + ok &= expect(v.is_live, "expected SRT bootstrap track to be isLive=true"); + ok &= expect(v.init_data == synthetic_init, + "expected SRT track init_data to be the synthetic init segment"); + + // SRT fragments arrive with group_id/object_id already assigned by the + // manager -- the SAME live object translation maps them. + MediaFragment kf; + kf.group_id = 3; + kf.object_id = 0; + kf.track_name = "video"; + kf.start_time_us = 90000; + kf.earliest_presentation_time_us = 99000; + kf.is_video_keyframe = true; + kf.payload.owned_bytes = {0x11, 0x22, 0x33}; + const LibmoqObjectTranslation o = make_libmoq_live_object(kf); + ok &= expect(o.group_id == 3 && o.object_id == 0, + "expected SRT-assigned group/object carried through"); + ok &= expect(o.starts_group && o.is_sync && !o.ends_group, + "expected SRT keyframe to start a group, be sync, and not end the group"); + ok &= expect(o.decode_time_us == 90000 && o.presentation_time_us == 99000, + "expected SRT fragment timing carried through"); + ok &= expect(o.payload == std::vector({0x11, 0x22, 0x33}), + "expected SRT fragment payload from owned bytes"); + + MediaFragment mid; + mid.group_id = 3; + mid.object_id = 2; + mid.track_name = "audio"; + mid.payload.owned_bytes = {0x44}; + const LibmoqObjectTranslation mo = make_libmoq_live_object(mid); + ok &= expect(!mo.starts_group, "expected a non-zero SRT object_id not to start a group"); + } + + // -- LiveObjectSource: metadata gate + track/object translation ---------- + { + // Bare legacy track (name only) lacks media metadata. + ok &= expect(!live_track_has_media_metadata(LiveTrack{.track_name = "events"}), + "expected a bare LiveTrack to lack libmoq media metadata"); + + // Video RAW track. + LiveTrack vtrack; + vtrack.track_name = "video"; + vtrack.media_type = LiveMediaType::kVideo; + vtrack.packaging = LivePackaging::kRaw; + vtrack.codec = "av01"; + vtrack.bitrate = 1500000; + vtrack.width = 1280; + vtrack.height = 720; + ok &= expect(live_track_has_media_metadata(vtrack), + "expected a video track with codec to have sufficient metadata"); + const LibmoqTrackTranslation vt = make_libmoq_live_object_track(vtrack); + ok &= expect(vt.media_type == MOQ_MEDIA_TYPE_VIDEO, "expected video media type"); + ok &= expect(vt.packaging == MOQ_MEDIA_PACKAGING_RAW, "expected RAW packaging for the video track"); + ok &= expect(vt.codec == "av01", "expected video codec carried"); + ok &= expect(vt.width == 1280 && vt.height == 720, "expected video geometry carried"); + ok &= expect(vt.bitrate == 1500000, "expected explicit video bitrate carried"); + ok &= expect(vt.is_live, "expected LiveObjectSource tracks to be isLive=true"); + + // Audio RAW track (bitrate omitted -> fallback). + LiveTrack atrack; + atrack.track_name = "audio"; + atrack.media_type = LiveMediaType::kAudio; + atrack.packaging = LivePackaging::kRaw; + atrack.codec = "opus"; + atrack.sample_rate = 48000; + atrack.channel_count = 2; + ok &= expect(live_track_has_media_metadata(atrack), + "expected an audio track with samplerate+channels to have metadata"); + ok &= expect(!live_track_has_media_metadata(LiveTrack{.track_name = "a", + .media_type = LiveMediaType::kAudio, + .codec = "opus"}), + "expected an audio track missing samplerate/channels to lack metadata"); + const LibmoqTrackTranslation at = make_libmoq_live_object_track(atrack); + ok &= expect(at.media_type == MOQ_MEDIA_TYPE_AUDIO, "expected audio media type"); + ok &= expect(at.packaging == MOQ_MEDIA_PACKAGING_RAW, "expected RAW packaging for the audio track"); + ok &= expect(at.samplerate == 48000, "expected audio samplerate carried"); + ok &= expect(at.channel_config == "2", "expected audio channel config from channel count"); + ok &= expect(at.bitrate == 128000, "expected audio bitrate fallback when unset"); + + // CMAF track carries init_data. + LiveTrack ctrack; + ctrack.track_name = "cmaf-video"; + ctrack.media_type = LiveMediaType::kVideo; + ctrack.packaging = LivePackaging::kCmaf; + ctrack.codec = "avc1.640028"; + ctrack.init_data = {0x66, 0x74, 0x79, 0x70}; // 'ftyp' (illustrative) + const LibmoqTrackTranslation ct = make_libmoq_live_object_track(ctrack); + ok &= expect(ct.packaging == MOQ_MEDIA_PACKAGING_CMAF, "expected CMAF packaging"); + ok &= expect(ct.init_data == std::vector({0x66, 0x74, 0x79, 0x70}), + "expected CMAF init_data carried into the track config"); + + // LiveObject -> send-object mapping. + LiveObject start; + start.track_name = "video"; + start.group_id = 7; + start.object_id = 0; + start.media_time_us = 250000; + start.payload = {0x01, 0x02}; + start.final_in_subgroup = true; + start.subgroup_contains_group_largest = true; + const LibmoqObjectTranslation so = make_libmoq_live_source_object(start); + ok &= expect(so.starts_group && so.is_sync, "expected object_id==0 to start a group and be sync"); + ok &= expect(so.ends_group, + "expected ends_group when final_in_subgroup && subgroup_contains_group_largest"); + ok &= expect(so.decode_time_us == 250000 && so.presentation_time_us == 250000, + "expected timing from media_time_us"); + ok &= expect(so.payload == std::vector({0x01, 0x02}), + "expected payload carried from the LiveObject"); + + LiveObject mid; + mid.track_name = "video"; + mid.group_id = 7; + mid.object_id = 1; + mid.final_in_subgroup = true; + mid.subgroup_contains_group_largest = false; // not the group's largest subgroup + const LibmoqObjectTranslation mo = make_libmoq_live_source_object(mid); + ok &= expect(!mo.starts_group && !mo.is_sync, + "expected a non-zero object_id not to start a group / not sync"); + ok &= expect(!mo.ends_group, + "expected no ends_group when the subgroup is not the group's largest"); + } + + // -- Driver guard: object source is NOT consumed before the driver runs -- + // Network-free: with cancel already set, publish_live_objects_via_libmoq + // returns cleanly (success) before connecting, and crucially never calls + // source.next_object() -- the app's source is not consumed with no demand. + { + LibmoqLiveHandle live; + live.cancel.store(true); + int next_calls = 0; + LiveObjectSource source; + source.tracks = {LiveTrack{.track_name = "video", + .media_type = LiveMediaType::kVideo, + .codec = "av01"}}; + source.next_object = [&next_calls]() { + ++next_calls; + return std::optional{}; + }; + + PublisherConfig config; + config.draft_version = DraftVersion::kDraft16; + + EndpointConfig endpoint; + endpoint.transport = TransportKind::kRawQuic; + endpoint.host = "192.0.2.1"; // TEST-NET-1: must never be dialed here + endpoint.port = 4443; + + LibmoqPublishStats stats; + const TransportStatus st = publish_live_objects_via_libmoq( + source, config, endpoint, TlsConfig{}, stats, &live); + ok &= expect(st.ok, + "expected a pre-set cancel to short-circuit publish_live_objects (no connect)"); + ok &= expect(next_calls == 0, + "expected the object source NOT to be consumed before the driver proceeds"); + } + + // -- Cancellation DURING readiness (the bug this fixes) ------------------ + // Network-free: libmoq_wait_ready() is the readiness primitive the drivers + // use. With injected ops we simulate disconnect() firing mid-wait (as + // request_cancel() does) and assert the wait returns kCancelled promptly -- + // not kTimeout. The endpoint interrupt latch is what makes the real wait() + // return at once; here the fake wait() returns immediately each tick. + { + std::atomic cancel{false}; + int waits = 0; + LibmoqReadyOps ops; + ops.is_ready = [] { return false; }; // never becomes ready on its own + ops.is_fatal = [] { return false; }; + ops.wait = [&waits, &cancel](std::uint64_t) -> int { + if (++waits == 3) { + cancel.store(true); // disconnect() fires mid-wait + } + return 0; // MOQ_OK + }; + const LibmoqReadyOutcome outcome = + libmoq_wait_ready(&cancel, /*timeout_us=*/0, /*step_us=*/1000, ops); + ok &= expect(outcome == LibmoqReadyOutcome::kCancelled, + "expected readiness wait to return kCancelled when cancel fires mid-wait"); + ok &= expect(waits == 3, "expected the wait to stop at the cancel, not spin further"); + } + + // libmoq_wait_ready outcome coverage: ready / timeout / closed. + { + LibmoqReadyOps ready_ops{[] { return true; }, [] { return false; }, + [](std::uint64_t) { return 0; }}; + ok &= expect(libmoq_wait_ready(nullptr, 0, 1000, ready_ops) == LibmoqReadyOutcome::kReady, + "expected kReady when is_ready() is already true"); + + int t_waits = 0; + LibmoqReadyOps timeout_ops{[] { return false; }, [] { return false; }, + [&t_waits](std::uint64_t) { + ++t_waits; + return 0; + }}; + ok &= expect(libmoq_wait_ready(nullptr, 3000, 1000, timeout_ops) == + LibmoqReadyOutcome::kTimeout, + "expected kTimeout when readiness never arrives within the budget"); + + LibmoqReadyOps closed_ops{[] { return false; }, [] { return false; }, + [](std::uint64_t) { return MOQ_ERR_CLOSED; }}; + ok &= expect(libmoq_wait_ready(nullptr, 0, 1000, closed_ops) == LibmoqReadyOutcome::kClosed, + "expected kClosed when the endpoint wait reports closed"); + } + + // -- Demand-wait primitive (libmoq_wait_demand) -------------------------- + // Network-free coverage of the subscriber-demand gate the drivers use to + // avoid hanging/consuming sources against a lazy relay. + { + // Immediate demand: a subscriber already present returns at once. + LibmoqDemandOps now{[] { return true; }, [] { return false; }, + [] { return false; }, [](std::uint64_t) {}}; + ok &= expect(libmoq_wait_demand(nullptr, 0, 1000, now) == LibmoqDemandOutcome::kSubscriber, + "expected kSubscriber when demand already exists"); + + // Callback wake: no demand until the 3rd wait, then a subscriber appears + // (as the on_subscriber_joined callback would surface it). + bool has = false; + int waits = 0; + LibmoqDemandOps wake{[&has] { return has; }, [] { return false; }, + [] { return false; }, + [&has, &waits](std::uint64_t) { + if (++waits == 3) has = true; + }}; + ok &= expect(libmoq_wait_demand(nullptr, 0, 1000, wake) == LibmoqDemandOutcome::kSubscriber, + "expected kSubscriber after the demand callback wakes the wait"); + ok &= expect(waits == 3, "expected the wait to return as soon as demand appears"); + + // Timeout: demand never arrives within the budget. + LibmoqDemandOps never{[] { return false; }, [] { return false; }, + [] { return false; }, [](std::uint64_t) {}}; + ok &= expect(libmoq_wait_demand(nullptr, 3000, 1000, never) == LibmoqDemandOutcome::kTimeout, + "expected kTimeout when no media subscriber arrives in time"); + + // Fatal / closed. + LibmoqDemandOps fatal{[] { return false; }, [] { return true; }, + [] { return false; }, [](std::uint64_t) {}}; + ok &= expect(libmoq_wait_demand(nullptr, 0, 1000, fatal) == LibmoqDemandOutcome::kFatal, + "expected kFatal when the sender/endpoint is fatal"); + LibmoqDemandOps closed{[] { return false; }, [] { return false; }, + [] { return true; }, [](std::uint64_t) {}}; + ok &= expect(libmoq_wait_demand(nullptr, 0, 1000, closed) == LibmoqDemandOutcome::kClosed, + "expected kClosed when the endpoint is closed"); + + // Cancel: a live cancel returns kCancelled. + std::atomic cancel{true}; + LibmoqDemandOps c{[] { return false; }, [] { return false; }, + [] { return false; }, [](std::uint64_t) {}}; + ok &= expect(libmoq_wait_demand(&cancel, 0, 1000, c) == LibmoqDemandOutcome::kCancelled, + "expected kCancelled when a live cancel is set"); + } + + // -- Bounded blocking-retry primitive (libmoq_retry_blocking) ------------ + // Network-free: this is what bounds the batch write / end_track WOULD_BLOCK + // loops so a stalled queue can no longer hang the publish. + { + const auto no_wait = [](std::uint64_t) {}; + const auto no = [] { return false; }; + int out = -1; + + // Immediate success, code captured. + LibmoqRetryOps r_ok{[] { return MOQ_OK; }, no, no, {}, no_wait}; + ok &= expect(libmoq_retry_blocking(nullptr, 0, 1000, r_ok, &out) == LibmoqRetryOutcome::kOk && + out == MOQ_OK, + "expected kOk when the op succeeds immediately"); + + // Succeeds after two WOULD_BLOCKs (the retry actually retries). + int attempts = 0; + LibmoqRetryOps r_blockok{ + [&attempts] { return ++attempts < 3 ? MOQ_ERR_WOULD_BLOCK : MOQ_OK; }, no, no, {}, + no_wait}; + ok &= expect(libmoq_retry_blocking(nullptr, 0, 1000, r_blockok, nullptr) == + LibmoqRetryOutcome::kOk && + attempts == 3, + "expected kOk after retrying through WOULD_BLOCK"); + + // A genuine (non-WOULD_BLOCK) error returns kError immediately with code. + LibmoqRetryOps r_err{[] { return MOQ_ERR_INVAL; }, no, no, {}, no_wait}; + ok &= expect(libmoq_retry_blocking(nullptr, 0, 1000, r_err, &out) == + LibmoqRetryOutcome::kError && + out == MOQ_ERR_INVAL, + "expected kError (with the code) for a non-WOULD_BLOCK error"); + + // WOULD_BLOCK forever -> bounded by timeout. + LibmoqRetryOps r_block{[] { return MOQ_ERR_WOULD_BLOCK; }, no, no, {}, no_wait}; + ok &= expect(libmoq_retry_blocking(nullptr, 3000, 1000, r_block, nullptr) == + LibmoqRetryOutcome::kTimeout, + "expected kTimeout when WOULD_BLOCK never clears"); + + // WOULD_BLOCK + demand gone -> kNoDemand (batch write semantics). + LibmoqRetryOps r_nd{[] { return MOQ_ERR_WOULD_BLOCK; }, no, no, no, no_wait}; + ok &= expect(libmoq_retry_blocking(nullptr, 0, 1000, r_nd, nullptr) == + LibmoqRetryOutcome::kNoDemand, + "expected kNoDemand when the subscriber leaves mid-retry"); + + // WOULD_BLOCK + fatal / closed / cancel. + LibmoqRetryOps r_ft{[] { return MOQ_ERR_WOULD_BLOCK; }, [] { return true; }, no, {}, no_wait}; + ok &= expect(libmoq_retry_blocking(nullptr, 0, 1000, r_ft, nullptr) == + LibmoqRetryOutcome::kFatal, + "expected kFatal during a WOULD_BLOCK retry"); + LibmoqRetryOps r_cl{[] { return MOQ_ERR_WOULD_BLOCK; }, no, [] { return true; }, {}, no_wait}; + ok &= expect(libmoq_retry_blocking(nullptr, 0, 1000, r_cl, nullptr) == + LibmoqRetryOutcome::kClosed, + "expected kClosed during a WOULD_BLOCK retry"); + std::atomic rcancel{true}; + LibmoqRetryOps r_cn{[] { return MOQ_ERR_WOULD_BLOCK; }, no, no, {}, no_wait}; + ok &= expect(libmoq_retry_blocking(&rcancel, 0, 1000, r_cn, nullptr) == + LibmoqRetryOutcome::kCancelled, + "expected kCancelled during a WOULD_BLOCK retry"); + } + + // -- Endpoint URL mapping ------------------------------------------------ + { + EndpointConfig raw; + raw.transport = TransportKind::kRawQuic; + raw.host = "relay.example.com"; + raw.port = 4443; + raw.path = "/moq"; + ok &= expect(libmoq_endpoint_url(raw) == "moqt://relay.example.com:4443/moq", + "expected raw QUIC URL to use moqt:// scheme"); + + EndpointConfig wt; + wt.transport = TransportKind::kWebTransport; + wt.host = "relay.example.com"; + wt.port = 443; + wt.path = "/moq"; + ok &= expect(libmoq_endpoint_url(wt) == "https://relay.example.com:443/moq", + "expected WebTransport URL to use https:// scheme"); + + EndpointConfig no_path; + no_path.transport = TransportKind::kRawQuic; + no_path.host = "h"; + no_path.port = 1; + ok &= expect(libmoq_endpoint_url(no_path) == "moqt://h:1/", + "expected empty path to default to '/'"); + } + + if (!ok) { + std::cerr << "libmoq translation tests FAILED\n"; + return 1; + } + std::cout << "libmoq translation tests passed\n"; + return 0; +} diff --git a/tests/publisher_api_test.cpp b/tests/publisher_api_test.cpp index c5d84f5..59586be 100644 --- a/tests/publisher_api_test.cpp +++ b/tests/publisher_api_test.cpp @@ -258,5 +258,34 @@ int main() { "expected live-object publish to preserve default raw draft ALPN"); } + // Backend-selection gate. When libmoq is the selected publish backend + // (OPENMOQ_USE_LIBMOQ_PUBLISHER=ON), a non-injected publish_live_objects with + // a bare/legacy LiveTrack is rejected up front by libmoq's media-metadata gate + // -- a deterministic, network-free signal that the libmoq route was taken. + // When the gate is OFF the libmoq route is compiled out and non-injected + // publishing stays on the MoqtSession path (which would attempt a real + // connection, so it is not exercised here -- the injected-factory cases above + // already cover the old path, and they force it regardless of the gate). +#ifdef OPENMOQ_ENABLE_LIBMOQ_PUBLISHER + { + Publisher publisher(PublisherConfig{}); // no injected factory + + LiveObjectSource source{ + .tracks = {LiveTrack{.track_name = "events"}}, + .next_object = []() { return std::nullopt; }, + }; + + EndpointConfig endpoint; + endpoint.transport = TransportKind::kRawQuic; + endpoint.host = "relay.example.com"; + endpoint.port = 443; + + const TransportStatus status = publisher.publish_live_objects(source, endpoint); + ok &= expect(!status.ok, "expected bare-track libmoq publish_live_objects to fail"); + ok &= expect(status.message.find("media metadata") != std::string::npos, + "expected a clear media-metadata failure on the libmoq route"); + } +#endif + return ok ? 0 : 1; }