Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ run-trace*.log
openmoq-publisher-trace.csv
/.claude
/third_party

# Stray dtrace-generated probe header (build artifact)
.tmp.dprobes.h
154 changes: 145 additions & 9 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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/cat4moq.cpp
src/cli_options.cpp
Expand All @@ -153,6 +245,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
Expand Down Expand Up @@ -195,6 +288,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)
Expand Down Expand Up @@ -258,6 +376,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-cat4moq-api-tests
Expand All @@ -278,6 +405,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
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ It turns MP4 input into CMSF-style publishable objects, builds draft-aware MOQT
- Supports draft-aware MOQT framing for drafts 14, 16, 17, and 18.
- Publishes over Raw QUIC or WebTransport when picoquic and picotls are available.
- Accepts live CTE LL-DASH/CMAF ingest over HTTP/1.1 chunked `POST` or `PUT` requests.
- 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

Expand Down
25 changes: 25 additions & 0 deletions docs/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 52 additions & 6 deletions docs/publisher-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,19 +203,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<openmoq::publisher::LiveObject> 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<openmoq::publisher::LiveObject> {
if (next >= objects.size()) {
return std::nullopt;
Expand All @@ -226,9 +251,30 @@ source.next_object = [&]() -> std::optional<openmoq::publisher::LiveObject> {
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.

## 11. ALPN Override Behavior

Expand Down
2 changes: 1 addition & 1 deletion include/openmoq/publisher/cli_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ struct CliOptions {
std::optional<transport::EndpointConfig> 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;
Expand Down
3 changes: 2 additions & 1 deletion include/openmoq/publisher/cmaf_segmenter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions include/openmoq/publisher/cmsf_packager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint8_t> owned_payload;
};
Expand Down
Loading