diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..397b4a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.log diff --git a/README.md b/README.md index 7798477..9178494 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,15 @@ dvledtx uses a JSON config file with three sections: | **interfaces** | `name` | PCI BDF address of the NIC (e.g. `0000:06:00.0`) | | | `sip` | Source IP address | | | `dip` | Destination multicast IP address | -| **video** | `width` | Frame width in pixels | -| | `height` | Frame height in pixels | +| **video** | `width` | Source frame width in pixels | +| | `height` | Source frame height in pixels | +| | `tx_url` | Path to the source video file | +| **tx_video** | `scale_width` | (Optional) Output width after scaling | +| | `scale_height` | (Optional) Output height after scaling | | | `fps` | Frames per second (25, 30, 50, 60) | | | `fmt` | Pixel format (see [Supported Formats](#supported-formats)) | -| | `tx_url` | Path to the source video file | | **tx_sessions[]** | `udp_port` | UDP port for the session | -| | `payload_type` | RTP payload type (typically 96) | +| | `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_single_session.json`): @@ -146,12 +148,14 @@ Example (`config/tx_fullhd_single_session.json`): { "name": "0000:06:00.0", "sip": "192.168.50.29", "dip": "239.168.85.20" } ], "video": { - "width": 1920, "height": 1080, "fps": 30, - "fmt": "yuv422p10le", + "width": 1920, "height": 1080, "tx_url": "bbb_sunflower_1080p_30fps_normal.mp4" }, + "tx_video": { + "fps": 30, "fmt": "yuv422p10le" + }, "tx_sessions": [ - { "udp_port": 20000, "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 1920, "h": 1080 } } + { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 1920, "h": 1080 } } ] } ``` @@ -236,6 +240,43 @@ When `log_file` is set, log output is written to that file in addition to the co > **Note:** Maximum supported resolution is 3840x2160. Width must be even for YUV format alignment. +### Video Scaling + +dvledtx supports upscaling and downscaling via optional `scale_width` and `scale_height` fields in the video configuration block. When set, the decoded source video is scaled to the specified dimensions before crop regions are applied. + +| Feature | Description | +|---------|-------------| +| **Upscale** | Scale smaller source (e.g. 1080p) to larger output (e.g. 4K) | +| **Downscale** | Scale larger source (e.g. 4K) to smaller output (e.g. 1080p) | +| **Single session** | Full scaled frame transmitted as one stream | +| **Multi-session** | Crop regions applied to scaled frame for tiled LED walls | +| **Max output** | 3840x2160 (4K UHD) | + +Example — upscale 1080p source to 4K output: +```json +{ + "video": { + "width": 1920, "height": 1080, + "tx_url": "source_1080p.mp4" + }, + "tx_video": { + "scale_width": 3840, "scale_height": 2160, + "fps": 30, "fmt": "yuv422p10le" + }, + "tx_sessions": [ + { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 3840, "h": 2160 } } + ] +} +``` + +Log output when scaling is active: +``` +[INFO ] Video: 1920x1080 -> scale 3840x2160 30fps yuv422p10le tx_url=source_1080p.mp4 +[INFO ] Session 0: udp_port=20000 pt=96 crop=[0,0 3840x2160] +``` + +> **Note:** When `scale_width`/`scale_height` are set, crop bounds are validated against the scaled dimensions, not the source dimensions. Both fields must be specified together, and the scaled dimensions must satisfy the pixel format's chroma-alignment constraints (e.g. `yuv420` requires even width and height; `yuv422*` requires even width). + ## Performance Considerations ### Optimization Features diff --git a/config/tx_2k_multi_session.json b/config/tx_2k_multi_session.json index 2b4c9f3..ebaea16 100644 --- a/config/tx_2k_multi_session.json +++ b/config/tx_2k_multi_session.json @@ -2,7 +2,7 @@ "log_file": "dvledtx.log", "interfaces": [ { - "name": "0000:03:00.1", + "name": "0000:06:00.0", "sip": "192.168.50.29", "dip": "239.168.85.20" } @@ -10,24 +10,25 @@ "video": { "width": 2560, "height": 1440, + "tx_url": "bbb_sunflower_1080p_30fps_normal.mp4" + }, + "tx_video": { + "scale_width": 2560, + "scale_height": 1440, "fps": 30, - "fmt": "yuv422p10le", - "tx_url": "ball_2k_yuv420p_30fps_5min.mp4" + "fmt": "yuv422p10le" }, "tx_sessions": [ { "udp_port": 20000, - "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 854, "h": 1440 } }, { "udp_port": 20002, - "payload_type": 96, "crop": { "x": 854, "y": 0, "w": 854, "h": 1440 } }, { "udp_port": 20004, - "payload_type": 96, "crop": { "x": 1708, "y": 0, "w": 852, "h": 1440 } } ] diff --git a/config/tx_2k_single_session.json b/config/tx_2k_single_session.json index a2a5c86..0be6239 100644 --- a/config/tx_2k_single_session.json +++ b/config/tx_2k_single_session.json @@ -10,14 +10,17 @@ "video": { "width": 2560, "height": 1440, - "fps": 30, - "fmt": "yuv422p10le", "tx_url": "ball_2k_yuv420p_30fps_5min.mp4" }, + "tx_video": { + "scale_width": 2560, + "scale_height": 1440, + "fps": 30, + "fmt": "yuv422p10le" + }, "tx_sessions": [ { "udp_port": 20000, - "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 2560, "h": 1440 } } ] diff --git a/config/tx_4k_multi_session.json b/config/tx_4k_multi_session.json index bb83a02..63800f2 100644 --- a/config/tx_4k_multi_session.json +++ b/config/tx_4k_multi_session.json @@ -10,25 +10,26 @@ "video": { "width": 3840, "height": 2160, + "tx_url": "ball_4k_gbrp10le_60fps_5min.mp4" + }, + "tx_video": { + "scale_width": 1920, + "scale_height": 1080, "fps": 30, - "fmt": "yuv422p10le", - "tx_url": "ball_4k_yuv420p_30fps_5min.mp4" + "fmt": "yuv422p10le" }, "tx_sessions": [ { "udp_port": 20000, - "payload_type": 96, - "crop": { "x": 0, "y": 0, "w": 1280, "h": 2160 } + "crop": { "x": 0, "y": 0, "w": 640, "h": 1080 } }, { "udp_port": 20002, - "payload_type": 96, - "crop": { "x": 1280, "y": 0, "w": 1280, "h": 2160 } + "crop": { "x": 640, "y": 0, "w": 640, "h": 1080 } }, { "udp_port": 20004, - "payload_type": 96, - "crop": { "x": 2560, "y": 0, "w": 1280, "h": 2160 } + "crop": { "x": 1280, "y": 0, "w": 640, "h": 1080 } } ] } diff --git a/config/tx_4k_single_session.json b/config/tx_4k_single_session.json index df69353..5fa2bbc 100644 --- a/config/tx_4k_single_session.json +++ b/config/tx_4k_single_session.json @@ -10,15 +10,18 @@ "video": { "width": 3840, "height": 2160, + "tx_url": "ball_4k_gbrp10le_60fps_5min.mp4" + }, + "tx_video": { + "scale_width": 900, + "scale_height": 600, "fps": 30, - "fmt": "yuv422p10le", - "tx_url": "ball_4k_yuv420p_30fps_5min.mp4" + "fmt": "yuv422p10le" }, "tx_sessions": [ { "udp_port": 20000, - "payload_type": 96, - "crop": { "x": 0, "y": 0, "w": 3840, "h": 2160 } + "crop": { "x": 0, "y": 0, "w": 900, "h": 600 } } ] } diff --git a/config/tx_fullhd_multi_session.json b/config/tx_fullhd_multi_session.json index 7c195c4..3e98b9e 100644 --- a/config/tx_fullhd_multi_session.json +++ b/config/tx_fullhd_multi_session.json @@ -10,24 +10,25 @@ "video": { "width": 1920, "height": 1080, - "fps": 30, - "fmt": "yuv422p10le", "tx_url": "bbb_sunflower_1080p_30fps_normal.mp4" }, + "tx_video": { + "scale_width": 1920, + "scale_height": 1080, + "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/config/tx_fullhd_single_session.json b/config/tx_fullhd_single_session.json index 82bcdff..2939030 100644 --- a/config/tx_fullhd_single_session.json +++ b/config/tx_fullhd_single_session.json @@ -2,7 +2,7 @@ "log_file": "dvledtx.log", "interfaces": [ { - "name": "0000:03:00.1", + "name": "0000:06:00.0", "sip": "192.168.50.29", "dip": "239.168.85.20" } @@ -10,15 +10,18 @@ "video": { "width": 3840, "height": 2160, - "fps": 60, - "fmt": "gbrp10le", - "tx_url": "ball_4k_gbrp10le_30fps_5min.mp4" + "tx_url": "bbb_sunflower_1080p_30fps_normal.mp4" + }, + "tx_video": { + "scale_width": 3840, + "scale_height": 2160, + "fps": 30, + "fmt": "yuv422p10le" }, "tx_sessions": [ { "udp_port": 20000, - "payload_type": 96, - "crop": { "x": 0, "y": 0, "w": 3840, "h": 2160 } + "crop": { "x": 0, "y": 0, "w": 1920, "h": 1080 } } ] } diff --git a/include/app_context.h b/include/app_context.h index 784e27c..f07def7 100644 --- a/include/app_context.h +++ b/include/app_context.h @@ -37,6 +37,8 @@ struct dvledtx_context { /* Video parameters */ uint32_t width; uint32_t height; + uint32_t scale_width; /* output width after scaling (0 = no scaling) */ + uint32_t scale_height; /* output height after scaling (0 = no scaling) */ int fps; /* frames per second: 25, 30, 50, 60 */ enum AVPixelFormat fmt; /* e.g. AV_PIX_FMT_YUV422P10LE */ diff --git a/include/util/config_reader.h b/include/util/config_reader.h index 227717a..5209fb3 100644 --- a/include/util/config_reader.h +++ b/include/util/config_reader.h @@ -30,6 +30,8 @@ struct dvledtx_config { /* video block */ uint32_t width; uint32_t height; + uint32_t scale_width; /* 0 = no scaling (use source width) */ + uint32_t scale_height; /* 0 = no scaling (use source height) */ int fps; char fmt[32]; /* e.g. "yuv422p10le" */ char tx_url[256]; diff --git a/src/core/session_manager.c b/src/core/session_manager.c index 0c90f86..6246e82 100644 --- a/src/core/session_manager.c +++ b/src/core/session_manager.c @@ -51,8 +51,10 @@ static void* st20p_tx_thread_shared(void* arg) { struct shared_decode_ctx* dec = ctx->shared_dec; int crop_x = ctx->crop_x_offset; int crop_y = ctx->crop_y_offset; - int crop_w = ctx->crop_width > 0 ? ctx->crop_width : (int)ctx->app->width; - int crop_h = ctx->crop_height > 0 ? ctx->crop_height : (int)ctx->app->height; + int eff_w = (int)(ctx->app->scale_width > 0 ? ctx->app->scale_width : ctx->app->width); + int eff_h = (int)(ctx->app->scale_height > 0 ? ctx->app->scale_height : ctx->app->height); + int crop_w = ctx->crop_width > 0 ? ctx->crop_width : eff_w; + int crop_h = ctx->crop_height > 0 ? ctx->crop_height : eff_h; LOG_INFO("ST20P TX(%d): shared thread started (crop x=%d y=%d w=%d h=%d)", ctx->idx, crop_x, crop_y, crop_w, crop_h); @@ -169,8 +171,10 @@ static void* st20p_tx_thread(void* arg) { * Path B yuv_frame is already crop-sized (strip) → no offset needed. */ int crop_x = (ctx->use_ffmpeg == true) ? ctx->crop_x_offset : 0; int crop_y = (ctx->use_ffmpeg == true) ? ctx->crop_y_offset : 0; - int crop_w = ctx->crop_width > 0 ? ctx->crop_width : (int)ctx->app->width; - int crop_h = ctx->crop_height > 0 ? ctx->crop_height : (int)ctx->app->height; + int eff_w = (int)(ctx->app->scale_width > 0 ? ctx->app->scale_width : ctx->app->width); + int eff_h = (int)(ctx->app->scale_height > 0 ? ctx->app->scale_height : ctx->app->height); + int crop_w = ctx->crop_width > 0 ? ctx->crop_width : eff_w; + int crop_h = ctx->crop_height > 0 ? ctx->crop_height : eff_h; while (ctx->app->exit == false && session_manager_should_exit() == false) { const AVFrame* frame = tx_fetch_next_frame(ctx); @@ -217,13 +221,15 @@ int create_st20p_tx_session(session_manager_t* manager, struct dvledtx_context* /* Fallback: divide the frame width evenly across all sessions */ if (ctx->crop_width == 0 || ctx->crop_height == 0) { int total = app->st20p_sessions > 0 ? app->st20p_sessions : 1; - int strip_w = (int)app->width / total; + int eff_w = (int)(app->scale_width > 0 ? app->scale_width : app->width); + int eff_h = (int)(app->scale_height > 0 ? app->scale_height : app->height); + int strip_w = eff_w / total; ctx->crop_x_offset = session_idx * strip_w; ctx->crop_y_offset = 0; ctx->crop_width = (session_idx == total - 1) - ? ((int)app->width - ctx->crop_x_offset) + ? (eff_w - ctx->crop_x_offset) : strip_w; - ctx->crop_height = (int)app->height; + ctx->crop_height = eff_h; } LOG_INFO("ST20P TX session %d: crop rect x=%d y=%d w=%d h=%d", session_idx, ctx->crop_x_offset, ctx->crop_y_offset, diff --git a/src/ffmpeg/ffmpeg_decoder.c b/src/ffmpeg/ffmpeg_decoder.c index 7cee9dc..5274a5c 100644 --- a/src/ffmpeg/ffmpeg_decoder.c +++ b/src/ffmpeg/ffmpeg_decoder.c @@ -321,9 +321,11 @@ static void close_ffmpeg_decoder( * ========================================================================= */ 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); return open_ffmpeg_decoder( filename, "Shared decode", - app->fmt, (int)app->width, (int)app->height, + app->fmt, target_w, target_h, &dec->fmt_ctx, &dec->codec_ctx, &dec->sws_ctx, &dec->av_frame, &dec->yuv_frame, &dec->av_packet, &dec->video_stream_idx); @@ -341,9 +343,11 @@ void close_shared_ffmpeg(struct shared_decode_ctx* dec) { static int open_ffmpeg_source(struct st20p_tx_ctx* ctx, const char* filename) { char log_prefix[64]; snprintf(log_prefix, sizeof(log_prefix), "ST20P TX(%d)", ctx->idx); + int target_w = (int)(ctx->app->scale_width > 0 ? ctx->app->scale_width : ctx->app->width); + 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->fmt, (int)ctx->app->width, (int)ctx->app->height, + 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, &ctx->video_stream_idx); diff --git a/src/util/config_reader.c b/src/util/config_reader.c index c475e07..e9d7b48 100644 --- a/src/util/config_reader.c +++ b/src/util/config_reader.c @@ -199,7 +199,8 @@ int peek_config_log_file(const char* config_file, char* out_buf, size_t out_size * Expected JSON structure: * { * "interfaces": [ { "name": "...", "sip": "...", "dip": "..." } ], - * "video": { "width": N, "height": N, "fps": N, "fmt": "...", "tx_url": "..." }, + * "video": { "width": N, "height": N, "tx_url": "..." }, + * "tx_video": { "scale_width": N, "scale_height": N, "fps": N, "fmt": "..." }, * "log_file": "/path/to/dvledtx.log", (optional — omit for console-only logging) * "tx_sessions": [ * { "udp_port": N, "payload_type": N, "crop": { "x":N, "y":N, "w":N, "h":N } }, @@ -261,11 +262,21 @@ 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; - v = extract_json_int(video_obj, video_end, "fps"); if (v > 0) config->fps = v; - extract_json_string(video_obj, video_end, "fmt", config->fmt, sizeof(config->fmt)); extract_json_string(video_obj, video_end, "tx_url", config->tx_url, sizeof(config->tx_url)); } + /* --- tx_video block (transmission parameters) --- */ + const char* tx_video_obj = find_object(json, buf_end, "tx_video"); + if (tx_video_obj != NULL) { + const char* tx_video_end = find_object_end(tx_video_obj, buf_end); + if (tx_video_end == NULL) tx_video_end = buf_end; + int v; + v = extract_json_int(tx_video_obj, tx_video_end, "scale_width"); if (v > 0) config->scale_width = v; + v = extract_json_int(tx_video_obj, tx_video_end, "scale_height"); if (v > 0) config->scale_height = v; + v = extract_json_int(tx_video_obj, tx_video_end, "fps"); if (v > 0) config->fps = v; + extract_json_string(tx_video_obj, tx_video_end, "fmt", config->fmt, sizeof(config->fmt)); + } + /* --- optional top-level log_file --- */ extract_json_string(json, buf_end, "log_file", config->log_file, sizeof(config->log_file)); @@ -309,10 +320,7 @@ int parse_tx_config(const char* config_file, struct dvledtx_config* config) { if (v > 0 && v <= 255) { s->payload_type = (uint8_t)v; } else { - LOG_ERROR("session %d: payload_type not set or invalid (%d)", - config->session_count, v); - free(json); - return -1; + s->payload_type = 96; /* default to 96 (first dynamic RTP payload type) */ } /* crop sub-object */ @@ -427,6 +435,44 @@ int validate_tx_config(const struct dvledtx_config* config) { 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) { + LOG_ERROR("scale_width and scale_height must both be set or both omitted"); + return -1; + } + if (config->scale_width > 3840 || config->scale_height > 2160) { + LOG_ERROR("scale resolution %ux%u exceeds maximum 3840x2160", + (unsigned)config->scale_width, (unsigned)config->scale_height); + return -1; + } + /* Validate scaled dimensions against pixel format chroma alignment */ + { + const char* fmt_lookup = config->fmt[0] ? config->fmt : "yuv422p10le"; + if (strcmp(fmt_lookup, "yuv420") == 0) fmt_lookup = "yuv420p"; + enum AVPixelFormat pix_fmt = av_get_pix_fmt(fmt_lookup); + const AVPixFmtDescriptor* desc = + (pix_fmt != AV_PIX_FMT_NONE) ? av_pix_fmt_desc_get(pix_fmt) : NULL; + int x_align = desc ? (1 << desc->log2_chroma_w) : 2; + int y_align = desc ? (1 << desc->log2_chroma_h) : 1; + + if (x_align > 1 && config->scale_width % x_align != 0) { + LOG_ERROR("scale_width %u must be a multiple of %d for pixel format '%s'", + (unsigned)config->scale_width, x_align, config->fmt); + return -1; + } + if (y_align > 1 && config->scale_height % y_align != 0) { + LOG_ERROR("scale_height %u must be a multiple of %d for pixel format '%s'", + (unsigned)config->scale_height, y_align, config->fmt); + return -1; + } + } + } + + /* Effective output dimensions (after scaling) for crop validation */ + uint32_t eff_width = config->scale_width > 0 ? config->scale_width : config->width; + uint32_t eff_height = config->scale_height > 0 ? config->scale_height : config->height; + /* FPS validation */ if (config->fps != 25 && config->fps != 30 && config->fps != 50 && config->fps != 60) { @@ -494,16 +540,16 @@ int validate_tx_config(const struct dvledtx_config* config) { s->crop_w, s->crop_h); return -1; } - if ((uint32_t)s->crop_x + (uint32_t)s->crop_w > config->width) { - LOG_ERROR("session %d: crop x=%d + w=%d = %u exceeds video width %u", + if ((uint32_t)s->crop_x + (uint32_t)s->crop_w > eff_width) { + LOG_ERROR("session %d: crop x=%d + w=%d = %u exceeds effective width %u", i, s->crop_x, s->crop_w, - (uint32_t)s->crop_x + (uint32_t)s->crop_w, config->width); + (uint32_t)s->crop_x + (uint32_t)s->crop_w, eff_width); return -1; } - if ((uint32_t)s->crop_y + (uint32_t)s->crop_h > config->height) { - LOG_ERROR("session %d: crop y=%d + h=%d = %u exceeds video height %u", + if ((uint32_t)s->crop_y + (uint32_t)s->crop_h > eff_height) { + LOG_ERROR("session %d: crop y=%d + h=%d = %u exceeds effective height %u", i, s->crop_y, s->crop_h, - (uint32_t)s->crop_y + (uint32_t)s->crop_h, config->height); + (uint32_t)s->crop_y + (uint32_t)s->crop_h, eff_height); return -1; } if (s->crop_w % 2 != 0) { @@ -516,8 +562,9 @@ int validate_tx_config(const struct dvledtx_config* config) { * Use the pixel format descriptor (if available) to determine * the required alignment from log2_chroma_w/h. */ { - enum AVPixelFormat pix_fmt = av_get_pix_fmt( - config->fmt[0] ? config->fmt : "yuv422p10le"); + const char* fmt_lookup = config->fmt[0] ? config->fmt : "yuv422p10le"; + if (strcmp(fmt_lookup, "yuv420") == 0) fmt_lookup = "yuv420p"; + enum AVPixelFormat pix_fmt = av_get_pix_fmt(fmt_lookup); const AVPixFmtDescriptor* desc = (pix_fmt != AV_PIX_FMT_NONE) ? av_pix_fmt_desc_get(pix_fmt) : NULL; int x_align = desc ? (1 << desc->log2_chroma_w) : 2; @@ -589,6 +636,8 @@ int load_and_apply_config(struct dvledtx_context* app, const char* config_file) /* Video */ app->width = config.width; app->height = config.height; + app->scale_width = config.scale_width; + app->scale_height = config.scale_height; app->fps = config.fps; if (strcmp(config.fmt, "yuv422p10le") == 0) app->fmt = AV_PIX_FMT_YUV422P10LE; @@ -636,9 +685,16 @@ int load_and_apply_config(struct dvledtx_context* app, const char* config_file) config_file, config.interface_name, config.interface_sip[0] ? config.interface_sip : "dhcp", config.interface_dip); - LOG_INFO("Video: %dx%d %dfps %s tx_url=%s", - config.width, config.height, config.fps, config.fmt, - config.tx_url[0] ? config.tx_url : ""); + if (config.scale_width > 0 && config.scale_height > 0) + LOG_INFO("Video: %ux%u -> scale %ux%u %dfps %s tx_url=%s", + config.width, config.height, + config.scale_width, config.scale_height, + config.fps, config.fmt, + config.tx_url[0] ? config.tx_url : ""); + else + LOG_INFO("Video: %ux%u %dfps %s tx_url=%s", + config.width, config.height, config.fps, config.fmt, + 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 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 241536b..ca9648a 100644 --- a/tests/test_config_reader.c +++ b/tests/test_config_reader.c @@ -560,7 +560,7 @@ static void test_parse_session_udp_port_exceeds_65535_fails(void **state) assert_int_equal(ret, -1); } -static void test_parse_session_missing_payload_type_fails(void **state) +static void test_parse_session_missing_payload_type_defaults_to_96(void **state) { (void)state; char *path = write_tmpfile( @@ -574,7 +574,8 @@ static void test_parse_session_missing_payload_type_fails(void **state) struct dvledtx_config cfg; int ret = parse_tx_config(path, &cfg); unlink(path); free(path); - assert_int_equal(ret, -1); + assert_int_equal(ret, 0); + assert_int_equal(cfg.sessions[0].payload_type, 96); } static void test_parse_session_no_crop_object_fails(void **state) @@ -640,6 +641,130 @@ static void test_validate_4k_height_exceeds_max_fails(void **state) assert_int_equal(validate_tx_config(&cfg), -1); } +/* ========================================================================== + * validate_tx_config — scaling tests + * ========================================================================== */ + +static void test_validate_scale_upscale_1080p_to_4k_passes(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.width = 1920; + cfg.height = 1080; + cfg.scale_width = 3840; + cfg.scale_height = 2160; + cfg.sessions[0].crop_w = 3840; + cfg.sessions[0].crop_h = 2160; + assert_int_equal(validate_tx_config(&cfg), 0); +} + +static void test_validate_scale_downscale_4k_to_1080p_passes(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.width = 3840; + cfg.height = 2160; + cfg.scale_width = 1920; + cfg.scale_height = 1080; + cfg.sessions[0].crop_w = 1920; + cfg.sessions[0].crop_h = 1080; + assert_int_equal(validate_tx_config(&cfg), 0); +} + +static void test_validate_scale_width_only_fails(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.scale_width = 3840; + cfg.scale_height = 0; + assert_int_equal(validate_tx_config(&cfg), -1); +} + +static void test_validate_scale_height_only_fails(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.scale_width = 0; + cfg.scale_height = 2160; + assert_int_equal(validate_tx_config(&cfg), -1); +} + +static void test_validate_scale_exceeds_max_fails(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.scale_width = 4096; + cfg.scale_height = 2160; + assert_int_equal(validate_tx_config(&cfg), -1); +} + +static void test_validate_scale_odd_width_fails(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.scale_width = 1921; + cfg.scale_height = 1080; + assert_int_equal(validate_tx_config(&cfg), -1); +} + +static void test_validate_scale_odd_height_yuv420_fails(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + strncpy(cfg.fmt, "yuv420", sizeof(cfg.fmt) - 1); + cfg.scale_width = 1920; + cfg.scale_height = 1081; /* yuv420 requires even height */ + cfg.sessions[0].crop_w = 1920; + cfg.sessions[0].crop_h = 1080; + assert_int_equal(validate_tx_config(&cfg), -1); +} + +static void test_validate_scale_crop_exceeds_scaled_dims_fails(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.width = 3840; + cfg.height = 2160; + cfg.scale_width = 1920; + cfg.scale_height = 1080; + /* crop is 3840x2160 but effective dims are 1920x1080 */ + cfg.sessions[0].crop_w = 3840; + cfg.sessions[0].crop_h = 2160; + assert_int_equal(validate_tx_config(&cfg), -1); +} + +static void test_validate_scale_crop_within_scaled_dims_passes(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.width = 3840; + cfg.height = 2160; + cfg.scale_width = 2560; + cfg.scale_height = 1440; + cfg.sessions[0].crop_w = 2560; + cfg.sessions[0].crop_h = 1440; + assert_int_equal(validate_tx_config(&cfg), 0); +} + +static void test_validate_no_scale_passes(void **state) +{ + (void)state; + struct dvledtx_config cfg; + fill_valid_config(&cfg); + cfg.scale_width = 0; + cfg.scale_height = 0; + assert_int_equal(validate_tx_config(&cfg), 0); +} + static void test_validate_duplicate_udp_ports_fails(void **state) { (void)state; @@ -1027,7 +1152,8 @@ static void test_load_and_apply_config_nonexistent_file_returns_minus1(void **st "{" \ " \"interfaces\": [{\"name\":\"0000:06:00.0\"," \ " \"sip\":\"192.168.50.29\",\"dip\":\"239.168.85.20\"}]," \ - " \"video\": {\"width\":1920,\"height\":1080,\"fps\":30,\"fmt\":\"" fmt_str "\"}," \ + " \"video\": {\"width\":1920,\"height\":1080}," \ + " \"tx_video\": {\"fps\":30,\"fmt\":\"" fmt_str "\"}," \ " \"tx_sessions\": [{\"udp_port\":20000,\"payload_type\":96," \ " \"crop\":{\"x\":0,\"y\":0,\"w\":1920,\"h\":1080}}]" \ "}" @@ -1110,7 +1236,8 @@ static void test_load_and_apply_config_copies_log_file(void **state) "{" " \"log_file\": \"myapp.log\"," " \"interfaces\": [{\"name\":\"0000:06:00.0\",\"sip\":\"192.168.50.29\",\"dip\":\"239.168.85.20\"}]," - " \"video\": {\"width\":1920,\"height\":1080,\"fps\":25,\"fmt\":\"yuv422p10le\"}," + " \"video\": {\"width\":1920,\"height\":1080}," + " \"tx_video\": {\"fps\":25,\"fmt\":\"yuv422p10le\"}," " \"tx_sessions\": [{\"udp_port\":20000,\"payload_type\":96," " \"crop\":{\"x\":0,\"y\":0,\"w\":1920,\"h\":1080}}]" "}"); @@ -1146,7 +1273,7 @@ int main(void) cmocka_unit_test(test_parse_returns_zero_fields_when_video_missing), 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_fails), + cmocka_unit_test(test_parse_session_missing_payload_type_defaults_to_96), cmocka_unit_test(test_parse_session_no_crop_object_fails), cmocka_unit_test(test_parse_session_negative_crop_x_fails), cmocka_unit_test(test_parse_session_zero_crop_w_fails), @@ -1178,6 +1305,16 @@ int main(void) cmocka_unit_test(test_validate_2k_resolution_passes), cmocka_unit_test(test_validate_4k_resolution_passes), cmocka_unit_test(test_validate_4k_height_exceeds_max_fails), + cmocka_unit_test(test_validate_scale_upscale_1080p_to_4k_passes), + cmocka_unit_test(test_validate_scale_downscale_4k_to_1080p_passes), + cmocka_unit_test(test_validate_scale_width_only_fails), + cmocka_unit_test(test_validate_scale_height_only_fails), + cmocka_unit_test(test_validate_scale_exceeds_max_fails), + cmocka_unit_test(test_validate_scale_odd_width_fails), + cmocka_unit_test(test_validate_scale_odd_height_yuv420_fails), + 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_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),