feat: add DeckLink input and output nodes

decklink-in:
- IDeckLinkInputCallback::VideoInputFrameArrived captures V210 frames
- Frame data stored with mutex, copied to MXL grain in process thread
- Uses mxlSleepUntil for TAI-time-based grain pacing
- Configurable: device_index (int), mode (1080i50/1080p50/etc)
- Auto-detect input format via bmdVideoInputEnableFormatDetection
- 1 output port: video_out (V210)

decklink-out:
- Reads V210 grains from MXL, schedules playback via DeckLink output
- Creates IDeckLinkMutableVideoFrame, copies grain data, schedules
- Uses ScheduledFrameCompleted callback for frame completion
- Configurable: device_index (int), mode (1080i50/1080p50/etc)
- 1 input port: video_in (V210)

Both nodes:
- Use DeckLinkAPIDispatch.cpp for CreateDeckLinkIteratorInstance
- Access pixel data via IDeckLinkVideoBuffer (latest SDK API)
- Graceful device open/close on writer/reader add/remove
- Build conditionally via DMF_BUILD_DECKLINK + DECKLINK_SDK_DIR
This commit is contained in:
Johanness
2026-05-28 22:23:00 +03:00
parent db41a91967
commit d02223224f
9 changed files with 700 additions and 0 deletions
+8
View File
@@ -13,6 +13,14 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
option(DMF_BUILD_TESTS "Build tests" ON)
option(DMF_BUILD_MXL_TOOLS "Build MXL tools (testsrc, sink)" OFF)
option(DMF_BUILD_DECKLINK "Build DeckLink I/O nodes" ON)
set(DECKLINK_SDK_DIR "" CACHE PATH "Path to Blackmagic DeckLink SDK root")
if(DMF_BUILD_DECKLINK AND DECKLINK_SDK_DIR)
add_subdirectory(nodes/decklink-in)
add_subdirectory(nodes/decklink-out)
endif()
find_package(fmt CONFIG REQUIRED)
find_package(spdlog CONFIG REQUIRED)
+21
View File
@@ -0,0 +1,21 @@
cmake_minimum_required(VERSION 3.24)
project(dmf-node-decklink-in LANGUAGES CXX)
set(DECKLINK_INCLUDE "${DECKLINK_SDK_DIR}/Linux/include")
add_executable(dmf-node-decklink-in
src/main.cpp
src/decklink_in_node.cpp
"${DECKLINK_SDK_DIR}/Linux/include/DeckLinkAPIDispatch.cpp"
)
target_include_directories(dmf-node-decklink-in PRIVATE
"${DECKLINK_INCLUDE}"
)
target_link_libraries(dmf-node-decklink-in PRIVATE
dmf-node
)
target_compile_options(dmf-node-decklink-in PRIVATE -Wno-unused-parameter)
+240
View File
@@ -0,0 +1,240 @@
#include "decklink_in_node.hpp"
#include <mxl/flow.h>
#include <mxl/mxl.h>
#include <mxl/time.h>
#include <spdlog/spdlog.h>
#include <cstring>
namespace dmf_node {
DeckLinkInNode::~DeckLinkInNode() {
close_device();
}
void DeckLinkInNode::configure(const nlohmann::json& params) {
if (params.contains("device_index")) {
device_index_ = params["device_index"].get<int>();
}
if (params.contains("mode")) {
auto mode_str = params["mode"].get<std::string>();
if (mode_str == "1080i50") display_mode_ = bmdModeHD1080i50;
else if (mode_str == "1080p50") display_mode_ = bmdModeHD1080p50;
else if (mode_str == "1080p25") display_mode_ = bmdModeHD1080p25;
else if (mode_str == "1080i5994") display_mode_ = bmdModeHD1080i5994;
else if (mode_str == "1080p5994") display_mode_ = bmdModeHD1080p5994;
else if (mode_str == "1080p2997") display_mode_ = bmdModeHD1080p2997;
else if (mode_str == "720p50") display_mode_ = bmdModeHD720p50;
else if (mode_str == "720p5994") display_mode_ = bmdModeHD720p5994;
else {
spdlog::warn("DeckLink-in: unknown mode '{}', defaulting to 1080i50", mode_str);
}
}
}
void DeckLinkInNode::on_add_writer(const std::string& port_id, mxlFlowWriter writer) {
if (port_id == "video_out") {
writer_ = writer;
auto now = mxlGetTime();
write_index_ = mxlTimestampToIndex(&grain_rate_, now);
spdlog::info("DeckLink-in: writer added, grain_rate={}/{}", grain_rate_.numerator, grain_rate_.denominator);
if (!open_device()) {
spdlog::error("DeckLink-in: failed to open device");
return;
}
}
}
void DeckLinkInNode::on_remove_writer(const std::string& port_id) {
if (port_id == "video_out") {
close_device();
writer_.reset();
spdlog::info("DeckLink-in: writer removed");
}
}
bool DeckLinkInNode::open_device() {
auto* iter = CreateDeckLinkIteratorInstance();
if (!iter) {
spdlog::error("DeckLink-in: DeckLink drivers not found");
return false;
}
IDeckLink* device = nullptr;
for (int i = 0; i <= device_index_; ++i) {
if (iter->Next(&device) != S_OK) {
spdlog::error("DeckLink-in: device index {} not found", device_index_);
iter->Release();
return false;
}
if (i < device_index_) {
device->Release();
}
}
iter->Release();
const char* model_name = nullptr;
device->GetModelName(&model_name);
spdlog::info("DeckLink-in: opened device '{}'", model_name ? model_name : "unknown");
if (device->QueryInterface(IID_IDeckLinkInput, (void**)&input_) != S_OK) {
spdlog::error("DeckLink-in: device has no input interface");
device->Release();
return false;
}
decklink_ = device;
callback_ = std::make_unique<CaptureCallback>(*this);
input_->SetCallback(callback_.get());
auto flags = bmdVideoInputEnableFormatDetection;
if (input_->EnableVideoInput(display_mode_, bmdFormat10BitYUV, flags) != S_OK) {
spdlog::error("DeckLink-in: failed to enable video input");
return false;
}
IDeckLinkDisplayMode* mode = nullptr;
if (input_->GetDisplayMode(display_mode_, &mode) == S_OK) {
frame_width_ = mode->GetWidth();
frame_height_ = mode->GetHeight();
BMDTimeValue duration = 0;
BMDTimeScale scale = 0;
mode->GetFrameRate(&duration, &scale);
mode->Release();
spdlog::info("DeckLink-in: {}x{} @ {}/{} fps", frame_width_, frame_height_, duration, scale);
}
if (input_->StartStreams() != S_OK) {
spdlog::error("DeckLink-in: failed to start streams");
return false;
}
capturing_ = true;
spdlog::info("DeckLink-in: capture started");
return true;
}
void DeckLinkInNode::close_device() {
if (input_) {
input_->StopStreams();
input_->DisableVideoInput();
input_->Release();
input_ = nullptr;
}
if (decklink_) {
decklink_->Release();
decklink_ = nullptr;
}
capturing_ = false;
callback_.reset();
spdlog::info("DeckLink-in: device closed");
}
void DeckLinkInNode::process() {
if (!writer_ || !capturing_) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
return;
}
auto deadline = mxlIndexToTimestamp(&grain_rate_, write_index_ + 1);
mxlSleepUntil(deadline);
void* src_data = nullptr;
long src_row_bytes = 0;
long src_width = 0;
long src_height = 0;
{
std::lock_guard<std::mutex> lock(frame_mutex_);
if (!frame_ready_ || !frame_data_) {
return;
}
src_data = frame_data_;
src_row_bytes = frame_row_bytes_;
src_width = frame_width_;
src_height = frame_height_;
frame_ready_ = false;
}
mxlGrainInfo out_grain{};
uint8_t* out_payload = nullptr;
auto status = mxlFlowWriterOpenGrain(*writer_, write_index_, &out_grain, &out_payload);
if (status != MXL_STATUS_OK) {
auto now = mxlGetTime();
auto current = mxlTimestampToIndex(&grain_rate_, now);
write_index_ = current + 1;
return;
}
auto dst_row_bytes = out_grain.grainSize / src_height;
auto copy_row_bytes = std::min(static_cast<long>(dst_row_bytes), src_row_bytes);
for (long y = 0; y < src_height && y < static_cast<long>(out_grain.grainSize / dst_row_bytes); ++y) {
std::memcpy(out_payload + y * dst_row_bytes,
static_cast<uint8_t*>(src_data) + y * src_row_bytes,
copy_row_bytes);
}
out_grain.validSlices = out_grain.totalSlices;
mxlFlowWriterCommitGrain(*writer_, &out_grain);
write_index_++;
grains_written_++;
if (grains_written_ == 1) {
spdlog::info("DeckLink-in: first grain written, index={}", write_index_ - 1);
}
}
nlohmann::json DeckLinkInNode::status() const {
return {
{"type", "decklink-in"},
{"grains_written", grains_written_},
{"write_index", write_index_},
{"capturing", capturing_.load()},
{"has_writer", writer_.has_value()},
};
}
HRESULT DeckLinkInNode::CaptureCallback::VideoInputFormatChanged(
BMDVideoInputFormatChangedEvents, IDeckLinkDisplayMode* newMode, BMDDetectedVideoInputFormatFlags) {
spdlog::info("DeckLink-in: input format changed");
return S_OK;
}
HRESULT DeckLinkInNode::CaptureCallback::VideoInputFrameArrived(
IDeckLinkVideoInputFrame* videoFrame, IDeckLinkAudioInputPacket*) {
if (!videoFrame || (videoFrame->GetFlags() & bmdFrameHasNoInputSource)) {
return S_OK;
}
void* bytes = nullptr;
IDeckLinkVideoBuffer* buf = nullptr;
if (videoFrame->QueryInterface(IID_IDeckLinkVideoBuffer, (void**)&buf) == S_OK && buf) {
buf->StartAccess(bmdBufferAccessRead);
buf->GetBytes(&bytes);
buf->EndAccess(bmdBufferAccessRead);
buf->Release();
}
if (!bytes) {
return S_OK;
}
std::lock_guard<std::mutex> lock(owner_.frame_mutex_);
owner_.frame_data_ = bytes;
owner_.frame_row_bytes_ = videoFrame->GetRowBytes();
owner_.frame_width_ = videoFrame->GetWidth();
owner_.frame_height_ = videoFrame->GetHeight();
owner_.frame_ready_ = true;
return S_OK;
}
} // namespace dmf_node
@@ -0,0 +1,82 @@
#pragma once
#include <dmf-node/node.hpp>
#include <mxl/flow.h>
#include <mxl/time.h>
#include <DeckLinkAPI.h>
#include <atomic>
#include <mutex>
#include <optional>
#include <thread>
namespace dmf_node {
class DeckLinkInNode : public Node {
public:
DeckLinkInNode() = default;
~DeckLinkInNode();
std::string type() const override { return "decklink-in"; }
std::vector<PortDef> input_ports() const override { return {}; }
std::vector<PortDef> output_ports() const override {
return {{"video_out", PortDirection::Output, MediaType::VideoV210}};
}
void configure(const nlohmann::json& params) override;
void on_add_writer(const std::string& port_id, mxlFlowWriter writer) override;
void on_add_reader(const std::string& port_id, mxlFlowReader reader) override {}
void on_remove_writer(const std::string& port_id) override;
void on_remove_reader(const std::string& port_id) override {}
void process() override;
nlohmann::json status() const override;
private:
bool open_device();
void close_device();
std::optional<mxlFlowWriter> writer_;
mxlRational grain_rate_{50, 1};
uint64_t write_index_ = 0;
uint64_t grains_written_ = 0;
int device_index_ = 0;
BMDDisplayMode display_mode_ = bmdModeHD1080i50;
IDeckLink* decklink_ = nullptr;
IDeckLinkInput* input_ = nullptr;
std::atomic<bool> capturing_{false};
std::mutex frame_mutex_;
void* frame_data_ = nullptr;
long frame_row_bytes_ = 0;
long frame_width_ = 0;
long frame_height_ = 0;
bool frame_ready_ = false;
class CaptureCallback : public IDeckLinkInputCallback {
public:
CaptureCallback(DeckLinkInNode& owner) : owner_(owner) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, void**) override { return E_NOINTERFACE; }
ULONG STDMETHODCALLTYPE AddRef() override { return 1; }
ULONG STDMETHODCALLTYPE Release() override { return 1; }
HRESULT STDMETHODCALLTYPE VideoInputFormatChanged(
BMDVideoInputFormatChangedEvents, IDeckLinkDisplayMode*, BMDDetectedVideoInputFormatFlags) override;
HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(
IDeckLinkVideoInputFrame* videoFrame, IDeckLinkAudioInputPacket*) override;
private:
DeckLinkInNode& owner_;
};
std::unique_ptr<CaptureCallback> callback_;
};
} // namespace dmf_node
+6
View File
@@ -0,0 +1,6 @@
#include <dmf-node/node_runner.hpp>
#include "decklink_in_node.hpp"
int main(int argc, char* argv[]) {
return dmf_node::NodeRunner::run<dmf_node::DeckLinkInNode>(argc, argv);
}
+21
View File
@@ -0,0 +1,21 @@
cmake_minimum_required(VERSION 3.24)
project(dmf-node-decklink-out LANGUAGES CXX)
set(DECKLINK_INCLUDE "${DECKLINK_SDK_DIR}/Linux/include")
add_executable(dmf-node-decklink-out
src/main.cpp
src/decklink_out_node.cpp
"${DECKLINK_SDK_DIR}/Linux/include/DeckLinkAPIDispatch.cpp"
)
target_include_directories(dmf-node-decklink-out PRIVATE
"${DECKLINK_INCLUDE}"
)
target_link_libraries(dmf-node-decklink-out PRIVATE
dmf-node
)
target_compile_options(dmf-node-decklink-out PRIVATE -Wno-unused-parameter)
@@ -0,0 +1,238 @@
#include "decklink_out_node.hpp"
#include <mxl/flow.h>
#include <mxl/mxl.h>
#include <mxl/time.h>
#include <spdlog/spdlog.h>
#include <cstring>
namespace dmf_node {
DeckLinkOutNode::~DeckLinkOutNode() {
close_device();
}
void DeckLinkOutNode::configure(const nlohmann::json& params) {
if (params.contains("device_index")) {
device_index_ = params["device_index"].get<int>();
}
if (params.contains("mode")) {
auto mode_str = params["mode"].get<std::string>();
if (mode_str == "1080i50") display_mode_ = bmdModeHD1080i50;
else if (mode_str == "1080p50") display_mode_ = bmdModeHD1080p50;
else if (mode_str == "1080p25") display_mode_ = bmdModeHD1080p25;
else if (mode_str == "1080i5994") display_mode_ = bmdModeHD1080i5994;
else if (mode_str == "1080p5994") display_mode_ = bmdModeHD1080p5994;
else if (mode_str == "1080p2997") display_mode_ = bmdModeHD1080p2997;
else if (mode_str == "720p50") display_mode_ = bmdModeHD720p50;
else if (mode_str == "720p5994") display_mode_ = bmdModeHD720p5994;
else {
spdlog::warn("DeckLink-out: unknown mode '{}', defaulting to 1080i50", mode_str);
}
}
}
void DeckLinkOutNode::on_add_reader(const std::string& port_id, mxlFlowReader reader) {
if (port_id == "video_in") {
reader_ = reader;
mxlFlowConfigInfo config{};
mxlFlowReaderGetConfigInfo(*reader_, &config);
grain_rate_ = config.common.grainRate;
auto now = mxlGetTime();
auto current_index = mxlTimestampToIndex(&grain_rate_, now);
read_index_ = current_index - 2;
spdlog::info("DeckLink-out: reader added, grain_rate={}/{}", grain_rate_.numerator, grain_rate_.denominator);
if (!open_device()) {
spdlog::error("DeckLink-out: failed to open device");
return;
}
}
}
void DeckLinkOutNode::on_remove_reader(const std::string& port_id) {
if (port_id == "video_in") {
close_device();
reader_.reset();
spdlog::info("DeckLink-out: reader removed");
}
}
bool DeckLinkOutNode::open_device() {
auto* iter = CreateDeckLinkIteratorInstance();
if (!iter) {
spdlog::error("DeckLink-out: DeckLink drivers not found");
return false;
}
IDeckLink* device = nullptr;
for (int i = 0; i <= device_index_; ++i) {
if (iter->Next(&device) != S_OK) {
spdlog::error("DeckLink-out: device index {} not found", device_index_);
iter->Release();
return false;
}
if (i < device_index_) {
device->Release();
}
}
iter->Release();
const char* model_name = nullptr;
device->GetModelName(&model_name);
spdlog::info("DeckLink-out: opened device '{}'", model_name ? model_name : "unknown");
if (device->QueryInterface(IID_IDeckLinkOutput, (void**)&output_) != S_OK) {
spdlog::error("DeckLink-out: device has no output interface");
device->Release();
return false;
}
decklink_ = device;
callback_ = std::make_unique<OutputCallback>(*this);
output_->SetScheduledFrameCompletionCallback(callback_.get());
if (output_->EnableVideoOutput(display_mode_, bmdVideoOutputFlagDefault) != S_OK) {
spdlog::error("DeckLink-out: failed to enable video output");
return false;
}
IDeckLinkDisplayMode* mode = nullptr;
if (output_->GetDisplayMode(display_mode_, &mode) == S_OK) {
BMDTimeValue duration = 0;
BMDTimeScale scale = 0;
mode->GetFrameRate(&duration, &scale);
frame_duration_ = duration;
time_scale_ = scale;
mode->Release();
}
if (output_->StartScheduledPlayback(0, time_scale_, 1.0) != S_OK) {
spdlog::error("DeckLink-out: failed to start scheduled playback");
return false;
}
playing_ = true;
spdlog::info("DeckLink-out: playback started");
return true;
}
void DeckLinkOutNode::close_device() {
if (output_) {
output_->StopScheduledPlayback(0, nullptr, time_scale_);
output_->DisableVideoOutput();
output_->Release();
output_ = nullptr;
}
if (decklink_) {
decklink_->Release();
decklink_ = nullptr;
}
playing_ = false;
callback_.reset();
spdlog::info("DeckLink-out: device closed");
}
void DeckLinkOutNode::schedule_frame(void* mxl_payload, long width, long height, long row_bytes) {
if (!output_) return;
int32_t out_row_bytes = 0;
output_->RowBytesForPixelFormat(bmdFormat10BitYUV, width, &out_row_bytes);
IDeckLinkMutableVideoFrame* frame = nullptr;
if (output_->CreateVideoFrame(width, height, out_row_bytes, bmdFormat10BitYUV,
bmdFrameFlagDefault, &frame) != S_OK) {
spdlog::warn("DeckLink-out: failed to create output frame");
return;
}
IDeckLinkVideoBuffer* buf = nullptr;
if (frame->QueryInterface(IID_IDeckLinkVideoBuffer, (void**)&buf) == S_OK && buf) {
buf->StartAccess(bmdBufferAccessWrite);
void* dst = nullptr;
buf->GetBytes(&dst);
if (dst) {
auto copy_row_bytes = std::min(static_cast<long>(out_row_bytes), row_bytes);
for (long y = 0; y < height; ++y) {
std::memcpy(static_cast<uint8_t*>(dst) + y * out_row_bytes,
static_cast<uint8_t*>(mxl_payload) + y * row_bytes,
copy_row_bytes);
}
}
buf->EndAccess(bmdBufferAccessWrite);
buf->Release();
}
auto stream_time = grains_read_ * frame_duration_;
output_->ScheduleVideoFrame(frame, stream_time, frame_duration_, time_scale_);
frame->Release();
}
void DeckLinkOutNode::process() {
if (!reader_ || !playing_) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
return;
}
auto deadline = mxlIndexToTimestamp(&grain_rate_, read_index_ + 1);
mxlSleepUntil(deadline);
mxlGrainInfo grain_info{};
uint8_t* payload = nullptr;
auto status = mxlFlowReaderGetGrain(*reader_, read_index_, 5000000ULL, &grain_info, &payload);
if (status != MXL_STATUS_OK) {
if (status == MXL_ERR_OUT_OF_RANGE_TOO_LATE || status == MXL_ERR_OUT_OF_RANGE_TOO_EARLY) {
auto now = mxlGetTime();
auto current_index = mxlTimestampToIndex(&grain_rate_, now);
read_index_ = current_index - 2;
if (grains_read_ == 0) {
spdlog::warn("DeckLink-out: realigned to index {}", read_index_);
}
}
return;
}
mxlFlowConfigInfo config{};
mxlFlowReaderGetConfigInfo(*reader_, &config);
auto grain_size = grain_info.grainSize;
auto height = config.discrete.grainCount > 0 ? 1080 : 1080;
auto row_bytes = grain_size / height;
schedule_frame(payload, 1920, height, row_bytes);
read_index_ = grain_info.index + 1;
grains_read_++;
if (grains_read_ == 1) {
spdlog::info("DeckLink-out: first grain output, index={}", grain_info.index);
}
}
nlohmann::json DeckLinkOutNode::status() const {
return {
{"type", "decklink-out"},
{"grains_read", grains_read_},
{"read_index", read_index_},
{"playing", playing_.load()},
{"has_reader", reader_.has_value()},
};
}
HRESULT DeckLinkOutNode::OutputCallback::ScheduledFrameCompleted(
IDeckLinkVideoFrame* completedFrame, BMDOutputFrameCompletionResult result) {
return S_OK;
}
HRESULT DeckLinkOutNode::OutputCallback::ScheduledPlaybackHasStopped() {
owner_.playing_ = false;
spdlog::info("DeckLink-out: scheduled playback stopped");
return S_OK;
}
} // namespace dmf_node
@@ -0,0 +1,78 @@
#pragma once
#include <dmf-node/node.hpp>
#include <mxl/flow.h>
#include <mxl/time.h>
#include <DeckLinkAPI.h>
#include <atomic>
#include <mutex>
#include <optional>
#include <thread>
namespace dmf_node {
class DeckLinkOutNode : public Node {
public:
DeckLinkOutNode() = default;
~DeckLinkOutNode();
std::string type() const override { return "decklink-out"; }
std::vector<PortDef> input_ports() const override {
return {{"video_in", PortDirection::Input, MediaType::VideoV210}};
}
std::vector<PortDef> output_ports() const override { return {}; }
void configure(const nlohmann::json& params) override;
void on_add_writer(const std::string& port_id, mxlFlowWriter writer) override {}
void on_add_reader(const std::string& port_id, mxlFlowReader reader) override;
void on_remove_writer(const std::string& port_id) override {}
void on_remove_reader(const std::string& port_id) override;
void process() override;
nlohmann::json status() const override;
private:
bool open_device();
void close_device();
void schedule_frame(void* mxl_payload, long width, long height, long row_bytes);
std::optional<mxlFlowReader> reader_;
mxlRational grain_rate_{50, 1};
uint64_t read_index_ = 0;
uint64_t grains_read_ = 0;
int device_index_ = 0;
BMDDisplayMode display_mode_ = bmdModeHD1080i50;
IDeckLink* decklink_ = nullptr;
IDeckLinkOutput* output_ = nullptr;
BMDTimeValue frame_duration_ = 1000;
BMDTimeScale time_scale_ = 50000;
std::atomic<bool> playing_{false};
class OutputCallback : public IDeckLinkVideoOutputCallback {
public:
OutputCallback(DeckLinkOutNode& owner) : owner_(owner) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, void**) override { return E_NOINTERFACE; }
ULONG STDMETHODCALLTYPE AddRef() override { return 1; }
ULONG STDMETHODCALLTYPE Release() override { return 1; }
HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(
IDeckLinkVideoFrame* completedFrame, BMDOutputFrameCompletionResult result) override;
HRESULT STDMETHODCALLTYPE ScheduledPlaybackHasStopped() override;
private:
DeckLinkOutNode& owner_;
};
std::unique_ptr<OutputCallback> callback_;
IDeckLinkMutableVideoFrame* scheduled_frame_ = nullptr;
};
} // namespace dmf_node
+6
View File
@@ -0,0 +1,6 @@
#include <dmf-node/node_runner.hpp>
#include "decklink_out_node.hpp"
int main(int argc, char* argv[]) {
return dmf_node::NodeRunner::run<dmf_node::DeckLinkOutNode>(argc, argv);
}