From 1caeeee17e664f06e707f104df088f47807e1b4a Mon Sep 17 00:00:00 2001 From: itten Date: Tue, 14 Jul 2026 18:59:09 +0300 Subject: [PATCH] st-2110-20 pipeline API MTL node in --- .vscode/c_cpp_properties.json | 3 +- CMakeLists.txt | 37 +++ flow-graphs/2110-20.json | 28 ++ .../decklink-ndi.json | 0 .../gain-check.json | 0 graph.json => flow-graphs/graph.json | 0 .../ndi-decklink.json | 0 ndi-ndi.json => flow-graphs/ndi-ndi.json | 0 video-ndi.json => flow-graphs/video-ndi.json | 0 nodes/st2110in/CMakeLists.txt | 4 + nodes/st2110in/main.cpp | 91 +++++++ shared/ST2110Receiver.hpp | 240 ++++++++++++++++++ shared/V210.hpp | 2 +- 13 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 flow-graphs/2110-20.json rename decklink-ndi.json => flow-graphs/decklink-ndi.json (100%) rename gain-check.json => flow-graphs/gain-check.json (100%) rename graph.json => flow-graphs/graph.json (100%) rename ndi-decklink.json => flow-graphs/ndi-decklink.json (100%) rename ndi-ndi.json => flow-graphs/ndi-ndi.json (100%) rename video-ndi.json => flow-graphs/video-ndi.json (100%) create mode 100644 nodes/st2110in/CMakeLists.txt create mode 100644 nodes/st2110in/main.cpp create mode 100644 shared/ST2110Receiver.hpp diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json index 672b1ad..0af8f43 100644 --- a/.vscode/c_cpp_properties.json +++ b/.vscode/c_cpp_properties.json @@ -6,7 +6,8 @@ "${workspaceFolder}/**", "${workspaceFolder}/shared", "${HOME}/SDK/NDI/include", - "${HOME}/SDK/decklink-sdk/Linux/include" + "${HOME}/SDK/decklink-sdk/Linux/include", + "${HOME}/SDK/mtl-26.01/include" ], "defines": [], "compilerPath": "/usr/bin/clang", diff --git a/CMakeLists.txt b/CMakeLists.txt index ecc4e80..ab15b99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,35 @@ FetchContent_MakeAvailable(json) # ── FFmpeg ────────────────────────────────────────────────────────────────── find_package(PkgConfig REQUIRED) +# ── Media Transport Library (SMPTE ST 2110) ───────────────────────────────── +# MTL is Meson-based, so consume an installed Meson build instead of adding the +# source tree as a CMake subdirectory. +set(MTL_SDK_DIR "" CACHE PATH "Path to installed Media Transport Library prefix") +if(MTL_SDK_DIR) + find_library(MTL_LIBRARY + NAMES mtl + PATHS "${MTL_SDK_DIR}/lib" "${MTL_SDK_DIR}/lib64" + NO_DEFAULT_PATH + REQUIRED + ) + + add_library(mtl::mtl SHARED IMPORTED GLOBAL) + set_target_properties(mtl::mtl PROPERTIES + IMPORTED_LOCATION "${MTL_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${MTL_SDK_DIR}/include" + ) +else() + pkg_check_modules(MTL IMPORTED_TARGET mtl) + if(MTL_FOUND) + add_library(mtl::mtl INTERFACE IMPORTED GLOBAL) + target_link_libraries(mtl::mtl INTERFACE PkgConfig::MTL) + target_include_directories(mtl::mtl INTERFACE ${MTL_INCLUDE_DIRS}) + target_link_directories(mtl::mtl INTERFACE ${MTL_LIBRARY_DIRS}) + target_compile_options(mtl::mtl INTERFACE ${MTL_CFLAGS_OTHER}) + target_link_options(mtl::mtl INTERFACE ${MTL_LDFLAGS_OTHER}) + endif() +endif() + # Check for FFmpeg components pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat @@ -112,6 +141,14 @@ if(DECKLINK_SDK_DIR) add_subdirectory(nodes/decklinkout) endif() +# ── SMPTE-2110 (Media Transport Library) nodes ────────────────────────────── +if(TARGET mtl::mtl) + add_subdirectory(nodes/st2110in) +else() + message(STATUS "Media Transport Library not found; skipping SMPTE ST 2110 nodes") +endif() + + add_subdirectory(nodes/videoin) add_subdirectory(nodes/pip) add_subdirectory(nodes/gaindb) diff --git a/flow-graphs/2110-20.json b/flow-graphs/2110-20.json new file mode 100644 index 0000000..a73b6c0 --- /dev/null +++ b/flow-graphs/2110-20.json @@ -0,0 +1,28 @@ +{ + "nodes": [ + { + "id": "2110in", + "type": "2110", + "params": { + "interface": "eno1np0", + "local_ip": "192.168.0.3", + "source_ip": "192.168.0.2", + "mcast_ip": "239.255.197.181", + "udp_port": 16388, + "payload_type": 96, + "width": 1920, + "height": 1080, + "fps_num": 25, + "fps_den": 1 + } + }, + { "id": "fakesink", "type": "fakesink", "params": {} } + ], + "edges": [ + { + "from": "2110in", "from_port": "video_flow_id", + "to": "fakesink", "to_port": "video_flow_id", + "format": { "kind": "video", "width": 1920, "height": 1080, "fps_num": 25, "fps_den": 1 } + } + ] +} diff --git a/decklink-ndi.json b/flow-graphs/decklink-ndi.json similarity index 100% rename from decklink-ndi.json rename to flow-graphs/decklink-ndi.json diff --git a/gain-check.json b/flow-graphs/gain-check.json similarity index 100% rename from gain-check.json rename to flow-graphs/gain-check.json diff --git a/graph.json b/flow-graphs/graph.json similarity index 100% rename from graph.json rename to flow-graphs/graph.json diff --git a/ndi-decklink.json b/flow-graphs/ndi-decklink.json similarity index 100% rename from ndi-decklink.json rename to flow-graphs/ndi-decklink.json diff --git a/ndi-ndi.json b/flow-graphs/ndi-ndi.json similarity index 100% rename from ndi-ndi.json rename to flow-graphs/ndi-ndi.json diff --git a/video-ndi.json b/flow-graphs/video-ndi.json similarity index 100% rename from video-ndi.json rename to flow-graphs/video-ndi.json diff --git a/nodes/st2110in/CMakeLists.txt b/nodes/st2110in/CMakeLists.txt new file mode 100644 index 0000000..33a71a5 --- /dev/null +++ b/nodes/st2110in/CMakeLists.txt @@ -0,0 +1,4 @@ +add_executable(dmf-node-2110 main.cpp) +target_compile_features(dmf-node-2110 PRIVATE cxx_std_20) +target_link_libraries(dmf-node-2110 PRIVATE dmf-shared mtl::mtl) +install(TARGETS dmf-node-2110 RUNTIME DESTINATION bin) diff --git a/nodes/st2110in/main.cpp b/nodes/st2110in/main.cpp new file mode 100644 index 0000000..949ff7d --- /dev/null +++ b/nodes/st2110in/main.cpp @@ -0,0 +1,91 @@ +#include +#include +#include + +#include +#include + +#include "NodeBase.hpp" +#include "ST2110Receiver.hpp" +#include "V210.hpp" + +namespace dmf { + +class ST2110In : public NodeBase { + void run() override { + ST2110ReceiverConfig cfg; + try { + cfg = parse_st2110_receiver_config(config()); + } catch (const std::exception& e) { + log("config error: %s", e.what()); + return; + } + + log("SMPTE 2110-20 RX %s:%u from %s on %s local=%s %dx%d @ %d/%d", + cfg.mcast_ip.c_str(), cfg.udp_port, cfg.source_ip.c_str(), cfg.ifname.c_str(), + cfg.local_ip.c_str(), cfg.width, cfg.height, cfg.fps_num, cfg.fps_den); + + try { + MTLContext mtl(cfg); + ST20RxSession rx(mtl.get(), mtl.port_name(), cfg); + MXLVideoWriter writer(instance(), cfg, node_id()); + + const mxlRational video_rate = {cfg.fps_num, cfg.fps_den}; + const uint32_t video_stride = writer.config().discrete.sliceSizes[0]; + const uint32_t uyvy_stride = static_cast(cfg.width * 2); + uint64_t frames_written = 0; + + while (g_running.load(std::memory_order_relaxed)) { + st_frame* frame = st20p_rx_get_frame(rx.get()); + if (!frame) { + continue; + } + + if (!st_is_frame_complete(frame->status)) { + st20p_rx_put_frame(rx.get(), frame); + continue; + } + + if (frame->fmt != ST_FRAME_FMT_UYVY) { + log("unexpected MTL frame fmt=%d; expected UYVY", frame->fmt); + st20p_rx_put_frame(rx.get(), frame); + continue; + } + + mxlGrainInfo grain{}; + uint8_t* video_buf = nullptr; + const uint64_t video_index = mxlGetCurrentIndex(&video_rate); + mxlStatus st = mxlFlowWriterOpenGrain(writer.get(), video_index, &grain, &video_buf); + if (st != MXL_STATUS_OK) { + log("mxlFlowWriterOpenGrain failed (%s) index=%llu", mxl_status_str(st), + static_cast(video_index)); + st20p_rx_put_frame(rx.get(), frame); + continue; + } + + v210::UYVYtoV210(static_cast(frame->addr[0]), video_buf, + cfg.width, cfg.height, uyvy_stride, video_stride); + grain.flags = 0; + grain.validSlices = grain.totalSlices; + mxlFlowWriterCommitGrain(writer.get(), &grain); + st20p_rx_put_frame(rx.get(), frame); + + frames_written++; + if (frames_written % 100 == 0) { + log("received %llu frames", static_cast(frames_written)); + } + } + + log("stopped after %llu frames", static_cast(frames_written)); + } catch (const std::exception& e) { + log("error: %s", e.what()); + } + } +}; + +} // namespace dmf + +int main() { + dmf::ST2110In node; + return node.execute(); +} diff --git a/shared/ST2110Receiver.hpp b/shared/ST2110Receiver.hpp new file mode 100644 index 0000000..929b3cb --- /dev/null +++ b/shared/ST2110Receiver.hpp @@ -0,0 +1,240 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include + +extern "C" { +#include +#include +#include +} + +#include + +#include "FlowDef.hpp" +#include "NodeBase.hpp" + +namespace dmf { + +struct ST2110ReceiverConfig { + std::string flow_id; + std::string ifname; + std::string local_ip; + std::string source_ip; + std::string mcast_ip; + uint16_t udp_port = 0; + uint8_t payload_type = 96; + int width = 1920; + int height = 1080; + int fps_num = 25; + int fps_den = 1; + int framebuff_cnt = 3; +}; + +inline void st2110_set_ip(uint8_t dst[MTL_IP_ADDR_LEN], const std::string& ip) { + if (inet_pton(AF_INET, ip.c_str(), dst) != 1) { + throw std::runtime_error("invalid IP address: " + ip); + } +} + +inline uint16_t st2110_checked_u16(const nlohmann::json& j, const char* key) { + const int value = j.at(key).get(); + if (value < 0 || value > 65535) { + throw std::runtime_error(std::string(key) + " out of uint16 range"); + } + return static_cast(value); +} + +inline uint8_t st2110_checked_u8(const nlohmann::json& j, const char* key, int fallback) { + const int value = j.value(key, fallback); + if (value < 0 || value > 255) { + throw std::runtime_error(std::string(key) + " out of uint8 range"); + } + return static_cast(value); +} + +inline enum st_fps st2110_to_st_fps(int fps_num, int fps_den) { + if (fps_den == 1) { + switch (fps_num) { + case 24: return ST_FPS_P24; + case 25: return ST_FPS_P25; + case 30: return ST_FPS_P30; + case 50: return ST_FPS_P50; + case 60: return ST_FPS_P60; + case 100: return ST_FPS_P100; + case 120: return ST_FPS_P120; + default: break; + } + } + if (fps_num == 24000 && fps_den == 1001) return ST_FPS_P23_98; + if (fps_num == 30000 && fps_den == 1001) return ST_FPS_P29_97; + if (fps_num == 60000 && fps_den == 1001) return ST_FPS_P59_94; + if (fps_num == 120000 && fps_den == 1001) return ST_FPS_P119_88; + throw std::runtime_error("unsupported ST 2110 frame rate"); +} + +inline ST2110ReceiverConfig parse_st2110_receiver_config(const nlohmann::json& cfg) { + if (!cfg.contains("video_flow_id")) { + throw std::runtime_error("no video output connected"); + } + + ST2110ReceiverConfig out; + out.flow_id = cfg.at("video_flow_id").at("id").get(); + out.ifname = cfg.at("interface").get(); + out.local_ip = cfg.at("local_ip").get(); + out.source_ip = cfg.at("source_ip").get(); + out.mcast_ip = cfg.at("mcast_ip").get(); + out.udp_port = st2110_checked_u16(cfg, "udp_port"); + out.payload_type = st2110_checked_u8(cfg, "payload_type", 96); + out.width = cfg.value("width", out.width); + out.height = cfg.value("height", out.height); + out.fps_num = cfg.value("fps_num", out.fps_num); + out.fps_den = cfg.value("fps_den", out.fps_den); + out.framebuff_cnt = cfg.value("framebuff_cnt", out.framebuff_cnt); + + if (out.width <= 0 || out.height <= 0) { + throw std::runtime_error("width and height must be positive"); + } + if (out.width % 6 != 0) { + throw std::runtime_error("width must be divisible by 6 for v210 output"); + } + if (out.fps_num <= 0 || out.fps_den <= 0) { + throw std::runtime_error("fps_num and fps_den must be positive"); + } + if (out.framebuff_cnt < 2 || out.framebuff_cnt > ST20_FB_MAX_COUNT) { + throw std::runtime_error("framebuff_cnt must be in [2, ST20_FB_MAX_COUNT]"); + } + + return out; +} + +class MTLContext { +public: + explicit MTLContext(const ST2110ReceiverConfig& cfg) { + mtl_init_params params{}; + params.num_ports = 1; + + port_name_ = "kernel:" + cfg.ifname; + std::snprintf(params.port[MTL_PORT_P], sizeof(params.port[MTL_PORT_P]), "%s", + port_name_.c_str()); + + params.pmd[MTL_PORT_P] = MTL_PMD_KERNEL_SOCKET; + params.net_proto[MTL_PORT_P] = MTL_PROTO_STATIC; + params.rx_queues_cnt[MTL_PORT_P] = 1; + params.tx_queues_cnt[MTL_PORT_P] = 0; + params.log_level = MTL_LOG_LEVEL_INFO; + params.flags = MTL_FLAG_DEV_AUTO_START_STOP; + st2110_set_ip(params.sip_addr[MTL_PORT_P], cfg.local_ip); + + handle_ = mtl_init(¶ms); + if (!handle_) { + throw std::runtime_error("mtl_init failed"); + } + } + + ~MTLContext() { + if (handle_) { + mtl_uninit(handle_); + } + } + + MTLContext(const MTLContext&) = delete; + MTLContext& operator=(const MTLContext&) = delete; + + mtl_handle get() const { return handle_; } + const std::string& port_name() const { return port_name_; } + +private: + mtl_handle handle_{nullptr}; + std::string port_name_; +}; + +class ST20RxSession { +public: + ST20RxSession(mtl_handle mt, const std::string& port_name, + const ST2110ReceiverConfig& cfg) { + st20p_rx_ops ops{}; + ops.name = "dmf-st2110in-video"; + ops.port.num_port = 1; + ops.port.udp_port[MTL_SESSION_PORT_P] = cfg.udp_port; + ops.port.payload_type = cfg.payload_type; + std::snprintf(ops.port.port[MTL_SESSION_PORT_P], + sizeof(ops.port.port[MTL_SESSION_PORT_P]), "%s", port_name.c_str()); + st2110_set_ip(ops.port.ip_addr[MTL_SESSION_PORT_P], cfg.mcast_ip); + st2110_set_ip(ops.port.mcast_sip_addr[MTL_SESSION_PORT_P], cfg.source_ip); + + ops.width = static_cast(cfg.width); + ops.height = static_cast(cfg.height); + ops.fps = st2110_to_st_fps(cfg.fps_num, cfg.fps_den); + ops.interlaced = false; + ops.transport_fmt = ST20_FMT_YUV_422_8BIT; + ops.output_fmt = ST_FRAME_FMT_UYVY; + ops.device = ST_PLUGIN_DEVICE_AUTO; + ops.framebuff_cnt = static_cast(cfg.framebuff_cnt); + ops.flags = ST20P_RX_FLAG_BLOCK_GET; + + handle_ = st20p_rx_create(mt, &ops); + if (!handle_) { + throw std::runtime_error("st20p_rx_create failed"); + } + st20p_rx_set_block_timeout(handle_, 100'000'000); + } + + ~ST20RxSession() { + if (handle_) { + st20p_rx_wake_block(handle_); + st20p_rx_free(handle_); + } + } + + ST20RxSession(const ST20RxSession&) = delete; + ST20RxSession& operator=(const ST20RxSession&) = delete; + + st20p_rx_handle get() const { return handle_; } + +private: + st20p_rx_handle handle_{nullptr}; +}; + +class MXLVideoWriter { +public: + MXLVideoWriter(mxlInstance instance, const ST2110ReceiverConfig& cfg, + const std::string& node_id) + : instance_(instance) { + bool created = false; + const std::string flow_def = + make_video_flow_def(cfg.flow_id, node_id, cfg.width, cfg.height, cfg.fps_num, + cfg.fps_den); + const mxlStatus st = + mxlCreateFlowWriter(instance_, flow_def.c_str(), "", &writer_, &config_, &created); + if (st != MXL_STATUS_OK) { + throw std::runtime_error(std::string("mxlCreateFlowWriter failed: ") + + mxl_status_str(st)); + } + } + + ~MXLVideoWriter() { + if (writer_) { + mxlReleaseFlowWriter(instance_, writer_); + } + } + + MXLVideoWriter(const MXLVideoWriter&) = delete; + MXLVideoWriter& operator=(const MXLVideoWriter&) = delete; + + mxlFlowWriter get() const { return writer_; } + const mxlFlowConfigInfo& config() const { return config_; } + +private: + mxlInstance instance_{nullptr}; + mxlFlowWriter writer_{nullptr}; + mxlFlowConfigInfo config_{}; +}; + +} // namespace dmf diff --git a/shared/V210.hpp b/shared/V210.hpp index 7b00ef6..c88e42d 100644 --- a/shared/V210.hpp +++ b/shared/V210.hpp @@ -119,7 +119,7 @@ inline void fill_white(uint8_t* buf, int width, int height, uint32_t stride) fill_solid(buf, width, height, stride, {940, 512, 512}); } -inline void UYVYtoV210(uint8_t* src_buf, uint8_t* dst_buf, int width, int height, uint32_t src_stride, uint32_t dst_stride) +inline void UYVYtoV210(const 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; uint8_t* dst = dst_buf;