Initial commit: testpattern → fakesink pipeline over MXL shared memory

- NodeBase, Signal, FlowDef, V210 shared headers
- testpattern node: SMPTE 75% color bars writer at 25fps
- fakesink node: non-blocking MXL reader with per-second stats
- studio-manager: FlowGraph data model, graph-driven fork/exec launcher
- mxl pinned as submodule at 0ae1dc5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
JohannesItten
2026-06-23 13:35:01 +03:00
commit ca682eaf0a
17 changed files with 1269 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
build/
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
[submodule "mxl"]
path = mxl
url = https://github.com/dmf-mxl/mxl.git
+511
View File
@@ -0,0 +1,511 @@
# DMF Studio — Architecture & Core Concepts
A node-based media signal processing system built on the EBU Dynamic Media Facility (DMF) architecture and the open-source MXL SDK.
---
## 1. What This Project Is
A software-defined broadcast processing environment where media workflows are built as graphs of independent processing nodes. Think of it like a virtual patchbay and signal processor combined — but running entirely in software, on standard compute.
**Example pipelines:**
```
Blackmagic SDI → [ingest node] → MXL → [denoise node] → MXL → [NDI-out node]
MXL video in → [PiP node] → MXL → [encoder node] → file
```
**Three planned user-facing layers:**
- **Nodes** — C++ programs that read/write media via MXL
- **Studio Manager** — orchestrator that launches nodes and manages the graph
- **Frontend** — Vue.js node editor (not yet built)
---
## 2. DMF Architecture Context
The EBU DMF Reference Architecture (White Paper v2.0, April 2026) defines a 6-layer stack:
```
┌─────────────────────────────┐
│ Application & UI │ ← Vue.js node editor (future)
├─────────────────────────────┤
│ Media Functions │ ← our nodes (testpattern, denoise, etc.)
├─────────────────────────────┤
│ Media Exchange (MXL) │ ← shared memory flows between nodes
├─────────────────────────────┤
│ Container Platform │ ← Docker / k8s (future)
├─────────────────────────────┤
│ Host Platform │ ← OS, drivers
├─────────────────────────────┤
│ Infrastructure │ ← physical servers, NICs, GPUs
└─────────────────────────────┘
```
Cross-cutting concerns (vertical columns): **Orchestration | Control | Monitoring | Security**
We are currently building the Media Functions layer and a simplified Orchestration stub (studio-manager). The Container Platform (k8s) comes later.
---
## 3. MXL — The Media Bus
**MXL (Media eXchange Layer)** is the open-source C++ SDK that implements the DMF Media Exchange layer.
Source: `./mxl/` (Apache 2.0 license)
Docs: `./mxl/docs/`
### Core Model
```
Domain (a directory in tmpfs)
└── <flow-uuid>.mxl-flow/
├── data ← ring buffer metadata (mmap'd)
├── flow_def.json ← NMOS IS-04 flow description
└── grains/ ← one mmap'd file per ring slot (video)
channels ← single mmap'd file (audio)
```
| Term | Meaning |
|------|---------|
| **Domain** | A directory path (typically `/dev/shm/dmf-studio`). Multiple can coexist. |
| **Flow** | A ring buffer of media data, identified by a UUID. |
| **Grain** | One unit of a discrete flow — a single video frame. |
| **Sample** | One unit of a continuous flow — one audio sample per channel. |
| **Instance** | A connection from one process to a domain (`mxlCreateInstance`). |
| **Writer** | One writer per flow (only one process can write). |
| **Reader** | Multiple readers allowed per flow. |
### Flow Types
| Type | Media | API |
|------|-------|-----|
| Discrete | video/v210, ancillary data | `OpenGrain` / `CommitGrain` / `GetGrain` |
| Continuous | audio/float32 | `OpenSamples` / `CommitSamples` / `GetSamples` |
### Inter-host (Fabrics API)
MXL also supports RDMA between hosts via libfabric (TCP/Verbs/EFA). Currently in beta. Addressing stays the same — `{host_id, domain_id, flow_id}` — the SDK handles transport transparently.
---
## 4. Node — Core Pattern
Each node is an **independent OS process**. This maps directly to a k8s Pod when containerized.
### What every node does
```
startup:
read NODE_CONFIG (JSON from env var)
read MXL_DOMAIN (path from env var)
mxlCreateInstance(domain)
create FlowWriter(s) and/or FlowReader(s)
loop until SIGTERM:
[read input grains]
process
[write output grains]
sleep to next frame time
shutdown:
mxlReleaseFlowWriter / mxlReleaseFlowReader
mxlDestroyInstance
```
### Threading per node
```
main thread
├── setup & teardown
└── processing loop (MXL read → process → MXL write)
implicit:
└── signal handler sets g_running = false → loop exits cleanly
```
No separate control thread needed for v1. For future reconfiguration, add a thread that listens on a Unix socket or WebSocket.
### NODE_CONFIG format
Passed as a JSON string in the `NODE_CONFIG` environment variable.
```json
{
"node_id": "testpattern",
"flow_id": "5fbec3b1-1b0f-417d-9059-8b94a47197ed",
"width": 1920,
"height": 1080,
"fps_num": 25,
"fps_den": 1
}
```
Nodes never care about who launched them or where other nodes are. They only care about flow UUIDs and domain path. This is what makes k8s migration trivial.
---
## 5. MXL API Patterns
### Writer (output node)
```cpp
// 1. Create instance (one per process)
mxlInstance inst = mxlCreateInstance("/dev/shm/dmf-studio", nullptr);
// 2. Create writer from NMOS IS-04 JSON
mxlFlowWriter writer{};
mxlFlowConfigInfo configInfo{};
bool created = false;
mxlCreateFlowWriter(inst, flow_def_json, nullptr, &writer, &configInfo, &created);
// MXL tells you the actual line stride — never hardcode it
uint32_t stride = configInfo.discrete.sliceSizes[0]; // bytes per V210 line
// 3. Write loop
mxlRational rate = {25, 1};
uint64_t index = mxlGetCurrentIndex(&rate);
while (running) {
mxlGrainInfo grain{};
uint8_t* buf = nullptr;
mxlFlowWriterOpenGrain(writer, index, &grain, &buf);
// buf is mmap'd memory — write directly into it
fill_frame(buf, width, height, stride);
grain.flags = 0; // MXL_GRAIN_FLAG_INVALID if you couldn't fill
mxlFlowWriterCommitGrain(writer, &grain);
// Sleep until next frame's start time
uint64_t ns = mxlGetNsUntilIndex(index + 1, &rate);
if (ns > 0 && ns < 2'000'000'000ULL)
mxlSleepForNs(ns);
index++;
}
// 4. Cleanup
mxlReleaseFlowWriter(inst, writer);
mxlDestroyInstance(inst);
```
### Reader (input node)
```cpp
mxlInstance inst = mxlCreateInstance("/dev/shm/dmf-studio", nullptr);
// Wait for the writer to start
bool active = false;
while (!active) {
mxlIsFlowActive(inst, flow_id, &active);
if (!active) mxlSleepForNs(100'000'000);
}
mxlFlowReader reader{};
mxlCreateFlowReader(inst, flow_id, nullptr, &reader);
mxlRational rate = {25, 1};
uint64_t index = mxlGetCurrentIndex(&rate);
uint64_t timeout_ns = 2'000'000'000ULL / 25; // 2 frame durations
while (running) {
mxlGrainInfo grain{};
uint8_t* buf = nullptr;
mxlStatus st = mxlFlowReaderGetGrain(reader, index, timeout_ns, &grain, &buf);
if (st == MXL_STATUS_OK) {
process(buf, grain.grainSize);
} else if (st == MXL_ERR_OUT_OF_RANGE_TOO_LATE) {
// Fell behind the ring buffer — jump to live head
index = mxlGetCurrentIndex(&rate);
continue;
} else if (st == MXL_ERR_TIMEOUT) {
// Writer may have stalled — log and continue
}
index++;
}
```
### Multi-input node (e.g. PiP — synchronize two flows)
```cpp
mxlFlowSynchronizationGroup group{};
mxlCreateFlowSynchronizationGroup(inst, &group);
mxlFlowSynchronizationGroupAddReader(group, reader_a);
mxlFlowSynchronizationGroupAddReader(group, reader_b);
mxlRational rate = {25, 1};
uint64_t nextIndex = mxlGetCurrentIndex(&rate);
while (running) {
uint64_t ts = mxlIndexToTimestamp(&rate, nextIndex);
// Blocks until BOTH flows have data at this timestamp
mxlFlowSynchronizationGroupWaitForDataAt(group, ts, timeout_ns);
// Now safe to read both
mxlFlowReaderGetGrain(reader_a, nextIndex, 0, &grain_a, &buf_a);
mxlFlowReaderGetGrain(reader_b, nextIndex, 0, &grain_b, &buf_b);
composite_pip(buf_a, buf_b, out_buf);
nextIndex++;
}
```
---
## 6. V210 Video Format
MXL's video format is `video/v210` — 10-bit 4:2:2 YCbCr.
### 10-bit limited range values
| Signal | Black | White | Neutral (chroma) |
|--------|-------|-------|-----------------|
| Y | 64 | 940 | — |
| Cb/Cr | 64 | 960 | 512 |
### Packing: 6 pixels → 16 bytes (4 × 32-bit words)
In 4:2:2, pixels are paired — each pair shares one Cb and one Cr sample.
```
Word 0 [bits 9:0][19:10][29:20]: Cb(pair0) | Y(px0) | Cr(pair0)
Word 1: Y(px1) | Cb(pair1)| Y(px2)
Word 2: Cr(pair1) | Y(px3) | Cb(pair2)
Word 3: Y(px4) | Cr(pair2)| Y(px5)
```
### Line size
For 1920 pixels: `1920/6 * 16 = 5120 bytes/line`
For 1920×1080 frame: `5120 * 1080 = 5,529,600 bytes ≈ 5.3 MB/frame`
**Always get the stride from MXL** (`configInfo.discrete.sliceSizes[0]`), not hardcoded.
### SMPTE 75% color bar values (10-bit)
| Bar | Y | Cb | Cr |
|---------|-----|-----|-----|
| White | 721 | 512 | 512 |
| Yellow | 674 | 176 | 543 |
| Cyan | 581 | 589 | 176 |
| Green | 534 | 253 | 207 |
| Magenta | 251 | 771 | 817 |
| Red | 204 | 435 | 848 |
| Blue | 111 | 848 | 481 |
---
## 7. Timing System
MXL uses **TAI (International Atomic Time)**, which is ahead of UTC by ~37 seconds. All timing goes through MXL's time API — do not mix with `std::chrono::system_clock` for frame timing.
| Function | Purpose |
|----------|---------|
| `mxlGetTime()` | Current time in nanoseconds (TAI) |
| `mxlGetCurrentIndex(&rate)` | Current frame index at system time |
| `mxlGetNsUntilIndex(index, &rate)` | Nanoseconds until that index starts |
| `mxlIndexToTimestamp(&rate, index)` | Index → TAI nanoseconds |
| `mxlTimestampToIndex(&rate, ts)` | TAI nanoseconds → index |
| `mxlSleepForNs(ns)` | Relative sleep in nanoseconds |
| `mxlSleepUntil(ts)` | Sleep until a TAI timestamp |
The standard write-loop timing pattern:
```cpp
// Sleep until next frame time, then advance
uint64_t ns = mxlGetNsUntilIndex(index + 1, &rate);
if (ns > 0 && ns < 2'000'000'000ULL) mxlSleepForNs(ns);
index++;
```
---
## 8. NMOS IS-04 Flow Definition
MXL requires an NMOS IS-04-compatible JSON to define a flow when creating a writer. This JSON is also stored as `flow_def.json` in the flow directory, making flows self-describing.
Minimum required fields for `video/v210`:
```json
{
"id": "<uuid>",
"format": "urn:x-nmos:format:video",
"label": "My Node Video Output",
"description": "My Node MXL Video Flow",
"media_type": "video/v210",
"parents": [],
"grain_rate": { "numerator": 25, "denominator": 1 },
"frame_width": 1920,
"frame_height": 1080,
"interlace_mode": "progressive",
"colorspace": "BT709",
"tags": {
"urn:x-nmos:tag:grouphint/v1.0": ["My Node:Video"]
},
"components": [
{ "name": "Y", "width": 1920, "height": 1080, "bit_depth": 10 },
{ "name": "Cb", "width": 960, "height": 1080, "bit_depth": 10 },
{ "name": "Cr", "width": 960, "height": 1080, "bit_depth": 10 }
]
}
```
Generated by `shared/FlowDef.hpp → dmf::make_video_flow_def(...)`.
---
## 9. Studio Manager
The control plane. Owns:
- Flow UUID generation
- Node process lifecycle (fork/exec with env vars)
- Graph state (v1: hardcoded; future: graph.json + WebSocket API)
Does **not** touch media. All media flows through MXL between node processes.
### Process model
```
studio-manager (pid X)
├── fork → dmf-node-testpattern (pid A) env: NODE_CONFIG, MXL_DOMAIN
└── fork → dmf-node-fakesink (pid B) env: NODE_CONFIG, MXL_DOMAIN
```
On SIGINT/SIGTERM: studio-manager sends SIGTERM to children, then waits.
### v1 hardcoded graph
```
testpattern → [video flow UUID] → fakesink
```
### Future graph.json
```json
{
"nodes": [
{ "id": "tp1", "type": "testpattern", "config": { ... } },
{ "id": "sink1", "type": "fakesink", "config": { ... } }
],
"flows": [
{ "id": "<uuid>", "from": "tp1", "to": ["sink1"] }
]
}
```
---
## 10. Project File Structure
```
dmf-studio-rnd/
├── mxl/ ← MXL SDK (open-source, Apache 2.0)
│ ├── lib/include/mxl/ ← public headers (mxl.h, flow.h, time.h, ...)
│ ├── lib/src/ ← C++ implementation
│ ├── docs/ ← Architecture.md, Fabrics.md, Usage.md, ...
│ └── examples/ ← Docker + k8s examples
├── shared/ ← header-only utilities, all targets link here
│ ├── Signal.hpp ← g_running atomic + SIGTERM/SIGINT handler
│ ├── FlowDef.hpp ← NMOS IS-04 JSON generator for video/v210
│ └── V210.hpp ← SMPTE 75% color bars V210 packer
├── nodes/
│ ├── testpattern/main.cpp ← generates color bars, writes MXL video flow
│ └── fakesink/main.cpp ← reads MXL video flow, logs frame statistics
├── studio-manager/main.cpp ← launches & monitors node processes
├── CMakeLists.txt ← root build: links mxl, fetches nlohmann/json
└── ARCHITECTURE.md ← this file
```
---
## 11. Build System
CMake 3.24+, C++20.
```bash
# First time
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j$(nproc)
# After source changes
cmake --build build -j$(nproc)
```
MXL is included via `add_subdirectory(mxl)` with tools/tests/docs/utils disabled. The core MXL library has no external dependencies.
`nlohmann/json` is fetched automatically by CMake on first configure (requires internet access once).
### Binaries produced
| Binary | Path |
|--------|------|
| Studio manager | `build/studio-manager/dmf-studio-manager` |
| TestPattern node | `build/nodes/testpattern/dmf-node-testpattern` |
| FakeSink node | `build/nodes/fakesink/dmf-node-fakesink` |
### Running
```bash
# Run everything (studio-manager launches node processes automatically)
./build/studio-manager/dmf-studio-manager
# Or run a node manually for testing
export MXL_DOMAIN=/dev/shm/dmf-studio
export NODE_CONFIG='{"node_id":"testpattern","flow_id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed","width":1920,"height":1080,"fps_num":25,"fps_den":1}'
mkdir -p $MXL_DOMAIN
./build/nodes/testpattern/dmf-node-testpattern
```
On macOS, `/dev/shm` does not exist — use `/tmp/dmf-studio` instead (no RAM-backing, but works for development).
---
## 12. What Does Not Exist Yet
| Feature | Notes |
|---------|-------|
| Audio nodes | MXL continuous flow API — `OpenSamples/CommitSamples/GetSamples` |
| Real processing nodes | denoise, PiP, format converter, encoder |
| External device nodes | Blackmagic DeckLink ingest, NDI output, ST 2110 |
| Vue.js frontend | Node graph editor; talks to studio-manager via WebSocket |
| graph.json | Persist and load graph state from file |
| WebSocket API | Studio-manager exposes REST+WebSocket for frontend |
| Reconfiguration | Nodes currently only configured at startup |
| Multi-host flows | MXL Fabrics API (libfabric, beta) for RDMA between hosts |
| k8s deployment | Replace studio-manager fork/exec with Pod specs + ConfigMaps |
| Security | MXL uses UNIX file permissions; future: TLS 1.3 + OAuth2 |
---
## 13. K8s Migration Path
The architecture is designed so **node code doesn't change** when moving to k8s.
| Now | With k8s |
|-----|----------|
| `fork/exec` in studio-manager | `kubectl apply` Pod spec |
| `NODE_CONFIG` env var set by studio-manager | `NODE_CONFIG` in Pod spec `env:` from ConfigMap |
| `MXL_DOMAIN=/dev/shm/dmf-studio` | `MXL_DOMAIN=/mnt/shm` as `hostPath` volume shared between Pods on same node |
| Studio-manager monitors children | k8s restarts failed pods |
| Studio-manager generates flow UUIDs | A lightweight k8s controller or init container |
The MXL domain on k8s requires all pods in the same pipeline to run on the same physical node (same shared memory). Use node affinity rules or a DaemonSet pattern.
---
## 14. Key Dependencies Reference
| Dependency | Version | Purpose | How included |
|------------|---------|---------|--------------|
| MXL SDK | 1.2.0-dev | Shared-memory media exchange | `add_subdirectory(mxl)` |
| nlohmann/json | 3.11.3 | JSON config parsing, flow def generation | CMake FetchContent |
| C++20 stdlib | — | Threading, filesystem, chrono | System |
MXL itself uses (only needed if building MXL tools/tests): `stduuid`, `spdlog`, `fmt`, `picojson`, `CLI11`, `catch2` — all via vcpkg. Not needed for our build.
+48
View File
@@ -0,0 +1,48 @@
# DMF Studio — AI Context
Read ARCHITECTURE.md first for the full picture. This file adds guidance specific to working in this codebase.
## Project in one sentence
Node-based media signal processing system: each node is a separate C++ process, nodes exchange video/audio via MXL shared memory, studio-manager orchestrates them, Vue.js frontend planned.
## Current state
- Core built: testpattern node (color bars writer), fakesink node (reader/logger), studio-manager (fork/exec orchestrator)
- Not built yet: real processing nodes, Vue frontend, graph.json persistence, WebSocket API
## Key conventions
- Node configuration comes from `NODE_CONFIG` env var (JSON). Never hardcode flow UUIDs.
- MXL domain comes from `MXL_DOMAIN` env var. Default: `/dev/shm/dmf-studio` (Linux) or `/tmp/dmf-studio` (macOS).
- All shared utilities live in `shared/` as header-only files. New nodes add a subdirectory under `nodes/`.
- Timing always goes through MXL time API (`mxlGetCurrentIndex`, `mxlGetNsUntilIndex`, `mxlSleepForNs`). Do not use `std::chrono::system_clock` for frame timing — MXL uses TAI.
- V210 line stride comes from `configInfo.discrete.sliceSizes[0]` after `mxlCreateFlowWriter`. Never hardcode 5120.
## What the user cares about
- Dmitry is not a very experienced C++ developer. Prefer architectural discussion before implementation.
- Keep code minimal and clear. No premature abstractions.
- When adding a new node type, follow the patterns in `nodes/testpattern/main.cpp` and `nodes/fakesink/main.cpp`.
## MXL API quick reference
```
mxlCreateInstance(domain, nullptr)
mxlCreateFlowWriter(inst, flow_def_json, nullptr, &writer, &configInfo, &created)
mxlCreateFlowReader(inst, flow_id, nullptr, &reader)
mxlFlowWriterOpenGrain(writer, index, &grain, &buf)
mxlFlowWriterCommitGrain(writer, &grain)
mxlFlowReaderGetGrain(reader, index, timeout_ns, &grain, &buf)
mxlGetCurrentIndex(&rate)
mxlGetNsUntilIndex(next_index, &rate) → ns to sleep
mxlSleepForNs(ns)
mxlIsFlowActive(inst, flow_id, &active)
mxlFlowSynchronizationGroup — for multi-input nodes (PiP)
```
## MXL error codes to handle in readers
- `MXL_STATUS_OK` — grain ready
- `MXL_ERR_TIMEOUT` — writer stalled, log and retry same index
- `MXL_ERR_OUT_OF_RANGE_TOO_LATE` — fell behind ring buffer, call `mxlGetCurrentIndex` and jump
+80
View File
@@ -0,0 +1,80 @@
cmake_minimum_required(VERSION 3.24 FATAL_ERROR)
project(dmf-studio VERSION 0.1.0 LANGUAGES CXX C)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# All executables land in build/bin so studio-manager finds node binaries next to itself
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
# Homebrew on Apple Silicon
if(APPLE)
list(APPEND CMAKE_PREFIX_PATH /opt/homebrew)
endif()
include(FetchContent)
# ── fmt (brew install fmt — already installed) ───────────────────────────────
find_package(fmt REQUIRED)
# ── spdlog (brew install spdlog — just installed) ────────────────────────────
find_package(spdlog REQUIRED)
# ── stduuid (header-only; no brew formula) ───────────────────────────────────
set(UUID_SYSTEM_GENERATOR ON CACHE BOOL "" FORCE) # use CoreFoundation on macOS
set(UUID_USING_CXX20_SPAN ON CACHE BOOL "" FORCE) # std::span, no GSL dependency
set(UUID_BUILD_TESTS OFF CACHE BOOL "" FORCE)
FetchContent_Declare(stduuid
GIT_REPOSITORY https://github.com/mariusbancila/stduuid.git
GIT_TAG v1.2.3
)
FetchContent_MakeAvailable(stduuid)
# ── picojson (single-header; no brew formula) ────────────────────────────────
FetchContent_Declare(picojson_fc
GIT_REPOSITORY https://github.com/kazuho/picojson.git
GIT_TAG v1.3.0
)
FetchContent_MakeAvailable(picojson_fc)
# MXL includes <picojson/picojson.h> and <picojson/wrapper.h>.
# We replicate what Findpicojson.cmake would do — correctly including both dirs.
set(_PICO_INC "${CMAKE_BINARY_DIR}/picojson-include")
set(_PICO_WRAP "${CMAKE_BINARY_DIR}/picojson-wrapper")
file(MAKE_DIRECTORY "${_PICO_INC}/picojson" "${_PICO_WRAP}/picojson")
file(COPY "${picojson_fc_SOURCE_DIR}/picojson.h"
DESTINATION "${_PICO_INC}/picojson/")
file(WRITE "${_PICO_WRAP}/picojson/wrapper.h"
"#pragma once\n"
"#pragma GCC diagnostic push\n"
"#pragma GCC diagnostic ignored \"-Wmaybe-uninitialized\"\n"
"#include <picojson/picojson.h>\n"
"#pragma GCC diagnostic pop\n")
add_library(picojson_impl INTERFACE)
target_include_directories(picojson_impl INTERFACE "${_PICO_INC}" "${_PICO_WRAP}")
add_library(picojson::picojson ALIAS picojson_impl)
# ── MXL SDK ──────────────────────────────────────────────────────────────────
# All four deps (fmt, spdlog, stduuid, picojson::picojson) are already targets,
# so MXL's "if (NOT TARGET ...)" guards skip its find_package() calls.
set(BUILD_TOOLS OFF CACHE BOOL "" FORCE)
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(BUILD_UTILS OFF CACHE BOOL "" FORCE)
add_subdirectory(mxl)
# ── nlohmann/json (for our code) ─────────────────────────────────────────────
FetchContent_Declare(json
URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz)
FetchContent_MakeAvailable(json)
# ── Shared utilities (Signal.hpp, NodeBase.hpp, FlowDef.hpp, V210.hpp) ───────
add_library(dmf-shared INTERFACE)
target_include_directories(dmf-shared INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/shared)
target_link_libraries(dmf-shared INTERFACE mxl nlohmann_json::nlohmann_json)
add_subdirectory(nodes/testpattern)
add_subdirectory(nodes/fakesink)
add_subdirectory(studio-manager)
Submodule
+1
Submodule mxl added at 0ae1dc5f9a
+4
View File
@@ -0,0 +1,4 @@
add_executable(dmf-node-fakesink main.cpp)
target_compile_features(dmf-node-fakesink PRIVATE cxx_std_20)
target_link_libraries(dmf-node-fakesink PRIVATE dmf-shared)
install(TARGETS dmf-node-fakesink RUNTIME DESTINATION bin)
+87
View File
@@ -0,0 +1,87 @@
#include <chrono>
#include <string>
#include <mxl/flow.h>
#include <mxl/time.h>
#include "NodeBase.hpp"
class FakeSinkNode : public dmf::NodeBase {
void run() override {
const auto flow_info = config().at("flow_id");
const auto flow_id = flow_info.at("id").get<std::string>();
const int fps_num = flow_info.value("fps_num", 25);
const int fps_den = flow_info.value("fps_den", 1);
log("flow=%s", flow_id.c_str());
log("waiting for flow to become active...");
bool active = false;
while (!active && dmf::g_running.load(std::memory_order_relaxed)) {
mxlIsFlowActive(instance(), flow_id.c_str(), &active);
if (!active) mxlSleepForNs(100'000'000);
}
if (!dmf::g_running) return;
log("flow active — starting read");
mxlFlowReader reader{};
mxlStatus st = mxlCreateFlowReader(instance(), flow_id.c_str(), nullptr, &reader);
if (st != MXL_STATUS_OK) {
log("mxlCreateFlowReader failed (status=%d)", st);
return;
}
const mxlRational rate = {fps_num, fps_den};
uint64_t index = mxlGetCurrentIndex(&rate);
uint64_t frame_count = 0;
uint64_t invalid_count = 0;
uint64_t late_count = 0;
auto wall_start = std::chrono::steady_clock::now();
auto last_log_time = wall_start;
while (dmf::g_running.load(std::memory_order_relaxed)) {
mxlGrainInfo grain{};
uint8_t* buf = nullptr;
st = mxlFlowReaderGetGrainNonBlocking(reader, index, &grain, &buf);
if (st == MXL_STATUS_OK) {
frame_count++;
if (grain.flags & MXL_GRAIN_FLAG_INVALID) invalid_count++;
index++;
} else if (st == MXL_ERR_OUT_OF_RANGE_TOO_EARLY) {
mxlSleepForNs(1'000'000); // 1 ms poll
} else if (st == MXL_ERR_OUT_OF_RANGE_TOO_LATE) {
late_count++;
// Jump to the most recent frame in the ring buffer
mxlFlowRuntimeInfo ri{};
mxlFlowReaderGetRuntimeInfo(reader, &ri);
index = ri.headIndex;
} else {
log("unexpected status=%d on index=%llu", st, index);
break;
}
// Log stats every second (wall clock) — avoid per-frame fprintf
auto now = std::chrono::steady_clock::now();
if (std::chrono::duration<double>(now - last_log_time).count() >= 1.0) {
const double elapsed = std::chrono::duration<double>(now - wall_start).count();
log("frames=%llu invalid=%llu late=%llu avg=%.2f fps",
frame_count, invalid_count, late_count,
static_cast<double>(frame_count) / elapsed);
last_log_time = now;
}
}
log("stopped — total frames=%llu invalid=%llu late=%llu",
frame_count, invalid_count, late_count);
mxlReleaseFlowReader(instance(), reader);
}
};
int main() {
FakeSinkNode node;
return node.execute();
}
+4
View File
@@ -0,0 +1,4 @@
add_executable(dmf-node-testpattern main.cpp)
target_compile_features(dmf-node-testpattern PRIVATE cxx_std_20)
target_link_libraries(dmf-node-testpattern PRIVATE dmf-shared)
install(TARGETS dmf-node-testpattern RUNTIME DESTINATION bin)
+70
View File
@@ -0,0 +1,70 @@
#include <string>
#include <mxl/flow.h>
#include <mxl/time.h>
#include "NodeBase.hpp"
#include "FlowDef.hpp"
#include "V210.hpp"
class TestPatternNode : public dmf::NodeBase {
void run() override {
const auto flow_info = config().at("flow_id");
const auto flow_id = flow_info.at("id").get<std::string>();
const int width = flow_info.value("width", 1920);
const int height = flow_info.value("height", 1080);
const int fps_num = flow_info.value("fps_num", 25);
const int fps_den = flow_info.value("fps_den", 1);
log("flow=%s %dx%d @ %d/%d fps", flow_id.c_str(), width, height, fps_num, fps_den);
const std::string flow_def =
dmf::make_video_flow_def(flow_id, node_id(), width, height, fps_num, fps_den);
mxlFlowWriter writer{};
mxlFlowConfigInfo cfg_info{};
bool created = false;
mxlStatus st = mxlCreateFlowWriter(
instance(), flow_def.c_str(), nullptr, &writer, &cfg_info, &created);
if (st != MXL_STATUS_OK) {
log("mxlCreateFlowWriter failed (status=%d)", st);
return;
}
const uint32_t stride = cfg_info.discrete.sliceSizes[0];
log("stride=%u B/line grain=%u B ring=%u grains",
stride, stride * static_cast<uint32_t>(height), cfg_info.discrete.grainCount);
const mxlRational rate = {fps_num, fps_den};
uint64_t index = mxlGetCurrentIndex(&rate);
log("start index=%llu", index);
while (dmf::g_running.load(std::memory_order_relaxed)) {
mxlGrainInfo grain{};
uint8_t* buf = nullptr;
st = mxlFlowWriterOpenGrain(writer, index, &grain, &buf);
if (st != MXL_STATUS_OK) {
log("OpenGrain failed (status=%d), skipping index=%llu", st, index);
index++;
continue;
}
dmf::v210::fill_frame(buf, width, height, stride);
grain.flags = 0;
grain.validSlices = grain.totalSlices; // mark grain complete so readers can consume it
mxlFlowWriterCommitGrain(writer, &grain);
const uint64_t ns = mxlGetNsUntilIndex(index + 1, &rate);
if (ns > 0 && ns < 2'000'000'000ULL) mxlSleepForNs(ns);
index++;
}
log("stopped at index=%llu", index);
mxlReleaseFlowWriter(instance(), writer);
}
};
int main() {
TestPatternNode node;
return node.execute();
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <string>
#include <nlohmann/json.hpp>
namespace dmf {
// Generates a minimal but valid NMOS IS-04 flow definition JSON string
// for a video/v210 flow. MXL uses this to set up the ring buffer geometry.
inline std::string make_video_flow_def(
const std::string& flow_id,
const std::string& label,
int width, int height,
int fps_num, int fps_den = 1)
{
using json = nlohmann::json;
return json{
{"id", flow_id},
{"format", "urn:x-nmos:format:video"},
{"label", label},
{"description", label + " MXL Video Flow"},
{"media_type", "video/v210"},
{"parents", json::array()},
{"grain_rate", {{"numerator", fps_num}, {"denominator", fps_den}}},
{"frame_width", width},
{"frame_height", height},
{"interlace_mode", "progressive"},
{"colorspace", "BT709"},
{"tags", {
{"urn:x-nmos:tag:grouphint/v1.0", json::array({label + ":Video"})}
}},
{"components", json::array({
{{"name","Y"}, {"width",width}, {"height",height}, {"bit_depth",10}},
{{"name","Cb"}, {"width",width/2}, {"height",height}, {"bit_depth",10}},
{{"name","Cr"}, {"width",width/2}, {"height",height}, {"bit_depth",10}}
})}
}.dump();
}
} // namespace dmf
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <nlohmann/json.hpp>
#include <mxl/mxl.h>
#include "Signal.hpp"
namespace dmf {
// Base class for all DMF node binaries.
//
// Handles the boilerplate every node needs:
// - NODE_CONFIG env var → parsed JSON config
// - MXL_DOMAIN env var → domain path
// - mxlCreateInstance / mxlDestroyInstance lifecycle
// - Signal handler installation
// - [node_id]-prefixed logging
//
// Derived class implements only run(), which receives a valid MXL instance
// and runs until dmf::g_running is false (set by SIGTERM/SIGINT).
class NodeBase {
public:
virtual ~NodeBase() = default;
// Entry point for main(). Returns 0 on success, 1 on error.
int execute() {
install_signal_handlers();
const char* config_env = std::getenv("NODE_CONFIG");
if (!config_env) {
fprintf(stderr, "[node] NODE_CONFIG not set\n");
return 1;
}
cfg_ = nlohmann::json::parse(config_env, nullptr, /*allow_exceptions=*/false);
if (cfg_.is_discarded()) {
fprintf(stderr, "[node] NODE_CONFIG is not valid JSON\n");
return 1;
}
node_id_ = cfg_.value("node_id", std::string("node"));
const char* domain_env = std::getenv("MXL_DOMAIN");
#ifdef __APPLE__
domain_ = domain_env ? domain_env : "/tmp/dmf-studio";
#else
domain_ = domain_env ? domain_env : "/dev/shm/dmf-studio";
#endif
log("domain=%s", domain_.c_str());
inst_ = mxlCreateInstance(domain_.c_str(), nullptr);
if (!inst_) {
log("mxlCreateInstance failed at %s", domain_.c_str());
return 1;
}
run();
mxlDestroyInstance(inst_);
inst_ = nullptr;
return 0;
}
protected:
// Implement the node's processing loop here.
// Create MXL writers/readers, loop while dmf::g_running, release them before returning.
virtual void run() = 0;
const nlohmann::json& config() const { return cfg_; }
mxlInstance instance() const { return inst_; }
const std::string& node_id() const { return node_id_; }
const std::string& domain() const { return domain_; }
// Printf-style log with automatic "[node_id] " prefix and trailing newline.
#if defined(__GNUC__) || defined(__clang__)
__attribute__((format(printf, 2, 3)))
#endif
void log(const char* fmt, ...) const {
fprintf(stderr, "[%s] ", node_id_.c_str());
va_list args;
va_start(args, fmt);
vfprintf(stderr, fmt, args);
va_end(args);
fputc('\n', stderr);
}
private:
nlohmann::json cfg_;
mxlInstance inst_{nullptr};
std::string node_id_{"node"};
std::string domain_;
};
} // namespace dmf
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <atomic>
#include <csignal>
namespace dmf {
inline std::atomic<bool> g_running{true};
namespace detail {
inline void on_signal(int) noexcept {
g_running.store(false, std::memory_order_relaxed);
}
}
inline void install_signal_handlers() {
std::signal(SIGTERM, detail::on_signal);
std::signal(SIGINT, detail::on_signal);
}
} // namespace dmf
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
namespace dmf::v210 {
// SMPTE 75% color bars — 10-bit limited range
// Y: 64 (black) to 940 (white)
// Cb/Cr: 64 to 960, 512 = neutral grey
struct Color { uint16_t y, cb, cr; };
constexpr std::array<Color, 7> SMPTE_BARS = {{
{721, 512, 512}, // white
{674, 176, 543}, // yellow
{581, 589, 176}, // cyan
{534, 253, 207}, // green
{251, 771, 817}, // magenta
{204, 435, 848}, // red
{111, 848, 481}, // blue
}};
// Pack 6 pixels into 4 x 32-bit V210 words (16 bytes total).
//
// V210 is 4:2:2 — each pair of pixels shares one Cb and one Cr sample.
// The three pairs in a block map to words like this (bits [9:0],[19:10],[29:20]):
// word 0: Cb(pair0) | Y(px0) | Cr(pair0)
// word 1: Y(px1) | Cb(pair1) | Y(px2)
// word 2: Cr(pair1) | Y(px3) | Cb(pair2)
// word 3: Y(px4) | Cr(pair2) | Y(px5)
inline void pack_block(
uint8_t* out,
Color p01, uint16_t y0, uint16_t y1, // pair 01
Color p23, uint16_t y2, uint16_t y3, // pair 23
Color p45, uint16_t y4, uint16_t y5) // pair 45
{
auto* w = reinterpret_cast<uint32_t*>(out);
w[0] = (p01.cb & 0x3FFu) | ((y0 & 0x3FFu) << 10) | ((p01.cr & 0x3FFu) << 20);
w[1] = (y1 & 0x3FFu) | ((p23.cb & 0x3FFu) << 10) | ((y2 & 0x3FFu) << 20);
w[2] = (p23.cr & 0x3FFu) | ((y3 & 0x3FFu) << 10) | ((p45.cb & 0x3FFu) << 20);
w[3] = (y4 & 0x3FFu) | ((p45.cr & 0x3FFu) << 10) | ((y5 & 0x3FFu) << 20);
}
// Write one horizontal line of SMPTE 75% bars.
// `stride` is the line size in bytes as returned by MXL (configInfo.discrete.sliceSizes[0]).
// Bytes beyond the active pixels are already zeroed by the mmap, so no explicit padding needed.
inline void write_bar_line(uint8_t* line, int width, uint32_t /*stride*/)
{
const int n = static_cast<int>(SMPTE_BARS.size());
const int blocks = width / 6; // one V210 block = 6 pixels = 16 bytes
for (int b = 0; b < blocks; b++) {
int x = b * 6;
auto color = [&](int px) -> const Color& {
return SMPTE_BARS[static_cast<size_t>(px * n / width)];
};
const Color& c01 = color(x);
const Color& c23 = color(x + 2);
const Color& c45 = color(x + 4);
pack_block(line + b * 16,
c01, c01.y, color(x+1).y,
c23, c23.y, color(x+3).y,
c45, c45.y, color(x+5).y);
}
}
// Fill an entire frame buffer with SMPTE 75% color bars.
inline void fill_frame(uint8_t* buf, int width, int height, uint32_t stride)
{
for (int y = 0; y < height; y++) {
write_bar_line(buf + static_cast<ptrdiff_t>(y) * stride, width, stride);
}
}
} // namespace dmf::v210
+4
View File
@@ -0,0 +1,4 @@
add_executable(dmf-studio-manager main.cpp)
target_compile_features(dmf-studio-manager PRIVATE cxx_std_20)
target_link_libraries(dmf-studio-manager PRIVATE dmf-shared)
install(TARGETS dmf-studio-manager RUNTIME DESTINATION bin)
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
namespace dmf {
// A node instance in the pipeline graph.
struct NodeDef {
std::string id; // unique instance id; becomes node_id in NODE_CONFIG
std::string type; // binary suffix: "testpattern" → "dmf-node-testpattern"
nlohmann::json params; // node-specific config (width, height, fps_num, ...)
};
// A directed edge between two nodes, carried by one MXL flow.
//
// from_port / to_port are the NODE_CONFIG keys each end receives the flow descriptor under.
// The injected value is a JSON object: { "id": "<uuid>", ...format fields }.
// Nodes read the UUID as cfg["flow_id"]["id"] and format fields as cfg["flow_id"]["fps_num"] etc.
//
// format examples:
// video: { "kind":"video", "width":1920, "height":1080, "fps_num":25, "fps_den":1 }
// audio: { "kind":"audio", "sample_rate":48000, "channels":2, "bit_depth":24 }
//
// PiP sink example: two edges with to_port "input_a_flow_id" and "input_b_flow_id".
struct FlowEdge {
std::string id; // UUID for the MXL flow (set by build_graph)
std::string from_node;
std::string from_port; // key injected into source's NODE_CONFIG
std::string to_node;
std::string to_port; // key injected into sink's NODE_CONFIG
nlohmann::json format; // flow format metadata (kind, width, height, fps_num, ...)
};
// The complete pipeline description.
struct FlowGraph {
std::vector<NodeDef> nodes;
std::vector<FlowEdge> edges;
// Returns the full NODE_CONFIG JSON for one node.
// Each connected edge injects a flow descriptor object under the port key:
// cfg[port] = { "id": "<uuid>", ...format fields }
nlohmann::json node_config(const std::string& node_id) const {
const NodeDef* nd = nullptr;
for (const auto& n : nodes)
if (n.id == node_id) { nd = &n; break; }
if (!nd) return {};
nlohmann::json cfg = nd->params;
cfg["node_id"] = nd->id;
for (const auto& e : edges) {
nlohmann::json port = e.format;
port["id"] = e.id;
if (e.from_node == node_id) cfg[e.from_port] = port;
if (e.to_node == node_id) cfg[e.to_port] = port;
}
return cfg;
}
};
} // namespace dmf
+162
View File
@@ -0,0 +1,162 @@
// Studio Manager — launches and monitors node processes for a single-host pipeline.
// Graph is defined in build_graph(). Later: load from graph.json, WebSocket API.
// Node binaries are looked up next to this binary (same directory).
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <string>
#include <vector>
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#include <mxl/time.h>
#include "Signal.hpp"
#include "FlowGraph.hpp"
namespace fs = std::filesystem;
// --- UUID generation ---------------------------------------------------------
static std::string gen_uuid() {
uint8_t b[16];
int fd = open("/dev/urandom", O_RDONLY);
if (fd < 0) { perror("open /dev/urandom"); exit(1); }
if (read(fd, b, sizeof(b)) != sizeof(b)) { perror("read"); exit(1); }
close(fd);
b[6] = (b[6] & 0x0Fu) | 0x40u; // version 4
b[8] = (b[8] & 0x3Fu) | 0x80u; // variant 1
char s[37];
snprintf(s, sizeof(s),
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7],
b[8],b[9], b[10],b[11],b[12],b[13],b[14],b[15]);
return s;
}
// --- Pipeline graph ----------------------------------------------------------
static dmf::FlowGraph build_graph() {
dmf::FlowGraph g;
g.nodes = {
{ "testpattern", "testpattern", {} },
{ "fakesink", "fakesink", {} },
};
g.edges = {
{ gen_uuid(), "testpattern", "flow_id", "fakesink", "flow_id",
{ {"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1} } },
};
return g;
}
// --- Process management ------------------------------------------------------
struct NodeProcess {
std::string name;
pid_t pid{-1};
};
// Fork the node binary, passing domain and config via environment variables.
static NodeProcess launch_node(
const std::string& binary,
const std::string& domain,
const nlohmann::json& config)
{
NodeProcess proc;
proc.name = config.value("node_id", binary);
pid_t pid = fork();
if (pid < 0) { perror("fork"); return proc; }
if (pid == 0) {
// Child: set env and exec
setenv("MXL_DOMAIN", domain.c_str(), 1);
setenv("NODE_CONFIG", config.dump().c_str(), 1);
execl(binary.c_str(), binary.c_str(), nullptr);
// execl only returns on error
perror(("execl " + binary).c_str());
_exit(1);
}
proc.pid = pid;
fprintf(stderr, "[studio-manager] launched %s pid=%d\n", proc.name.c_str(), pid);
return proc;
}
// Poll children with WNOHANG; log and clear pid if one has exited.
static void check_children(std::vector<NodeProcess>& nodes) {
for (auto& node : nodes) {
if (node.pid <= 0) continue;
int wstatus = 0;
if (waitpid(node.pid, &wstatus, WNOHANG) == node.pid) {
fprintf(stderr, "[studio-manager] node %s (pid=%d) exited (status=%d)\n",
node.name.c_str(), node.pid, WEXITSTATUS(wstatus));
node.pid = -1;
}
}
}
// Send SIGTERM to all live children, then wait for them.
static void shutdown_children(std::vector<NodeProcess>& nodes) {
fprintf(stderr, "[studio-manager] sending SIGTERM to all nodes\n");
for (auto& node : nodes) {
if (node.pid > 0) {
kill(node.pid, SIGTERM);
}
}
for (auto& node : nodes) {
if (node.pid > 0) {
waitpid(node.pid, nullptr, 0);
fprintf(stderr, "[studio-manager] node %s stopped\n", node.name.c_str());
}
}
}
// -----------------------------------------------------------------------------
int main(int argc, char* argv[]) {
dmf::install_signal_handlers();
// Resolve node binary paths relative to this binary
const fs::path bin_dir = fs::path(argv[0]).parent_path();
#ifdef __APPLE__
const std::string domain = "/tmp/dmf-studio";
#else
const std::string domain = "/dev/shm/dmf-studio";
#endif
// Ensure the domain directory exists
std::error_code ec;
fs::create_directories(domain, ec);
if (ec) {
fprintf(stderr, "[studio-manager] cannot create domain dir %s: %s\n",
domain.c_str(), ec.message().c_str());
return 1;
}
fprintf(stderr, "[studio-manager] domain: %s\n", domain.c_str());
// --- Build and launch the pipeline graph ---
const dmf::FlowGraph graph = build_graph();
for (const auto& e : graph.edges)
fprintf(stderr, "[studio-manager] flow %s → %s id=%s\n",
e.from_node.c_str(), e.to_node.c_str(), e.id.c_str());
std::vector<NodeProcess> nodes;
for (const auto& node : graph.nodes) {
const std::string binary = (bin_dir / ("dmf-node-" + node.type)).string();
nodes.push_back(launch_node(binary, domain, graph.node_config(node.id)));
}
// --- Run until Ctrl+C or SIGTERM ---
fprintf(stderr, "[studio-manager] running — Ctrl+C to stop\n");
while (dmf::g_running.load(std::memory_order_relaxed)) {
check_children(nodes);
mxlSleepForNs(500'000'000); // check every 500 ms
}
shutdown_children(nodes);
fprintf(stderr, "[studio-manager] done\n");
return 0;
}