Compare commits

51 Commits

Author SHA1 Message Date
JohannesItten b72b60b9c4 MTL implementation md 2026-07-13 19:06:11 +03:00
JohannesItten c8a96af0fc telegram post 2026-07-10 00:17:39 +03:00
JohannesItten 4899c4e9a6 pip perf fix 2026-07-09 23:43:58 +03:00
JohannesItten e489a730b8 decklink in timing fix 2026-07-09 23:35:31 +03:00
JohannesItten a3d0a338c5 cache removed 2026-07-09 23:26:08 +03:00
JohannesItten f9e7fe79d4 removed sync groups 2026-07-09 23:21:59 +03:00
JohannesItten 1f133507bf test pattern amplitude as param 2026-07-09 21:30:28 +03:00
JohannesItten 4973d0f9fc gaindb node 2026-07-09 20:48:07 +03:00
itten 8f28b2f768 gaindb node 2026-07-09 20:39:41 +03:00
itten 30202e112e Merge pull request 'Pip node' (#6) from pip-node into main
Reviewed-on: #6
2026-07-09 19:46:05 +03:00
JohannesItten c561bf569e wait fixes 2026-07-09 19:27:22 +03:00
JohannesItten f6985e0b21 logs + signals desc 2026-07-09 19:18:02 +03:00
JohannesItten 944f330eb3 fixes errors 2026-07-09 19:07:59 +03:00
JohannesItten 79e0e0e81a fixes TAI index 2026-07-09 18:56:39 +03:00
JohannesItten 041588b990 pip based 2026-07-09 18:31:53 +03:00
JohannesItten 3820d0eeb7 combiner.md 2026-07-09 15:49:35 +03:00
JohannesItten d2095d14d3 Read info about source from source/flow_def 2026-07-09 12:43:10 +03:00
JohannesItten d3ef011319 tons of fixes 2026-07-07 23:41:05 +03:00
JohannesItten 6c6225d831 fix: don't double-send status to command requester
load_graph/stop_node/start_node each called notify() (push to all
clients) and then sent a direct response — the requesting client
got two identical messages, breaking test recv sequencing.

Use thread_local tl_requester to skip the requesting connection
in the push. notify() is called synchronously from the command,
so the thread_local is visible from the status_cb. Other clients
still receive the push; the requester gets only the direct response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:39:09 +03:00
JohannesItten 59b2ee437c test: WebSocket API test suite (19 cases)
Covers connection, protocol robustness, get_status, load_graph,
stop/start_node, crash detection, and multi-client push.
Runs against a live studio-manager; --skip-nodes skips tests
that need node binaries and MXL.

Run: ./tests/.venv/bin/python3 tests/test_ws_api.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:26:28 +03:00
JohannesItten 32fe6aa22a fix: DeckLink macOS build — CFStringRef and CoreFoundation
GetDisplayName returns CFStringRef on macOS (not const char*).
Add #ifdef __APPLE__ handling in DeckLinkSender.hpp and
DeckLinkReceiver.hpp, and link -framework CoreFoundation in
decklinkin/decklinkout CMakeLists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:58:46 +03:00
JohannesItten 8dfeeacecd fix ndi->decklinkout slowdown 2026-07-07 19:50:09 +03:00
JohannesItten 370cbd89a2 fixes + cmake fixes for macos 2026-07-07 19:33:55 +03:00
JohannesItten 31346c48a6 NDIReceiver probe fix 2026-07-07 19:21:13 +03:00
JohannesItten cbe933eca3 and another one 2026-07-07 19:11:09 +03:00
JohannesItten 32b608ed3c DeckLinkSender green screen fix 2026-07-07 19:09:48 +03:00
JohannesItten 0b6e9ce46a fist API trys 2026-07-07 18:56:15 +03:00
itten 592686b6d7 Merge pull request 'Feature/decklink out' (#5) from feature/decklink-out into main
Reviewed-on: #5
2026-07-07 17:27:34 +03:00
JohannesItten 2d154e90dc fixes + refactoring + plan 2026-07-07 17:26:47 +03:00
itten 2faf2f9076 decklink + audio 2026-07-07 16:46:03 +03:00
JohannesItten 8e5ea3210a decklink out plan md 2026-07-06 02:25:14 +03:00
itten 0bdf486859 decklink-ndi.json defaults change 2026-07-06 02:13:56 +03:00
itten be601e358e Merge pull request 'Decklink in' (#4) from decklink-in into main
Reviewed-on: #4
2026-07-06 02:08:29 +03:00
JohannesItten 94da6f3283 audio support 2026-07-06 01:51:47 +03:00
JohannesItten 7847a8b70d fix: use IDeckLinkVideoBuffer::GetBytes for frame access
On Linux, GetBytes lives on IDeckLinkVideoBuffer (via QueryInterface),
not directly on IDeckLinkVideoInputFrame. Also restores StartAccess/
EndAccess around the copy for correct buffer lifecycle management.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 01:42:32 +03:00
JohannesItten e335461aac refactor: DeckLinkReceiver readability and error handling
DeckLinkReceiver:
- Group private members with section comments (SDK objects / sync / frame state)
- Change wait_for_format timeout param from uint64_t to int (matches
  std::chrono::milliseconds and all call sites)

decklinkin main.cpp:
- Wrap start_capture in try/catch — logs error and returns cleanly on
  device init failure (consistent with NDIInNode pattern)
- Remove redundant zero-init on frame_buf (vector<uint8_t> zero-inits anyway)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 01:38:13 +03:00
JohannesItten 640271384e refactor: DeckLinkReceiver cleanup and encapsulation
DeckLinkReceiver:
- Make InputCallback a private nested class — no longer exposed publicly
- DeckLinkReceiver owns all shared state (mutex, CVs, frame buffer)
- Replace get_input_callback() with clean wait_for_frame() API
- Fix device list leak: enumerate_devices stores raw IDeckLink* in
  raw_devices; destructor releases all of them + selected_device's AddRef
- Remove dead members: device_config, device_status, deckLink_notification
- Remove dead SourceInfo::stride field; rename SourceInfo → VideoInfo
- Remove dead video_source_info public member
- Remove printf; use no logging in receiver (caller logs)
- Remove commented-out notification code
- Fix dead return false after throw in enumerate_devices
- Fix dead null check after new InputCallback
- Replace IDeckLinkVideoBuffer QueryInterface with simpler GetBytes()
- Replace plain bool frame_ready/format_detected with consistent usage
  under mutex (no longer mixing atomic + CV pattern)
- Call StopStreams/DisableVideoInput in destructor
- Consistent snake_case naming throughout

decklinkin main.cpp:
- Rename NodeDeckLinkIn → DeckLinkInNode
- Get device_index from config().value("device_index", 0u)
- Remove unused includes: <time.h>, <algorithm>, <cstdio>, <DeckLinkAPI.h>
- Use clean receiver.wait_for_frame() instead of reaching into callback
- Use mxlGetCurrentIndex resync after sleep (consistent with other nodes)
- Fix return node.execute() (was node.execute(); return 0)
- Fix main() spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 01:34:01 +03:00
itten c60c5645ed working video setup 2026-07-06 01:27:19 +03:00
itten f37c576bdc Merge pull request 'Feature/video in' (#3) from feature/video-in into main
Reviewed-on: #3
2026-07-05 12:57:38 +03:00
JohannesItten a16cc7c3b7 docs: document .ts-only audio pacing limitation in videoin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 12:54:23 +03:00
JohannesItten 1496cc2e1e refactor: rename video port flow_id → video_flow_id everywhere
All nodes now use video_flow_id/audio_flow_id consistently:
- nodes/testpattern: flow_id → video_flow_id
- nodes/fakesink:    flow_id → video_flow_id
- graph.json:        from_port/to_port flow_id → video_flow_id
- studio-manager:    hardcoded build_graph edges + FlowGraph.hpp comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 12:52:48 +03:00
JohannesItten 7047690647 fix: resync video_index to MXL clock to prevent falling behind
After each video frame sleep, use mxlGetCurrentIndex instead of a
simple +1 increment — if audio processing ate into the next frame's
time we now skip the stale index rather than writing a late grain.
Same pattern ndiin already uses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 12:49:23 +03:00
JohannesItten fe6b7b10ed fix: VideoReader crash on video-only file at EOF and dead bool return
- Guard swr_close/swr_init at EOF inside if (has_audio) — calling
  swr_init(nullptr) on a video-only file crashed at first loop
- Change open_file from bool to void — it never returned false, only
  threw, so the if (!open_file()) check in the constructor was dead code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 12:46:00 +03:00
JohannesItten 0298cf9b48 refactor: VideoReader and videoin post-audio review cleanup
VideoReader:
- Remove unused #include <iostream>
- Remove dead AudioInfo::samples and ::channel_stride fields
- Rename have_video → has_video (consistent with has_audio)
- Rename get_next_frame → read_next (returns audio too, not just frames)
- Fix outdated comment on read_next
- Remove trailing blank line in allocate_audio_conversion_buffers

videoin:
- Remove redundant FFmpeg includes (VideoReader.hpp provides them)
- Fix bug: mxlFlowWriterGetMaxWriteLengthSamples called with invalid
  audio_writer when mxlCreateFlowWriter fails — moved inside else branch
- Rename call site: get_next_frame → read_next
- Rename have_video → has_video at call sites
- Use = nullptr for audio_writer (consistent with video_writer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 12:44:35 +03:00
itten 166efc3c59 Some fixes. For a now we fully support only .ts, due to huge problem with h264/hevc audio bursts 2026-07-05 12:39:06 +03:00
itten b2534efb0f audio works, holy shit 2026-07-04 03:24:20 +03:00
JohannesItten 96475acf62 refactor: VideoReader and videoin cleanup
VideoReader:
- Guard get_source_info/allocate_conversion_buffers behind have_video
  check — prevents crash on audio-only files
- Remove unused audiobuf parameter from get_next_frame
- Remove dead return statements after throw
- Remove unused SourceInfo::stride field and AudioInfo struct
- Pass const std::string& instead of by value in constructor/open_file
- Remove redundant struct keyword on SourceInfo source_info{}
- Fix video_stream_index never guarded against -1 in open_file
- Check av_image_alloc and avcodec_parameters_to_context return values
- Remove extra av_packet_unref after seek (was harmless but confusing)
- Use SWS_BILINEAR for sws_getContext flags instead of 0
- Replace NULL with nullptr in sws_getContext
- Remove unused #include <libavutil/pixdesc.h>

videoin main.cpp:
- Early return if have_video is false after open
- Inline make_video_flow_def call (remove intermediate variable)
- Add grain count log line matching other nodes
- Change continue to break when get_next_frame returns false
- Make video_rate const
- Remove double blank line before main()
- Remove trailing spaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 19:13:19 +03:00
JohannesItten bb7d1bb5a3 fix: VideoReader bugs — stride, memory, audio detection, UB
Pass MXL grain stride (from sliceSizes[0]) into get_next_frame so
YUV422P10toV210 writes with the correct line width instead of a
self-computed value that may not match the MXL buffer.

Fix audio stream detection: condition was inverted (!= -1 → == -1),
so the first audio stream was never picked up.

Add return false at end of get_next_frame to fix UB when g_running
goes false and the loop exits without returning.

Replace av_frame_unref/av_packet_unref with av_frame_free/av_packet_free
in destructor — unref only releases data, not the struct itself.

Add av_freep(&p10_data[0]) in destructor to free av_image_alloc memory.

Remove unused p10_buffer and v210_buffer allocations.

Read filename from config("file") instead of hardcoded path.
Add early return if mxlCreateFlowWriter fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 19:11:18 +03:00
itten 0b5b113d4e fixed ndiout flow_id -> video_flow_id 2026-07-03 18:51:10 +03:00
itten 32a8fa9837 refactored videoin to NodeBase 2026-07-03 18:48:11 +03:00
itten 2c43f356d1 it's alive 2026-07-03 14:01:13 +03:00
42 changed files with 3597 additions and 326 deletions
+1
View File
@@ -1,2 +1,3 @@
build/
.DS_Store
tests/.venv/
+2 -1
View File
@@ -5,7 +5,8 @@
"includePath": [
"${workspaceFolder}/**",
"${workspaceFolder}/shared",
"${HOME}/SDK/NDI/include"
"${HOME}/SDK/NDI/include",
"${HOME}/SDK/decklink-sdk/Linux/include"
],
"defines": [],
"compilerPath": "/usr/bin/clang",
+47 -1
View File
@@ -73,18 +73,64 @@ FetchContent_Declare(json
URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz)
FetchContent_MakeAvailable(json)
# ── FFmpeg ──────────────────────────────────────────────────────────────────
find_package(PkgConfig REQUIRED)
# Check for FFmpeg components
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET
libavformat
libavcodec
libswscale
libavutil
libswresample
)
# Wrap in a named alias so nodes just link against "ffmpeg"
add_library(ffmpeg INTERFACE)
target_link_libraries(ffmpeg INTERFACE PkgConfig::FFMPEG)
# ── 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)
# ── Basic nodes ──────────────────────────────────────────────────────────────
add_subdirectory(nodes/testpattern)
add_subdirectory(nodes/fakesink)
# ── NDI nodes ────────────────────────────────────────────────────────────────
set(NDI_SDK_DIR "" CACHE PATH "Path to NDI SDK root")
if(NDI_SDK_DIR)
add_subdirectory(nodes/ndiout)
add_subdirectory(nodes/ndiin)
add_subdirectory(nodes/ndiout)
endif()
# ── DeckLink nodes ────────────────────────────────────────────────────────────────
set(DECKLINK_SDK_DIR "" CACHE PATH "Path to DeckLink SDK root")
if(DECKLINK_SDK_DIR)
add_subdirectory(nodes/decklinkin)
add_subdirectory(nodes/decklinkout)
endif()
add_subdirectory(nodes/videoin)
add_subdirectory(nodes/pip)
add_subdirectory(nodes/gaindb)
# ── Asio standalone (needed by Crow; no Boost) ───────────────────────────────
FetchContent_Declare(asio_fc
GIT_REPOSITORY https://github.com/chriskohlhoff/asio.git
GIT_TAG asio-1-30-2)
FetchContent_MakeAvailable(asio_fc)
set(ASIO_INCLUDE_DIR "${asio_fc_SOURCE_DIR}/asio/include" CACHE PATH "" FORCE)
# ── Crow (HTTP + WebSocket) ───────────────────────────────────────────────────
set(CROW_USE_BOOST OFF CACHE BOOL "" FORCE)
set(CROW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(CROW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
FetchContent_Declare(crow_fc
GIT_REPOSITORY https://github.com/CrowCpp/Crow.git
GIT_TAG v1.2.0)
FetchContent_MakeAvailable(crow_fc)
# ── Core server ──────────────────────────────────────────────────────────────
add_subdirectory(studio-manager)
+122
View File
@@ -0,0 +1,122 @@
# DMF Studio — Roadmap
## Next steps (in order)
### 1. WebSocket API in studio-manager
Allow the graph to be changed at runtime without restarting.
- Add a WebSocket server to `studio-manager`
- API: load/reload graph, start/stop individual nodes, query status
- `studio-manager` already has `load_graph()` — the WS layer calls it on demand
and diffs against the running set (stop removed nodes, fork new ones)
- **Required before**: frontend, live source switching, PiP (otherwise every
graph change is a full restart)
### 2. Processing nodes — PiP / mixer
First node that takes multiple input flows and produces an output flow.
- Uses `mxlFlowSynchronizationGroup` to align grains from two inputs
- Reference implementation: `nodes/testpattern` (writer) + `nodes/fakesink` (reader)
- Only becomes useful with the WebSocket API (so you can switch sources live)
### 3. Vue.js frontend
Visual graph editor that drives the WebSocket API.
---
## Redundancy
Key constraint: **one writer per MXL flow** — can't run two identical nodes writing
the same flow simultaneously. Redundancy lives at the pipeline level, not the node level.
### Dual pipeline on separate machines
```
Machine 1 (k8s node A) Machine 2 (k8s node B)
decklinkin → [MXL] → ndiout decklinkin → [MXL] → ndiout
↓ ↓
(primary path) (backup path)
\ /
└──────→ [selector node] ←──────────┘
ndiout (final)
```
**Selector node** — reads two input flows, monitors grain validity flags, switches
to backup when primary fails. Fits the existing node model; uses
`mxlFlowSynchronizationGroup` to watch both flows. Key processing node to build
once redundancy becomes a requirement.
MXL shared memory requires all pods in a pipeline to be co-located on the same
physical machine. Redundant pipelines naturally go on *different* machines — which
is exactly right for hardware failure redundancy.
### What k8s gives for free
- Stateless processing nodes (PiP, denoise, format convert): k8s restarts on crash,
~1-2 s gap — acceptable for non-critical path
- `PodDisruptionBudget`: ensures critical nodes survive cluster maintenance
- Leader election (k8s lease objects): two studio-managers, one active, one standby;
automatic failover with no node code changes
---
## Kubernetes integration (mxl-k8s)
Source: `~/codeproj/mxl-k8s` — not official, treat as reference, not truth.
mxl-k8s is a full k8s control plane for MXL flows. Four runtime pieces:
- **Operator** (Deployment): watches `MxlReceiver` CRDs, creates `MxlFlowMirror` per target node
- **Agent** (DaemonSet): watches each node's MXL domain via `fanotify`, publishes `MxlFlow` CRDs with where flows live
- **Gateway** (DaemonSet, `hostNetwork`): drives libmxl-fabrics RDMA/TCP between nodes — zero-copy grain transfer via registered mmap regions
- **Shim** (`libmxl-intent.so`, LD_PRELOAD): intercepts `openat`/`stat`/`access` on `.mxl-flow/` paths in consumer pods; when a flow isn't local, asks the agent's UDS socket (`/run/mxl/agent.sock`) to materialize it via mirror, then retries — transparent to node code
### What changes for our nodes in k8s
**Producer pods** (decklinkin, ndiin, testpattern): **zero code change**.
- Add `hostPath: /run/mxl/domain` volume + `IPC_LOCK`, `SYS_RESOURCE` capabilities
- `NODE_CONFIG` → Pod env var (from ConfigMap)
- `MXL_DOMAIN``/run/mxl/domain` (standardized in k8s context)
**Consumer pods, same node**: same as above, no code change.
**Consumer pods, different node**: still no code change.
- Add `initContainer` copying `libmxl-intent.so` from shim image
- Set `LD_PRELOAD=/opt/mxl-intent/libmxl-intent.so`
- Mount `/run/mxl` (whole dir, not just `/domain`) so agent socket is accessible
- Create an `MxlReceiver` CRD pointing at the flow — operator handles the mirror plumbing
### What studio-manager becomes in k8s
Currently: fork/exec child processes. In k8s: apply/delete Pods (or Deployments) with `NODE_CONFIG` env vars. For cross-node flows: create `MxlReceiver` CRDs instead of wiring flows manually.
Same-node pipeline: all pods get `nodeAffinity: requiredDuringScheduling → same host`.
Cross-node: add shim + `MxlReceiver`; mxl-k8s handles the rest.
---
## Architecture decisions
### Separate audio and video threads in nodes
**Decision**: sink nodes (`decklinkout`, `ndiout`) and likely source nodes should
process audio and video on separate threads.
**Why**: audio and video have different timing granularities.
- Video: one grain every ~40 ms (at 25 fps) — coarse, can block
- Audio: must flow continuously at sample granularity — any stall causes dropout
In the current single-thread model, video stalls (e.g. `TOO_EARLY` retries)
pause audio too. In `decklinkout` this is especially bad — DeckLink's timestamped
audio buffer underruns if it isn't fed consistently.
**What it looks like**:
- **Audio thread**: tight loop, continuously drains MXL audio ring buffer and
pushes to output (DeckLink `ScheduleAudioSamples` / NDI send). No video logic.
- **Video thread**: current main loop, handles grain read → process → output
at frame rate.
- **Shared state**: only `g_running` and the output handle. No frame data crosses
the boundary — each thread reads its own MXL flow independently.
MXL clock keeps them in sync without explicit A/V coordination.
**When**: after the WebSocket API, when running real content and audio quality matters.
Current single-thread model is acceptable for development.
+18
View File
@@ -0,0 +1,18 @@
{
"nodes": [
{ "id": "decklinkin", "type": "decklinkin", "params": {} },
{ "id": "ndiout", "type": "ndiout", "params": {} }
],
"edges": [
{
"from": "decklinkin", "from_port": "video_flow_id",
"to": "ndiout", "to_port": "video_flow_id",
"format": { "kind": "video", "width": 1920, "height": 1080, "fps_num": 25, "fps_den": 1 }
},
{
"from": "decklinkin", "from_port": "audio_flow_id",
"to": "ndiout", "to_port": "audio_flow_id",
"format": { "kind": "audio", "sample_rate": 48000, "channels": 2, "bit_depth": 32 }
}
]
}
+67
View File
@@ -0,0 +1,67 @@
# DeckLink Output Node — Architecture Plan
## What you'll build
- `shared/DeckLinkSender.hpp` — analogous to `DeckLinkReceiver.hpp`
- `nodes/decklinkout/main.cpp` + `CMakeLists.txt`
## The key difference: scheduled playback
DeckLink output uses a **pull model** — the card calls you when it needs frames,
rather than you reading when a frame arrives.
Steps:
1. `EnableVideoOutput(mode, bmdVideoOutputFlagDefault)`
2. `EnableAudioOutput(48kHz, int32, channels, bmdAudioOutputStreamContinuous)`
3. Allocate a pool of ~3 `IDeckLinkMutableVideoFrame` objects via `CreateVideoFrame`
4. Pre-fill and `ScheduleVideoFrame` those 3 frames to prime the pipeline
5. `StartScheduledPlayback(0, fps_num, 1.0)`
6. The card fires `ScheduledFrameCompleted` callback when a frame has been displayed —
fill it with the next grain and re-schedule it
Audio is pushed separately:
`ScheduleAudioSamples(buf, count, stream_time, timescale, &written)` — no callback,
just keep it full.
## DeckLinkSender.hpp public API
Use a condition variable the same way as the receiver, but inverted — the callback
signals that a frame slot is free:
```
DeckLinkSender:
start_output(device_index, width, height, fps_num, fps_den, channels)
submit_frame(const uint8_t* src, uint32_t stride) // blocks until a slot is free
submit_audio(const float* planar, int samples) // non-blocking push
```
## Implementation steps
1. **`nodes/decklinkout/CMakeLists.txt`** — copy from `decklinkin`, rename target to `dmf-node-decklinkout`
2. **`shared/DeckLinkSender.hpp`**
- `enumerate_devices()` — identical to receiver
- `start_output()`: `EnableVideoOutput``EnableAudioOutput` → allocate 3 frames →
pre-fill with black → `StartScheduledPlayback`
- `OutputCallback` (nested private class) implementing
`IDeckLinkVideoOutputCallback::ScheduledFrameCompleted` — pushes freed frame back
to a queue, signals a CV
- `submit_frame()`: waits for a free frame from the queue, `memcpy` src → frame,
call `ScheduleVideoFrame`
- `submit_audio()`: convert float32 planar → int32 interleaved (reverse of
receiver), then call `ScheduleAudioSamples`
3. **`nodes/decklinkout/main.cpp`** — create MXL flow readers, detect format, create
sender, loop reading grains and calling `submit_frame` / `submit_audio`
## Gotcha: stream time
The `stream_time` passed to `ScheduleVideoFrame` must be monotonically increasing in
units of `fps_num` (the timescale passed to `StartScheduledPlayback`).
Easiest approach: keep a counter and pass `frame_count * fps_den` as the stream time.
```cpp
ScheduleVideoFrame(frame, frame_count * fps_den, fps_den, fps_num);
frame_count++;
```
+24
View File
@@ -0,0 +1,24 @@
{
"nodes": [
{ "id": "testpattern", "type": "testpattern", "params": { "pattern": "bars" } },
{ "id": "gaindb", "type": "gaindb", "params": {} },
{ "id": "ndiout", "type": "ndiout", "params": {} }
],
"edges": [
{
"from": "testpattern", "from_port": "video_flow_id",
"to": "ndiout", "to_port": "video_flow_id",
"format": { "kind": "video", "width": 1920, "height": 1080, "fps_num": 25, "fps_den": 1 }
},
{
"from": "testpattern", "from_port": "audio_flow_id",
"to": "gaindb", "to_port": "audio_in_flow_id",
"format": { "kind": "audio", "sample_rate": 48000, "channels": 2, "bit_depth": 32 }
},
{
"from": "gaindb", "from_port": "audio_out_flow_id",
"to": "ndiout", "to_port": "audio_flow_id",
"format": { "kind": "audio", "sample_rate": 48000, "channels": 2, "bit_depth": 32 }
}
]
}
+2 -2
View File
@@ -5,8 +5,8 @@
],
"edges": [
{
"from": "testpattern", "from_port": "flow_id",
"to": "ndiout", "to_port": "flow_id",
"from": "testpattern", "from_port": "video_flow_id",
"to": "ndiout", "to_port": "video_flow_id",
"format": { "kind": "video", "width": 1920, "height": 1080, "fps_num": 25, "fps_den": 1 }
},
{
+81
View File
@@ -0,0 +1,81 @@
# ST 2110-20 receiver with Intel MTL
The MTL pattern is structurally identical to `DeckLinkReceiver` — callback-based, MTL manages frame buffers, you drain them into MXL.
## MTL initialization (once per process)
```cpp
mtl_init_params p{};
p.ports[MTL_PORT_P] = "0000:31:00.0"; // PCI address or netdev for AF_XDP
p.num_ports = 1;
p.flags = MTL_FLAG_BIND_NUMA;
// For AF_XDP mode (no DPDK hugepages needed):
p.transport = MTL_TRANSPORT_AF_XDP;
mtl_handle dev = mtl_init(&p);
```
## Create RX session
```cpp
st20_rx_ops ops{};
ops.port.num_port = 1;
memcpy(ops.port.sip_addr[MTL_PORT_P], multicast_ip, 4); // join this group
ops.port.udp_port[MTL_PORT_P] = 20000;
ops.width = 1280;
ops.height = 720;
ops.fps = ST_FPS_P25;
ops.fmt = ST20_FMT_YUV_422_10BIT; // RFC 4175 packed — see note below
ops.framebuff_cnt = 3;
ops.notify_frame_ready = on_frame_ready; // your callback
ops.priv = this;
st20_rx_handle rx = st20_rx_create(dev, &ops);
```
## Callback — replaces `wait_for_frame()`
```cpp
static int on_frame_ready(void* priv, void* frame, st20_rx_frame_meta* meta) {
auto* self = static_cast<St2110InNode*>(priv);
// frame points to a complete assembled frame — MTL did the reassembly
const uint64_t index = mxlGetCurrentIndex(&self->video_rate);
mxlGrainInfo grain{};
uint8_t* buf = nullptr;
if (mxlFlowWriterOpenGrain(self->writer, index, &grain, &buf) == MXL_STATUS_OK) {
memcpy(buf, frame, self->frame_size); // ← see format note
grain.validSlices = grain.totalSlices;
mxlFlowWriterCommitGrain(self->writer, &grain);
}
st20_rx_put_framebuff(self->rx, frame); // return buffer to MTL pool
return 0;
}
```
## Format conversion: RFC 4175 vs V210
ST 2110-20 wire format is RFC 4175 packed 10-bit — **not V210**. They encode the same YCbCr 4:2:2 10-bit data differently:
- RFC 4175: 5 bytes per 2 pixels, big-endian packed
- V210: 4 bytes per 3 luma + 2 chroma, little-endian with padding bits
MXL in this project uses V210. Check MTL's `output_fmt` option — newer MTL versions support `ST_FRAME_FMT_V210` as the output format, which means MTL does the conversion internally. If your version doesn't have it, you'll need a small RFC4175→V210 conversion step before the `memcpy`.
Check `st_frame_fmt` enum in MTL headers for what's available.
## Teardown
```cpp
st20_rx_free(rx);
mtl_uninit(dev);
```
## Main loop
The callback is called from MTL's internal thread (like DeckLink's), so the MXL write happens inside the callback rather than in the main loop. The main loop just blocks on `g_running`:
```cpp
while (dmf::g_running.load(std::memory_order_relaxed))
mxlSleepForNs(10'000'000);
```
MTL drives the pacing, exactly like DeckLink hardware does.
+18
View File
@@ -0,0 +1,18 @@
{
"nodes": [
{ "id": "ndiin", "type": "ndiin", "params": {} },
{ "id": "decklinkout", "type": "decklinkout", "params": { "device_index": 0 } }
],
"edges": [
{
"from": "ndiin", "from_port": "video_flow_id",
"to": "decklinkout", "to_port": "video_flow_id",
"format": { "kind": "video", "width": 1920, "height": 1080, "fps_num": 25, "fps_den": 1 }
},
{
"from": "ndiin", "from_port": "audio_flow_id",
"to": "decklinkout", "to_port": "audio_flow_id",
"format": { "kind": "audio", "sample_rate": 48000, "channels": 2, "bit_depth": 32 }
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"nodes": [
{ "id": "ndiin", "type": "ndiin", "params": {} },
{ "id": "ndiout", "type": "ndiout", "params": {} }
],
"edges": [
{
"from": "ndiin", "from_port": "video_flow_id",
"to": "ndiout", "to_port": "video_flow_id",
"format": { "kind": "video", "width": 1920, "height": 1080, "fps_num": 25, "fps_den": 1 }
}
]
}
+14
View File
@@ -0,0 +1,14 @@
set(DECKLINK_INCLUDE "${DECKLINK_SDK_DIR}/include"
CACHE PATH "Path to DeckLink API headers")
add_executable(dmf-node-decklinkin
main.cpp
"${DECKLINK_INCLUDE}/DeckLinkAPIDispatch.cpp"
)
target_compile_features(dmf-node-decklinkin PRIVATE cxx_std_20)
target_include_directories(dmf-node-decklinkin PRIVATE "${DECKLINK_INCLUDE}")
target_link_libraries(dmf-node-decklinkin PRIVATE dmf-shared ${CMAKE_DL_LIBS})
if(APPLE)
target_link_libraries(dmf-node-decklinkin PRIVATE "-framework CoreFoundation")
endif()
install(TARGETS dmf-node-decklinkin RUNTIME DESTINATION bin)
+181
View File
@@ -0,0 +1,181 @@
#include <cstring>
#include <vector>
#include <mxl/flow.h>
#include <mxl/time.h>
#include "NodeBase.hpp"
#include "FlowDef.hpp"
#include "DeckLinkReceiver.hpp"
class DeckLinkInNode : public dmf::NodeBase {
void run() override {
const uint32_t device_index = config().value("device_index", 0u);
const bool want_audio = config().contains("audio_flow_id");
const int channels = want_audio
? config().at("audio_flow_id").value("channels", 2) : 0;
dmf::DeckLinkReceiver receiver;
try {
log("Available DeckLink devices:");
for (const auto& d : receiver.devices)
log(" %u) %s", d.index, d.name.c_str());
receiver.start_capture(device_index, channels);
log("Capturing from: %s", receiver.devices[device_index].name.c_str());
} catch (const std::runtime_error& e) {
log("DeckLink init error: %s", e.what());
return;
}
if (!receiver.wait_for_format(5000)) {
log("Timeout waiting for format detection");
return;
}
const auto& vi = receiver.video_info;
if (vi.width == 0 || vi.fps_num == 0) { log("Invalid format detected"); return; }
log("Detected: %dx%d @ %d/%d fps", vi.width, vi.height, vi.fps_num, vi.fps_den);
// --- Video writer ---
if (!config().contains("video_flow_id")) { log("no video output connected — exiting"); return; }
const auto video_flow_info = config().at("video_flow_id");
const auto video_flow_id = video_flow_info.at("id").get<std::string>();
const int width = vi.width;
const int height = vi.height;
const int fps_num = vi.fps_num;
const int fps_den = vi.fps_den;
log("video flow=%s %dx%d @ %d/%d fps", video_flow_id.c_str(), width, height, fps_num, fps_den);
mxlFlowWriter video_writer = nullptr;
mxlFlowConfigInfo video_cfg{};
bool created = false;
mxlStatus vst = mxlCreateFlowWriter(
instance(),
dmf::make_video_flow_def(video_flow_id, node_id(), width, height, fps_num, fps_den).c_str(),
"", &video_writer, &video_cfg, &created);
if (vst != MXL_STATUS_OK) {
log("mxlCreateFlowWriter failed (%s)", dmf::mxl_status_str(vst));
return;
}
const uint32_t video_stride = video_cfg.discrete.sliceSizes[0];
log("video stride=%u B/line grain=%u B ring=%u grains",
video_stride, video_stride * static_cast<uint32_t>(height), video_cfg.discrete.grainCount);
// --- Audio writer (optional) ---
mxlFlowWriter audio_writer = nullptr;
int max_audio_samples = 0;
const bool has_audio = want_audio && receiver.has_audio;
if (has_audio) {
const auto audio_flow_info = config().at("audio_flow_id");
const auto audio_flow_id = audio_flow_info.at("id").get<std::string>();
const int sample_rate = receiver.audio_info.sample_rate;
const int bit_depth = 32;
log("audio flow=%s %d Hz %dch %d-bit",
audio_flow_id.c_str(), sample_rate, channels, bit_depth);
mxlFlowConfigInfo audio_cfg{};
mxlStatus ast = mxlCreateFlowWriter(
instance(),
dmf::make_audio_flow_def(audio_flow_id, node_id(), sample_rate, channels, bit_depth,
fps_num, fps_den).c_str(),
"", &audio_writer, &audio_cfg, &created);
if (ast != MXL_STATUS_OK) {
log("audio mxlCreateFlowWriter failed (%s) — continuing without audio",
dmf::mxl_status_str(ast));
} else {
log("audio channels=%u buffer=%u samples",
audio_cfg.continuous.channelCount, audio_cfg.continuous.bufferLength);
size_t max_write = 0;
mxlFlowWriterGetMaxWriteLengthSamples(audio_writer, &max_write);
max_audio_samples = static_cast<int>(max_write);
}
}
// --- Buffers ---
std::vector<uint8_t> frame_buf(static_cast<size_t>(video_stride) * static_cast<size_t>(height));
// Planar float32: channel c at audio_buf[c * max_audio_samples]
std::vector<float> audio_buf(static_cast<size_t>(max_audio_samples) * static_cast<size_t>(channels));
// --- Clock ---
// video_index is determined AFTER each hardware frame arrives so it reflects
// the actual TAI slot the frame landed in. wait_for_frame() is the natural
// pacer — no separate sleep needed.
const mxlRational video_rate = {fps_num, fps_den};
const mxlRational audio_rate = {receiver.audio_info.sample_rate, 1};
uint64_t audio_index = has_audio ? mxlGetCurrentIndex(&audio_rate) : 0;
uint64_t frame_count = 0, drop_count = 0;
log("ready, waiting for first frame...");
// --- Capture loop ---
while (dmf::g_running.load(std::memory_order_relaxed)) {
int samples_written = 0;
// Blocks until DeckLink hardware delivers a frame — this IS the pacing.
if (!receiver.wait_for_frame(
frame_buf.data(), video_stride, width, height,
(has_audio && audio_writer) ? audio_buf.data() : nullptr,
max_audio_samples,
(has_audio && audio_writer) ? &samples_written : nullptr)) break;
// Resolve TAI index now — after the frame arrived, not before.
const uint64_t video_index = mxlGetCurrentIndex(&video_rate);
// Video grain
mxlGrainInfo grain{};
uint8_t* video_buf_ptr = nullptr;
vst = mxlFlowWriterOpenGrain(video_writer, video_index, &grain, &video_buf_ptr);
if (vst == MXL_STATUS_OK) {
std::memcpy(video_buf_ptr, frame_buf.data(), frame_buf.size());
grain.flags = 0;
grain.validSlices = grain.totalSlices;
mxlFlowWriterCommitGrain(video_writer, &grain);
frame_count++;
if (frame_count % 25 == 0)
log("heartbeat frames=%llu drops=%llu index=%llu",
frame_count, drop_count, video_index);
} else {
drop_count++;
log("OpenGrain failed (%s) at index=%llu drops=%llu",
dmf::mxl_status_str(vst), video_index, drop_count);
}
// Audio samples
if (has_audio && audio_writer && samples_written > 0) {
mxlMutableWrappedMultiBufferSlice slice{};
mxlStatus ast = mxlFlowWriterOpenSamples(
audio_writer, audio_index, static_cast<size_t>(samples_written), &slice);
if (ast == MXL_STATUS_OK) {
for (int ch = 0; ch < channels; ++ch) {
const uint8_t* src = reinterpret_cast<const uint8_t*>(
audio_buf.data() + ch * max_audio_samples);
uint8_t* dst0 = static_cast<uint8_t*>(
slice.base.fragments[0].pointer) + ch * slice.stride;
const size_t frag0_bytes = slice.base.fragments[0].size;
const size_t total_bytes = static_cast<size_t>(samples_written) * sizeof(float);
if (total_bytes <= frag0_bytes) {
std::memcpy(dst0, src, total_bytes);
} else {
std::memcpy(dst0, src, frag0_bytes);
uint8_t* dst1 = static_cast<uint8_t*>(
slice.base.fragments[1].pointer) + ch * slice.stride;
std::memcpy(dst1, src + frag0_bytes, total_bytes - frag0_bytes);
}
}
mxlFlowWriterCommitSamples(audio_writer);
} else {
log("audio OpenSamples failed (%s) index=%llu — skipping",
dmf::mxl_status_str(ast), static_cast<unsigned long long>(audio_index));
}
audio_index += static_cast<uint64_t>(samples_written);
}
}
log("stopped frames=%llu drops=%llu", frame_count, drop_count);
mxlReleaseFlowWriter(instance(), video_writer);
if (audio_writer) mxlReleaseFlowWriter(instance(), audio_writer);
}
};
int main() {
DeckLinkInNode node;
return node.execute();
}
+14
View File
@@ -0,0 +1,14 @@
set(DECKLINK_INCLUDE "${DECKLINK_SDK_DIR}/include"
CACHE PATH "Path to DeckLink API headers")
add_executable(dmf-node-decklinkout
main.cpp
"${DECKLINK_INCLUDE}/DeckLinkAPIDispatch.cpp"
)
target_compile_features(dmf-node-decklinkout PRIVATE cxx_std_20)
target_include_directories(dmf-node-decklinkout PRIVATE "${DECKLINK_INCLUDE}")
target_link_libraries(dmf-node-decklinkout PRIVATE dmf-shared ${CMAKE_DL_LIBS})
if(APPLE)
target_link_libraries(dmf-node-decklinkout PRIVATE "-framework CoreFoundation")
endif()
install(TARGETS dmf-node-decklinkout RUNTIME DESTINATION bin)
+249
View File
@@ -0,0 +1,249 @@
#include <chrono>
#include <cstring>
#include <string>
#include <vector>
#include <mxl/flow.h>
#include <mxl/time.h>
#include "NodeBase.hpp"
#include "FlowDef.hpp"
#include "DeckLinkSender.hpp"
class DeckLinkOutNode : public dmf::NodeBase {
void run() override {
const uint32_t device_index = config().value("device_index", 0u);
// --- video flow (optional) ---
bool has_video = config().contains("video_flow_id");
int width = 1920;
int height = 1080;
int fps_num = 25;
int fps_den = 1;
std::string flow_id;
mxlFlowReader video_reader{};
uint32_t video_stride = 0;
if (has_video) {
flow_id = config().at("video_flow_id").at("id").get<std::string>();
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;
mxlFlowConfigInfo video_cfg{};
mxlStatus vst = mxlCreateFlowReader(instance(), flow_id.c_str(), "", &video_reader);
if (vst != MXL_STATUS_OK) {
log("video mxlCreateFlowReader failed (%s)", dmf::mxl_status_str(vst));
return;
}
mxlFlowReaderGetConfigInfo(video_reader, &video_cfg);
video_stride = video_cfg.discrete.sliceSizes[0];
// Read actual format from what the source wrote — never trust config values here
const auto vfi = dmf::read_video_flow_info(domain(), flow_id);
width = vfi.width;
height = vfi.height;
fps_num = vfi.fps_num;
fps_den = vfi.fps_den;
log("video flow=%s %dx%d @ %d/%d fps stride=%u",
flow_id.c_str(), width, height, fps_num, fps_den, video_stride);
}
// --- audio flow (optional) ---
mxlFlowReader audio_reader{};
int sample_rate = 48000;
int channels = 0;
int samples_per_frame = 0;
bool has_audio = config().contains("audio_flow_id");
std::string audio_flow_id;
if (has_audio) {
audio_flow_id = config().at("audio_flow_id").at("id").get<std::string>();
log("waiting for audio flow to become active...");
bool active = false;
while (!active && dmf::g_running.load(std::memory_order_relaxed)) {
mxlIsFlowActive(instance(), audio_flow_id.c_str(), &active);
if (!active) mxlSleepForNs(100'000'000);
}
if (!dmf::g_running) { has_audio = false; }
}
if (has_audio) {
mxlStatus ast = mxlCreateFlowReader(instance(), audio_flow_id.c_str(), "", &audio_reader);
if (ast != MXL_STATUS_OK) {
log("audio mxlCreateFlowReader failed (%s) — continuing without audio",
dmf::mxl_status_str(ast));
has_audio = false;
} else {
mxlFlowConfigInfo audio_cfg{};
mxlFlowReaderGetConfigInfo(audio_reader, &audio_cfg);
const auto afi = dmf::read_audio_flow_info(domain(), audio_flow_id);
sample_rate = afi.sample_rate;
channels = afi.channels;
samples_per_frame = afi.samples_per_grain;
log("audio flow=%s %d Hz %dch %d samples/frame",
audio_flow_id.c_str(), sample_rate, channels, samples_per_frame);
log("audio mxl buffer=%u samples",
audio_cfg.continuous.bufferLength);
}
}
if (!has_video && !has_audio) { log("no flows configured — exiting"); return; }
dmf::DeckLinkSender sender;
try {
log("Available DeckLink output devices:");
for (const auto& d : sender.devices)
log(" %u) %s", d.index, d.name.c_str());
sender.start_output(device_index, width, height, fps_num, fps_den, channels);
} catch (const std::runtime_error& e) {
log("DeckLink init error: %s", e.what());
if (has_video && video_reader) mxlReleaseFlowReader(instance(), video_reader);
if (has_audio && audio_reader) mxlReleaseFlowReader(instance(), audio_reader);
return;
}
// Pre-allocate audio staging buffer (planar float32)
std::vector<float> audio_planar(
static_cast<size_t>(channels) * static_cast<size_t>(samples_per_frame));
// --- Clock init ---
uint64_t video_index = 0;
uint64_t audio_index = 0;
if (has_video) {
const mxlRational video_rate = {fps_num, fps_den};
video_index = mxlGetCurrentIndex(&video_rate);
}
if (has_audio) {
const mxlRational audio_rate = {sample_rate, 1};
audio_index = mxlGetCurrentIndex(&audio_rate);
}
uint64_t frame_count = 0;
uint64_t invalid_count = 0;
uint64_t late_count = 0;
std::chrono::steady_clock::time_point wall_start;
bool timing_started = false;
auto last_log_time = std::chrono::steady_clock::now();
// --- Main loop ---
while (dmf::g_running.load(std::memory_order_relaxed)) {
// Audio: non-blocking, one chunk per video frame
bool audio_advanced = false;
if (has_audio) {
mxlWrappedMultiBufferSlice audio_slices{};
mxlStatus ast = mxlFlowReaderGetSamplesNonBlocking(
audio_reader, audio_index, samples_per_frame, &audio_slices);
if (ast == MXL_STATUS_OK) {
const size_t frag0 = audio_slices.base.fragments[0].size / sizeof(float);
const size_t frag1 = audio_slices.base.fragments[1].size / sizeof(float);
for (int c = 0; c < channels; ++c) {
float* dst = audio_planar.data() + c * samples_per_frame;
const auto* src0 = reinterpret_cast<const float*>(
static_cast<const uint8_t*>(audio_slices.base.fragments[0].pointer)
+ static_cast<size_t>(c) * audio_slices.stride);
std::memcpy(dst, src0, frag0 * sizeof(float));
if (frag1 > 0) {
const auto* src1 = reinterpret_cast<const float*>(
static_cast<const uint8_t*>(audio_slices.base.fragments[1].pointer)
+ static_cast<size_t>(c) * audio_slices.stride);
std::memcpy(dst + frag0, src1, frag1 * sizeof(float));
}
}
sender.submit_audio(audio_planar.data(), samples_per_frame);
audio_index += samples_per_frame;
audio_advanced = true;
} else if (ast == MXL_ERR_OUT_OF_RANGE_TOO_LATE) {
mxlFlowRuntimeInfo ari{};
mxlFlowReaderGetRuntimeInfo(audio_reader, &ari);
audio_index = ari.headIndex;
} else if (ast == MXL_ERR_FLOW_INVALID) {
log("audio flow invalidated — reconnecting...");
mxlReleaseFlowReader(instance(), audio_reader);
audio_reader = nullptr;
mxlSleepForNs(100'000'000);
if (mxlCreateFlowReader(instance(), audio_flow_id.c_str(), "", &audio_reader) == MXL_STATUS_OK) {
log("audio flow reconnected");
const mxlRational r = {sample_rate, 1};
audio_index = mxlGetCurrentIndex(&r);
}
}
}
// Video
if (has_video) {
mxlGrainInfo video_grain{};
uint8_t* video_buf = nullptr;
mxlStatus vst = mxlFlowReaderGetGrainNonBlocking(
video_reader, video_index, &video_grain, &video_buf);
if (vst == MXL_STATUS_OK) {
if (!timing_started) {
wall_start = std::chrono::steady_clock::now();
last_log_time = wall_start;
timing_started = true;
}
if (video_grain.flags & MXL_GRAIN_FLAG_INVALID) invalid_count++;
sender.submit_frame(video_buf, video_stride);
frame_count++;
video_index++;
} else if (vst == MXL_ERR_OUT_OF_RANGE_TOO_EARLY) {
mxlSleepForNs(1'000'000);
} else if (vst == MXL_ERR_OUT_OF_RANGE_TOO_LATE) {
late_count++;
mxlFlowRuntimeInfo ri{};
mxlFlowReaderGetRuntimeInfo(video_reader, &ri);
video_index = ri.headIndex;
} else if (vst == MXL_ERR_FLOW_INVALID) {
log("video flow invalidated — reconnecting...");
mxlReleaseFlowReader(instance(), video_reader);
video_reader = nullptr;
mxlSleepForNs(100'000'000);
if (mxlCreateFlowReader(instance(), flow_id.c_str(), "", &video_reader) == MXL_STATUS_OK) {
log("video flow reconnected");
const mxlRational r = {fps_num, fps_den};
video_index = mxlGetCurrentIndex(&r);
}
} else {
log("unexpected video status (%s) on index=%llu",
dmf::mxl_status_str(vst), static_cast<unsigned long long>(video_index));
break;
}
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",
static_cast<unsigned long long>(frame_count),
static_cast<unsigned long long>(invalid_count),
static_cast<unsigned long long>(late_count),
static_cast<double>(frame_count) / elapsed);
last_log_time = now;
}
} else if (!audio_advanced) {
mxlSleepForNs(1'000'000);
}
}
if (has_video)
log("stopped — total frames=%llu invalid=%llu late=%llu",
static_cast<unsigned long long>(frame_count),
static_cast<unsigned long long>(invalid_count),
static_cast<unsigned long long>(late_count));
else
log("stopped");
if (has_video && video_reader) mxlReleaseFlowReader(instance(), video_reader);
if (has_audio && audio_reader) mxlReleaseFlowReader(instance(), audio_reader);
}
};
int main() {
DeckLinkOutNode node;
return node.execute();
}
+1 -1
View File
@@ -6,7 +6,7 @@
class FakeSinkNode : public dmf::NodeBase {
void run() override {
const auto flow_info = config().at("flow_id");
const auto flow_info = config().at("video_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);
+4
View File
@@ -0,0 +1,4 @@
add_executable(dmf-node-gaindb main.cpp)
target_compile_features(dmf-node-gaindb PRIVATE cxx_std_20)
target_link_libraries(dmf-node-gaindb PRIVATE dmf-shared)
install(TARGETS dmf-node-gaindb RUNTIME DESTINATION bin)
+153
View File
@@ -0,0 +1,153 @@
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
#include <mxl/flow.h>
#include <mxl/time.h>
#include "NodeBase.hpp"
#include "FlowDef.hpp"
class GainDbNode : public dmf::NodeBase {
void run() override {
if (!config().contains("audio_in_flow_id")) { log("no audio input connected"); return; }
if (!config().contains("audio_out_flow_id")) { log("no audio output connected"); return; }
const auto in_id = config().at("audio_in_flow_id").at("id").get<std::string>();
const auto out_id = config().at("audio_out_flow_id").at("id").get<std::string>();
const float gain_db = config().value("gain_db", 0.0f);
const float gain_linear = std::pow(10.0f, gain_db / 20.0f);
log("gain=%.2f dB (x%.4f linear)", gain_db, gain_linear);
// --- wait for input flow ---
log("waiting for flow %s...", in_id.c_str());
bool active = false;
while (!active && dmf::g_running.load(std::memory_order_relaxed)) {
mxlIsFlowActive(instance(), in_id.c_str(), &active);
if (!active) mxlSleepForNs(100'000'000);
}
if (!dmf::g_running) return;
// --- create reader ---
mxlFlowReader in_reader{};
if (mxlCreateFlowReader(instance(), in_id.c_str(), "", &in_reader) != MXL_STATUS_OK) {
log("mxlCreateFlowReader failed"); return;
}
// --- read format from upstream flow_def ---
const auto fi = dmf::read_audio_flow_info(domain(), in_id);
const int sample_rate = fi.sample_rate;
const int channels = fi.channels;
const int samples_per_grain = fi.samples_per_grain;
log("audio: %d Hz %dch %d samples/grain", sample_rate, channels, samples_per_grain);
// --- create output writer (same format as input) ---
mxlFlowWriter out_writer{};
mxlFlowConfigInfo out_cfg{};
bool created = false;
// gr_num/gr_den = sample_rate/samples_per_grain (e.g. 48000/1920 = 25/1)
const mxlStatus wst = mxlCreateFlowWriter(
instance(),
dmf::make_audio_flow_def(out_id, node_id(),
sample_rate, channels, /*bit_depth=*/32,
/*gr_num=*/sample_rate, /*gr_den=*/samples_per_grain).c_str(),
"", &out_writer, &out_cfg, &created);
if (wst != MXL_STATUS_OK) {
log("mxlCreateFlowWriter failed (%s)", dmf::mxl_status_str(wst));
mxlReleaseFlowReader(instance(), in_reader);
return;
}
log("output ready buffer=%u samples", out_cfg.continuous.bufferLength);
// temp flat buffer for one channel — handles ring wrap on both in and out slices
std::vector<float> temp(static_cast<size_t>(samples_per_grain));
const mxlRational audio_rate = {sample_rate, 1};
uint64_t audio_index = mxlGetCurrentIndex(&audio_rate);
log("start index=%llu", audio_index);
uint64_t grain_count = 0, late_count = 0;
while (dmf::g_running.load(std::memory_order_relaxed)) {
mxlWrappedMultiBufferSlice in_slice{};
const mxlStatus rst = mxlFlowReaderGetSamplesNonBlocking(
in_reader, audio_index,
static_cast<size_t>(samples_per_grain), &in_slice);
if (rst == MXL_STATUS_OK) {
mxlMutableWrappedMultiBufferSlice out_slice{};
const mxlStatus ost = mxlFlowWriterOpenSamples(
out_writer, audio_index,
static_cast<size_t>(samples_per_grain), &out_slice);
if (ost == MXL_STATUS_OK) {
const size_t in_f0 = in_slice.base.fragments[0].size / sizeof(float);
const size_t in_f1 = in_slice.base.fragments[1].size / sizeof(float);
const size_t out_f0 = out_slice.base.fragments[0].size / sizeof(float);
const size_t out_f1 = out_slice.base.fragments[1].size / sizeof(float);
for (int c = 0; c < channels; ++c) {
// flatten input channel c into temp
const auto* s0 = reinterpret_cast<const float*>(
static_cast<const uint8_t*>(in_slice.base.fragments[0].pointer)
+ c * in_slice.stride);
std::memcpy(temp.data(), s0, in_f0 * sizeof(float));
if (in_f1 > 0) {
const auto* s1 = reinterpret_cast<const float*>(
static_cast<const uint8_t*>(in_slice.base.fragments[1].pointer)
+ c * in_slice.stride);
std::memcpy(temp.data() + in_f0, s1, in_f1 * sizeof(float));
}
// apply gain
for (size_t i = 0; i < static_cast<size_t>(samples_per_grain); ++i)
temp[i] *= gain_linear;
// scatter to output channel c (may also wrap)
auto* d0 = reinterpret_cast<float*>(
static_cast<uint8_t*>(out_slice.base.fragments[0].pointer)
+ c * out_slice.stride);
std::memcpy(d0, temp.data(), out_f0 * sizeof(float));
if (out_f1 > 0) {
auto* d1 = reinterpret_cast<float*>(
static_cast<uint8_t*>(out_slice.base.fragments[1].pointer)
+ c * out_slice.stride);
std::memcpy(d1, temp.data() + out_f0, out_f1 * sizeof(float));
}
}
mxlFlowWriterCommitSamples(out_writer);
grain_count++;
} else {
log("OpenSamples failed (%s) at index=%llu",
dmf::mxl_status_str(ost), audio_index);
}
audio_index += static_cast<uint64_t>(samples_per_grain);
const uint64_t ns = mxlGetNsUntilIndex(audio_index, &audio_rate);
if (ns > 0 && ns < 200'000'000ULL) mxlSleepForNs(ns);
} else if (rst == MXL_ERR_OUT_OF_RANGE_TOO_EARLY) {
mxlSleepForNs(1'000'000);
} else if (rst == MXL_ERR_OUT_OF_RANGE_TOO_LATE) {
late_count++;
mxlFlowRuntimeInfo ri{};
mxlFlowReaderGetRuntimeInfo(in_reader, &ri);
audio_index = ri.headIndex;
} else {
log("read error (%s) at index=%llu", dmf::mxl_status_str(rst), audio_index);
break;
}
}
log("stopped grains=%llu late=%llu", grain_count, late_count);
mxlReleaseFlowReader(instance(), in_reader);
mxlReleaseFlowWriter(instance(), out_writer);
}
};
int main() {
GainDbNode node;
return node.execute();
}
+6 -1
View File
@@ -7,5 +7,10 @@ set(NDI_INCLUDE "${NDI_SDK_DIR}/include")
target_include_directories(dmf-node-ndiin PRIVATE
"${NDI_INCLUDE}"
)
find_library(NDI_LIB NAMES ndi PATHS "${NDI_SDK_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu" "${NDI_SDK_DIR}/lib/x64" "${NDI_SDK_DIR}/lib" NO_DEFAULT_PATH)
find_library(NDI_LIB NAMES ndi PATHS
"${NDI_SDK_DIR}/lib/macOS"
"${NDI_SDK_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu"
"${NDI_SDK_DIR}/lib/x64"
"${NDI_SDK_DIR}/lib"
NO_DEFAULT_PATH)
target_link_libraries(dmf-node-ndiin PRIVATE ${NDI_LIB})
+64 -36
View File
@@ -1,3 +1,4 @@
#include <chrono>
#include <cstring>
#include <string>
#include <vector>
@@ -26,12 +27,13 @@ class NDIInNode : public dmf::NodeBase {
}
// --- video flow ---
if (!config().contains("video_flow_id")) { log("no video output connected — exiting"); return; }
const auto video_flow_info = config().at("video_flow_id");
const auto video_flow_id = video_flow_info.at("id").get<std::string>();
const int width = video_flow_info.value("width", src.width);
const int height = video_flow_info.value("height", src.height);
const int fps_num = video_flow_info.value("fps_num", src.fps_num);
const int fps_den = video_flow_info.value("fps_den", src.fps_den);
const int width = src.width;
const int height = src.height;
const int fps_num = src.fps_num;
const int fps_den = src.fps_den;
log("video flow=%s %dx%d @ %d/%d fps", video_flow_id.c_str(), width, height, fps_num, fps_den);
@@ -59,9 +61,19 @@ class NDIInNode : public dmf::NodeBase {
if (has_audio) {
const auto audio_flow_info = config().at("audio_flow_id");
const auto audio_flow_id = audio_flow_info.at("id").get<std::string>();
sample_rate = audio_flow_info.value("sample_rate", 48000);
channels = audio_flow_info.value("channels", 2);
bit_depth = audio_flow_info.value("bit_depth", 32);
bit_depth = audio_flow_info.value("bit_depth", 32);
log("probing audio format from NDI source...");
try {
const auto ap = ndi.probe_audio();
sample_rate = ap.sample_rate;
channels = ap.channels;
log("detected audio: %d Hz %dch", sample_rate, channels);
} catch (const std::runtime_error& e) {
log("audio probe failed (%s) — using config defaults", e.what());
sample_rate = audio_flow_info.value("sample_rate", 48000);
channels = audio_flow_info.value("channels", 2);
}
log("audio flow=%s %d Hz %dch %d-bit", audio_flow_id.c_str(), sample_rate, channels, bit_depth);
@@ -96,40 +108,53 @@ class NDIInNode : public dmf::NodeBase {
dmf::NDIReceiver::AudioInfo audio_info;
while (dmf::g_running.load(std::memory_order_relaxed)) {
dmf::NDIReceiver::FrameKind kind;
// Drain available NDI frames before writing one MXL grain.
// With audio enabled, many audio packets may be queued ahead of a video frame.
// Budget: drain for at most half a frame period so the grain clock stays on time
// even when catching up from a startup audio backlog.
try {
kind = ndi.capture(latest_video.data(), video_stride, audio_buf, audio_info);
const auto drain_deadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(20);
bool first = true;
while (dmf::g_running.load(std::memory_order_relaxed) &&
std::chrono::steady_clock::now() < drain_deadline) {
auto kind = ndi.capture(latest_video.data(), video_stride,
audio_buf, audio_info, has_audio,
first ? 5 : 0);
first = false;
if (kind == dmf::NDIReceiver::FrameKind::Video) {
have_video = true;
break; // got video — write grain now, remaining audio deferred to next window
} else if (kind == dmf::NDIReceiver::FrameKind::Audio && has_audio) {
mxlMutableWrappedMultiBufferSlice slice{};
if (mxlFlowWriterOpenSamples(audio_writer, audio_index,
static_cast<size_t>(audio_info.samples),
&slice) == MXL_STATUS_OK) {
const size_t frag0 = slice.base.fragments[0].size / sizeof(float);
const size_t frag1 = slice.base.fragments[1].size / sizeof(float);
for (int c = 0; c < audio_info.channels; ++c) {
const float* src = &audio_buf[c * audio_info.channel_stride];
auto* dst0 = reinterpret_cast<float*>(
static_cast<uint8_t*>(slice.base.fragments[0].pointer) + c * slice.stride);
std::memcpy(dst0, src, frag0 * sizeof(float));
if (frag1 > 0) {
auto* dst1 = reinterpret_cast<float*>(
static_cast<uint8_t*>(slice.base.fragments[1].pointer) + c * slice.stride);
std::memcpy(dst1, src + frag0, frag1 * sizeof(float));
}
}
mxlFlowWriterCommitSamples(audio_writer);
}
audio_index += audio_info.samples;
} else {
break; // None — queue drained
}
}
} catch (const std::runtime_error& e) {
log("NDI error: %s — stopping", e.what());
break;
}
if (kind == dmf::NDIReceiver::FrameKind::Video) {
have_video = true;
} else if (kind == dmf::NDIReceiver::FrameKind::Audio && has_audio) {
mxlMutableWrappedMultiBufferSlice slice{};
if (mxlFlowWriterOpenSamples(audio_writer, audio_index,
static_cast<size_t>(audio_info.samples), &slice) == MXL_STATUS_OK) {
// MXL audio is float32 planar: each channel occupies its own ring buffer
// region, accessed at base + c * stride. Fragments handle ring wraparound.
const size_t frag0 = slice.base.fragments[0].size / sizeof(float);
const size_t frag1 = slice.base.fragments[1].size / sizeof(float);
for (int c = 0; c < audio_info.channels; ++c) {
const float* src = &audio_buf[c * audio_info.channel_stride];
auto* dst0 = reinterpret_cast<float*>(
static_cast<uint8_t*>(slice.base.fragments[0].pointer) + c * slice.stride);
std::memcpy(dst0, src, frag0 * sizeof(float));
if (frag1 > 0) {
auto* dst1 = reinterpret_cast<float*>(
static_cast<uint8_t*>(slice.base.fragments[1].pointer) + c * slice.stride);
std::memcpy(dst1, src + frag0, frag1 * sizeof(float));
}
}
mxlFlowWriterCommitSamples(audio_writer);
}
audio_index += audio_info.samples;
}
// Write video grain whenever the MXL clock has reached video_index
const uint64_t current = mxlGetCurrentIndex(&video_rate);
if (current >= video_index) {
@@ -145,10 +170,13 @@ class NDIInNode : public dmf::NodeBase {
}
grain.validSlices = grain.totalSlices;
mxlFlowWriterCommitGrain(video_writer, &grain);
const uint64_t ns = mxlGetNsUntilIndex(video_index + 1, &video_rate);
if (ns > 0 && ns < 2'000'000'000ULL) mxlSleepForNs(ns);
video_index = mxlGetCurrentIndex(&video_rate);
} else {
log("video OpenGrain failed (%s) at index=%llu", dmf::mxl_status_str(st), video_index);
video_index = current + 1;
}
video_index = current + 1;
}
}
+6 -1
View File
@@ -7,5 +7,10 @@ set(NDI_INCLUDE "${NDI_SDK_DIR}/include")
target_include_directories(dmf-node-ndiout PRIVATE
"${NDI_INCLUDE}"
)
find_library(NDI_LIB NAMES ndi PATHS "${NDI_SDK_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu" "${NDI_SDK_DIR}/lib/x64" "${NDI_SDK_DIR}/lib" NO_DEFAULT_PATH)
find_library(NDI_LIB NAMES ndi PATHS
"${NDI_SDK_DIR}/lib/macOS"
"${NDI_SDK_DIR}/lib/${CMAKE_SYSTEM_PROCESSOR}-linux-gnu"
"${NDI_SDK_DIR}/lib/x64"
"${NDI_SDK_DIR}/lib"
NO_DEFAULT_PATH)
target_link_libraries(dmf-node-ndiout PRIVATE ${NDI_LIB})
+30 -19
View File
@@ -6,6 +6,7 @@
#include <mxl/flow.h>
#include <mxl/time.h>
#include "NodeBase.hpp"
#include "FlowDef.hpp"
#include <Processing.NDI.Lib.h>
// RAII wrapper: init NDI, create sender, destroy both on scope exit.
@@ -34,7 +35,7 @@ struct NDIContext {
class NDIOutNode : public dmf::NodeBase {
void run() override {
// --- video flow (optional) ---
bool has_video = config().contains("flow_id");
bool has_video = config().contains("video_flow_id");
int width = 1920;
int height = 1080;
@@ -45,14 +46,7 @@ class NDIOutNode : public dmf::NodeBase {
uint32_t video_stride = 0;
if (has_video) {
const auto flow_info = config().at("flow_id");
flow_id = flow_info.at("id").get<std::string>();
width = flow_info.value("width", 1920);
height = flow_info.value("height", 1080);
fps_num = flow_info.value("fps_num", 25);
fps_den = flow_info.value("fps_den", 1);
log("video flow=%s %dx%d @ %d/%d fps", flow_id.c_str(), width, height, fps_num, fps_den);
flow_id = config().at("video_flow_id").at("id").get<std::string>();
log("waiting for flow to become active...");
bool active = false;
@@ -61,13 +55,21 @@ class NDIOutNode : public dmf::NodeBase {
if (!active) mxlSleepForNs(100'000'000);
}
if (!dmf::g_running) return;
log("flow active — starting read");
mxlFlowConfigInfo video_cfg{};
mxlStatus vst = mxlCreateFlowReader(instance(), flow_id.c_str(), "", &video_reader);
if (vst != MXL_STATUS_OK) { log("video mxlCreateFlowReader failed (%s)", dmf::mxl_status_str(vst)); return; }
mxlFlowReaderGetConfigInfo(video_reader, &video_cfg);
video_stride = video_cfg.discrete.sliceSizes[0];
// Read actual format from what the source wrote — never trust config values here
const auto vfi = dmf::read_video_flow_info(domain(), flow_id);
width = vfi.width;
height = vfi.height;
fps_num = vfi.fps_num;
fps_den = vfi.fps_den;
log("video flow=%s %dx%d @ %d/%d fps stride=%u",
flow_id.c_str(), width, height, fps_num, fps_den, video_stride);
}
// --- audio flow (optional) ---
@@ -80,23 +82,32 @@ class NDIOutNode : public dmf::NodeBase {
std::string audio_flow_id;
if (has_audio) {
const auto audio_flow_info = config().at("audio_flow_id");
audio_flow_id = audio_flow_info.at("id").get<std::string>();
sample_rate = audio_flow_info.value("sample_rate", 48000);
channels = audio_flow_info.value("channels", 2);
samples_per_frame = sample_rate * fps_den / fps_num;
audio_flow_id = config().at("audio_flow_id").at("id").get<std::string>();
log("audio flow=%s %d Hz %dch %d samples/frame",
audio_flow_id.c_str(), sample_rate, channels, samples_per_frame);
log("waiting for audio flow to become active...");
bool active = false;
while (!active && dmf::g_running.load(std::memory_order_relaxed)) {
mxlIsFlowActive(instance(), audio_flow_id.c_str(), &active);
if (!active) mxlSleepForNs(100'000'000);
}
if (!dmf::g_running) { has_audio = false; }
}
if (has_audio) {
mxlStatus ast = mxlCreateFlowReader(instance(), audio_flow_id.c_str(), "", &audio_reader);
if (ast != MXL_STATUS_OK) {
log("audio mxlCreateFlowReader failed (%s) — continuing without audio", dmf::mxl_status_str(ast));
has_audio = false;
} else {
mxlFlowReaderGetConfigInfo(audio_reader, &audio_cfg);
log("audio channels=%u buffer=%u samples",
audio_cfg.continuous.channelCount, audio_cfg.continuous.bufferLength);
const auto afi = dmf::read_audio_flow_info(domain(), audio_flow_id);
sample_rate = afi.sample_rate;
channels = afi.channels;
samples_per_frame = afi.samples_per_grain;
log("audio flow=%s %d Hz %dch %d samples/frame",
audio_flow_id.c_str(), sample_rate, channels, samples_per_frame);
log("audio mxl buffer=%u samples",
audio_cfg.continuous.bufferLength);
}
}
+4
View File
@@ -0,0 +1,4 @@
add_executable(dmf-node-pip main.cpp)
target_compile_features(dmf-node-pip PRIVATE cxx_std_20)
target_link_libraries(dmf-node-pip PRIVATE dmf-shared)
install(TARGETS dmf-node-pip RUNTIME DESTINATION bin)
+201
View File
@@ -0,0 +1,201 @@
#include <algorithm>
#include <cstring>
#include <string>
#include <vector>
#include <mxl/flow.h>
#include <mxl/time.h>
#include "NodeBase.hpp"
#include "FlowDef.hpp"
#include "V210.hpp"
// Round down to nearest V210-aligned pixel count (multiple of 6).
static int v210_align(int pixels) { return (pixels / 6) * 6; }
class PiPNode : public dmf::NodeBase {
void run() override {
if (!config().contains("background_flow_id")) { log("no background connected"); return; }
if (!config().contains("inset_flow_id")) { log("no inset connected"); return; }
if (!config().contains("video_flow_id")) { log("no output connected"); return; }
const auto bg_id = config().at("background_flow_id").at("id").get<std::string>();
const auto inset_id = config().at("inset_flow_id").at("id").get<std::string>();
const auto out_id = config().at("video_flow_id").at("id").get<std::string>();
// Position and size of the inset in the output frame.
// x and width are snapped to 6-pixel V210 boundaries.
const int pip_x = v210_align(config().value("x", 0));
const int pip_y = config().value("y", 0);
const int pip_w = v210_align(config().value("width", 480));
const int pip_h = config().value("height", 270);
// --- Wait for both input flows ---
for (const auto* fid : {&bg_id, &inset_id}) {
log("waiting for flow %s...", fid->c_str());
bool active = false;
while (!active && dmf::g_running.load(std::memory_order_relaxed)) {
mxlIsFlowActive(instance(), fid->c_str(), &active);
if (!active) mxlSleepForNs(100'000'000);
}
if (!dmf::g_running) return;
}
// --- Create readers ---
mxlFlowReader bg_reader{}, inset_reader{};
mxlFlowConfigInfo bg_cfg{}, inset_cfg{};
if (mxlCreateFlowReader(instance(), bg_id.c_str(), "", &bg_reader) != MXL_STATUS_OK) {
log("background mxlCreateFlowReader failed"); return;
}
if (mxlCreateFlowReader(instance(), inset_id.c_str(), "", &inset_reader) != MXL_STATUS_OK) {
log("inset mxlCreateFlowReader failed");
mxlReleaseFlowReader(instance(), bg_reader);
return;
}
mxlFlowReaderGetConfigInfo(bg_reader, &bg_cfg);
mxlFlowReaderGetConfigInfo(inset_reader, &inset_cfg);
const uint32_t bg_stride = bg_cfg.discrete.sliceSizes[0];
const uint32_t inset_stride = inset_cfg.discrete.sliceSizes[0];
// --- Read formats from flow_def.json ---
const auto bg_fi = dmf::read_video_flow_info(domain(), bg_id);
const auto inset_fi = dmf::read_video_flow_info(domain(), inset_id);
const int bg_w = bg_fi.width;
const int bg_h = bg_fi.height;
const int fps_num = bg_fi.fps_num;
const int fps_den = bg_fi.fps_den;
const int inset_w = inset_fi.width;
const int inset_h = inset_fi.height;
log("background: %dx%d @ %d/%d fps stride=%u",
bg_w, bg_h, fps_num, fps_den, bg_stride);
log("inset src: %dx%d stride=%u", inset_w, inset_h, inset_stride);
log("pip region: %dx%d at (%d,%d)", pip_w, pip_h, pip_x, pip_y);
// Clamp pip region to background bounds
const int clamped_w = v210_align(std::min(pip_w, bg_w - pip_x));
const int clamped_h = std::min(pip_h, bg_h - pip_y);
if (clamped_w <= 0 || clamped_h <= 0) {
log("pip region is outside background bounds — exiting");
mxlReleaseFlowReader(instance(), bg_reader);
mxlReleaseFlowReader(instance(), inset_reader);
return;
}
// --- Create output writer (same format as background) ---
mxlFlowWriter out_writer{};
mxlFlowConfigInfo out_cfg{};
bool created = false;
mxlStatus vst = mxlCreateFlowWriter(
instance(),
dmf::make_video_flow_def(out_id, node_id(), bg_w, bg_h, fps_num, fps_den).c_str(),
"", &out_writer, &out_cfg, &created);
if (vst != MXL_STATUS_OK) {
log("mxlCreateFlowWriter failed (%s)", dmf::mxl_status_str(vst));
mxlReleaseFlowReader(instance(), bg_reader);
mxlReleaseFlowReader(instance(), inset_reader);
return;
}
const uint32_t out_stride = out_cfg.discrete.sliceSizes[0];
log("output: stride=%u grain=%u B ring=%u grains",
out_stride, out_stride * static_cast<uint32_t>(bg_h), out_cfg.discrete.grainCount);
// --- Pre-allocate bilinear scaling workspace (reused every frame) ---
std::vector<uint16_t> Y0(inset_w), Y1(inset_w);
std::vector<uint16_t> Cb0(inset_w / 2), Cb1(inset_w / 2);
std::vector<uint16_t> Cr0(inset_w / 2), Cr1(inset_w / 2);
// --- Clock: driven by background (inset follows best-effort) ---
const mxlRational rate = {fps_num, fps_den};
uint64_t index = mxlGetCurrentIndex(&rate);
log("start index=%llu", index);
uint64_t frame_count = 0, stall_count = 0;
bool fatal = false;
while (dmf::g_running.load(std::memory_order_relaxed)) {
// Background is the master clock.
mxlGrainInfo bg_grain{};
uint8_t* bg_buf = nullptr;
const mxlStatus bg_st = mxlFlowReaderGetGrain(
bg_reader, index, 80'000'000, &bg_grain, &bg_buf);
if (bg_st == MXL_STATUS_OK && bg_buf) {
// Try inset at same index (short timeout — hardware often lands one frame behind).
// On any miss, fall back to headIndex: the ring buffer is the cache.
mxlGrainInfo inset_grain{};
uint8_t* inset_buf = nullptr;
mxlStatus in_st = mxlFlowReaderGetGrain(
inset_reader, index, 8'000'000, &inset_grain, &inset_buf);
if (in_st != MXL_STATUS_OK) {
mxlFlowRuntimeInfo ri{};
mxlFlowReaderGetRuntimeInfo(inset_reader, &ri);
in_st = mxlFlowReaderGetGrain(
inset_reader, ri.headIndex, 8'000'000, &inset_grain, &inset_buf);
stall_count++;
}
mxlGrainInfo out_grain{};
uint8_t* out_buf = nullptr;
const mxlStatus wst = mxlFlowWriterOpenGrain(
out_writer, index, &out_grain, &out_buf);
if (wst == MXL_STATUS_OK) {
std::memcpy(out_buf, bg_buf,
static_cast<size_t>(bg_stride) * static_cast<size_t>(bg_h));
if (in_st == MXL_STATUS_OK && inset_buf) {
dmf::v210::scale_and_overlay(
inset_buf, inset_stride, inset_w, inset_h,
out_buf, out_stride,
pip_x, pip_y, clamped_w, clamped_h,
Y0, Y1, Cb0, Cb1, Cr0, Cr1);
}
out_grain.flags = bg_grain.flags & MXL_GRAIN_FLAG_INVALID;
out_grain.validSlices = out_grain.totalSlices;
mxlFlowWriterCommitGrain(out_writer, &out_grain);
frame_count++;
if (frame_count % 25 == 0)
log("heartbeat frames=%llu stalls=%llu index=%llu",
frame_count, stall_count, index);
} else {
log("writer OpenGrain failed (%s) at index=%llu",
dmf::mxl_status_str(wst), index);
}
index++;
} else if (bg_st == MXL_ERR_OUT_OF_RANGE_TOO_EARLY) {
// Background stalled longer than 80 ms — skip this index.
stall_count++;
log("bg stall (TOO_EARLY) at index=%llu frames=%llu", index, frame_count);
index++;
} else if (bg_st == MXL_ERR_OUT_OF_RANGE_TOO_LATE) {
stall_count++;
mxlFlowRuntimeInfo ri{};
mxlFlowReaderGetRuntimeInfo(bg_reader, &ri);
log("bg TOO_LATE at index=%llu → jumping to %llu frames=%llu",
index, ri.headIndex, frame_count);
index = ri.headIndex;
} else {
log("bg fatal (%s) at index=%llu", dmf::mxl_status_str(bg_st), index);
fatal = true;
break;
}
}
log("stopped: %s frames=%llu stalls=%llu index=%llu",
fatal ? "fatal error" : "shutdown signal",
frame_count, stall_count, index);
mxlReleaseFlowReader(instance(), bg_reader);
mxlReleaseFlowReader(instance(), inset_reader);
mxlReleaseFlowWriter(instance(), out_writer);
}
};
int main() {
PiPNode node;
return node.execute();
}
+5 -3
View File
@@ -10,7 +10,8 @@
class TestPatternNode : public dmf::NodeBase {
void run() override {
// --- video flow ---
const auto flow_info = config().at("flow_id");
if (!config().contains("video_flow_id")) { log("no video output connected — exiting"); return; }
const auto flow_info = config().at("video_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);
@@ -68,8 +69,9 @@ class TestPatternNode : public dmf::NodeBase {
const size_t samples_per_frame =
static_cast<size_t>(sample_rate) * static_cast<size_t>(fps_den) / static_cast<size_t>(fps_num);
// -18 dBFS broadcast reference level
const float amplitude = static_cast<float>(std::pow(10.0, -18.0 / 20.0));
// default -18 dBFS broadcast reference level
const float amplitude_db = config().value("amplitude_db", -18.0f);
const float amplitude = std::pow(10.0f, amplitude_db / 20.0f);
uint64_t video_index = mxlGetCurrentIndex(&video_rate);
uint64_t audio_index = 0;
+8
View File
@@ -0,0 +1,8 @@
add_executable(dmf-node-videoin main.cpp)
target_compile_features(dmf-node-videoin PRIVATE cxx_std_20)
target_link_libraries(dmf-node-videoin
PRIVATE
dmf-shared
ffmpeg
)
install(TARGETS dmf-node-videoin RUNTIME DESTINATION bin)
+181
View File
@@ -0,0 +1,181 @@
#include <string>
#include <mxl/flow.h>
#include <mxl/time.h>
#include "V210.hpp"
#include "FlowDef.hpp"
#include "NodeBase.hpp"
#include "VideoReader.hpp"
class VideoInNode : public dmf::NodeBase {
void run() override {
const std::string filename = config().value("file", std::string{});
if (filename.empty()) { log("config missing 'file'"); return; }
log("file: %s", filename.c_str());
// Audio pacing assumes steady fixed-size chunks from the demuxer (e.g. 1024-sample AAC
// packets in MPEG-TS). Containers like MP4/MKV can deliver audio in large bursts, which
// would require a separate metering buffer to pace correctly. Stick to .ts for now.
dmf::VideoReader video_reader(filename);
if (!video_reader.has_video && !video_reader.has_audio) {
log("no video or audio stream found"); return;
}
const bool has_video = config().contains("video_flow_id") && video_reader.has_video;
mxlFlowWriter video_writer = nullptr;
mxlFlowConfigInfo video_cfg{};
uint32_t video_stride = 0;
int width = 0, height = 0, fps_num = 25, fps_den = 1;
std::string video_flow_id;
if (has_video) {
const auto video_flow_info = config().at("video_flow_id");
video_flow_id = video_flow_info.at("id").get<std::string>();
width = video_reader.video_info.width;
height = video_reader.video_info.height;
fps_num = video_reader.video_info.fps_num;
fps_den = video_reader.video_info.fps_den;
log("video flow=%s %dx%d @ %d/%d fps", video_flow_id.c_str(), width, height, fps_num, fps_den);
bool created = false;
mxlStatus vst = mxlCreateFlowWriter(
instance(),
dmf::make_video_flow_def(video_flow_id, node_id(), width, height, fps_num, fps_den).c_str(),
"", &video_writer, &video_cfg, &created);
if (vst != MXL_STATUS_OK) {
log("mxlCreateFlowWriter failed (%s)", dmf::mxl_status_str(vst));
return;
}
video_stride = video_cfg.discrete.sliceSizes[0];
log("video stride=%u B/line grain=%u B ring=%u grains",
video_stride, video_stride * static_cast<uint32_t>(height), video_cfg.discrete.grainCount);
}
mxlFlowWriter audio_writer = nullptr;
mxlFlowConfigInfo audio_cfg{};
int sample_rate = video_reader.audio_info.sample_rate;
int channels = video_reader.audio_info.channels;
int bit_depth = 32;
bool has_audio = config().contains("audio_flow_id") && video_reader.has_audio;
int max_audio_samples = 0;
if (has_audio) {
const auto audio_flow_info = config().at("audio_flow_id");
const auto audio_flow_id = audio_flow_info.at("id").get<std::string>();
log("audio flow=%s %d Hz %dch %d-bit", audio_flow_id.c_str(), sample_rate, channels, bit_depth);
bool created = false;
mxlStatus ast = mxlCreateFlowWriter(
instance(),
dmf::make_audio_flow_def(audio_flow_id, node_id(), sample_rate, channels, bit_depth,
fps_num, fps_den).c_str(),
"", &audio_writer, &audio_cfg, &created);
if (ast != MXL_STATUS_OK) {
log("audio mxlCreateFlowWriter failed (%s) — continuing without audio", dmf::mxl_status_str(ast));
has_audio = false;
} else {
log("audio channels=%u buffer=%u samples",
audio_cfg.continuous.channelCount, audio_cfg.continuous.bufferLength);
size_t max_write = 0;
mxlFlowWriterGetMaxWriteLengthSamples(audio_writer, &max_write);
max_audio_samples = static_cast<int>(max_write);
}
}
std::vector<uint8_t> audio_temp(max_audio_samples * channels * sizeof(float));
const mxlRational video_rate = {fps_num, fps_den};
const mxlRational audio_rate = {sample_rate, 1};
uint64_t audio_index = 0;
if (has_audio) {
audio_index = mxlGetCurrentIndex(&audio_rate);
}
uint64_t video_index = has_video ? mxlGetCurrentIndex(&video_rate) : 0;
while (dmf::g_running.load(std::memory_order_relaxed)) {
uint8_t* video_buf = nullptr;
mxlGrainInfo grain{};
mxlStatus vst = MXL_ERR_UNSUPPORTED_OPERATION;
if (has_video) {
vst = mxlFlowWriterOpenGrain(video_writer, video_index, &grain, &video_buf);
}
int out_samples_written = 0;
dmf::VideoReader::FrameKind frame_kind = video_reader.read_next(
has_video ? video_buf : nullptr,
video_stride,
has_audio ? audio_temp.data() : nullptr,
max_audio_samples,
out_samples_written
);
if (frame_kind == dmf::VideoReader::FrameKind::None) break;
if (frame_kind == dmf::VideoReader::FrameKind::Video) {
if (has_video && vst == MXL_STATUS_OK) {
grain.flags = 0;
grain.validSlices = grain.totalSlices;
mxlFlowWriterCommitGrain(video_writer, &grain);
}
if (has_video) {
const uint64_t ns = mxlGetNsUntilIndex(video_index + 1, &video_rate);
if (ns > 0 && ns < 2'000'000'000ULL) mxlSleepForNs(ns);
video_index = mxlGetCurrentIndex(&video_rate);
}
} else if (frame_kind == dmf::VideoReader::FrameKind::Audio) {
if (has_video && vst == MXL_STATUS_OK) mxlFlowWriterCancelGrain(video_writer);
// Wait for audio clock to catch up — prevents TOO_EARLY and sample loss
while (dmf::g_running.load(std::memory_order_relaxed)) {
const uint64_t audio_now = mxlGetCurrentIndex(&audio_rate);
if (audio_index + static_cast<uint64_t>(out_samples_written) <= audio_now) break;
const uint64_t ns = mxlGetNsUntilIndex(audio_index + out_samples_written, &audio_rate);
if (ns > 0 && ns < 2'000'000'000ULL) mxlSleepForNs(ns);
else break;
}
if (!dmf::g_running.load(std::memory_order_relaxed)) break;
mxlMutableWrappedMultiBufferSlice slice{};
mxlStatus ast = mxlFlowWriterOpenSamples(audio_writer, audio_index, out_samples_written, &slice);
if (ast != MXL_STATUS_OK) {
log("audio OpenSamples failed (%s) index=%llu — skipping", dmf::mxl_status_str(ast), audio_index);
audio_index += out_samples_written; // advance even on failure — keeps alignment
continue;
}
// copy per channel from audio_temp into slice
for (int ch = 0; ch < channels; ch++) {
uint8_t* dst0 = static_cast<uint8_t*>(slice.base.fragments[0].pointer)
+ ch * slice.stride;
uint8_t* src = audio_temp.data() + ch * max_audio_samples * sizeof(float);
size_t frag0_bytes = slice.base.fragments[0].size;
size_t total_bytes = out_samples_written * sizeof(float);
if (total_bytes <= frag0_bytes) {
std::memcpy(dst0, src, total_bytes);
} else {
// Wrapped — copy first fragment, then second
std::memcpy(dst0, src, frag0_bytes);
uint8_t* dst1 = static_cast<uint8_t*>(slice.base.fragments[1].pointer)
+ ch * slice.stride;
std::memcpy(dst1, src + frag0_bytes, total_bytes - frag0_bytes);
}
}
mxlFlowWriterCommitSamples(audio_writer);
audio_index += out_samples_written;
if (has_video) {
const uint64_t current = mxlGetCurrentIndex(&video_rate);
if (current > video_index) video_index = current;
}
}
}
log("stopped at video_index=%llu", static_cast<unsigned long long>(video_index));
if (has_video) mxlReleaseFlowWriter(instance(), video_writer);
if (has_audio) mxlReleaseFlowWriter(instance(), audio_writer);
}
};
int main() {
VideoInNode node;
return node.execute();
}
+300
View File
@@ -0,0 +1,300 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <mutex>
#include <numeric>
#include <stdexcept>
#include <string>
#include <vector>
#include <DeckLinkAPI.h>
#ifdef __APPLE__
#include <CoreFoundation/CoreFoundation.h>
#endif
#include "Signal.hpp"
namespace dmf {
class DeckLinkReceiver {
public:
struct VideoInfo {
int width = 0;
int height = 0;
int fps_num = 0;
int fps_den = 0;
};
struct AudioInfo {
int sample_rate = 48000; // DeckLink always delivers 48 kHz
int channels = 0;
};
struct DeviceInfo {
uint32_t index;
std::string name;
};
std::vector<DeviceInfo> devices;
VideoInfo video_info{};
AudioInfo audio_info{};
bool has_audio = false;
DeckLinkReceiver() { enumerate_devices(); }
~DeckLinkReceiver() {
if (decklink_input) {
decklink_input->StopStreams();
decklink_input->DisableVideoInput();
if (has_audio) decklink_input->DisableAudioInput();
decklink_input->SetCallback(nullptr);
decklink_input->Release();
}
delete input_callback;
for (auto* d : raw_devices) d->Release();
if (selected_device) selected_device->Release();
}
// audio_channels > 0 enables audio capture at 48 kHz / 32-bit int.
void start_capture(uint32_t device_index, int audio_channels = 0) {
if (device_index >= raw_devices.size())
throw std::runtime_error("Device index out of range");
selected_device = raw_devices[device_index];
selected_device->AddRef();
HRESULT r = selected_device->QueryInterface(IID_IDeckLinkInput, (void**)&decklink_input);
if (r != S_OK) throw std::runtime_error("Could not obtain IDeckLinkInput");
input_callback = new InputCallback(*this);
r = decklink_input->SetCallback(input_callback);
if (r != S_OK) throw std::runtime_error("Could not set input callback");
r = decklink_input->EnableVideoInput(bmdModeNTSC, bmdFormat10BitYUV,
bmdVideoInputEnableFormatDetection);
if (r != S_OK) throw std::runtime_error("Could not enable video input");
if (audio_channels > 0) {
r = decklink_input->EnableAudioInput(bmdAudioSampleRate48kHz,
bmdAudioSampleType32bitInteger,
static_cast<uint32_t>(audio_channels));
if (r != S_OK) throw std::runtime_error("Could not enable audio input");
audio_info.channels = audio_channels;
has_audio = true;
}
r = decklink_input->StartStreams();
if (r != S_OK) throw std::runtime_error("Could not start streams");
}
bool wait_for_format(int timeout_ms = 5000) {
std::unique_lock<std::mutex> lk(mutex);
return format_cv.wait_for(lk, std::chrono::milliseconds(timeout_ms),
[this] { return format_detected; });
}
// Blocks until a fresh frame arrives or g_running goes false.
// Copies video into video_dst and (if audio_dst != nullptr) deinterleaved
// float32 audio into audio_dst[channel * max_samples + sample].
// Returns false on shutdown.
bool wait_for_frame(uint8_t* video_dst, uint32_t dst_stride, int width, int height,
float* audio_dst = nullptr, int max_samples = 0, int* samples_written = nullptr) {
std::unique_lock<std::mutex> lk(mutex);
frame_cv.wait(lk, [this] {
return frame_ready || !dmf::g_running.load(std::memory_order_relaxed);
});
if (!dmf::g_running.load(std::memory_order_relaxed)) return false;
// Video copy
const uint32_t src_stride = frame_row_bytes;
const int rows = std::min(frame_height, height);
const uint32_t copy_bytes = std::min(src_stride, dst_stride);
std::memset(video_dst, 0, static_cast<size_t>(dst_stride) * static_cast<size_t>(height));
const uint8_t* src = frame_buffer.data();
uint8_t* d = video_dst;
for (int y = 0; y < rows; ++y, src += src_stride, d += dst_stride)
std::memcpy(d, src, copy_bytes);
// Audio copy — planar float32: channel c starts at audio_dst + c * max_samples
if (audio_dst && max_samples > 0 && samples_written) {
const int n = std::min(audio_samples, max_samples);
const int ch = audio_info.channels;
*samples_written = n;
for (int c = 0; c < ch; ++c)
std::memcpy(audio_dst + c * max_samples,
audio_buffer.data() + c * audio_samples,
static_cast<size_t>(n) * sizeof(float));
} else if (samples_written) {
*samples_written = 0;
}
frame_ready = false;
return true;
}
private:
class InputCallback : public IDeckLinkInputCallback {
public:
explicit InputCallback(DeckLinkReceiver& owner) : owner(owner) {}
HRESULT STDMETHODCALLTYPE VideoInputFormatChanged(
BMDVideoInputFormatChangedEvents events,
IDeckLinkDisplayMode* mode,
BMDDetectedVideoInputFormatFlags /*flags*/) override
{
if (!(events & bmdVideoInputDisplayModeChanged)) return S_OK;
{
std::lock_guard<std::mutex> lk(owner.mutex);
owner.video_info.width = static_cast<int>(mode->GetWidth());
owner.video_info.height = static_cast<int>(mode->GetHeight());
BMDTimeValue dur = 0; BMDTimeScale ts = 0;
mode->GetFrameRate(&dur, &ts);
if (dur > 0 && ts > 0) {
owner.video_info.fps_num = static_cast<int>(ts);
owner.video_info.fps_den = static_cast<int>(dur);
const int g = std::gcd(owner.video_info.fps_num, owner.video_info.fps_den);
if (g > 1) { owner.video_info.fps_num /= g; owner.video_info.fps_den /= g; }
}
owner.frame_ready = false;
owner.frame_buffer.clear();
owner.format_detected = true;
}
owner.format_cv.notify_one();
HRESULT r = owner.decklink_input->StopStreams();
if (r == S_OK) {
r = owner.decklink_input->EnableVideoInput(
mode->GetDisplayMode(), bmdFormat10BitYUV,
bmdVideoInputEnableFormatDetection);
if (r == S_OK) owner.decklink_input->StartStreams();
}
return S_OK;
}
HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(
IDeckLinkVideoInputFrame* video_frame,
IDeckLinkAudioInputPacket* audio_packet) override
{
if (!video_frame) return S_OK;
IDeckLinkVideoBuffer* buf = nullptr;
if (video_frame->QueryInterface(IID_IDeckLinkVideoBuffer, (void**)&buf) != S_OK)
return S_OK;
buf->StartAccess(bmdBufferAccessRead);
void* src = nullptr;
buf->GetBytes(&src);
if (src) {
const uint32_t row_bytes = static_cast<uint32_t>(video_frame->GetRowBytes());
const int fw = static_cast<int>(video_frame->GetWidth());
const int fh = static_cast<int>(video_frame->GetHeight());
const size_t sz = static_cast<size_t>(row_bytes) * static_cast<size_t>(fh);
std::lock_guard<std::mutex> lk(owner.mutex);
// Video
if (owner.frame_buffer.size() < sz) owner.frame_buffer.resize(sz);
std::memcpy(owner.frame_buffer.data(), src, sz);
owner.frame_row_bytes = row_bytes;
owner.frame_width = fw;
owner.frame_height = fh;
// Audio — deinterleave int32 → float32 planar under the same lock
if (owner.has_audio && audio_packet) {
void* asrc = nullptr;
audio_packet->GetBytes(&asrc);
const long nb = audio_packet->GetSampleFrameCount();
const int ch = owner.audio_info.channels;
if (asrc && nb > 0 && ch > 0) {
const auto* in = static_cast<const int32_t*>(asrc);
const size_t need = static_cast<size_t>(ch) * static_cast<size_t>(nb);
if (owner.audio_buffer.size() < need) owner.audio_buffer.resize(need);
for (long s = 0; s < nb; ++s)
for (int c = 0; c < ch; ++c)
owner.audio_buffer[static_cast<size_t>(c) * static_cast<size_t>(nb) + static_cast<size_t>(s)]
= static_cast<float>(in[s * ch + c]) / 2147483648.0f;
owner.audio_samples = static_cast<int>(nb);
}
}
owner.frame_ready = true;
}
buf->EndAccess(bmdBufferAccessRead);
buf->Release();
if (src) owner.frame_cv.notify_one();
return S_OK;
}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, LPVOID*) override { return E_NOINTERFACE; }
ULONG STDMETHODCALLTYPE AddRef() override { return ++ref_count; }
ULONG STDMETHODCALLTYPE Release() override { return --ref_count; }
private:
DeckLinkReceiver& owner;
std::atomic<int32_t> ref_count{1};
};
// DeckLink SDK objects
std::vector<IDeckLink*> raw_devices;
IDeckLink* selected_device = nullptr;
IDeckLinkInput* decklink_input = nullptr;
InputCallback* input_callback = nullptr;
// Synchronisation — one mutex guards all shared state below
std::mutex mutex;
std::condition_variable format_cv; // signalled when format is detected
std::condition_variable frame_cv; // signalled when a frame arrives
bool format_detected = false;
// Frame state — written by InputCallback, read by wait_for_frame (both under mutex)
std::vector<uint8_t> frame_buffer;
uint32_t frame_row_bytes = 0;
int frame_width = 0;
int frame_height = 0;
bool frame_ready = false;
// Audio state — planar float32: channel c at audio_buffer[c * audio_samples + s]
std::vector<float> audio_buffer;
int audio_samples = 0;
void enumerate_devices() {
IDeckLinkIterator* it = CreateDeckLinkIteratorInstance();
if (!it) throw std::runtime_error("DeckLink drivers not installed");
IDeckLink* device = nullptr;
uint32_t index = 0;
while (it->Next(&device) == S_OK) {
IDeckLinkInput* inp = nullptr;
if (device->QueryInterface(IID_IDeckLinkInput, (void**)&inp) == S_OK) {
inp->Release();
std::string name = "?";
#ifdef __APPLE__
CFStringRef cfName = nullptr;
if (device->GetDisplayName(&cfName) == S_OK && cfName) {
char buf[256] = {};
CFStringGetCString(cfName, buf, sizeof(buf), kCFStringEncodingUTF8);
name = buf;
CFRelease(cfName);
}
#else
const char* cname = nullptr;
if (device->GetDisplayName(&cname) == S_OK && cname) name = cname;
#endif
devices.push_back({index, name});
raw_devices.push_back(device);
index++;
} else {
device->Release();
}
}
it->Release();
if (raw_devices.empty())
throw std::runtime_error("No DeckLink input devices found");
}
};
} // namespace dmf
+295
View File
@@ -0,0 +1,295 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <cmath>
#include <condition_variable>
#include <cstring>
#include <mutex>
#include <stdexcept>
#include <string>
#include <vector>
#include <DeckLinkAPI.h>
#ifdef __APPLE__
#include <CoreFoundation/CoreFoundation.h>
#endif
#include "Signal.hpp"
#include "V210.hpp"
namespace dmf {
class DeckLinkSender {
public:
struct VideoInfo {
int width = 0;
int height = 0;
int fps_num = 0;
int fps_den = 0;
};
struct AudioInfo {
int sample_rate = 48000;
int channels = 0;
};
struct DeviceInfo {
uint32_t index;
std::string name;
};
std::vector<DeviceInfo> devices;
VideoInfo video_info{};
AudioInfo audio_info{};
bool has_audio = false;
DeckLinkSender() { enumerate_devices(); }
~DeckLinkSender() {
if (decklink_output) {
if (playback_started_) decklink_output->StopScheduledPlayback(0, nullptr, 1);
decklink_output->DisableVideoOutput();
if (has_audio) decklink_output->DisableAudioOutput();
decklink_output->SetScheduledFrameCompletionCallback(nullptr);
decklink_output->Release();
}
delete output_callback;
for (auto* vf : all_frames) vf->Release();
for (auto* d : raw_devices) d->Release();
if (selected_device) selected_device->Release();
}
// channels > 0 enables audio output at 48 kHz / 32-bit int.
void start_output(uint32_t device_index, int width, int height,
int fps_num, int fps_den, int channels = 0) {
if (device_index >= raw_devices.size())
throw std::runtime_error("Device index out of range");
video_info = {width, height, fps_num, fps_den};
selected_device = raw_devices[device_index];
selected_device->AddRef();
HRESULT r = selected_device->QueryInterface(IID_IDeckLinkOutput, (void**)&decklink_output);
if (r != S_OK) throw std::runtime_error("Could not obtain IDeckLinkOutput");
output_callback = new OutputCallback(*this);
r = decklink_output->SetScheduledFrameCompletionCallback(output_callback);
if (r != S_OK) throw std::runtime_error("Could not set output callback");
const BMDDisplayMode mode = pick_display_mode(width, height, fps_num, fps_den);
// Verify the card supports this mode in 10-bit YUV before enabling
bool is_supported = false;
BMDDisplayMode actual_mode = mode;
r = decklink_output->DoesSupportVideoMode(
bmdVideoConnectionUnspecified,
mode,
bmdFormat10BitYUV,
bmdNoVideoOutputConversion,
bmdSupportedVideoModeDefault,
&actual_mode,
&is_supported);
if (r != S_OK || !is_supported)
throw std::runtime_error("Display mode not supported in 10-bit YUV");
r = decklink_output->EnableVideoOutput(actual_mode, bmdVideoOutputFlagDefault);
if (r != S_OK) throw std::runtime_error("Could not enable video output");
if (channels > 0) {
r = decklink_output->EnableAudioOutput(bmdAudioSampleRate48kHz,
bmdAudioSampleType32bitInteger,
static_cast<uint32_t>(channels),
bmdAudioOutputStreamTimestamped);
if (r != S_OK) throw std::runtime_error("Could not enable audio output");
audio_info.channels = channels;
has_audio = true;
}
int32_t row_bytes = 0;
r = decklink_output->RowBytesForPixelFormat(bmdFormat10BitYUV, width, &row_bytes);
if (r != S_OK) throw std::runtime_error("Could not get row bytes for pixel format");
// Create frame pool. Frames start in free_frames; submit_frame() schedules
// them as live content arrives. StartScheduledPlayback is deferred until
// kPreroll frames have been scheduled so the card has a buffer to draw from.
for (size_t i = 0; i < kPreroll; ++i) {
IDeckLinkMutableVideoFrame* vf = nullptr;
r = decklink_output->CreateVideoFrame(
width, height, row_bytes, bmdFormat10BitYUV, bmdFrameFlagDefault, &vf);
if (r != S_OK) throw std::runtime_error("Could not create video frame");
all_frames.push_back(vf);
free_frames.push_back(vf);
}
}
// Blocks until a frame slot is free (returned by the card via callback),
// then copies src into it and schedules it for display.
void submit_frame(const uint8_t* src, uint32_t stride) {
IDeckLinkMutableVideoFrame* vf = nullptr;
{
std::unique_lock<std::mutex> lk(pool_mutex);
pool_cv.wait(lk, [this] {
return !free_frames.empty() ||
!dmf::g_running.load(std::memory_order_relaxed);
});
if (free_frames.empty()) return;
vf = free_frames.back();
free_frames.pop_back();
}
IDeckLinkVideoBuffer* buf = nullptr;
if (vf->QueryInterface(IID_IDeckLinkVideoBuffer, (void**)&buf) != S_OK) return;
buf->StartAccess(bmdBufferAccessWrite);
void* ptr = nullptr;
buf->GetBytes(&ptr);
if (ptr) {
const uint32_t dst_stride = static_cast<uint32_t>(vf->GetRowBytes());
const uint32_t copy_stride = std::min(stride, dst_stride);
for (int y = 0; y < video_info.height; ++y)
std::memcpy(static_cast<uint8_t*>(ptr) + y * dst_stride,
src + y * stride, copy_stride);
}
buf->EndAccess(bmdBufferAccessWrite);
buf->Release();
const int64_t t = scheduled_time;
scheduled_time += video_info.fps_den;
decklink_output->ScheduleVideoFrame(vf, t, video_info.fps_den, video_info.fps_num);
if (!playback_started_ && ++frames_scheduled_ >= kPreroll) {
playback_started_ = true;
decklink_output->StartScheduledPlayback(0, video_info.fps_num, 1.0);
}
}
// Converts float32 planar → interleaved int32 and pushes to DeckLink audio buffer.
void submit_audio(const float* planar, int samples) {
if (!has_audio || samples <= 0) return;
const int ch = audio_info.channels;
const size_t total = static_cast<size_t>(ch) * static_cast<size_t>(samples);
if (audio_convert_buf.size() < total) audio_convert_buf.resize(total);
auto& interleaved = audio_convert_buf;
for (int s = 0; s < samples; ++s)
for (int c = 0; c < ch; ++c) {
float v = std::max(-1.0f, std::min(1.0f, planar[c * samples + s]));
interleaved[static_cast<size_t>(s) * ch + c] =
static_cast<int32_t>(v * 2147483647.0f);
}
const int64_t t = audio_stream_time;
audio_stream_time += samples;
uint32_t written = 0;
decklink_output->ScheduleAudioSamples(
interleaved.data(), static_cast<uint32_t>(samples),
t, audio_info.sample_rate, &written);
}
private:
class OutputCallback : public IDeckLinkVideoOutputCallback {
public:
explicit OutputCallback(DeckLinkSender& owner) : owner(owner) {}
HRESULT ScheduledFrameCompleted(IDeckLinkVideoFrame* completed,
BMDOutputFrameCompletionResult) override {
{
std::lock_guard<std::mutex> lk(owner.pool_mutex);
owner.free_frames.push_back(
static_cast<IDeckLinkMutableVideoFrame*>(completed));
}
owner.pool_cv.notify_one();
return S_OK;
}
HRESULT ScheduledPlaybackHasStopped() override { return S_OK; }
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, LPVOID*) override { return E_NOINTERFACE; }
ULONG STDMETHODCALLTYPE AddRef() override { return ++ref_count; }
ULONG STDMETHODCALLTYPE Release() override { return --ref_count; }
private:
DeckLinkSender& owner;
std::atomic<int32_t> ref_count{1};
};
// DeckLink SDK objects
std::vector<IDeckLink*> raw_devices;
IDeckLink* selected_device = nullptr;
IDeckLinkOutput* decklink_output = nullptr;
OutputCallback* output_callback = nullptr;
std::vector<IDeckLinkMutableVideoFrame*> all_frames; // for destructor cleanup
// Frame pool — frames returned by ScheduledFrameCompleted land here
std::mutex pool_mutex;
std::condition_variable pool_cv;
std::vector<IDeckLinkMutableVideoFrame*> free_frames;
static constexpr size_t kPreroll = 3;
// Scheduling state — only written from submit_frame/submit_audio (single-threaded caller)
bool playback_started_ = false;
size_t frames_scheduled_ = 0;
int64_t scheduled_time = 0; // next video frame position (fps_num units)
int64_t audio_stream_time = 0; // next audio batch position (sample units)
std::vector<int32_t> audio_convert_buf; // reused across submit_audio calls
// Maps width/height/fps to a BMDDisplayMode. Uses float comparison to handle
// any representation of drop-frame rates (e.g. 30000/1001 or 2997/100).
static BMDDisplayMode pick_display_mode(int width, int height, int fps_num, int fps_den) {
const double fps = static_cast<double>(fps_num) / static_cast<double>(fps_den);
if (width == 1920 && height == 1080) {
if (std::abs(fps - 23.976) < 0.01) return bmdModeHD1080p2398;
else if (std::abs(fps - 24.0) < 0.01) return bmdModeHD1080p24;
else if (std::abs(fps - 25.0) < 0.01) return bmdModeHD1080p25;
else if (std::abs(fps - 29.97) < 0.01) return bmdModeHD1080p2997;
else if (std::abs(fps - 30.0) < 0.01) return bmdModeHD1080p30;
else if (std::abs(fps - 50.0) < 0.01) return bmdModeHD1080p50;
else if (std::abs(fps - 59.94) < 0.01) return bmdModeHD1080p5994;
else if (std::abs(fps - 60.0) < 0.01) return bmdModeHD1080p6000;
} else if (width == 1280 && height == 720) {
if (std::abs(fps - 50.0) < 0.01) return bmdModeHD720p50;
else if (std::abs(fps - 59.94) < 0.01) return bmdModeHD720p5994;
else if (std::abs(fps - 60.0) < 0.01) return bmdModeHD720p60;
}
throw std::runtime_error(
"No DeckLink display mode for " + std::to_string(width) + "x" +
std::to_string(height) + " @ " + std::to_string(fps_num) +
"/" + std::to_string(fps_den) + " fps");
}
void enumerate_devices() {
IDeckLinkIterator* it = CreateDeckLinkIteratorInstance();
if (!it) throw std::runtime_error("DeckLink drivers not installed");
IDeckLink* device = nullptr;
uint32_t index = 0;
while (it->Next(&device) == S_OK) {
IDeckLinkOutput* out = nullptr;
if (device->QueryInterface(IID_IDeckLinkOutput, (void**)&out) == S_OK) {
out->Release();
std::string name = "?";
#ifdef __APPLE__
CFStringRef cfName = nullptr;
if (device->GetDisplayName(&cfName) == S_OK && cfName) {
char buf[256] = {};
CFStringGetCString(cfName, buf, sizeof(buf), kCFStringEncodingUTF8);
name = buf;
CFRelease(cfName);
}
#else
const char* cname = nullptr;
if (device->GetDisplayName(&cname) == S_OK && cname) name = cname;
#endif
devices.push_back({index, name});
raw_devices.push_back(device);
index++;
} else {
device->Release();
}
}
it->Release();
if (raw_devices.empty())
throw std::runtime_error("No DeckLink output devices found");
}
};
} // namespace dmf
+52
View File
@@ -1,4 +1,5 @@
#pragma once
#include <fstream>
#include <string>
#include <nlohmann/json.hpp>
@@ -72,4 +73,55 @@ inline std::string make_audio_flow_def(
}.dump();
}
// Actual video format as written into the MXL flow_def.json by a source node.
// Sink nodes use this to read width/height/fps from MXL instead of trusting config values.
struct VideoFlowInfo {
int width = 1920;
int height = 1080;
int fps_num = 25;
int fps_den = 1;
};
inline VideoFlowInfo read_video_flow_info(const std::string& domain, const std::string& flow_id) {
VideoFlowInfo info;
const std::string path = domain + "/" + flow_id + ".mxl-flow/flow_def.json";
std::ifstream f(path);
if (!f.is_open()) return info;
const auto j = nlohmann::json::parse(f, nullptr, /*allow_exceptions=*/false);
if (j.is_discarded()) return info;
info.width = j.value("frame_width", 1920);
info.height = j.value("frame_height", 1080);
if (j.contains("grain_rate")) {
info.fps_num = j["grain_rate"].value("numerator", 25);
info.fps_den = j["grain_rate"].value("denominator", 1);
}
return info;
}
// Actual audio format as written into the MXL flow_def.json by a source node.
struct AudioFlowInfo {
int sample_rate = 48000;
int channels = 2;
int samples_per_grain = 1920; // sample_rate * grain_rate_den / grain_rate_num
};
inline AudioFlowInfo read_audio_flow_info(const std::string& domain, const std::string& flow_id) {
AudioFlowInfo info;
const std::string path = domain + "/" + flow_id + ".mxl-flow/flow_def.json";
std::ifstream f(path);
if (!f.is_open()) return info;
const auto j = nlohmann::json::parse(f, nullptr, /*allow_exceptions=*/false);
if (j.is_discarded()) return info;
if (j.contains("sample_rate"))
info.sample_rate = j["sample_rate"].value("numerator", 48000);
info.channels = j.value("channel_count", 2);
if (j.contains("grain_rate") && info.sample_rate > 0) {
const int gr_num = j["grain_rate"].value("numerator", 25);
const int gr_den = j["grain_rate"].value("denominator", 1);
if (gr_num > 0)
info.samples_per_grain = info.sample_rate * gr_den / gr_num;
}
return info;
}
} // namespace dmf
+50 -18
View File
@@ -121,40 +121,72 @@ public:
int channel_stride = 0; // floats between channel planes (NDI planar layout)
};
// Waits for the first audio packet and returns its format.
// Call after probe(). Audio packets are typically already queued at that point.
// Discards any video frames encountered while searching. Respects g_running.
AudioInfo probe_audio(uint32_t timeout_ms = 5000) {
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(timeout_ms);
while (dmf::g_running.load(std::memory_order_relaxed) &&
std::chrono::steady_clock::now() < deadline) {
NDIlib_video_frame_v2_t video_frame{};
NDIlib_audio_frame_v3_t audio_frame{};
auto type = NDIlib_recv_capture_v3(recv_, &video_frame, &audio_frame, nullptr, 100);
if (type == NDIlib_frame_type_error)
throw std::runtime_error("NDI source lost during audio probe");
if (type == NDIlib_frame_type_video) {
NDIlib_recv_free_video_v2(recv_, &video_frame);
continue;
}
if (type == NDIlib_frame_type_audio) {
const bool is_fltp = (audio_frame.FourCC == NDIlib_FourCC_audio_type_FLTP);
AudioInfo info;
if (is_fltp) {
info.sample_rate = audio_frame.sample_rate;
info.channels = audio_frame.no_channels;
info.samples = audio_frame.no_samples;
info.channel_stride = audio_frame.channel_stride_in_bytes / sizeof(float);
}
NDIlib_recv_free_audio_v3(recv_, &audio_frame);
if (is_fltp) return info;
}
}
throw std::runtime_error("Timeout waiting for first audio packet");
}
// Receives one NDI frame. On video: converts to V210 in frame_buffer/frame_stride.
// On audio: copies float32 planar samples into audio_out and fills audio_info.
// Returns FrameKind::None on timeout or non-A/V frames.
// Throws on source lost or video format change.
// Throws on source lost or unsupported video format.
// timeout_ms=0 is non-blocking (useful for draining queued frames after an initial wait).
FrameKind capture(uint8_t* frame_buffer, uint32_t frame_stride,
std::vector<float>& audio_out, AudioInfo& audio_info) {
std::vector<float>& audio_out, AudioInfo& audio_info,
bool want_audio = false, int timeout_ms = 5) {
NDIlib_video_frame_v2_t video_frame{};
NDIlib_audio_frame_v3_t audio_frame{};
auto type = NDIlib_recv_capture_v3(recv_, &video_frame, &audio_frame, nullptr, 5);
auto type = NDIlib_recv_capture_v3(recv_, &video_frame,
want_audio ? &audio_frame : nullptr,
nullptr, timeout_ms);
if (type == NDIlib_frame_type_error)
throw std::runtime_error("NDI source lost");
if (type == NDIlib_frame_type_status_change) {
const SourceInfo old = info_;
info_ = probe();
if (info_.width != old.width || info_.height != old.height ||
info_.fps_num != old.fps_num || info_.fps_den != old.fps_den) {
throw std::runtime_error(
"source format changed: " +
std::to_string(old.width) + "x" + std::to_string(old.height) +
" @" + std::to_string(old.fps_num) + "/" + std::to_string(old.fps_den) +
" -> " +
std::to_string(info_.width) + "x" + std::to_string(info_.height) +
" @" + std::to_string(info_.fps_num) + "/" + std::to_string(info_.fps_den));
}
// Don't call probe() here — it would consume a video frame from the queue.
// Format changes are detected on the next actual video frame below.
return FrameKind::None;
}
if (type == NDIlib_frame_type_video) {
switch (info_.fourcc) {
// Use the actual frame's FourCC and stride, not the stale values from probe().
const auto fourcc = static_cast<NDIlib_FourCC_type_e>(video_frame.FourCC);
const int stride = video_frame.line_stride_in_bytes > 0
? video_frame.line_stride_in_bytes
: video_frame.xres * bytes_per_pixel(fourcc);
switch (fourcc) {
case NDIlib_FourCC_type_UYVY:
v210::UYVYtoV210(video_frame.p_data, frame_buffer,
info_.width, info_.height, info_.stride, frame_stride);
video_frame.xres, video_frame.yres, stride, frame_stride);
break;
case NDIlib_FourCC_type_P216: {
NDIlib_video_frame_v2_t dst{};
@@ -165,7 +197,7 @@ public:
}
default:
NDIlib_recv_free_video_v2(recv_, &video_frame);
throw std::runtime_error("Unsupported NDI color format: " + fourcc_str(info_.fourcc));
throw std::runtime_error("Unsupported NDI color format: " + fourcc_str(fourcc));
}
NDIlib_recv_free_video_v2(recv_, &video_frame);
return FrameKind::Video;
-2
View File
@@ -37,8 +37,6 @@ inline const char* mxl_status_str(mxlStatus s) noexcept {
}
}
// Base class for all DMF node binaries.
//
// Handles the boilerplate every node needs:
+158 -33
View File
@@ -1,8 +1,10 @@
#pragma once
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
namespace dmf::v210 {
@@ -60,29 +62,6 @@ inline void pack_block(
w[3] = (y4 & 0x3FFu) | ((p45.cr & 0x3FFu) << 10) | ((y5 & 0x3FFu) << 20);
}
// Write one horizontal line of 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);
}
}
// Write one horizontal line of an arbitrary bar palette.
template<std::size_t N>
inline void write_palette_line(uint8_t* line, int width, const std::array<Color, N>& palette)
@@ -140,12 +119,6 @@ inline void fill_white(uint8_t* buf, int width, int height, uint32_t stride)
fill_solid(buf, width, height, stride, {940, 512, 512});
}
// Kept for backward compatibility.
inline void fill_frame(uint8_t* buf, int width, int height, uint32_t stride)
{
fill_colorbars(buf, width, height, stride);
}
inline void UYVYtoV210(uint8_t* src_buf, uint8_t* dst_buf, int width, int height, uint32_t src_stride, uint32_t dst_stride)
{
const uint8_t* src = src_buf;
@@ -160,14 +133,166 @@ inline void UYVYtoV210(uint8_t* src_buf, uint8_t* dst_buf, int width, int height
// mp[8]=U2, mp[9]=Y4, mp[10]=V2, mp[11]=Y5
dmf::v210::pack_block(dst + b * 16,
{0, (uint16_t)(mp[0]<<2), (uint16_t)(mp[2]<<2)}, (uint16_t)(mp[1]<<2), (uint16_t)(mp[3]<<2),
{0, (uint16_t)(mp[4]<<2), (uint16_t)(mp[6]<<2)}, (uint16_t)(mp[5]<<2), (uint16_t)(mp[7]<<2),
{0, (uint16_t)(mp[8]<<2), (uint16_t)(mp[10]<<2)}, (uint16_t)(mp[9]<<2), (uint16_t)(mp[11]<<2)
);
{0, static_cast<uint16_t>(mp[0]<<2), static_cast<uint16_t>(mp[2]<<2)},
static_cast<uint16_t>(mp[1]<<2), static_cast<uint16_t>(mp[3]<<2),
{0, static_cast<uint16_t>(mp[4]<<2), static_cast<uint16_t>(mp[6]<<2)},
static_cast<uint16_t>(mp[5]<<2), static_cast<uint16_t>(mp[7]<<2),
{0, static_cast<uint16_t>(mp[8]<<2), static_cast<uint16_t>(mp[10]<<2)},
static_cast<uint16_t>(mp[9]<<2), static_cast<uint16_t>(mp[11]<<2));
}
src += src_stride;
dst += dst_stride;
}
}
inline void YUV422P10toV210(
const uint16_t* y, const uint16_t* u, const uint16_t* v,
uint8_t* dst, int width, int height,
int y_stride, int u_stride, int v_stride,
uint32_t dst_stride)
{
for (int row = 0; row < height; row++) {
const uint16_t* y_row = reinterpret_cast<const uint16_t*>(
reinterpret_cast<const uint8_t*>(y) + row * y_stride);
const uint16_t* u_row = reinterpret_cast<const uint16_t*>(
reinterpret_cast<const uint8_t*>(u) + row * u_stride);
const uint16_t* v_row = reinterpret_cast<const uint16_t*>(
reinterpret_cast<const uint8_t*>(v) + row * v_stride);
uint8_t* dst_row = dst + static_cast<ptrdiff_t>(row) * dst_stride;
const int blocks = width / 6;
for (int b = 0; b < blocks; b++) {
const int x = b * 6;
const uint16_t cb0 = u_row[x/2], cb1 = u_row[x/2+1], cb2 = u_row[x/2+2];
const uint16_t cr0 = v_row[x/2], cr1 = v_row[x/2+1], cr2 = v_row[x/2+2];
const uint16_t y0 = y_row[x], y1 = y_row[x+1], y2 = y_row[x+2];
const uint16_t y3 = y_row[x+3], y4 = y_row[x+4], y5 = y_row[x+5];
auto* w = reinterpret_cast<uint32_t*>(dst_row + b * 16);
w[0] = (cb0 & 0x3FFu) | ((y0 & 0x3FFu) << 10) | ((cr0 & 0x3FFu) << 20);
w[1] = (y1 & 0x3FFu) | ((cb1 & 0x3FFu) << 10) | ((y2 & 0x3FFu) << 20);
w[2] = (cr1 & 0x3FFu) | ((y3 & 0x3FFu) << 10) | ((cb2 & 0x3FFu) << 20);
w[3] = (y4 & 0x3FFu) | ((cr2 & 0x3FFu) << 10) | ((y5 & 0x3FFu) << 20);
}
}
}
// Unpack one V210 row into planar uint16_t Y (width values),
// Cb and Cr (width/2 values each). Width must be a multiple of 6.
inline void unpack_row(const uint8_t* src, int width,
uint16_t* Y, uint16_t* Cb, uint16_t* Cr)
{
const auto* w = reinterpret_cast<const uint32_t*>(src);
const int blocks = width / 6;
for (int b = 0; b < blocks; ++b, w += 4) {
const int x = b * 6;
Cb[x/2] = (w[0] >> 0) & 0x3FF;
Y[x] = (w[0] >> 10) & 0x3FF;
Cr[x/2] = (w[0] >> 20) & 0x3FF;
Y[x+1] = (w[1] >> 0) & 0x3FF;
Cb[x/2+1] = (w[1] >> 10) & 0x3FF;
Y[x+2] = (w[1] >> 20) & 0x3FF;
Cr[x/2+1] = (w[2] >> 0) & 0x3FF;
Y[x+3] = (w[2] >> 10) & 0x3FF;
Cb[x/2+2] = (w[2] >> 20) & 0x3FF;
Y[x+4] = (w[3] >> 0) & 0x3FF;
Cr[x/2+2] = (w[3] >> 10) & 0x3FF;
Y[x+5] = (w[3] >> 20) & 0x3FF;
}
}
// Scale the inset V210 frame into a rectangular region of dst using bilinear
// interpolation. pip_x and pip_w must be multiples of 6 (V210 alignment).
// Workspace vectors are passed in to avoid per-call heap allocation.
inline void scale_and_overlay(
const uint8_t* inset, uint32_t inset_stride, int inset_w, int inset_h,
uint8_t* dst, uint32_t dst_stride,
int pip_x, int pip_y, int pip_w, int pip_h,
std::vector<uint16_t>& Y0_buf, std::vector<uint16_t>& Y1_buf,
std::vector<uint16_t>& Cb0_buf, std::vector<uint16_t>& Cb1_buf,
std::vector<uint16_t>& Cr0_buf, std::vector<uint16_t>& Cr1_buf)
{
Y0_buf.resize(inset_w); Y1_buf.resize(inset_w);
Cb0_buf.resize(inset_w / 2); Cb1_buf.resize(inset_w / 2);
Cr0_buf.resize(inset_w / 2); Cr1_buf.resize(inset_w / 2);
const int out_blocks = pip_w / 6;
const int dst_x_bytes = (pip_x / 6) * 16;
const float inv_pip_h = static_cast<float>(inset_h) / pip_h;
const float inv_pip_w = static_cast<float>(inset_w) / pip_w;
const float inv_pip_cw = static_cast<float>(inset_w / 2) / (pip_w / 2);
// Precompute horizontal source positions once — they are the same for every row.
// thread_local avoids heap allocation on repeated calls with the same dimensions.
struct XS { int x0, x1; float fx, ifx; };
static thread_local std::vector<XS> y_xs, c_xs;
static thread_local int cached_pip_w = 0, cached_inset_w = 0;
if (pip_w != cached_pip_w || inset_w != cached_inset_w) {
y_xs.resize(pip_w);
for (int dx = 0; dx < pip_w; ++dx) {
const float sx = (dx + 0.5f) * inv_pip_w - 0.5f;
const int x0 = std::max(0, static_cast<int>(sx));
const float fx = sx - static_cast<float>(x0);
y_xs[dx] = { x0, std::min(inset_w - 1, x0 + 1), fx, 1.0f - fx };
}
c_xs.resize(pip_w / 2);
for (int cx = 0; cx < pip_w / 2; ++cx) {
const float sx = (cx + 0.5f) * inv_pip_cw - 0.5f;
const int x0 = std::max(0, static_cast<int>(sx));
const float fx = sx - static_cast<float>(x0);
c_xs[cx] = { x0, std::min(inset_w / 2 - 1, x0 + 1), fx, 1.0f - fx };
}
cached_pip_w = pip_w;
cached_inset_w = inset_w;
}
int cur_row0 = -1, cur_row1 = -1;
for (int dy = 0; dy < pip_h; ++dy) {
const float sy = (dy + 0.5f) * inv_pip_h - 0.5f;
const int sy0 = std::max(0, static_cast<int>(sy));
const int sy1 = std::min(inset_h - 1, sy0 + 1);
const float fy = sy - static_cast<float>(sy0);
const float w0 = 1.0f - fy;
const float w1 = fy;
if (sy0 != cur_row0) {
unpack_row(inset + static_cast<size_t>(sy0) * inset_stride, inset_w,
Y0_buf.data(), Cb0_buf.data(), Cr0_buf.data());
cur_row0 = sy0;
}
if (sy1 != cur_row1) {
unpack_row(inset + static_cast<size_t>(sy1) * inset_stride, inset_w,
Y1_buf.data(), Cb1_buf.data(), Cr1_buf.data());
cur_row1 = sy1;
}
uint8_t* dst_row = dst + static_cast<size_t>(pip_y + dy) * dst_stride + dst_x_bytes;
for (int b = 0; b < out_blocks; ++b) {
const int bx = b * 6;
uint16_t Y[6], Cb[3], Cr[3];
for (int i = 0; i < 6; ++i) {
const XS& xs = y_xs[bx + i];
Y[i] = static_cast<uint16_t>(
(Y0_buf[xs.x0] * xs.ifx + Y0_buf[xs.x1] * xs.fx) * w0 +
(Y1_buf[xs.x0] * xs.ifx + Y1_buf[xs.x1] * xs.fx) * w1 + 0.5f);
}
for (int i = 0; i < 3; ++i) {
const XS& cs = c_xs[b * 3 + i];
Cb[i] = static_cast<uint16_t>(
(Cb0_buf[cs.x0] * cs.ifx + Cb0_buf[cs.x1] * cs.fx) * w0 +
(Cb1_buf[cs.x0] * cs.ifx + Cb1_buf[cs.x1] * cs.fx) * w1 + 0.5f);
Cr[i] = static_cast<uint16_t>(
(Cr0_buf[cs.x0] * cs.ifx + Cr0_buf[cs.x1] * cs.fx) * w0 +
(Cr1_buf[cs.x0] * cs.ifx + Cr1_buf[cs.x1] * cs.fx) * w1 + 0.5f);
}
pack_block(dst_row + b * 16,
{0, Cb[0], Cr[0]}, Y[0], Y[1],
{0, Cb[1], Cr[1]}, Y[2], Y[3],
{0, Cb[2], Cr[2]}, Y[4], Y[5]);
}
}
}
} // namespace dmf::v210
+278
View File
@@ -0,0 +1,278 @@
#pragma once
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libavutil/imgutils.h>
#include <libswscale/swscale.h>
#include <libswresample/swresample.h>
#include <libavutil/opt.h>
}
#include <stdexcept>
#include <string>
#include <vector>
#include "Signal.hpp"
#include "V210.hpp"
namespace dmf {
class VideoReader {
public:
struct VideoInfo {
int width = 0;
int height = 0;
int fps_num = 0;
int fps_den = 0;
AVPixelFormat pix_fmt{};
};
struct AudioInfo {
int sample_rate = 0;
int channels = 0;
};
enum class FrameKind { None, Video, Audio };
VideoInfo video_info{};
AudioInfo audio_info{};
bool has_audio = false;
bool has_video = false;
explicit VideoReader(const std::string& filename) {
open_file(filename);
get_source_info();
if (has_video) allocate_video_conversion_buffers();
if (has_audio) allocate_audio_conversion_buffers();
}
~VideoReader() {
avcodec_free_context(&video_codec_context);
avcodec_free_context(&audio_codec_context);
avformat_close_input(&format_context);
sws_freeContext(sws_video_ctx);
swr_free(&swr_audio_ctx);
av_freep(&p10_data[0]);
av_frame_free(&video_frame);
av_frame_free(&audio_frame);
av_packet_free(&packet);
}
// Returns Video or Audio when a frame/packet was decoded, None when g_running goes false.
FrameKind read_next(uint8_t* video_buf, uint32_t mxl_stride, uint8_t* audio_buf, int max_audio_samples, int& out_samples_written) {
while (dmf::g_running.load(std::memory_order_relaxed)) {
// Drain any frames buffered in the decoder first
if (has_video && avcodec_receive_frame(video_codec_context, video_frame) == 0) {
if (!video_buf) {
av_frame_unref(video_frame);
continue; // nowhere to write — discard frame
}
sws_scale(
sws_video_ctx,
video_frame->data,
video_frame->linesize,
0,
video_info.height,
p10_data,
p10_linesizes
);
dmf::v210::YUV422P10toV210(
reinterpret_cast<uint16_t*>(p10_data[0]),
reinterpret_cast<uint16_t*>(p10_data[1]),
reinterpret_cast<uint16_t*>(p10_data[2]),
video_buf,
video_info.width,
video_info.height,
p10_linesizes[0],
p10_linesizes[1],
p10_linesizes[2],
mxl_stride
);
av_frame_unref(video_frame);
return FrameKind::Video;
}
if (has_audio && avcodec_receive_frame(audio_codec_context, audio_frame) == 0) {
if (!audio_buf) {
av_frame_unref(audio_frame);
continue; // nowhere to write — discard frame
}
int dst_nb_samples = av_rescale_rnd(
swr_get_delay(swr_audio_ctx, audio_codec_context->sample_rate) + audio_frame->nb_samples,
audio_codec_context->sample_rate, audio_codec_context->sample_rate, AV_ROUND_UP
);
// Guard against buffer overflows
if (dst_nb_samples > max_audio_samples) {
dst_nb_samples = max_audio_samples;
}
std::vector<uint8_t*> dst(audio_info.channels);
for (int ch = 0; ch < audio_info.channels; ch++) {
dst[ch] = audio_buf + ch * max_audio_samples * sizeof(float);
}
// Convert/Resample the audio layout and sample format
int converted_samples = swr_convert(
swr_audio_ctx,
dst.data(), dst_nb_samples,
(const uint8_t**)audio_frame->data, audio_frame->nb_samples
);
out_samples_written = converted_samples;
av_frame_unref(audio_frame);
return FrameKind::Audio;
}
// No buffered frame — read next packet
av_packet_unref(packet);
if (av_read_frame(format_context, packet) < 0) {
// EOF — loop back to start
avformat_seek_file(format_context, -1, 0, 0, 0, AVSEEK_FLAG_BACKWARD);
if (has_video) avcodec_flush_buffers(video_codec_context);
if (has_audio) {
avcodec_flush_buffers(audio_codec_context);
swr_close(swr_audio_ctx);
swr_init(swr_audio_ctx);
}
continue;
}
if (packet->stream_index == video_stream_index) {
avcodec_send_packet(video_codec_context, packet);
} else if (packet->stream_index == audio_stream_index) {
avcodec_send_packet(audio_codec_context, packet);
}
}
return FrameKind::None;
}
private:
int video_stream_index = -1;
int audio_stream_index = -1;
AVFormatContext* format_context = nullptr;
AVPacket* packet = av_packet_alloc();
AVCodecContext* video_codec_context = nullptr;
AVFrame* video_frame = av_frame_alloc();
SwsContext* sws_video_ctx = nullptr;
int p10_linesizes[4] = {0, 0, 0, 0};
uint8_t* p10_data[4] = {nullptr, nullptr, nullptr, nullptr};
AVCodecContext* audio_codec_context = nullptr;
AVFrame* audio_frame = av_frame_alloc();
SwrContext* swr_audio_ctx = nullptr;
void open_file(const std::string& filename) {
if (avformat_open_input(&format_context, filename.c_str(), nullptr, nullptr) != 0)
throw std::runtime_error("Could not open file: " + filename);
if (avformat_find_stream_info(format_context, nullptr) < 0) {
avformat_close_input(&format_context);
throw std::runtime_error("Could not find stream info");
}
for (unsigned int i = 0; i < format_context->nb_streams; ++i) {
const AVMediaType type = format_context->streams[i]->codecpar->codec_type;
if (type == AVMEDIA_TYPE_VIDEO && video_stream_index == -1) {
video_stream_index = static_cast<int>(i);
has_video = true;
} else if (type == AVMEDIA_TYPE_AUDIO && audio_stream_index == -1) {
audio_stream_index = static_cast<int>(i);
has_audio = true;
}
}
if (!has_video && !has_audio) {
avformat_close_input(&format_context);
throw std::runtime_error("No audio/video stream found in: " + filename);
}
}
void get_source_info() {
if (video_stream_index != -1) {
AVCodecParameters* video_codec_params = format_context->streams[video_stream_index]->codecpar;
const AVCodec* video_codec = avcodec_find_decoder(video_codec_params->codec_id);
if (!video_codec)
throw std::runtime_error("Unsupported video codec");
video_codec_context = avcodec_alloc_context3(video_codec);
if (avcodec_parameters_to_context(video_codec_context, video_codec_params) < 0)
throw std::runtime_error("Could not copy video codec parameters");
if (avcodec_open2(video_codec_context, video_codec, nullptr) < 0) {
avcodec_free_context(&video_codec_context);
throw std::runtime_error("Could not open video codec");
}
AVRational fps = video_codec_context->framerate;
if (fps.num == 0 || fps.den == 0)
fps = format_context->streams[video_stream_index]->avg_frame_rate;
video_info.width = video_codec_context->width;
video_info.height = video_codec_context->height;
video_info.fps_num = fps.num;
video_info.fps_den = fps.den;
video_info.pix_fmt = video_codec_context->pix_fmt;
} else {
has_video = false;
}
// audio part
if (audio_stream_index == -1) return;
AVCodecParameters* audio_codec_params = format_context->streams[audio_stream_index]->codecpar;
const AVCodec* audio_codec = avcodec_find_decoder(audio_codec_params->codec_id);
if (!audio_codec)
throw std::runtime_error("Unsupported audio codec");
audio_codec_context = avcodec_alloc_context3(audio_codec);
if (avcodec_parameters_to_context(audio_codec_context, audio_codec_params) < 0)
throw std::runtime_error("Could not copy audio codec parameters");
if (avcodec_open2(audio_codec_context, audio_codec, nullptr) < 0) {
avcodec_free_context(&audio_codec_context);
throw std::runtime_error("Could not open audio codec");
}
audio_info.sample_rate = audio_codec_context->sample_rate;
audio_info.channels = audio_codec_context->ch_layout.nb_channels;
}
void allocate_video_conversion_buffers() {
sws_video_ctx = sws_getContext(
video_info.width, video_info.height, video_info.pix_fmt,
video_info.width, video_info.height, AV_PIX_FMT_YUV422P10LE,
SWS_BILINEAR, nullptr, nullptr, nullptr
);
if (!sws_video_ctx)
throw std::runtime_error("Failed to create SwsContext");
if (av_image_alloc(p10_data, p10_linesizes,
video_info.width, video_info.height,
AV_PIX_FMT_YUV422P10LE, 64) < 0)
throw std::runtime_error("Failed to allocate YUV422P10 buffer");
}
void allocate_audio_conversion_buffers() {
swr_audio_ctx = swr_alloc();
// Set input options
av_opt_set_chlayout(swr_audio_ctx, "in_chlayout", &audio_codec_context->ch_layout, 0);
av_opt_set_int(swr_audio_ctx, "in_sample_rate", audio_info.sample_rate, 0);
av_opt_set_sample_fmt(swr_audio_ctx, "in_sample_fmt", audio_codec_context->sample_fmt, 0);
// Set output options
av_opt_set_chlayout(swr_audio_ctx, "out_chlayout", &audio_codec_context->ch_layout, 0);
av_opt_set_int(swr_audio_ctx, "out_sample_rate", audio_info.sample_rate, 0);
av_opt_set_sample_fmt(swr_audio_ctx, "out_sample_fmt", AV_SAMPLE_FMT_FLTP, 0);
// Initialize the context
if (swr_init(swr_audio_ctx) < 0) {
throw std::runtime_error("Failed to create SwrContext");
}
}
};
} // namespace dmf
+1 -1
View File
@@ -1,4 +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)
target_link_libraries(dmf-studio-manager PRIVATE dmf-shared Crow::Crow)
install(TARGETS dmf-studio-manager RUNTIME DESTINATION bin)
+1 -1
View File
@@ -17,7 +17,7 @@ struct NodeDef {
//
// 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.
// Nodes read the UUID as cfg["video_flow_id"]["id"] and format fields as cfg["video_flow_id"]["fps_num"] etc.
//
// format examples:
// video: { "kind":"video", "width":1920, "height":1080, "fps_num":25, "fps_den":1 }
+294
View File
@@ -0,0 +1,294 @@
#pragma once
#include <atomic>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <mutex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include <fcntl.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include <mxl/mxl.h>
#include <mxl/time.h>
#include <nlohmann/json.hpp>
#include "FlowGraph.hpp"
#include "Signal.hpp"
namespace fs = std::filesystem;
namespace dmf {
enum class NodeState { Running, Stopped, Crashed };
struct NodeProcess {
std::string id;
std::string type;
pid_t pid{-1};
NodeState state{NodeState::Stopped};
};
class StudioManager {
public:
StudioManager(fs::path bin_dir, std::string domain)
: bin_dir_(std::move(bin_dir))
, domain_(std::move(domain))
, save_path_(bin_dir_ / "last_graph.json") {}
~StudioManager() { shutdown(); }
// Replace the running pipeline with a new graph.
// Stops all current nodes, then starts the new ones.
nlohmann::json load_graph(const nlohmann::json& graph_json) {
nlohmann::json s;
std::string err;
{
std::lock_guard lk(mutex_);
stop_all_locked();
garbage_collect_locked();
try {
graph_ = parse_graph(graph_json);
save_graph_locked(graph_json);
start_all_locked();
} catch (const std::exception& e) {
err = e.what();
}
s = status_locked();
}
notify(s);
if (!err.empty())
return {{"type", "error"}, {"message", err}};
return s;
}
nlohmann::json load_graph_file(const std::string& path) {
std::ifstream f(path);
if (!f) return {{"type", "error"}, {"message", "cannot open: " + path}};
std::ostringstream ss;
ss << f.rdbuf();
auto j = nlohmann::json::parse(ss.str(), nullptr, false);
if (j.is_discarded())
return {{"type", "error"}, {"message", "invalid JSON in: " + path}};
return load_graph(j);
}
nlohmann::json stop_node(const std::string& id) {
nlohmann::json s;
{
std::lock_guard lk(mutex_);
for (auto& p : processes_) {
if (p.id == id && p.pid > 0) {
kill(p.pid, SIGTERM);
waitpid(p.pid, nullptr, 0);
p.pid = -1;
p.state = NodeState::Stopped;
fprintf(stderr, "[studio-manager] stopped node %s\n", id.c_str());
break;
}
}
s = status_locked();
}
notify(s);
return s;
}
nlohmann::json start_node(const std::string& id) {
nlohmann::json s;
{
std::lock_guard lk(mutex_);
for (auto& p : processes_) {
if (p.id == id && p.pid <= 0) {
launch_locked(p);
break;
}
}
s = status_locked();
}
notify(s);
return s;
}
nlohmann::json get_status() {
std::lock_guard lk(mutex_);
return status_locked();
}
// Register a callback invoked (without mutex held) whenever node state changes.
// Safe to call send_text() from inside the callback.
void on_status_change(std::function<void(nlohmann::json)> cb) {
std::lock_guard lk(mutex_);
status_cb_ = std::move(cb);
}
void shutdown() {
std::lock_guard lk(mutex_);
stop_all_locked();
}
// Blocks until g_running is false. Call from a dedicated thread.
void run_monitor() {
while (dmf::g_running.load(std::memory_order_relaxed)) {
mxlSleepForNs(500'000'000);
check_children();
}
}
private:
fs::path bin_dir_;
std::string domain_;
fs::path save_path_;
FlowGraph graph_;
std::vector<NodeProcess> processes_;
std::mutex mutex_;
std::function<void(nlohmann::json)> status_cb_;
// ── locked helpers — call only while holding mutex_ ──────────────────────
void stop_all_locked() {
for (auto& p : processes_)
if (p.pid > 0) kill(p.pid, SIGTERM);
for (auto& p : processes_) {
if (p.pid > 0) {
waitpid(p.pid, nullptr, 0);
fprintf(stderr, "[studio-manager] stopped node %s\n", p.id.c_str());
p.pid = -1;
p.state = NodeState::Stopped;
}
}
processes_.clear();
}
void garbage_collect_locked() {
mxlInstance gc = mxlCreateInstance(domain_.c_str(), "");
if (gc) {
mxlGarbageCollectFlows(gc);
mxlDestroyInstance(gc);
}
}
void start_all_locked() {
processes_.clear();
for (const auto& node : graph_.nodes) {
processes_.push_back({node.id, node.type, -1, NodeState::Stopped});
launch_locked(processes_.back());
}
}
void launch_locked(NodeProcess& p) {
const std::string binary = (bin_dir_ / ("dmf-node-" + p.type)).string();
const nlohmann::json cfg = graph_.node_config(p.id);
pid_t pid = fork();
if (pid < 0) { perror("fork"); return; }
if (pid == 0) {
setenv("MXL_DOMAIN", domain_.c_str(), 1);
setenv("NODE_CONFIG", cfg.dump().c_str(), 1);
execl(binary.c_str(), binary.c_str(), nullptr);
perror(("execl " + binary).c_str());
_exit(1);
}
p.pid = pid;
p.state = NodeState::Running;
fprintf(stderr, "[studio-manager] launched %s pid=%d\n", p.id.c_str(), pid);
}
nlohmann::json status_locked() const {
auto nodes = nlohmann::json::array();
for (const auto& p : processes_) {
const char* state =
p.state == NodeState::Running ? "running" :
p.state == NodeState::Crashed ? "crashed" : "stopped";
nodes.push_back({{"id", p.id}, {"type", p.type},
{"pid", p.pid}, {"state", state}});
}
return {{"type", "status"}, {"nodes", nodes}};
}
// ── unlocked helpers ─────────────────────────────────────────────────────
void notify(const nlohmann::json& s) {
// Called WITHOUT mutex_ held so the callback can safely call back into us.
if (status_cb_) status_cb_(s);
}
void check_children() {
bool changed = false;
nlohmann::json s;
{
std::lock_guard lk(mutex_);
for (auto& p : processes_) {
if (p.pid <= 0) continue;
int ws = 0;
if (waitpid(p.pid, &ws, WNOHANG) == p.pid) {
fprintf(stderr, "[studio-manager] node %s (pid=%d) exited (status=%d)\n",
p.id.c_str(), p.pid, WEXITSTATUS(ws));
p.pid = -1;
p.state = NodeState::Crashed;
changed = true;
}
}
if (changed) s = status_locked();
}
if (changed) notify(s);
}
void save_graph_locked(const nlohmann::json& j) {
std::ofstream f(save_path_);
if (f) f << j.dump(2);
else fprintf(stderr, "[studio-manager] warning: could not save graph to %s\n",
save_path_.c_str());
}
// ── static helpers ───────────────────────────────────────────────────────
static FlowGraph parse_graph(const nlohmann::json& j) {
FlowGraph g;
for (const auto& n : j.at("nodes"))
g.nodes.push_back({
n.at("id").get<std::string>(),
n.at("type").get<std::string>(),
n.value("params", nlohmann::json::object())
});
std::map<std::pair<std::string,std::string>, std::string> flow_ids;
for (const auto& e : j.at("edges")) {
auto from = e.at("from").get<std::string>();
auto port = e.at("from_port").get<std::string>();
auto key = std::make_pair(from, port);
if (!flow_ids.count(key)) flow_ids[key] = gen_uuid();
g.edges.push_back({
flow_ids.at(key),
from, port,
e.value("to", std::string{}),
e.value("to_port", std::string{}),
e.at("format")
});
}
return g;
}
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)) != (ssize_t)sizeof(b)) { perror("read"); exit(1); }
close(fd);
b[6] = (b[6] & 0x0Fu) | 0x40u;
b[8] = (b[8] & 0x3Fu) | 0x80u;
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;
}
};
} // namespace dmf
+91 -206
View File
@@ -1,190 +1,16 @@
// 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 <fstream>
#include <map>
#include <sstream>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#include <mxl/mxl.h>
#include <mxl/time.h>
#include "Signal.hpp"
#include "FlowGraph.hpp"
#include <thread>
#include <unordered_set>
#include <crow.h>
#include "StudioManager.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 load_graph(const std::string& path) {
std::ifstream f(path);
if (!f) {
fprintf(stderr, "[studio-manager] cannot open graph file: %s\n", path.c_str());
exit(1);
}
std::ostringstream ss;
ss << f.rdbuf();
auto j = nlohmann::json::parse(ss.str(), nullptr, /*allow_exceptions=*/false);
if (j.is_discarded()) {
fprintf(stderr, "[studio-manager] invalid JSON in %s\n", path.c_str());
exit(1);
}
dmf::FlowGraph g;
for (const auto& n : j.at("nodes")) {
g.nodes.push_back({
n.at("id").get<std::string>(),
n.at("type").get<std::string>(),
n.value("params", nlohmann::json::object())
});
}
// Edges sharing the same from_node+from_port reuse the same flow UUID,
// so one writer can be read by multiple consumers (MXL supports N readers per flow).
std::map<std::pair<std::string,std::string>, std::string> flow_ids;
for (const auto& e : j.at("edges")) {
auto from_node = e.at("from").get<std::string>();
auto from_port = e.at("from_port").get<std::string>();
auto key = std::make_pair(from_node, from_port);
if (!flow_ids.count(key)) flow_ids[key] = gen_uuid();
g.edges.push_back({
flow_ids.at(key),
from_node,
from_port,
e.value("to", std::string{}),
e.value("to_port", std::string{}),
e.at("format")
});
}
fprintf(stderr, "[studio-manager] loaded graph from %s (%zu nodes, %zu edges)\n",
path.c_str(), g.nodes.size(), g.edges.size());
return g;
}
static dmf::FlowGraph build_graph() {
dmf::FlowGraph g;
const std::string tp_video_flow = gen_uuid();
const std::string tp_audio_flow = gen_uuid();
const std::string ndi_video_flow = gen_uuid();
const std::string ndi_audio_flow = gen_uuid();
g.nodes = {
{ "testpattern", "testpattern", {{"pattern", "bars"}} },
// { "ndiin", "ndiin", {} },
// { "fakesink", "fakesink", {} },
{ "ndiout", "ndiout", {} },
};
const nlohmann::json video_fmt = {
{"kind","video"}, {"width",1920}, {"height",1080}, {"fps_num",25}, {"fps_den",1}
};
const nlohmann::json audio_fmt = {
{"kind","audio"}, {"sample_rate",48000}, {"channels",2}, {"bit_depth",32}
};
g.edges = {
// { tp_video_flow, "testpattern", "flow_id", "fakesink", "flow_id", video_fmt },
// { tp_audio_flow, "testpattern", "audio_flow_id", "", "", audio_fmt },
// { ndi_video_flow, "ndiin", "video_flow_id", "ndiout", "flow_id", video_fmt },
// { ndi_audio_flow, "ndiin", "audio_flow_id", "", "", audio_fmt },
{ tp_video_flow, "testpattern", "flow_id", "ndiout", "flow_id", video_fmt },
{ tp_audio_flow, "testpattern", "audio_flow_id", "ndiout", "audio_flow_id", audio_fmt },
};
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__
@@ -193,47 +19,106 @@ int main(int argc, char* argv[]) {
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());
fprintf(stderr, "[studio-manager] cannot create domain dir: %s\n", ec.message().c_str());
return 1;
}
fprintf(stderr, "[studio-manager] domain: %s\n", domain.c_str());
// Clean up stale flow directories left by any previous crashed run
dmf::StudioManager manager(bin_dir, domain);
{
mxlInstance gc = mxlCreateInstance(domain.c_str(), "");
if (gc) {
mxlGarbageCollectFlows(gc);
mxlDestroyInstance(gc);
fprintf(stderr, "[studio-manager] garbage collected stale flows\n");
bool clean = false;
const char* explicit_path = nullptr;
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]) == "--clean") clean = true;
else explicit_path = argv[i];
}
fs::path load_path = explicit_path ? fs::path(explicit_path) : bin_dir / "last_graph.json";
if (!clean && fs::exists(load_path)) {
fprintf(stderr, "[studio-manager] restoring graph from %s\n", load_path.c_str());
auto r = manager.load_graph_file(load_path.string());
if (r.value("type", "") == "error") {
fprintf(stderr, "[studio-manager] %s\n",
r.value("message", "load failed").c_str());
if (explicit_path) return 1; // explicit path failure is fatal; auto-restore is not
}
}
}
// --- Build and launch the pipeline graph ---
const dmf::FlowGraph graph = (argc > 1) ? load_graph(argv[1]) : build_graph();
// ── WebSocket server ──────────────────────────────────────────────────────
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());
crow::SimpleApp app;
app.signal_clear(); // let our signal handler manage SIGTERM/SIGINT
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)));
}
std::mutex ws_mutex;
std::unordered_set<crow::websocket::connection*> clients;
// --- 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
}
// Push status to every connected client except the one whose command triggered
// the change — that client gets the direct response from the handler instead.
// tl_requester is thread-local: notify() is called synchronously from within
// the manager command, so it runs on the same thread as onmessage.
thread_local crow::websocket::connection* tl_requester = nullptr;
shutdown_children(nodes);
manager.on_status_change([&](nlohmann::json status) {
std::lock_guard lk(ws_mutex);
const std::string msg = status.dump();
for (auto* c : clients)
if (c != tl_requester) c->send_text(msg);
});
CROW_WEBSOCKET_ROUTE(app, "/ws")
.onopen([&](crow::websocket::connection& conn) {
{
std::lock_guard lk(ws_mutex);
clients.insert(&conn);
}
// get_status() acquires manager mutex — must NOT hold ws_mutex here
conn.send_text(manager.get_status().dump());
fprintf(stderr, "[studio-manager] client connected\n");
})
.onclose([&](crow::websocket::connection& conn, const std::string&) {
std::lock_guard lk(ws_mutex);
clients.erase(&conn);
fprintf(stderr, "[studio-manager] client disconnected\n");
})
.onmessage([&](crow::websocket::connection& conn, const std::string& data, bool) {
auto j = nlohmann::json::parse(data, nullptr, false);
if (j.is_discarded()) {
conn.send_text(R"({"type":"error","message":"invalid JSON"})");
return;
}
nlohmann::json response;
const std::string type = j.value("type", "");
tl_requester = &conn;
try {
if (type == "load_graph") response = manager.load_graph(j.at("graph"));
else if (type == "stop_node") response = manager.stop_node(j.at("id"));
else if (type == "start_node") response = manager.start_node(j.at("id"));
else if (type == "get_status") response = manager.get_status();
else response = {{"type","error"},{"message","unknown command: " + type}};
} catch (const std::exception& e) {
response = {{"type","error"},{"message", e.what()}};
}
tl_requester = nullptr;
conn.send_text(response.dump());
});
// ── Monitor thread — detects crashes, stops Crow on shutdown ─────────────
std::thread monitor([&] {
manager.run_monitor();
app.stop();
});
fprintf(stderr, "[studio-manager] WebSocket API at ws://0.0.0.0:7070/ws\n");
app.port(7070).multithreaded().run();
manager.shutdown();
monitor.join();
fprintf(stderr, "[studio-manager] done\n");
return 0;
}
+465
View File
@@ -0,0 +1,465 @@
#!/usr/bin/env python3
"""
WebSocket API test suite for dmf-studio-manager.
Usage:
./tests/.venv/bin/python3 tests/test_ws_api.py [options]
Options:
--url URL WebSocket server URL (default: ws://localhost:7070/ws)
--skip-nodes Skip tests that require node binaries and MXL
Prerequisites:
- studio-manager running (no graph loaded, or will be reset)
- Node binaries in the same dir as studio-manager (for node tests)
"""
import argparse
import asyncio
import json
import os
import signal
import sys
import websockets
# ── Minimal graph: testpattern → fakesink (no hardware needed) ────────────────
SIMPLE_GRAPH = {
"nodes": [
{"id": "src", "type": "testpattern", "params": {}},
{"id": "sink", "type": "fakesink", "params": {}},
],
"edges": [
{
"from": "src", "from_port": "video_flow_id",
"to": "sink", "to_port": "video_flow_id",
"format": {"kind": "video", "width": 1920, "height": 1080,
"fps_num": 25, "fps_den": 1},
}
],
}
EMPTY_GRAPH = {"nodes": [], "edges": []}
# ── Suite / reporter ──────────────────────────────────────────────────────────
class Suite:
def __init__(self):
self.passed = self.failed = self.skipped = 0
def ok(self, name):
print(f" \033[32m✓\033[0m {name}")
self.passed += 1
def fail(self, name, reason=""):
msg = f": {reason}" if reason else ""
print(f" \033[31m✗\033[0m {name}{msg}")
self.failed += 1
def skip(self, name, reason=""):
msg = f" ({reason})" if reason else ""
print(f" \033[33m\033[0m {name}{msg}")
self.skipped += 1
def section(self, title):
print(f"\n{title}")
print("" * len(title))
def summary(self):
total = self.passed + self.failed + self.skipped
parts = [f"{self.passed}/{total} passed"]
if self.skipped:
parts.append(f"{self.skipped} skipped")
if self.failed:
parts.append(f"\033[31m{self.failed} failed\033[0m")
print("\n" + " ".join(parts))
return self.failed == 0
# ── Low-level helpers ─────────────────────────────────────────────────────────
async def recv(ws, timeout=3.0):
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
return json.loads(raw)
async def send_recv(ws, payload, timeout=3.0):
await ws.send(json.dumps(payload))
return await recv(ws, timeout)
async def open_ws(url):
return await websockets.connect(url)
async def reset(ws):
"""Load empty graph to clear all nodes between tests."""
r = await send_recv(ws, {"type": "load_graph", "graph": EMPTY_GRAPH})
return r
def is_status(msg):
return isinstance(msg, dict) and msg.get("type") == "status"
def is_error(msg):
return isinstance(msg, dict) and msg.get("type") == "error"
def nodes_by_id(status):
return {n["id"]: n for n in status.get("nodes", [])}
# ── Test groups ───────────────────────────────────────────────────────────────
async def run_connection_tests(url, s):
s.section("Connection")
# 1. Connect → immediate status push
ws = await open_ws(url)
try:
msg = await recv(ws, timeout=3.0)
if is_status(msg) and "nodes" in msg:
s.ok("connect → immediate status received")
else:
s.fail("connect → immediate status received", f"got {msg}")
except asyncio.TimeoutError:
s.fail("connect → immediate status received", "timeout")
finally:
await ws.close()
# 2. Two clients connect → both receive status independently
ws1 = await open_ws(url)
ws2 = await open_ws(url)
try:
m1 = await recv(ws1, timeout=3.0)
m2 = await recv(ws2, timeout=3.0)
if is_status(m1) and is_status(m2):
s.ok("two clients connect → both receive status")
else:
s.fail("two clients connect → both receive status",
f"ws1={m1.get('type')} ws2={m2.get('type')}")
except asyncio.TimeoutError:
s.fail("two clients connect → both receive status", "timeout")
finally:
await ws1.close()
await ws2.close()
async def run_protocol_tests(url, s):
s.section("Protocol robustness")
ws = await open_ws(url)
await recv(ws) # discard initial status
# 3. Invalid JSON → error, connection stays open
await ws.send("not json {{")
try:
msg = await recv(ws, timeout=3.0)
if is_error(msg):
s.ok("invalid JSON → error response")
else:
s.fail("invalid JSON → error response", f"got type={msg.get('type')}")
except asyncio.TimeoutError:
s.fail("invalid JSON → error response", "timeout")
# 4. Unknown command type → error, connection stays open
try:
msg = await send_recv(ws, {"type": "explode"})
if is_error(msg):
s.ok("unknown command → error response")
else:
s.fail("unknown command → error response", f"got type={msg.get('type')}")
except asyncio.TimeoutError:
s.fail("unknown command → error response", "timeout")
# 5. Connection still alive after errors
try:
msg = await send_recv(ws, {"type": "get_status"})
if is_status(msg):
s.ok("connection alive after error responses")
else:
s.fail("connection alive after error responses", f"got {msg}")
except Exception as e:
s.fail("connection alive after error responses", str(e))
# 6. load_graph with missing 'graph' key → error
try:
msg = await send_recv(ws, {"type": "load_graph"})
if is_error(msg):
s.ok("load_graph missing 'graph' key → error")
else:
s.fail("load_graph missing 'graph' key → error", f"got {msg}")
except asyncio.TimeoutError:
s.fail("load_graph missing 'graph' key → error", "timeout")
# 7. stop_node with missing 'id' key → error
try:
msg = await send_recv(ws, {"type": "stop_node"})
if is_error(msg):
s.ok("stop_node missing 'id' key → error")
else:
s.fail("stop_node missing 'id' key → error", f"got {msg}")
except asyncio.TimeoutError:
s.fail("stop_node missing 'id' key → error", "timeout")
await ws.close()
async def run_status_tests(url, s):
s.section("get_status")
ws = await open_ws(url)
push = await recv(ws)
# 8. get_status response matches last push
try:
reply = await send_recv(ws, {"type": "get_status"})
if reply.get("nodes") == push.get("nodes"):
s.ok("get_status matches initial push")
else:
s.fail("get_status matches initial push",
f"push={push['nodes']} reply={reply['nodes']}")
except asyncio.TimeoutError:
s.fail("get_status matches initial push", "timeout")
await ws.close()
async def run_node_tests(url, s):
s.section("Graph and node lifecycle (requires node binaries + MXL)")
ws = await open_ws(url)
await recv(ws) # discard initial status
# 9. load_graph with valid simple graph → nodes Running
try:
r = await send_recv(ws, {"type": "load_graph", "graph": SIMPLE_GRAPH}, timeout=10.0)
nodes = nodes_by_id(r)
if (is_status(r)
and "src" in nodes and nodes["src"]["state"] == "running"
and "sink" in nodes and nodes["sink"]["state"] == "running"):
s.ok("load valid graph → nodes running")
else:
s.fail("load valid graph → nodes running",
f"states={[(n['id'], n['state']) for n in r.get('nodes', [])]}")
except asyncio.TimeoutError:
s.fail("load valid graph → nodes running", "timeout")
# 10. get_status consistent with load_graph response
try:
r = await send_recv(ws, {"type": "get_status"})
nodes = nodes_by_id(r)
if "src" in nodes and "sink" in nodes:
s.ok("get_status consistent after load")
else:
s.fail("get_status consistent after load", f"nodes={list(nodes.keys())}")
except asyncio.TimeoutError:
s.fail("get_status consistent after load", "timeout")
# 11. load_graph again → previous nodes stopped, new ones started
try:
r1 = await send_recv(ws, {"type": "get_status"})
old_pids = {n["id"]: n["pid"] for n in r1.get("nodes", [])}
r2 = await send_recv(ws, {"type": "load_graph", "graph": SIMPLE_GRAPH}, timeout=10.0)
new_nodes = nodes_by_id(r2)
new_pids = {nid: n["pid"] for nid, n in new_nodes.items()}
if (all(n["state"] == "running" for n in new_nodes.values())
and any(new_pids.get(k) != old_pids.get(k) for k in old_pids)):
s.ok("reload graph → new PIDs, all running")
else:
s.fail("reload graph → new PIDs, all running",
f"old={old_pids} new={new_pids}")
except asyncio.TimeoutError:
s.fail("reload graph → new PIDs, all running", "timeout")
# 12. stop_node → state becomes stopped
try:
r = await send_recv(ws, {"type": "stop_node", "id": "src"})
nodes = nodes_by_id(r)
if nodes.get("src", {}).get("state") == "stopped":
s.ok("stop_node → state=stopped")
else:
s.fail("stop_node → state=stopped",
f"state={nodes.get('src', {}).get('state')}")
except asyncio.TimeoutError:
s.fail("stop_node → state=stopped", "timeout")
# 13. stop already-stopped node → no crash, state still stopped
try:
r = await send_recv(ws, {"type": "stop_node", "id": "src"})
nodes = nodes_by_id(r)
if is_status(r) and nodes.get("src", {}).get("state") == "stopped":
s.ok("stop already-stopped node → graceful no-op")
else:
s.fail("stop already-stopped node → graceful no-op", f"got {r}")
except asyncio.TimeoutError:
s.fail("stop already-stopped node → graceful no-op", "timeout")
# 14. stop nonexistent node → error or status (not a crash)
try:
r = await send_recv(ws, {"type": "stop_node", "id": "does-not-exist"})
if is_status(r) or is_error(r):
s.ok("stop nonexistent node → no crash")
else:
s.fail("stop nonexistent node → no crash", f"got {r}")
except asyncio.TimeoutError:
s.fail("stop nonexistent node → no crash", "timeout")
# 15. start_node → state becomes running
try:
r = await send_recv(ws, {"type": "start_node", "id": "src"}, timeout=5.0)
nodes = nodes_by_id(r)
if nodes.get("src", {}).get("state") == "running":
s.ok("start_node → state=running")
else:
s.fail("start_node → state=running",
f"state={nodes.get('src', {}).get('state')}")
except asyncio.TimeoutError:
s.fail("start_node → state=running", "timeout")
# 16. start already-running node → graceful no-op
try:
r = await send_recv(ws, {"type": "start_node", "id": "src"})
nodes = nodes_by_id(r)
if is_status(r) and nodes.get("src", {}).get("state") == "running":
s.ok("start already-running node → graceful no-op")
else:
s.fail("start already-running node → graceful no-op", f"got {r}")
except asyncio.TimeoutError:
s.fail("start already-running node → graceful no-op", "timeout")
# 17. Node crash detection: kill a node externally → pushed as crashed
try:
status = await send_recv(ws, {"type": "get_status"})
src_pid = nodes_by_id(status).get("src", {}).get("pid", -1)
if src_pid > 0:
os.kill(src_pid, signal.SIGKILL)
# Monitor polls every 500ms; wait up to 2s for crash push
deadline = asyncio.get_event_loop().time() + 2.0
crashed = False
while asyncio.get_event_loop().time() < deadline:
try:
push = await recv(ws, timeout=1.5)
if is_status(push):
node = nodes_by_id(push).get("src", {})
if node.get("state") == "crashed":
crashed = True
break
except asyncio.TimeoutError:
break
if crashed:
s.ok("crashed node detected and pushed within 2s")
else:
s.fail("crashed node detected and pushed within 2s", "no push received")
else:
s.fail("crashed node detected and pushed within 2s", "couldn't get src pid")
except Exception as e:
s.fail("crashed node detected and pushed within 2s", str(e))
# 18. Push sent to all connected clients simultaneously
ws2 = await open_ws(url)
await recv(ws2) # discard initial status
try:
r = await send_recv(ws, {"type": "load_graph", "graph": SIMPLE_GRAPH}, timeout=10.0)
# ws gets the response; ws2 should also get a push
if is_status(r):
try:
push2 = await recv(ws2, timeout=3.0)
if is_status(push2) and len(push2.get("nodes", [])) > 0:
s.ok("load_graph push reaches all connected clients")
else:
s.fail("load_graph push reaches all connected clients",
f"ws2 got {push2}")
except asyncio.TimeoutError:
s.fail("load_graph push reaches all connected clients",
"ws2 got no push")
else:
s.fail("load_graph push reaches all connected clients",
f"load response was {r}")
except asyncio.TimeoutError:
s.fail("load_graph push reaches all connected clients", "timeout on load")
finally:
await ws2.close()
# 19. Unknown node type → node quickly transitions to crashed
try:
r = await send_recv(ws,
{"type": "load_graph", "graph": {
"nodes": [{"id": "bad", "type": "nonexistent-node-type", "params": {}}],
"edges": []
}}, timeout=5.0)
# The load itself may succeed (fork happens), but the node should crash fast
if is_status(r):
nodes = nodes_by_id(r)
initial_state = nodes.get("bad", {}).get("state")
if initial_state == "crashed":
s.ok("unknown node type → immediately crashed")
else:
# Wait for crash push (exec fails → child exits → monitor detects)
try:
push = await recv(ws, timeout=2.0)
if is_status(push) and nodes_by_id(push).get("bad", {}).get("state") == "crashed":
s.ok("unknown node type → crashes quickly")
else:
s.fail("unknown node type → crashes quickly",
f"initial={initial_state}, push={push}")
except asyncio.TimeoutError:
s.fail("unknown node type → crashes quickly",
f"initial state={initial_state}, no crash push")
else:
s.fail("unknown node type → crashes quickly", f"load returned {r}")
except asyncio.TimeoutError:
s.fail("unknown node type → crashes quickly", "timeout")
# Cleanup
await reset(ws)
await ws.close()
# ── Entry point ───────────────────────────────────────────────────────────────
async def main(url, skip_nodes):
print(f"studio-manager WebSocket API tests")
print(f"server: {url}")
s = Suite()
try:
await run_connection_tests(url, s)
await run_protocol_tests(url, s)
await run_status_tests(url, s)
if skip_nodes:
s.section("Graph and node lifecycle (requires node binaries + MXL)")
for name in [
"load valid graph → nodes running",
"get_status consistent after load",
"reload graph → new PIDs, all running",
"stop_node → state=stopped",
"stop already-stopped node → graceful no-op",
"stop nonexistent node → no crash",
"start_node → state=running",
"start already-running node → graceful no-op",
"crashed node detected and pushed within 2s",
"load_graph push reaches all connected clients",
"unknown node type → crashes quickly",
]:
s.skip(name, "--skip-nodes")
else:
await run_node_tests(url, s)
except OSError as e:
print(f"\n\033[31mCannot connect to {url}: {e}\033[0m")
print("Is studio-manager running?")
sys.exit(2)
s.summary()
sys.exit(0 if s.failed == 0 else 1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--url", default="ws://localhost:7070/ws",
help="WebSocket server URL")
parser.add_argument("--skip-nodes", action="store_true",
help="Skip tests requiring node binaries and MXL")
args = parser.parse_args()
asyncio.run(main(args.url, args.skip_nodes))
+78
View File
@@ -0,0 +1,78 @@
# DMF Studio — dev update
**DMF Studio** is an open-architecture broadcast signal processor built around a node graph model. The idea is simple: every processing element — signal generator, capture input, compositor, gain stage, output — is an independent node. You wire them together visually in a browser, hit run, and the system routes live video and audio between them in real time.
Unlike traditional broadcast routers or vision mixers that bundle routing and processing into a closed appliance, DMF Studio runs on commodity hardware (a Linux server with DeckLink cards) and exposes the entire signal graph as software. Every node is a separate C++ process. Inter-node transport is MXL shared memory — a low-latency ring buffer system that timestamps every grain against a TAI clock. The browser frontend builds a JSON graph, sends it over WebSocket to a studio-manager daemon, which forks and execs the node processes and wires them together via UUID-addressed memory flows.
The long-term goal: a fully modular, vendor-neutral broadcast backbone — live production, ingest, playout — where the signal graph is code you can version-control and deploy like any other infrastructure.
---
Building a node-based broadcast signal processor where every node is an isolated C++ process exchanging video and audio through MXL shared memory over a TAI clock grid. Think of it as a modular patchbay you assemble visually and run on bare metal — no GPU, no frameworks, just shared memory and tight timing.
This stage we got a live SDI → PiP → SDI chain running from a browser UI. Here's what it took.
---
**What shipped**
- **PiP node** — composites two live video flows into one. Background drives the clock, inset overlays with V210 10-bit bilinear scaling. Supports any combination of sources: testpattern, NDI In, DeckLink In.
- **gaindb node** — audio gain/attenuation node. Reads a continuous MXL audio flow, applies dB gain via `pow(10, gain_db/20)` per sample, writes back out. Chain it anywhere between a source and a sink.
- **DeckLink In / NDI In** — both now write to the correct TAI grain index. Turns out there was a double-pacing bug: the capture loop was blocking on hardware frame delivery AND sleeping to the MXL clock — two pacers fighting each other, causing silent frame drops when they drifted apart. Fixed by resolving `mxlGetCurrentIndex()` after the hardware frame lands, not before.
- **Frontend** — Vue Flow canvas for building pipelines. Fixed a type coercion bug where select inputs (device port, sample rate) were storing strings, which made nlohmann/json throw on the C++ side. Also cleaned up auto-detected params: DeckLink and NDI sources don't expose resolution/fps in the UI anymore since they probe from the signal.
---
**The hard parts**
**MXL sync groups and TAI time.**
MXL uses TAI (not UTC, not wall clock — TAI, currently offset by 37 seconds from UTC). Grain indices are absolute TAI nanosecond timestamps divided by frame period. When we first wired up the PiP sync group, it was silently passing grain index (~44 billion) instead of TAI nanoseconds (~1.784 × 10¹⁸). The node exited in under a millisecond with status 0 and no log. We had to read MXL internals — `flow.cpp`, `Timing.hpp`, `FlowSynchronizationGroup.cpp` — to figure out that `mxlFlowSynchronizationGroupWaitForDataAt` expects nanoseconds since TAI epoch, not a frame counter.
**Sync groups don't work with hardware inputs.**
Even after fixing the timestamp, sync groups kept failing with TOO_LATE cascades when mixing testpattern with DeckLink. The reason: sync groups require both flows to have a grain at the *exact same TAI index*. A software source (testpattern) writes precisely on the TAI grid. A hardware source (DeckLink) writes to whatever index is current when the frame arrives from the SDI callback — which has hardware jitter of a few milliseconds. Even one miss causes a timeout, the node jumps forward, and you get a cascade.
The fix: drop sync groups for hardware-mixed inputs. Background is the master clock (blocking grain read). Inset is best-effort — try the exact index, fall back to `ri.headIndex` from the MXL ring buffer. One frame stale on the inset is visually invisible in a PiP.
Sync groups are still the right tool when both sources are locked to the same TAI reference via PTP — for example two genlocked DeckLink inputs with `ptp4l` + `phc2sys` on Linux syncing `CLOCK_TAI` to a PTP grandmaster (SMPTE ST 2059-2). On macOS dev machines we just live with best-effort.
**Bilinear scaling at 1278×720 was too slow.**
First test: testpattern background + DeckLink inset at configured 1280×720 PiP size → PiP running at 17fps instead of 25. The culprit was inside `scale_and_overlay`: for every output pixel on every row it was recomputing the source X coordinate — `sx = (dx + 0.5) * scale - 0.5`, floor, clamp, fractional weight. That's ~1.6 million float multiplies per frame that produce the same result on every row. Fixed by precomputing an X sample map (x0, x1, fx, 1-fx) once per call. Scale time dropped from ~32ms to ~8ms, PiP runs at 25fps with headroom.
---
**Architecture in one picture**
```
[testpattern]──video──┐
├──[pip]──video──[decklinkout]
[decklinkin]──video───┘
└──audio──[gaindb]──audio──[decklinkout]
```
Every box is a separate process. Arrows are MXL shared memory flows — ring buffers in `/dev/shm`, addressed by UUID, timestamped in TAI. The studio-manager forks and execs nodes, injects `NODE_CONFIG` as a JSON env var, and monitors for crashes. The browser UI builds the graph and sends it over WebSocket.
---
**What's next**
- More processing nodes: audio mixer, video switcher/mixer (A/B cut, dissolve)
- PTP integration for production use — genlocked multi-source sync via `ptp4l` + `phc2sys``CLOCK_TAI`, enabling sync groups across hardware sources
- Graph persistence and live reconfiguration without full restart
- NDI discovery UI — pick sources by name, not by index number
- Proper crash recovery in studio-manager — restart crashed nodes and reconnect flows
**The bigger step: Kubernetes.**
Right now every DMF Studio node is a process on the same machine, exchanging video through `/dev/shm`. That works — and it's fast, zero-copy, nanosecond-timestamped. But it doesn't scale beyond one box.
The cluster layer for this is [`mxl-k8s`](https://github.com/qvest-digital/mxl-k8s) — a Kubernetes control plane for MXL built around the EBU Dynamic Media Facility Reference Architecture (V2.0, April 2026). The idea: each DMF Studio node becomes a pod. Flows that cross machine boundaries are handled transparently by a per-node gateway DaemonSet that owns the `libmxl-fabrics` handles (RDMA/RoCEv2/EFA/TCP), drives the cross-node grain transfer loop, and recovers on restarts. The media function itself never touches `libmxl-fabrics` — it still calls `mxlCreateFlowReader` against its local domain. An LD_PRELOAD shim intercepts the first access to a not-yet-materialised remote flow and blocks until the gateway has mirrored it locally.
The control plane: an agent DaemonSet watches each node's MXL domain via `fanotify` and publishes flows to the Kubernetes API. A cluster-scoped operator reconciles `MxlReceiver` intent ("this pod wants to consume that flow") into `MxlFlowMirror` objects, with ref-counted sharing when multiple consumers on the same node want the same flow.
What this means for DMF Studio: a testpattern pod on node A, a PiP pod on node B, a DeckLink Out pod on node C — wired together in the browser exactly the same way as today, with the cluster handling the fabric underneath. Horizontal scaling, failure isolation, and fabric rollout (from TCP in dev to RDMA in production) become Kubernetes operational concerns, not application code.
Demo video attached.
+18
View File
@@ -0,0 +1,18 @@
{
"nodes": [
{ "id": "videoin", "type": "videoin", "params": {"file": "/home/itten/test-vid/1.ts"} },
{ "id": "ndiout", "type": "ndiout", "params": {} }
],
"edges": [
{
"from": "videoin", "from_port": "video_flow_id",
"to": "ndiout", "to_port": "video_flow_id",
"format": { "kind": "video", "width": 1920, "height": 1080, "fps_num": 24, "fps_den": 1 }
},
{
"from": "videoin", "from_port": "audio_flow_id",
"to": "ndiout", "to_port": "audio_flow_id",
"format": { "kind": "audio", "sample_rate": 48000, "channels": 6, "bit_depth": 32 }
}
]
}