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:
@@ -0,0 +1,2 @@
|
|||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "mxl"]
|
||||||
|
path = mxl
|
||||||
|
url = https://github.com/dmf-mxl/mxl.git
|
||||||
+511
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 0–1
|
||||||
|
Color p23, uint16_t y2, uint16_t y3, // pair 2–3
|
||||||
|
Color p45, uint16_t y4, uint16_t y5) // pair 4–5
|
||||||
|
{
|
||||||
|
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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user