diff --git a/.github/actions/environment-check/action.yml b/.github/actions/environment-check/action.yml index 4b10977..955e319 100644 --- a/.github/actions/environment-check/action.yml +++ b/.github/actions/environment-check/action.yml @@ -19,6 +19,13 @@ inputs: description: 'FFmpeg release branch to build' required: false default: '7.0' + env-cache-version: + description: >- + Bump this whenever a change alters what gets built/installed into the + cached environment (e.g. new APT packages needed by the FFmpeg build) + so runners rebuild instead of restoring a stale cache. + required: false + default: '2' runs: using: composite @@ -30,7 +37,7 @@ runs: path: | ${{ runner.temp }}/dvledtx-deps ${{ runner.temp }}/mtl-source - key: env-dpdk-${{ inputs.dpdk-version }}-mtl-${{ inputs.mtl-version }}-ffmpeg-${{ inputs.ffmpeg-version }}-${{ runner.os }} + key: env-dpdk-${{ inputs.dpdk-version }}-mtl-${{ inputs.mtl-version }}-ffmpeg-${{ inputs.ffmpeg-version }}-v${{ inputs.env-cache-version }}-${{ runner.os }} - name: Deploy cache to system if: steps.env-cache.outputs.cache-hit == 'true' @@ -104,7 +111,7 @@ runs: shell: bash run: | echo "===== APT Package Setup =====" - PKGS="git gcc meson python3 python3-pip pkg-config libnuma-dev libjson-c-dev libpcap-dev libgtest-dev libssl-dev systemtap-sdt-dev llvm clang flex byacc libcmocka-dev nasm wget patch unzip shellcheck cppcheck" + PKGS="git gcc meson python3 python3-pip pkg-config libnuma-dev libjson-c-dev libpcap-dev libgtest-dev libssl-dev systemtap-sdt-dev llvm clang flex byacc libcmocka-dev nasm wget patch unzip shellcheck cppcheck libx11-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev" MISSING="" for pkg in $PKGS; do if dpkg -s "$pkg" &>/dev/null; then @@ -318,6 +325,14 @@ runs: exit 1 fi + # Check x11grab device is registered (required for screen-capture input_mode) + if echo "$DEVICES" | grep -qi "x11grab"; then + echo " [OK] x11grab device registered in FFmpeg avdevices" + else + echo "ERROR: FFmpeg x11grab device not found after build." + exit 1 + fi + - name: Report cache size if: steps.env-cache.outputs.cache-hit != 'true' shell: bash @@ -332,4 +347,4 @@ runs: path: | ${{ runner.temp }}/dvledtx-deps ${{ runner.temp }}/mtl-source - key: env-dpdk-${{ inputs.dpdk-version }}-mtl-${{ inputs.mtl-version }}-ffmpeg-${{ inputs.ffmpeg-version }}-${{ runner.os }} + key: env-dpdk-${{ inputs.dpdk-version }}-mtl-${{ inputs.mtl-version }}-ffmpeg-${{ inputs.ffmpeg-version }}-v${{ inputs.env-cache-version }}-${{ runner.os }} diff --git a/README.md b/README.md index ef53b65..bc1a978 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ - [Usage](#usage) - [Binding Ethernet Controller to DPDK PMD and Hugepage Setup](#binding-ethernet-controller-to-dpdk-pmd-and-hugepage-setup) - [JSON Configuration](#json-configuration) + - [Ensuring an X11 session (required for screen capture)](#ensuring-an-x11-session-required-for-screen-capture) + - [Screen capture on a headless machine (no physical monitor)](#screen-capture-on-a-headless-machine-no-physical-monitor) - [Logging](#logging) - [Command-Line Options](#command-line-options) - [Supported Formats](#supported-formats) @@ -93,6 +95,20 @@ FFmpeg is an open source project licensed under LGPL and GPL. See https://www.ff - [Build and install DPDK](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/ffmpeg-plugin-extra-pixel-format/doc/build.md#2-dpdk-build-and-install) - [Build and install MTL](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/ffmpeg-plugin-extra-pixel-format/doc/build.md#3-build-media-transport-library-and-app) - [FFmpeg 7.0 with MTL Plugin](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/ffmpeg-plugin-extra-pixel-format/ecosystem/ffmpeg_plugin/README.md#1-build) + - **Screen capture support (`input_mode: screen_capture`) requires FFmpeg's `x11grab` device.** It is auto-detected and compiled in by FFmpeg's `./configure` script, but only if these packages are installed *before* building FFmpeg: + ```bash + sudo apt-get install -y libx11-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev + ``` + After building, verify support with: + ```bash + ffmpeg -devices | grep x11grab + ``` + If this prints nothing, FFmpeg needs to be reconfigured/rebuilt after installing the packages above — screen capture will otherwise fail at runtime with `x11grab input format not found`. + - **`x11grab` only works against an X11 (Xorg) display, not Wayland** — see [Ensuring an X11 session](#ensuring-an-x11-session-required-for-screen-capture) below if you're capturing from a machine's own physical desktop session. + - **Headless machines (no physical monitor)** additionally need a virtual display to capture from — see [Screen capture on a headless machine](#screen-capture-on-a-headless-machine-no-physical-monitor) below, which requires: + ```bash + sudo apt-get install -y xserver-xorg-video-dummy ubuntu-desktop + ``` ### Build Steps @@ -132,7 +148,9 @@ dvledtx uses a JSON config file with three sections: | | `dip` | Destination multicast IP address | | **video** | `width` | Source frame width in pixels | | | `height` | Source frame height in pixels | -| | `tx_url` | Path to the source video file | +| | `input_mode` | (Optional) Video input mode: `file` (default) or `screen_capture` | +| | `tx_url` | Path to the source video file (used when `input_mode=file`) | +| | `screen_input` | (Optional) x11grab source string for screen capture (used when `input_mode=screen_capture`, default `:0.0+0,0`) | | **tx_video** | `scale_width` | (Optional) Output width after scaling | | | `scale_height` | (Optional) Output height after scaling | | | `fps` | Frames per second (25, 30, 50, 60) | @@ -142,7 +160,7 @@ dvledtx uses a JSON config file with three sections: | | `payload_type` | (Optional) RTP payload type — defaults to `96` if not present | | | `crop` | Region to transmit: `x`, `y`, `w`, `h` in pixels | -Example (`config/tx_fullhd_multi_nic.json`): +#### Example: MP4 file input (`config/tx_fullhd_single_session.json`) ```json { "log_file": "dvledtx.log", @@ -168,6 +186,168 @@ Example (`config/tx_fullhd_multi_nic.json`): Multiple sessions can be defined in `tx_sessions` to transmit different crop regions of the same video simultaneously (see `config/tx_fullhd_multi_session.json`). +#### Ensuring an X11 session (required for screen capture) + +`x11grab` can only capture from a native X11 (Xorg) display — it does not work against a Wayland compositor. This mainly affects **machines with a physical monitor** logging into their own desktop session: current Ubuntu releases (22.04 and later) default new GDM logins to a **Wayland** session, which causes `screen_capture` to fail (or silently capture a blank/incorrect frame) even though FFmpeg was built correctly with `x11grab` support. + +> The [headless setup below](#screen-capture-on-a-headless-machine-no-physical-monitor) is unaffected by this — it starts a real Xorg server directly (`Xorg :99 ...`) and forces the desktop session running on top of it with `XDG_SESSION_TYPE=x11`, so no GDM/Wayland session is ever involved. + +To capture from a machine's own physical display, make sure that desktop session is running X11, not Wayland: + +- **Per-login (manual)**: at the GDM login screen, click the gear icon next to the password field and choose **"Ubuntu on Xorg"** instead of the default **"Ubuntu"** (Wayland) before signing in. +- **System-wide (automatic)**: disable Wayland in GDM so every login defaults to X11 — edit `/etc/gdm3/custom.conf` and uncomment/set `WaylandEnable=false` under the `[daemon]` section, or apply it directly: + ```bash + sudo cp /etc/gdm3/custom.conf /etc/gdm3/custom.conf.bak + sudo sed -i 's/^#\?WaylandEnable=.*/WaylandEnable=false/' /etc/gdm3/custom.conf + grep -q '^WaylandEnable=false' /etc/gdm3/custom.conf || \ + sudo sed -i '/^\[daemon\]/a WaylandEnable=false' /etc/gdm3/custom.conf + sudo systemctl restart gdm3 # or: sudo reboot + ``` + Restarting `gdm3` logs out the current session — save your work first. After logging back in, verify the session type: + ```bash + echo $XDG_SESSION_TYPE # should print: x11 + ``` + +#### Screen capture on a headless machine (no physical monitor) + +`x11grab` needs a real X11 display to attach to — it does not work against a raw framebuffer or DRM device. On a machine with no monitor connected, create a virtual display using Xorg with the `dummy` video driver and run a desktop session on it so there's actual content to capture. Unlike Xvfb, a real Xorg server claims physical input devices — your keyboard and mouse work directly on the virtual display. + +1. **Install prerequisites** (once): see [Software Requirements](#software-requirements) for the `xserver-xorg-video-dummy`/`ubuntu-desktop` packages and the `x11grab`-enabled FFmpeg build. + +2. **Create an Xorg config** for the dummy driver: + ```bash + sudo tee /tmp/xorg-dummy.conf > /dev/null << 'EOF' + Section "Device" + Identifier "dummy" + Driver "dummy" + VideoRam 256000 + EndSection + + Section "Monitor" + Identifier "monitor" + HorizSync 28.0-80.0 + VertRefresh 48.0-75.0 + Modeline "1920x1080" 148.50 1920 2008 2052 2200 1080 1084 1089 1125 +hsync +vsync + EndSection + + Section "Screen" + Identifier "screen" + Device "dummy" + Monitor "monitor" + DefaultDepth 24 + SubSection "Display" + Depth 24 + Modes "1920x1080" + EndSubSection + EndSection + + Section "ServerLayout" + Identifier "layout" + Screen "screen" + Option "AllowEmptyInput" "false" + EndSection + EOF + ``` + +3. **Start Xorg** on display `:99`: + ```bash + sudo Xorg :99 -config /tmp/xorg-dummy.conf -noreset & + ``` + +4. **Allow root access** and **start a GNOME desktop session** (software GL rendering is required since there is no GPU; `PULSE_SERVER` routes audio to the physical audio device): + ```bash + DISPLAY=:99 xhost +local:root + mkdir -p /tmp/gnome99-runtime && chmod 700 /tmp/gnome99-runtime + DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 XDG_SESSION_TYPE=x11 \ + XDG_RUNTIME_DIR=/tmp/gnome99-runtime \ + PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native \ + dbus-run-session -- gnome-session --session=ubuntu & + ``` + +5. **Verify** the display is working (give GNOME a few seconds to start): + ```bash + DISPLAY=:99 ffmpeg -f x11grab -video_size 1920x1080 -i :99.0+0,0 -frames:v 1 -update 1 /tmp/check.png + ``` + +Your physical keyboard and mouse now control the virtual display directly. + +> **Audio:** If audio is not working (e.g. PulseAudio only has a `null` sink), load your physical audio device manually: +> ```bash +> # List available ALSA devices +> aplay -l +> # Load the desired device (e.g. USB headset on card 1) +> pactl load-module module-alsa-sink device=hw:1,0 sink_name=plantronics sink_properties=device.description="Plantronics_Headset" +> pactl set-default-sink plantronics +> ``` +> +> Applications launched on the virtual display (e.g. Chrome) must also have `PULSE_SERVER` set: +> ```bash +> DISPLAY=:99 PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native google-chrome & +> ``` + +#### Example: screen-capture input (`config/tx_fullhd_screen_capture.json`) + +```json +{ + "interfaces": [ + { "name": "0000:06:00.0", "sip": "192.168.50.29", "dip": "239.168.85.20" } + ], + "video": { + "width": 1920, + "height": 1080, + "input_mode": "screen_capture", + "screen_input": ":99.0+0,0" + }, + "tx_video": { + "fps": 30, + "fmt": "yuv422p10le" + }, + "tx_sessions": [ + { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 1920, "h": 1080 } } + ] +} +``` + +#### Example: multi-session screen capture (`config/tx_fullhd_screen_capture_multi_session.json`) + +Splits a 1920×1080 screen capture into 3 vertical strips, each transmitted as a separate ST2110 session: + +```json +{ + "interfaces": [ + { "name": "0000:06:00.0", "sip": "192.168.50.29", "dip": "239.168.85.20" } + ], + "video": { + "width": 1920, + "height": 1080, + "input_mode": "screen_capture", + "screen_input": ":99.0+0,0" + }, + "tx_video": { + "fps": 30, + "fmt": "yuv422p10le" + }, + "tx_sessions": [ + { "udp_port": 20000, "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 640, "h": 1080 } }, + { "udp_port": 20002, "payload_type": 96, "crop": { "x": 640, "y": 0, "w": 640, "h": 1080 } }, + { "udp_port": 20004, "payload_type": 96, "crop": { "x": 1280, "y": 0, "w": 640, "h": 1080 } } + ] +} +``` + +`screen_input` follows FFmpeg's `x11grab` URL syntax: `[+,]` (e.g. `:99.0+0,0` captures display `:99`, matching the virtual display created above, starting at offset `0,0`). The capture resolution/framerate are taken from the `width`/`height`/`fps` fields above. + +Run dvledtx as usual — `x11grab` will capture whatever is rendered on the display (desktop, windows, applications) and transmit it, exactly as it would for a physical display: +```bash +./build/dvledtx --config config/tx_fullhd_screen_capture.json +``` + +**Tear down** the virtual display when done: +```bash +pkill -f "gnome-session --session=ubuntu" +sudo pkill -f "Xorg :99" +``` + ## Logging dvledtx includes a built-in logger with configurable output targets and log levels. diff --git a/config/tx_1session_12bit.json b/config/tx_1session_12bit.json new file mode 100644 index 0000000..9154858 --- /dev/null +++ b/config/tx_1session_12bit.json @@ -0,0 +1,25 @@ +{ + "interfaces": [ + { + "name": "0000:06:00.0", + "sip": "192.168.50.29", + "dip": "239.168.85.20" + } + ], + "video": { + "width": 1920, + "height": 1080, + "tx_url": "bbb_sunflower_1080p_30fps_normal.mp4" + }, + "tx_video": { + "fps": 30, + "fmt": "yuv422p12le" + }, + "tx_sessions": [ + { + "udp_port": 20000, + "payload_type": 96, + "crop": { "x": 0, "y": 0, "w": 1920, "h": 1080 } + } + ] +} diff --git a/config/tx_fullhd_screen_capture.json b/config/tx_fullhd_screen_capture.json new file mode 100644 index 0000000..d45a9ce --- /dev/null +++ b/config/tx_fullhd_screen_capture.json @@ -0,0 +1,26 @@ +{ + "interfaces": [ + { + "name": "0000:06:00.0", + "sip": "192.168.50.29", + "dip": "239.168.85.20" + } + ], + "video": { + "width": 1920, + "height": 1080, + "input_mode": "screen_capture", + "screen_input": ":99.0+0,0" + }, + "tx_video": { + "fps": 30, + "fmt": "yuv422p10le" + }, + "tx_sessions": [ + { + "udp_port": 20000, + "payload_type": 96, + "crop": { "x": 0, "y": 0, "w": 1920, "h": 1080 } + } + ] +} diff --git a/config/tx_fullhd_screen_capture_multi_session.json b/config/tx_fullhd_screen_capture_multi_session.json new file mode 100644 index 0000000..8a45e81 --- /dev/null +++ b/config/tx_fullhd_screen_capture_multi_session.json @@ -0,0 +1,36 @@ +{ + "interfaces": [ + { + "name": "0000:06:00.0", + "sip": "192.168.50.29", + "dip": "239.168.85.20" + } + ], + "video": { + "width": 1920, + "height": 1080, + "input_mode": "screen_capture", + "screen_input": ":99.0+0,0" + }, + "tx_video": { + "fps": 30, + "fmt": "yuv422p10le" + }, + "tx_sessions": [ + { + "udp_port": 20000, + "payload_type": 96, + "crop": { "x": 0, "y": 0, "w": 640, "h": 1080 } + }, + { + "udp_port": 20002, + "payload_type": 96, + "crop": { "x": 640, "y": 0, "w": 640, "h": 1080 } + }, + { + "udp_port": 20004, + "payload_type": 96, + "crop": { "x": 1280, "y": 0, "w": 640, "h": 1080 } + } + ] +} diff --git a/include/app_context.h b/include/app_context.h index 78fcbc3..cab3577 100644 --- a/include/app_context.h +++ b/include/app_context.h @@ -43,6 +43,7 @@ struct dvledtx_context { struct nic_config* nics; /* heap-allocated, nic_count elements */ char tx_url[256]; + char screen_input[128]; char config_file[256]; uint16_t udp_port; uint8_t payload_type; /* RTP dynamic payload type (default: 96) */ @@ -57,6 +58,7 @@ struct dvledtx_context { /* Session controls */ int st20p_sessions; + bool use_screen_capture; bool exit; bool force_dhcp; int test_time_s; diff --git a/include/util/config_reader.h b/include/util/config_reader.h index d0c0d08..c4e9c40 100644 --- a/include/util/config_reader.h +++ b/include/util/config_reader.h @@ -37,6 +37,8 @@ struct dvledtx_config { uint32_t scale_height; /* 0 = no scaling (use source height) */ int fps; char fmt[32]; /* e.g. "yuv422p10le" */ + char input_mode[32]; /* "file" (default) or "screen_capture" */ + char screen_input[128]; /* x11grab source, e.g. ":0.0+0,0" */ char tx_url[256]; /* optional log file path (empty = console only) */ diff --git a/meson.build b/meson.build index c7d8527..a85c8b2 100644 --- a/meson.build +++ b/meson.build @@ -16,24 +16,17 @@ libswscale_dep = dependency('libswscale', required: true) # TX path selection — see meson_options.txt for details. enable_mtl_tx = get_option('enable_mtl_tx') -# mtl_tx.c always calls mtl_init()/mtl_uninit() (unconditionally, not just -# under ENABLE_MTL_TX) so that the FFmpeg avdevice path can pre-register all -# NIC ports with DPDK EAL before the mtl_st20p muxer opens its own session — -# see the comment above mtl_tx_init() in src/mtl/mtl_tx.c. MTL is therefore a -# required dependency for both TX paths, not just the direct-MTL one. -# -# Its include path comes entirely from `dependency('mtl')`'s own pkg-config -# Cflags (which already resolves to wherever MTL headers are installed, e.g. -# `-I/usr/local/include -I/usr/local/include/mtl`) — no hardcoded include -# path is added here, so this works regardless of MTL's install location. +# MTL is a required dependency for both TX paths — see src/mtl/mtl_tx.c. mtl_dep = dependency('mtl', required: true) -avdevice_dep = [] c_args = ['-DALLOW_EXPERIMENTAL_API', '-D_FORTIFY_SOURCE=2', '-O2'] +# libavdevice is needed regardless of the TX path: the mtl_st20p muxer TX +# path uses it directly, and the MTL-native TX path still needs it for the +# x11grab screen-capture input decoder (avdevice_register_all()). +avdevice_dep = dependency('libavdevice', required: true) + if enable_mtl_tx c_args += ['-DENABLE_MTL_TX'] -else - avdevice_dep = dependency('libavdevice', required: true) endif all_deps = [m_dep, pthread_dep, diff --git a/src/core/session_manager.c b/src/core/session_manager.c index 6b82085..d1957d5 100644 --- a/src/core/session_manager.c +++ b/src/core/session_manager.c @@ -253,7 +253,7 @@ int create_st20p_tx_session(session_manager_t* manager, struct dvledtx_context* if (manager->shared_dec) { ctx->shared_dec = manager->shared_dec; LOG_INFO("ST20P TX session %d: using shared decoder", session_idx); - } else if (strlen(app->tx_url) > 0) { + } else if (app->use_screen_capture == true || strlen(app->tx_url) > 0) { if (load_video_source(ctx, app->tx_url) < 0) { LOG_ERROR("ST20P TX session %d: load_video_source failed", session_idx); return -1; @@ -278,8 +278,8 @@ int session_manager_init(session_manager_t* manager, struct dvledtx_context* app /* Use shared decoder only when > 1 session and source needs decoding */ bool use_shared = (app->st20p_sessions > 1 && - strlen(app->tx_url) > 0 && - is_raw_yuv(app->tx_url) == false); + (app->use_screen_capture == true || + (strlen(app->tx_url) > 0 && is_raw_yuv(app->tx_url) == false))); if (use_shared) { manager->shared_dec = calloc(1, sizeof(struct shared_decode_ctx)); diff --git a/src/ffmpeg/ffmpeg_decoder.c b/src/ffmpeg/ffmpeg_decoder.c index 5274a5c..718070c 100644 --- a/src/ffmpeg/ffmpeg_decoder.c +++ b/src/ffmpeg/ffmpeg_decoder.c @@ -20,6 +20,7 @@ #include #include #include +#include /* ========================================================================= * Helpers @@ -199,6 +200,7 @@ void* shared_decode_thread(void* arg) { * ========================================================================= */ static int open_ffmpeg_decoder( const char* filename, const char* log_prefix, + bool use_screen_capture, const char* screen_input, int capture_w, int capture_h, int capture_fps, enum AVPixelFormat target_fmt, int target_w, int target_h, AVFormatContext** out_fmt_ctx, AVCodecContext** out_codec_ctx, struct SwsContext** out_sws_ctx, AVFrame** out_av_frame, @@ -207,10 +209,37 @@ static int open_ffmpeg_decoder( char errbuf[256]; int ret; - ret = avformat_open_input(out_fmt_ctx, filename, NULL, NULL); + if (use_screen_capture == true) { + const char* input_url = (screen_input && screen_input[0] != '\0') ? screen_input : ":0.0+0,0"; + char video_size[32]; + char framerate[16]; + snprintf(video_size, sizeof(video_size), "%dx%d", capture_w, capture_h); + snprintf(framerate, sizeof(framerate), "%d", capture_fps > 0 ? capture_fps : 30); + + const AVInputFormat* in_fmt = av_find_input_format("x11grab"); + if (in_fmt == NULL) { + LOG_ERROR("%s: x11grab input format not found", log_prefix); + return -1; + } + + AVDictionary* options = NULL; + av_dict_set(&options, "video_size", video_size, 0); + av_dict_set(&options, "framerate", framerate, 0); + + ret = avformat_open_input(out_fmt_ctx, input_url, in_fmt, &options); + av_dict_free(&options); + if (ret < 0) { + av_strerror(ret, errbuf, sizeof(errbuf)); + LOG_ERROR("%s: cannot open x11grab source %s: %s", log_prefix, input_url, errbuf); + return -1; + } + } else { + ret = avformat_open_input(out_fmt_ctx, filename, NULL, NULL); + } if (ret < 0) { av_strerror(ret, errbuf, sizeof(errbuf)); - LOG_ERROR("%s: cannot open %s: %s", log_prefix, filename, errbuf); + LOG_ERROR("%s: cannot open %s: %s", log_prefix, + use_screen_capture ? "x11grab input" : filename, errbuf); return -1; } ret = avformat_find_stream_info(*out_fmt_ctx, NULL); @@ -323,8 +352,13 @@ int open_shared_ffmpeg(struct shared_decode_ctx* dec, const char* filename) { const struct dvledtx_context* app = dec->app; int target_w = (int)(app->scale_width > 0 ? app->scale_width : app->width); int target_h = (int)(app->scale_height > 0 ? app->scale_height : app->height); + /* filename may be empty when screen capture is enabled (app->tx_url is + * not required in that mode); use the screen_input descriptor instead so + * log messages stay meaningful. */ + const char* effective_source = app->use_screen_capture ? app->screen_input : filename; return open_ffmpeg_decoder( - filename, "Shared decode", + effective_source, "Shared decode", + app->use_screen_capture, app->screen_input, (int)app->width, (int)app->height, app->fps, app->fmt, target_w, target_h, &dec->fmt_ctx, &dec->codec_ctx, &dec->sws_ctx, &dec->av_frame, &dec->yuv_frame, &dec->av_packet, @@ -347,6 +381,7 @@ static int open_ffmpeg_source(struct st20p_tx_ctx* ctx, const char* filename) { int target_h = (int)(ctx->app->scale_height > 0 ? ctx->app->scale_height : ctx->app->height); int ret = open_ffmpeg_decoder( filename, log_prefix, + ctx->app->use_screen_capture, ctx->app->screen_input, (int)ctx->app->width, (int)ctx->app->height, ctx->app->fps, ctx->app->fmt, target_w, target_h, &ctx->fmt_ctx, &ctx->codec_ctx, &ctx->sws_ctx, &ctx->av_frame, &ctx->yuv_frame, &ctx->av_packet, @@ -366,6 +401,10 @@ void close_ffmpeg_source(struct st20p_tx_ctx* ctx) { * Video source loading * ========================================================================= */ int load_video_source(struct st20p_tx_ctx* ctx, const char* filename) { + if (ctx->app->use_screen_capture == true) { + return open_ffmpeg_source(ctx, ctx->app->screen_input); + } + if (!filename || strlen(filename) == 0) { LOG_WARN("ST20P TX(%d): no source file configured", ctx->idx); return 0; diff --git a/src/main.c b/src/main.c index 2609e14..9568b80 100644 --- a/src/main.c +++ b/src/main.c @@ -14,10 +14,10 @@ #include #include -/* libavdevice is only needed for the FFmpeg mtl_st20p muxer TX path */ -#ifndef ENABLE_MTL_TX +/* libavdevice is needed for both TX paths: the FFmpeg mtl_st20p muxer TX + * path, and the x11grab screen-capture input decoder used by the MTL-native + * TX path (ENABLE_MTL_TX). */ #include -#endif #include #include "app_context.h" #include "util/config_reader.h" @@ -285,11 +285,10 @@ int main(int argc, char** argv) { if (signal(SIGTERM, dvledtx_sig_handler) == SIG_ERR) LOG_WARN("Failed to install SIGTERM handler"); - /* Register all FFmpeg devices (required for the MTL mtl_st20p muxer - * which lives in libavdevice, not libavformat) */ -#ifndef ENABLE_MTL_TX + /* Register all FFmpeg devices (required for the MTL mtl_st20p muxer, + * which lives in libavdevice, and for the x11grab screen-capture input + * decoder used by both TX paths) */ avdevice_register_all(); -#endif /* I-3: Suppress verbose FFmpeg internal logging in production to avoid * leaking internal paths or memory addresses via av_strerror output. */ diff --git a/src/util/config_reader.c b/src/util/config_reader.c index f480ea8..52bc5cb 100644 --- a/src/util/config_reader.c +++ b/src/util/config_reader.c @@ -231,6 +231,7 @@ int parse_tx_config(const char* config_file, struct dvledtx_config* config) { const char* buf_end = json + nread; memset(config, 0, sizeof(*config)); + strncpy(config->input_mode, "file", sizeof(config->input_mode) - 1); /* --- interfaces[] — parse all entries, growing arrays as needed --- */ const char* ifaces_arr = find_array(json, buf_end, "interfaces"); @@ -310,7 +311,13 @@ int parse_tx_config(const char* config_file, struct dvledtx_config* config) { int v; v = extract_json_int(video_obj, video_end, "width"); if (v > 0) config->width = v; v = extract_json_int(video_obj, video_end, "height"); if (v > 0) config->height = v; + extract_json_string(video_obj, video_end, "input_mode", config->input_mode, sizeof(config->input_mode)); + extract_json_string(video_obj, video_end, "screen_input", config->screen_input, sizeof(config->screen_input)); extract_json_string(video_obj, video_end, "tx_url", config->tx_url, sizeof(config->tx_url)); + + if (strcmp(config->input_mode, "screen_capture") == 0 && config->screen_input[0] == '\0') { + strncpy(config->screen_input, ":0.0+0,0", sizeof(config->screen_input) - 1); + } } /* --- tx_video block (transmission parameters) --- */ @@ -528,6 +535,20 @@ int validate_tx_config(const struct dvledtx_config* config) { return -1; } + /* Input mode validation */ + if (config->input_mode[0] == '\0' || strcmp(config->input_mode, "file") == 0) { + /* Default/normal mode */ + } else if (strcmp(config->input_mode, "screen_capture") == 0) { + if (config->screen_input[0] == '\0') { + LOG_ERROR("video.screen_input must be set when input_mode is 'screen_capture'"); + return -1; + } + } else { + LOG_ERROR("unsupported input_mode '%s' (supported: file, screen_capture)", + config->input_mode); + return -1; + } + /* Scale dimensions validation (optional — 0 means no scaling) */ if (config->scale_width != 0 || config->scale_height != 0) { if (config->scale_width == 0 || config->scale_height == 0) { @@ -588,8 +609,10 @@ int validate_tx_config(const struct dvledtx_config* config) { return -1; } - /* Video source file validation */ - if (config->tx_url[0] != '\0') { + /* Video source file validation — screen_input was already validated + * above in the "Input mode validation" block, so only check the file + * path here when not in screen-capture mode. */ + if (strcmp(config->input_mode, "screen_capture") != 0 && config->tx_url[0] != '\0') { FILE* f = fopen(config->tx_url, "rb"); if (!f) { LOG_ERROR("video source file not found: %s", config->tx_url); @@ -775,6 +798,11 @@ int load_and_apply_config(struct dvledtx_context* app, const char* config_file) strncpy(app->tx_url, config.tx_url, sizeof(app->tx_url) - 1); app->tx_url[sizeof(app->tx_url) - 1] = '\0'; } + if (config.screen_input[0] != '\0') { + strncpy(app->screen_input, config.screen_input, sizeof(app->screen_input) - 1); + app->screen_input[sizeof(app->screen_input) - 1] = '\0'; + } + app->use_screen_capture = (strcmp(config.input_mode, "screen_capture") == 0); /* Copy per-session network + crop into app->session_net[] */ for (int i = 0; i < config.session_count; i++) { @@ -806,15 +834,21 @@ int load_and_apply_config(struct dvledtx_context* app, const char* config_file) config.interface_sip[ni][0] ? config.interface_sip[ni] : "dhcp", config.interface_dip[ni]); if (config.scale_width > 0 && config.scale_height > 0) - LOG_INFO("Video: %ux%u -> scale %ux%u %dfps %s tx_url=%s", + LOG_INFO("Video: %ux%u -> scale %ux%u %dfps %s mode=%s source=%s", config.width, config.height, config.scale_width, config.scale_height, config.fps, config.fmt, - config.tx_url[0] ? config.tx_url : ""); + config.input_mode[0] ? config.input_mode : "file", + strcmp(config.input_mode, "screen_capture") == 0 + ? (config.screen_input[0] ? config.screen_input : "") + : (config.tx_url[0] ? config.tx_url : "")); else - LOG_INFO("Video: %ux%u %dfps %s tx_url=%s", + LOG_INFO("Video: %ux%u %dfps %s mode=%s source=%s", config.width, config.height, config.fps, config.fmt, - config.tx_url[0] ? config.tx_url : ""); + config.input_mode[0] ? config.input_mode : "file", + strcmp(config.input_mode, "screen_capture") == 0 + ? (config.screen_input[0] ? config.screen_input : "") + : (config.tx_url[0] ? config.tx_url : "")); for (int i = 0; i < config.session_count; i++) LOG_INFO(" Session %d: udp_port=%u pt=%u nic=%d crop=[%d,%d %dx%d]", i, config.sessions[i].udp_port, config.sessions[i].payload_type, diff --git a/tests/test_config_reader.c b/tests/test_config_reader.c index a7d2f2f..8d248ab 100644 --- a/tests/test_config_reader.c +++ b/tests/test_config_reader.c @@ -75,6 +75,7 @@ static void fill_valid_config(struct dvledtx_config *cfg) cfg->height = 1080; cfg->fps = 30; strncpy(cfg->fmt, "yuv422p10le", sizeof(cfg->fmt) - 1); + strncpy(cfg->input_mode, "file", sizeof(cfg->input_mode) - 1); /* tx_url intentionally left empty — skips file-open check in validate */ cfg->session_cap = 1; cfg->sessions = calloc(1, sizeof(*cfg->sessions)); @@ -263,6 +264,74 @@ static void test_parse_returns_zero_fields_when_video_missing(void **state) assert_int_equal((int)cfg.width, 0); assert_int_equal((int)cfg.height, 0); assert_int_equal(cfg.fps, 0); + assert_string_equal(cfg.input_mode, "file"); + dvledtx_config_free(&cfg); +} + +static void test_parse_input_mode_defaults_to_file_when_omitted(void **state) +{ + (void)state; + char *path = write_tmpfile( + "{" + " \"interfaces\": [{\"name\":\"eth0\",\"sip\":\"1.2.3.4\",\"dip\":\"239.1.1.1\"}]," + " \"video\": {\"width\":1920,\"height\":1080,\"tx_url\":\"/tmp/f.mp4\"}," + " \"tx_video\": {\"fps\":25,\"fmt\":\"yuv422p10le\"}," + " \"tx_sessions\": [{\"udp_port\":20000,\"payload_type\":96," + " \"crop\":{\"x\":0,\"y\":0,\"w\":1920,\"h\":1080}}]" + "}"); + assert_non_null(path); + + struct dvledtx_config cfg; + int ret = parse_tx_config(path, &cfg); + unlink(path); free(path); + assert_int_equal(ret, 0); + assert_string_equal(cfg.input_mode, "file"); + assert_int_equal(cfg.screen_input[0], '\0'); + dvledtx_config_free(&cfg); +} + +static void test_parse_screen_capture_defaults_screen_input(void **state) +{ + (void)state; + char *path = write_tmpfile( + "{" + " \"interfaces\": [{\"name\":\"eth0\",\"sip\":\"1.2.3.4\",\"dip\":\"239.1.1.1\"}]," + " \"video\": {\"width\":1920,\"height\":1080,\"input_mode\":\"screen_capture\"}," + " \"tx_video\": {\"fps\":25,\"fmt\":\"yuv422p10le\"}," + " \"tx_sessions\": [{\"udp_port\":20000,\"payload_type\":96," + " \"crop\":{\"x\":0,\"y\":0,\"w\":1920,\"h\":1080}}]" + "}"); + assert_non_null(path); + + struct dvledtx_config cfg; + int ret = parse_tx_config(path, &cfg); + unlink(path); free(path); + assert_int_equal(ret, 0); + assert_string_equal(cfg.input_mode, "screen_capture"); + assert_string_equal(cfg.screen_input, ":0.0+0,0"); + dvledtx_config_free(&cfg); +} + +static void test_parse_screen_capture_keeps_explicit_screen_input(void **state) +{ + (void)state; + char *path = write_tmpfile( + "{" + " \"interfaces\": [{\"name\":\"eth0\",\"sip\":\"1.2.3.4\",\"dip\":\"239.1.1.1\"}]," + " \"video\": {\"width\":1920,\"height\":1080," + " \"input_mode\":\"screen_capture\",\"screen_input\":\":1.0+100,200\"}," + " \"tx_video\": {\"fps\":25,\"fmt\":\"yuv422p10le\"}," + " \"tx_sessions\": [{\"udp_port\":20000,\"payload_type\":96," + " \"crop\":{\"x\":0,\"y\":0,\"w\":1920,\"h\":1080}}]" + "}"); + assert_non_null(path); + + struct dvledtx_config cfg; + int ret = parse_tx_config(path, &cfg); + unlink(path); free(path); + assert_int_equal(ret, 0); + assert_string_equal(cfg.input_mode, "screen_capture"); + assert_string_equal(cfg.screen_input, ":1.0+100,200"); dvledtx_config_free(&cfg); } @@ -836,6 +905,35 @@ static void test_validate_no_scale_passes(void **state) dvledtx_config_free(&cfg); } +static void test_validate_unknown_input_mode_fails(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + strncpy(cfg.input_mode, "camera", sizeof(cfg.input_mode) - 1); + assert_int_equal(validate_tx_config(&cfg), -1); +} + +static void test_validate_screen_capture_without_screen_input_fails(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + strncpy(cfg.input_mode, "screen_capture", sizeof(cfg.input_mode) - 1); + cfg.screen_input[0] = '\0'; + assert_int_equal(validate_tx_config(&cfg), -1); +} + +static void test_validate_screen_capture_with_screen_input_passes(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + strncpy(cfg.input_mode, "screen_capture", sizeof(cfg.input_mode) - 1); + strncpy(cfg.screen_input, ":0.0+0,0", sizeof(cfg.screen_input) - 1); + assert_int_equal(validate_tx_config(&cfg), 0); +} + static void test_validate_duplicate_udp_ports_fails(void **state) { (void)state; @@ -1354,6 +1452,28 @@ static void test_load_and_apply_config_copies_log_file(void **state) dvledtx_context_free(&app); } +static void test_load_and_apply_config_maps_screen_capture_fields(void **state) +{ + (void)state; + char *path = write_tmpfile( + "{" + " \"interfaces\": [{\"name\":\"0000:06:00.0\",\"sip\":\"192.168.50.29\",\"dip\":\"239.168.85.20\"}]," + " \"video\": {\"width\":1920,\"height\":1080,\"input_mode\":\"screen_capture\",\"screen_input\":\":1.0+100,200\"}," + " \"tx_video\": {\"fps\":25,\"fmt\":\"yuv422p10le\"}," + " \"tx_sessions\": [{\"udp_port\":20000,\"payload_type\":96," + " \"crop\":{\"x\":0,\"y\":0,\"w\":1920,\"h\":1080}}]" + "}"); + assert_non_null(path); + + struct dvledtx_context app; + memset(&app, 0, sizeof(app)); + int ret = load_and_apply_config(&app, path); + unlink(path); free(path); + assert_int_equal(ret, 0); + assert_true(app.use_screen_capture); + assert_string_equal(app.screen_input, ":1.0+100,200"); +} + /* ========================================================================== * main * ========================================================================== */ @@ -1375,6 +1495,9 @@ int main(void) cmocka_unit_test(test_parse_returns_minus1_when_sessions_key_absent), cmocka_unit_test(test_parse_returns_minus1_when_sessions_array_empty), cmocka_unit_test(test_parse_returns_zero_fields_when_video_missing), + cmocka_unit_test(test_parse_input_mode_defaults_to_file_when_omitted), + cmocka_unit_test(test_parse_screen_capture_defaults_screen_input), + cmocka_unit_test(test_parse_screen_capture_keeps_explicit_screen_input), cmocka_unit_test(test_parse_session_missing_udp_port_fails), cmocka_unit_test(test_parse_session_udp_port_exceeds_65535_fails), cmocka_unit_test(test_parse_session_missing_payload_type_defaults_to_96), @@ -1419,6 +1542,9 @@ int main(void) cmocka_unit_test(test_validate_scale_crop_exceeds_scaled_dims_fails), cmocka_unit_test(test_validate_scale_crop_within_scaled_dims_passes), cmocka_unit_test(test_validate_no_scale_passes), + cmocka_unit_test(test_validate_unknown_input_mode_fails), + cmocka_unit_test(test_validate_screen_capture_without_screen_input_fails), + cmocka_unit_test(test_validate_screen_capture_with_screen_input_passes), cmocka_unit_test(test_validate_duplicate_udp_ports_fails), cmocka_unit_test(test_validate_crop_x_misaligned_for_yuv422_fails), cmocka_unit_test(test_validate_tx_url_nonexistent_file_fails), @@ -1464,6 +1590,7 @@ int main(void) cmocka_unit_test(test_load_and_apply_config_fmt_yuv420), cmocka_unit_test(test_load_and_apply_config_unknown_fmt_fails), cmocka_unit_test(test_load_and_apply_config_copies_log_file), + cmocka_unit_test(test_load_and_apply_config_maps_screen_capture_fields), }; return cmocka_run_group_tests(tests, NULL, NULL); diff --git a/tests/test_ffmpeg_decoder_mock.c b/tests/test_ffmpeg_decoder_mock.c index c15543a..c89946b 100644 --- a/tests/test_ffmpeg_decoder_mock.c +++ b/tests/test_ffmpeg_decoder_mock.c @@ -43,6 +43,7 @@ #include #include +#include #include #include #include @@ -285,6 +286,91 @@ static void test_load_video_source_nonexistent_mp4_returns_minus1(void **state) assert_null(ctx.fmt_ctx); } +/* ========================================================================= + * Test: screen capture (x11grab) input path + * + * Regression coverage for the bug fixed in commit f74a727: the x11grab + * demuxer is only discoverable via av_find_input_format("x11grab") after + * avdevice_register_all() has run. This test binary never calls that + * function on its own (only src/main.c does, which is not linked here), + * so ordering matters: the "before registration" test below MUST run + * before the "after registration" test registers it process-wide. + * ========================================================================= */ + +static void test_screen_capture_before_avdevice_register_fails(void **state) +{ + (void)state; + /* Sanity guard: at this point nothing in this test binary has called + * avdevice_register_all() yet, so x11grab must not be resolvable — + * mirrors the exact symptom of the original bug + * ("x11grab input format not found"). */ + assert_null(av_find_input_format("x11grab")); + + struct dvledtx_context app; + fill_app_16x16(&app, 1); + app.use_screen_capture = true; + strncpy(app.screen_input, ":0.0+0,0", sizeof(app.screen_input) - 1); + + struct shared_decode_ctx dec; + memset(&dec, 0, sizeof(dec)); + dec.app = &app; + dec.num_sessions = 1; + + int ret = open_shared_ffmpeg(&dec, "unused_filename.mp4"); + assert_int_equal(ret, -1); + assert_null(dec.fmt_ctx); +} + +static void test_screen_capture_after_avdevice_register_finds_x11grab(void **state) +{ + (void)state; + avdevice_register_all(); + assert_non_null(av_find_input_format("x11grab")); +} + +static void test_screen_capture_shared_invalid_display_fails_gracefully(void **state) +{ + (void)state; + /* x11grab is registered (previous test), but the configured display + * does not exist in this headless CI environment (or anywhere, + * given the absurd display number) — avformat_open_input() must fail + * and open_shared_ffmpeg() must propagate -1 without crashing. */ + struct dvledtx_context app; + fill_app_16x16(&app, 1); + app.use_screen_capture = true; + strncpy(app.screen_input, ":424242.0+0,0", sizeof(app.screen_input) - 1); + + struct shared_decode_ctx dec; + memset(&dec, 0, sizeof(dec)); + dec.app = &app; + dec.num_sessions = 1; + + int ret = open_shared_ffmpeg(&dec, "unused_filename.mp4"); + assert_int_equal(ret, -1); + assert_null(dec.fmt_ctx); +} + +static void test_screen_capture_per_session_invalid_display_fails_gracefully(void **state) +{ + (void)state; + /* Same as above but through the single-session load_video_source() -> + * open_ffmpeg_source() path used when st20p_sessions == 1. */ + struct dvledtx_context app; + fill_app_16x16(&app, 1); + app.use_screen_capture = true; + strncpy(app.screen_input, ":424242.0+0,0", sizeof(app.screen_input) - 1); + + struct st20p_tx_ctx ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.idx = 0; + ctx.app = &app; + + int ret = load_video_source(&ctx, app.screen_input); + assert_int_equal(ret, -1); + assert_false(ctx.use_ffmpeg); + assert_null(ctx.fmt_ctx); +} + /* ========================================================================= * Test: shared_decode_thread — runs with generated video + barriers * ========================================================================= */ @@ -421,6 +507,13 @@ int main(void) cmocka_unit_test(test_load_video_source_mp4_calls_open_ffmpeg_source), cmocka_unit_test(test_load_video_source_nonexistent_mp4_returns_minus1), + /* screen capture (x11grab) — order matters: avdevice_register_all() + * must not have run yet for the first test below. */ + cmocka_unit_test(test_screen_capture_before_avdevice_register_fails), + cmocka_unit_test(test_screen_capture_after_avdevice_register_finds_x11grab), + cmocka_unit_test(test_screen_capture_shared_invalid_display_fails_gracefully), + cmocka_unit_test(test_screen_capture_per_session_invalid_display_fails_gracefully), + /* shared_decode_thread */ cmocka_unit_test(test_shared_decode_thread_decodes_frames), diff --git a/tests/test_main.c b/tests/test_main.c index a4f529f..41f7a4c 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -383,6 +383,27 @@ static void test_main_happy_path_exits_immediately(void **state) assert_int_equal(ret, 0); } +/* Regression test for commit f74a727: avdevice_register_all() must be + * called unconditionally in tx_app_real_main(), NOT gated behind an + * ENABLE_MTL_TX ifdef. Previously the MTL-native TX build path skipped + * avdevice registration entirely, so av_find_input_format("x11grab") + * always returned NULL and screen-capture input silently failed with + * "x11grab input format not found" — even though libavdevice itself was + * linked. Running the full main() body here and then checking that the + * x11grab demuxer becomes discoverable catches any future regression of + * that ifdef. */ +static void test_main_registers_avdevice_for_x11grab(void **state) +{ + (void)state; + reset_stubs(); + stub_load_config_set_exit = true; /* exit loop immediately */ + optind = 1; + char *argv[] = {"dvledtx", "--config", "test.json", NULL}; + int ret = tx_app_real_main(3, argv); + assert_int_equal(ret, 0); + assert_non_null(av_find_input_format("x11grab")); +} + /* Happy path with test_time_s = 1: exercises the for-loop branch. * app.exit is set true so the for loop exits after checking !app.exit. */ static void test_main_test_time_path(void **state) @@ -691,6 +712,7 @@ int main(void) /* tx_app_real_main (full main() body) */ cmocka_unit_test(test_main_no_config_returns_minus1), cmocka_unit_test(test_main_happy_path_exits_immediately), + cmocka_unit_test(test_main_registers_avdevice_for_x11grab), cmocka_unit_test(test_main_test_time_path), cmocka_unit_test(test_main_config_load_fails), cmocka_unit_test(test_main_session_init_fails), diff --git a/tests/test_session_manager.c b/tests/test_session_manager.c index 8c81efc..426d520 100644 --- a/tests/test_session_manager.c +++ b/tests/test_session_manager.c @@ -385,6 +385,51 @@ static void test_init_3sessions_raw_yuv_no_shared_decoder(void **state) session_manager_cleanup(&mgr); } +/* ========================================================================== + * session_manager_init — 3 sessions with screen capture -> shared decoder + * ========================================================================== */ + +static void test_init_3sessions_screen_capture_uses_shared_decoder(void **state) +{ + (void)state; + reset_mock_counters(); + struct dvledtx_context app; + fill_app(&app, 3, ""); + app.use_screen_capture = true; + strncpy(app.screen_input, ":0.0+0,0", sizeof(app.screen_input) - 1); + + session_manager_t mgr; + assert_int_equal(session_manager_init(&mgr, &app), 0); + + assert_non_null(mgr.shared_dec); + assert_int_equal(mock_open_shared_ffmpeg_calls, 1); + assert_int_equal(mock_load_video_source_calls, 0); + + session_manager_cleanup(&mgr); +} + +/* ========================================================================== + * session_manager_init — single session screen capture -> per-session source + * ========================================================================== */ + +static void test_init_single_session_screen_capture_loads_source(void **state) +{ + (void)state; + reset_mock_counters(); + struct dvledtx_context app; + fill_app(&app, 1, ""); + app.use_screen_capture = true; + strncpy(app.screen_input, ":0.0+0,0", sizeof(app.screen_input) - 1); + + session_manager_t mgr; + assert_int_equal(session_manager_init(&mgr, &app), 0); + + assert_null(mgr.shared_dec); + assert_int_equal(mock_load_video_source_calls, 1); + + session_manager_cleanup(&mgr); +} + /* ========================================================================== * session_manager_init — open_ffmpeg_tx failure * ========================================================================== */ @@ -937,6 +982,8 @@ int main(void) cmocka_unit_test(test_init_single_session_no_url), cmocka_unit_test(test_init_3sessions_with_url_uses_shared_decoder), cmocka_unit_test(test_init_3sessions_raw_yuv_no_shared_decoder), + cmocka_unit_test(test_init_3sessions_screen_capture_uses_shared_decoder), + cmocka_unit_test(test_init_single_session_screen_capture_loads_source), cmocka_unit_test(test_init_fails_when_open_output_fails), cmocka_unit_test(test_init_fails_when_open_shared_ffmpeg_fails), cmocka_unit_test(test_init_zero_sessions),