commit ca682eaf0a8d5c8f21a8cdbe47d9bccbde8b564f Author: JohannesItten Date: Tue Jun 23 13:35:01 2026 +0300 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..880ac59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0162c50 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "mxl"] + path = mxl + url = https://github.com/dmf-mxl/mxl.git diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..9dc2317 --- /dev/null +++ b/ARCHITECTURE.md @@ -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) +└── .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": "", + "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": "", "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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e41366c --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..c8af78d --- /dev/null +++ b/CMakeLists.txt @@ -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 and . +# 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 \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) diff --git a/mxl b/mxl new file mode 160000 index 0000000..0ae1dc5 --- /dev/null +++ b/mxl @@ -0,0 +1 @@ +Subproject commit 0ae1dc5f9a70b35ce8f57e04d04dc21e471b6af8 diff --git a/nodes/fakesink/CMakeLists.txt b/nodes/fakesink/CMakeLists.txt new file mode 100644 index 0000000..41b5ef7 --- /dev/null +++ b/nodes/fakesink/CMakeLists.txt @@ -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) diff --git a/nodes/fakesink/main.cpp b/nodes/fakesink/main.cpp new file mode 100644 index 0000000..6717204 --- /dev/null +++ b/nodes/fakesink/main.cpp @@ -0,0 +1,87 @@ +#include +#include +#include +#include +#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(); + 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(now - last_log_time).count() >= 1.0) { + const double elapsed = std::chrono::duration(now - wall_start).count(); + log("frames=%llu invalid=%llu late=%llu avg=%.2f fps", + frame_count, invalid_count, late_count, + static_cast(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(); +} diff --git a/nodes/testpattern/CMakeLists.txt b/nodes/testpattern/CMakeLists.txt new file mode 100644 index 0000000..6bcb50b --- /dev/null +++ b/nodes/testpattern/CMakeLists.txt @@ -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) diff --git a/nodes/testpattern/main.cpp b/nodes/testpattern/main.cpp new file mode 100644 index 0000000..5752a5d --- /dev/null +++ b/nodes/testpattern/main.cpp @@ -0,0 +1,70 @@ +#include +#include +#include +#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(); + 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(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(); +} diff --git a/shared/FlowDef.hpp b/shared/FlowDef.hpp new file mode 100644 index 0000000..c6ee717 --- /dev/null +++ b/shared/FlowDef.hpp @@ -0,0 +1,39 @@ +#pragma once +#include +#include + +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 diff --git a/shared/NodeBase.hpp b/shared/NodeBase.hpp new file mode 100644 index 0000000..9605542 --- /dev/null +++ b/shared/NodeBase.hpp @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#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 diff --git a/shared/Signal.hpp b/shared/Signal.hpp new file mode 100644 index 0000000..288995c --- /dev/null +++ b/shared/Signal.hpp @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +namespace dmf { + +inline std::atomic 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 diff --git a/shared/V210.hpp b/shared/V210.hpp new file mode 100644 index 0000000..3503041 --- /dev/null +++ b/shared/V210.hpp @@ -0,0 +1,75 @@ +#pragma once +#include +#include +#include + +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 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(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(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(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(y) * stride, width, stride); + } +} + +} // namespace dmf::v210 diff --git a/studio-manager/CMakeLists.txt b/studio-manager/CMakeLists.txt new file mode 100644 index 0000000..cd1d4b6 --- /dev/null +++ b/studio-manager/CMakeLists.txt @@ -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) diff --git a/studio-manager/FlowGraph.hpp b/studio-manager/FlowGraph.hpp new file mode 100644 index 0000000..398c693 --- /dev/null +++ b/studio-manager/FlowGraph.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include + +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": "", ...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 nodes; + std::vector 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": "", ...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 diff --git a/studio-manager/main.cpp b/studio-manager/main.cpp new file mode 100644 index 0000000..fa02837 --- /dev/null +++ b/studio-manager/main.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#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& 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& 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 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; +}