Files
dmf-studio-rnd/ARCHITECTURE.md
JohannesItten ca682eaf0a 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>
2026-06-23 13:35:01 +03:00

16 KiB
Raw Permalink Blame History

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.

{
  "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)

// 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)

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)

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:

// 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:

{
  "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

{
  "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.

# 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

# 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.