diff --git a/CMakeLists.txt b/CMakeLists.txt index fb1da31..c25c901 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/nodes/decklink-in/CMakeLists.txt b/nodes/decklink-in/CMakeLists.txt new file mode 100644 index 0000000..9fe5ff9 --- /dev/null +++ b/nodes/decklink-in/CMakeLists.txt @@ -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) diff --git a/nodes/decklink-in/src/decklink_in_node.cpp b/nodes/decklink-in/src/decklink_in_node.cpp new file mode 100644 index 0000000..f21eeec --- /dev/null +++ b/nodes/decklink-in/src/decklink_in_node.cpp @@ -0,0 +1,240 @@ +#include "decklink_in_node.hpp" + +#include +#include +#include + +#include + +#include + +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(); + } + if (params.contains("mode")) { + auto mode_str = params["mode"].get(); + 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(*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 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(dst_row_bytes), src_row_bytes); + + for (long y = 0; y < src_height && y < static_cast(out_grain.grainSize / dst_row_bytes); ++y) { + std::memcpy(out_payload + y * dst_row_bytes, + static_cast(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 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 diff --git a/nodes/decklink-in/src/decklink_in_node.hpp b/nodes/decklink-in/src/decklink_in_node.hpp new file mode 100644 index 0000000..e55ec5b --- /dev/null +++ b/nodes/decklink-in/src/decklink_in_node.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace dmf_node { + +class DeckLinkInNode : public Node { +public: + DeckLinkInNode() = default; + ~DeckLinkInNode(); + + std::string type() const override { return "decklink-in"; } + + std::vector input_ports() const override { return {}; } + + std::vector 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 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 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 callback_; +}; + +} // namespace dmf_node diff --git a/nodes/decklink-in/src/main.cpp b/nodes/decklink-in/src/main.cpp new file mode 100644 index 0000000..59cde0a --- /dev/null +++ b/nodes/decklink-in/src/main.cpp @@ -0,0 +1,6 @@ +#include +#include "decklink_in_node.hpp" + +int main(int argc, char* argv[]) { + return dmf_node::NodeRunner::run(argc, argv); +} diff --git a/nodes/decklink-out/CMakeLists.txt b/nodes/decklink-out/CMakeLists.txt new file mode 100644 index 0000000..5962ec2 --- /dev/null +++ b/nodes/decklink-out/CMakeLists.txt @@ -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) diff --git a/nodes/decklink-out/src/decklink_out_node.cpp b/nodes/decklink-out/src/decklink_out_node.cpp new file mode 100644 index 0000000..f22e259 --- /dev/null +++ b/nodes/decklink-out/src/decklink_out_node.cpp @@ -0,0 +1,238 @@ +#include "decklink_out_node.hpp" + +#include +#include +#include + +#include + +#include + +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(); + } + if (params.contains("mode")) { + auto mode_str = params["mode"].get(); + 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(*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(out_row_bytes), row_bytes); + for (long y = 0; y < height; ++y) { + std::memcpy(static_cast(dst) + y * out_row_bytes, + static_cast(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 diff --git a/nodes/decklink-out/src/decklink_out_node.hpp b/nodes/decklink-out/src/decklink_out_node.hpp new file mode 100644 index 0000000..027fce5 --- /dev/null +++ b/nodes/decklink-out/src/decklink_out_node.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace dmf_node { + +class DeckLinkOutNode : public Node { +public: + DeckLinkOutNode() = default; + ~DeckLinkOutNode(); + + std::string type() const override { return "decklink-out"; } + + std::vector input_ports() const override { + return {{"video_in", PortDirection::Input, MediaType::VideoV210}}; + } + + std::vector 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 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 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 callback_; + IDeckLinkMutableVideoFrame* scheduled_frame_ = nullptr; +}; + +} // namespace dmf_node diff --git a/nodes/decklink-out/src/main.cpp b/nodes/decklink-out/src/main.cpp new file mode 100644 index 0000000..d061655 --- /dev/null +++ b/nodes/decklink-out/src/main.cpp @@ -0,0 +1,6 @@ +#include +#include "decklink_out_node.hpp" + +int main(int argc, char* argv[]) { + return dmf_node::NodeRunner::run(argc, argv); +}