diff --git a/.gitignore b/.gitignore index 6c1faaa5e3..e4f32cdd24 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ test-driver src/dir-stamp test/run_tests unittest/run_tests +# Bootstrapped by ext-deps/bootstrap_rapidcheck.sh; not part of the tree. +/ext-deps/rapidcheck/ .cache .clang-format .clang-tidy diff --git a/Makefile.in b/Makefile.in index 13faac3ede..a160df9099 100644 --- a/Makefile.in +++ b/Makefile.in @@ -142,7 +142,14 @@ COMMON_OBJS = \ tools/ipc_frame_ug.o \ tools/ipc_frame_unix.o \ tools/ipc_frame.o \ + src/utils/alpha_blend.o \ src/utils/audio_buffer.o \ + src/utils/overlay_config.o \ + src/utils/overlay_layout.o \ + src/utils/overlay_pam.o \ + src/utils/overlay_scale.o \ + src/utils/overlay_soft_edge.o \ + src/utils/overlay_watch.o \ src/utils/color_out.o \ src/utils/config_file.o \ src/utils/dictionary.o \ @@ -212,6 +219,13 @@ TEST_OBJS = $(COMMON_OBJS) \ @TEST_OBJS@ \ test/codec_conversions_test.o \ test/ff_codec_conversions_test.o \ + test/test_alpha_blend.o \ + test/test_overlay_config.o \ + test/test_overlay_layout.o \ + test/test_overlay_pam.o \ + test/test_overlay_scale.o \ + test/test_overlay_soft_edge.o \ + test/test_overlay_watch.o \ test/get_framerate_test.o \ test/gpujpeg_test.o \ test/libavcodec_test.o \ @@ -530,6 +544,45 @@ configure-messages: tests: $(TEST_TARGET) @export DYLD_LIBRARY_PATH=$(MY_DYLD_LIBRARY_PATH); $(TEST_TARGET) +.PHONY: e2e-tests +e2e-tests: $(TARGET) + @$(srcdir)/test/run_overlay_e2e.sh + +# RapidCheck property-based tests for the overlay/alpha-blend code. +# Built only when configure --enable-rapidcheck succeeds (the conditional +# is set up in configure.ac and surfaces here as @HAVE_RAPIDCHECK_TRUE@). +@HAVE_RAPIDCHECK_TRUE@RAPIDCHECK_CFLAGS = @RAPIDCHECK_CFLAGS@ +@HAVE_RAPIDCHECK_TRUE@RAPIDCHECK_CXXFLAGS = $(CXXFLAGS) $(RAPIDCHECK_CFLAGS) $(INC) +@HAVE_RAPIDCHECK_TRUE@RAPIDCHECK_TEST_SRCS = \ +@HAVE_RAPIDCHECK_TRUE@ test/test_alpha_blend_rapidcheck.cpp \ +@HAVE_RAPIDCHECK_TRUE@ test/test_overlay_rapidcheck.cpp \ +@HAVE_RAPIDCHECK_TRUE@ test/test_soft_edges_rapidcheck.cpp \ +@HAVE_RAPIDCHECK_TRUE@ test/test_file_monitoring_rapidcheck.cpp \ +@HAVE_RAPIDCHECK_TRUE@ test/test_rapidcheck_main.cpp +@HAVE_RAPIDCHECK_TRUE@RAPIDCHECK_TEST_OBJS = $(RAPIDCHECK_TEST_SRCS:.cpp=.o) + +@HAVE_RAPIDCHECK_TRUE@test/%_rapidcheck.o: test/%_rapidcheck.cpp +@HAVE_RAPIDCHECK_TRUE@ $(MKDIR_P) $$(dirname $@) +@HAVE_RAPIDCHECK_TRUE@ $(CXX) $(RAPIDCHECK_CXXFLAGS) -MD -c $< -o $@ +@HAVE_RAPIDCHECK_TRUE@ $(POSTPROCESS_DEPS) + +@HAVE_RAPIDCHECK_TRUE@test/test_rapidcheck_main.o: test/test_rapidcheck_main.cpp +@HAVE_RAPIDCHECK_TRUE@ $(MKDIR_P) $$(dirname $@) +@HAVE_RAPIDCHECK_TRUE@ $(CXX) $(RAPIDCHECK_CXXFLAGS) -MD -c $< -o $@ +@HAVE_RAPIDCHECK_TRUE@ $(POSTPROCESS_DEPS) + +@HAVE_RAPIDCHECK_TRUE@bin/rapidcheck_tests: $(RAPIDCHECK_TEST_OBJS) \ +@HAVE_RAPIDCHECK_TRUE@ src/utils/alpha_blend.o src/color_space.o \ +@HAVE_RAPIDCHECK_TRUE@ src/utils/overlay_layout.o \ +@HAVE_RAPIDCHECK_TRUE@ src/utils/overlay_soft_edge.o \ +@HAVE_RAPIDCHECK_TRUE@ src/utils/overlay_watch.o +@HAVE_RAPIDCHECK_TRUE@ $(CXX) $(CXXFLAGS) $(RAPIDCHECK_CFLAGS) -o $@ $^ \ +@HAVE_RAPIDCHECK_TRUE@ ./ext-deps/rapidcheck/build/librapidcheck.a -pthread + +@HAVE_RAPIDCHECK_TRUE@.PHONY: rapidcheck-tests +@HAVE_RAPIDCHECK_TRUE@rapidcheck-tests: bin/rapidcheck_tests +@HAVE_RAPIDCHECK_TRUE@ @./bin/rapidcheck_tests + .PHONY: check check: tests @$(srcdir)/data/scripts/check.sh "$(TARGET)" "$(REFLECTOR_TARGET)" diff --git a/configure.ac b/configure.ac index 020a7004ba..3c0d28be25 100644 --- a/configure.ac +++ b/configure.ac @@ -2795,6 +2795,40 @@ if test "$found_zfec" = yes; then fi ENSURE_FEATURE_PRESENT([$zfec_req], [$found_zfec], [Zfec not found]) +# ------------------------------------------------------------------------------------------------- +# RapidCheck — property-based testing for the overlay/alpha-blend code. +# Build is opt-in (--enable-rapidcheck); ext-deps/bootstrap_rapidcheck.sh +# fetches and statically builds it under ext-deps/rapidcheck without +# touching the system. A system pkg-config install is also accepted. + +AC_ARG_ENABLE(rapidcheck, + AS_HELP_STRING([--enable-rapidcheck], [enable RapidCheck property-based testing]), + [rapidcheck_req=$enableval], + [rapidcheck_req=auto]) + +rapidcheck=no +if test "$rapidcheck_req" != no; then + if test -f "$srcdir/ext-deps/rapidcheck/include/rapidcheck.h" || test -f "./ext-deps/rapidcheck/include/rapidcheck.h"; then + rapidcheck=yes + RAPIDCHECK_CFLAGS="-I$srcdir/ext-deps/rapidcheck/include" + else + PKG_CHECK_MODULES([RAPIDCHECK], [rapidcheck], [rapidcheck=yes], [ + AC_CHECK_HEADER([rapidcheck.h], [ + rapidcheck=yes + RAPIDCHECK_CFLAGS="" + ]) + ]) + fi + + if test "$rapidcheck" = yes; then + AC_DEFINE([HAVE_RAPIDCHECK], [1], [RapidCheck is available]) + AC_SUBST(RAPIDCHECK_CFLAGS) + fi +fi +ENSURE_FEATURE_PRESENT([$rapidcheck_req], [$rapidcheck], + [RapidCheck not found. Run ext-deps/bootstrap_rapidcheck.sh or install system-wide.]) +AM_CONDITIONAL([HAVE_RAPIDCHECK], [test "$rapidcheck" = yes]) + # ------------------------------------------------------------------------------------------------- # # Jack stuff @@ -3633,6 +3667,7 @@ if test "$build_default" != no || test "$req_files" = all; then src/vo_postprocess/deinterlace.o src/vo_postprocess/delay.o src/vo_postprocess/interlace.o + src/vo_postprocess/overlay.o src/vo_postprocess/split.o src/vo_postprocess/temporal-deint.o src/vo_postprocess/temporal_3d.o diff --git a/ext-deps/bootstrap_rapidcheck.sh b/ext-deps/bootstrap_rapidcheck.sh new file mode 100755 index 0000000000..0f16a5b7c5 --- /dev/null +++ b/ext-deps/bootstrap_rapidcheck.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Bootstrap script for RapidCheck - property-based testing for C++ +# +# This script downloads and prepares RapidCheck for static linking +# with UltraGrid without requiring system-wide installation. + +set -e + +SCRIPT_DIR=$(dirname "$0") +RAPIDCHECK_DIR="$SCRIPT_DIR/rapidcheck" +# Pinned to a known-good commit so re-runs (and CI) get a reproducible +# build. The upstream has no tags; bump this when picking up a fix. +RAPIDCHECK_REF="b2d9ed2dddefc4b84318d664b4f221eb792d89c7" + +echo "Bootstrapping RapidCheck..." + +# Clean existing directory if present +if [ -d "$RAPIDCHECK_DIR" ]; then + echo "Removing existing RapidCheck directory..." + rm -rf "$RAPIDCHECK_DIR" +fi + +# Clone RapidCheck +echo "Cloning RapidCheck @ $RAPIDCHECK_REF..." +git clone https://github.com/emil-e/rapidcheck.git "$RAPIDCHECK_DIR" +cd "$RAPIDCHECK_DIR" +git checkout --quiet "$RAPIDCHECK_REF" + +# Build RapidCheck as a static library +echo "Building RapidCheck static library..." +mkdir -p build +cd build +cmake .. -DCMAKE_BUILD_TYPE=Release -DRC_ENABLE_TESTS=OFF -DRC_ENABLE_EXAMPLES=OFF +make -j4 + +cd .. + +# Create a pkg-config file for easier integration +cat > rapidcheck.pc << EOF +prefix=$RAPIDCHECK_DIR +includedir=\${prefix}/include +libdir=\${prefix}/build + +Name: RapidCheck +Description: Property-based testing for C++ +Version: 0.0.0 +Cflags: -I\${includedir} +Libs: -L\${libdir} -lrapidcheck +EOF + +echo "RapidCheck bootstrapped successfully!" +echo "To use in configure:" +echo " PKG_CONFIG_PATH=$RAPIDCHECK_DIR:\$PKG_CONFIG_PATH ./configure" \ No newline at end of file diff --git a/src/utils/alpha_blend.c b/src/utils/alpha_blend.c new file mode 100644 index 0000000000..84367595a1 --- /dev/null +++ b/src/utils/alpha_blend.c @@ -0,0 +1,591 @@ +/** + * @file utils/alpha_blend.c + * @author Ben Roeder + * @brief Alpha blending of 16-bit RGBA overlay onto native video formats + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include // for memcpy + +#include "color_space.h" // for get_color_coeffs, RGB_TO_*, COMP_BASE, LIMIT_* +#include "types.h" // for DEPTH8, DEPTH10, DEPTH16 +#include "utils/alpha_blend.h" +#include "utils/macros.h" // for CLAMP + +/* For 16-bit RGB input through DEPTH-N coefficients, the right-shift accounts + * for the difference between input bit depth (16) and output bit depth (N). + * Pattern matches vc_copylineRG48toV210 in pixfmt_conv.c. */ +#define COMP_OFF_8 (COMP_BASE + 8) +#define COMP_OFF_10 (COMP_BASE + 6) +#define COMP_OFF_16 (COMP_BASE + 0) + +/* Limited-range YCbCr offsets: Y zero is at 16, CbCr zero is at 128 (8-bit). + * For other depths use (1 << (depth - 4)) and (1 << (depth - 1)) respectively. */ +#define Y_OFFSET_8 (1 << (DEPTH8 - 4)) +#define CBCR_OFFSET_8 (1 << (DEPTH8 - 1)) +#define Y_OFFSET_10 (1 << (DEPTH10 - 4)) +#define CBCR_OFFSET_10 (1 << (DEPTH10 - 1)) +#define Y_OFFSET_16 (1 << (DEPTH16 - 4)) +#define CBCR_OFFSET_16 (1 << (DEPTH16 - 1)) + +/* Unsigned types and a literal divisor let the compiler emit magic-multiply + * for the divide instead of idiv. The dividend max is bounded (max*max fits + * in 32 bits at every used depth) so unsigned is always safe. */ +#define BLEND_N(src, dst, a, max) \ + (((src) * (a) + (dst) * ((max) - (a))) / (max)) + +static inline uint8_t blend8(unsigned src, unsigned dst, unsigned a) +{ + return BLEND_N(src, dst, a, 255u); +} + +static inline uint16_t blend10(unsigned src, unsigned dst, unsigned a) +{ + return BLEND_N(src, dst, a, 1023u); +} + +/* 16-bit blend needs 64-bit accumulation: 65535 * 65535 already overflows + * uint32_t and the BLEND_N expansion adds two such products. */ +static inline uint16_t blend16(unsigned src, unsigned dst, unsigned a) +{ + return ((uint64_t)src * a + (uint64_t)dst * (65535u - a)) / 65535u; +} + +void alpha_blend_rgba(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + for (int x = 0; x < width; x++) { + unsigned r = rgba16[0] >> 8; + unsigned g = rgba16[1] >> 8; + unsigned b = rgba16[2] >> 8; + unsigned a = rgba16[3] >> 8; + + dst[0] = blend8(r, dst[0], a); + dst[1] = blend8(g, dst[1], a); + dst[2] = blend8(b, dst[2], a); + /* Porter-Duff "over": alpha_out = alpha_src + alpha_dst*(1-alpha_src) */ + dst[3] = a + (dst[3] * (255u - a)) / 255u; + + rgba16 += 4; + dst += 4; + } +} + +void alpha_blend_rgb(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + for (int x = 0; x < width; x++) { + unsigned r = rgba16[0] >> 8; + unsigned g = rgba16[1] >> 8; + unsigned b = rgba16[2] >> 8; + unsigned a = rgba16[3] >> 8; + + dst[0] = blend8(r, dst[0], a); + dst[1] = blend8(g, dst[1], a); + dst[2] = blend8(b, dst[2], a); + + rgba16 += 4; + dst += 3; + } +} + +/* 4:2:2 packed YUV holds 2 pixels in 4 bytes with Y per-pixel and Cb/Cr + * shared across the pair. UYVY and YUYV differ only in pack order. */ +struct yuv422_pair { + uint8_t y0, y1, cb, cr; + uint8_t a_y0, a_y1, a_chroma; +}; + +static struct yuv422_pair convert_pair_16bit_rgb_to_yuv422( + const uint16_t *rgba16, const struct color_coeffs *cfs) +{ + uint16_t r0 = rgba16[0], g0 = rgba16[1], b0 = rgba16[2]; + uint16_t a0 = rgba16[3]; + uint16_t r1 = rgba16[4], g1 = rgba16[5], b1 = rgba16[6]; + uint16_t a1 = rgba16[7]; + + int y0 = (RGB_TO_Y(*cfs, r0, g0, b0) >> COMP_OFF_8) + Y_OFFSET_8; + int y1 = (RGB_TO_Y(*cfs, r1, g1, b1) >> COMP_OFF_8) + Y_OFFSET_8; + + int32_t cb_sum = RGB_TO_CB(*cfs, r0, g0, b0) + + RGB_TO_CB(*cfs, r1, g1, b1); + int32_t cr_sum = RGB_TO_CR(*cfs, r0, g0, b0) + + RGB_TO_CR(*cfs, r1, g1, b1); + int cb = ((cb_sum / 2) >> COMP_OFF_8) + CBCR_OFFSET_8; + int cr = ((cr_sum / 2) >> COMP_OFF_8) + CBCR_OFFSET_8; + + struct yuv422_pair p; + p.y0 = CLAMP(y0, LIMIT_LO(DEPTH8), LIMIT_HI_Y(DEPTH8)); + p.y1 = CLAMP(y1, LIMIT_LO(DEPTH8), LIMIT_HI_Y(DEPTH8)); + p.cb = CLAMP(cb, LIMIT_LO(DEPTH8), LIMIT_HI_CBCR(DEPTH8)); + p.cr = CLAMP(cr, LIMIT_LO(DEPTH8), LIMIT_HI_CBCR(DEPTH8)); + p.a_y0 = a0 >> 8; + p.a_y1 = a1 >> 8; + p.a_chroma = (p.a_y0 + p.a_y1 + 1) >> 1; + return p; +} + +/* + * UYVY: byte order U Y0 V Y1. Width must be even (caller's responsibility); + * an odd trailing pixel is silently dropped. + */ +void alpha_blend_uyvy(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + const struct color_coeffs cfs = *get_color_coeffs(CS_DFL, DEPTH8); + for (int x = 0; x + 2 <= width; x += 2) { + struct yuv422_pair p = convert_pair_16bit_rgb_to_yuv422(rgba16, &cfs); + dst[0] = blend8(p.cb, dst[0], p.a_chroma); + dst[1] = blend8(p.y0, dst[1], p.a_y0); + dst[2] = blend8(p.cr, dst[2], p.a_chroma); + dst[3] = blend8(p.y1, dst[3], p.a_y1); + rgba16 += 8; + dst += 4; + } +} + +/* + * YUYV: byte order Y0 U Y1 V. Same conversion as UYVY, different pack order. + */ +void alpha_blend_yuyv(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + const struct color_coeffs cfs = *get_color_coeffs(CS_DFL, DEPTH8); + for (int x = 0; x + 2 <= width; x += 2) { + struct yuv422_pair p = convert_pair_16bit_rgb_to_yuv422(rgba16, &cfs); + dst[0] = blend8(p.y0, dst[0], p.a_y0); + dst[1] = blend8(p.cb, dst[1], p.a_chroma); + dst[2] = blend8(p.y1, dst[2], p.a_y1); + dst[3] = blend8(p.cr, dst[3], p.a_chroma); + rgba16 += 8; + dst += 4; + } +} + +/* + * Y416: 16-bit YUV 4:4:4 + alpha, layout U(2) Y(2) V(2) A(2) little-endian. + * Full chroma resolution. + */ +void alpha_blend_y416(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + const struct color_coeffs cfs = *get_color_coeffs(CS_DFL, DEPTH16); + for (int x = 0; x < width; x++) { + uint16_t r = rgba16[0], g = rgba16[1], b = rgba16[2]; + uint16_t a = rgba16[3]; + int inv = 65535 - a; + + int y = (RGB_TO_Y(cfs, r, g, b) >> COMP_OFF_16) + Y_OFFSET_16; + int cb = (RGB_TO_CB(cfs, r, g, b) >> COMP_OFF_16) + CBCR_OFFSET_16; + int cr = (RGB_TO_CR(cfs, r, g, b) >> COMP_OFF_16) + CBCR_OFFSET_16; + y = CLAMP(y, LIMIT_LO(DEPTH16), LIMIT_HI_Y(DEPTH16)); + cb = CLAMP(cb, LIMIT_LO(DEPTH16), LIMIT_HI_CBCR(DEPTH16)); + cr = CLAMP(cr, LIMIT_LO(DEPTH16), LIMIT_HI_CBCR(DEPTH16)); + + uint16_t du, dy, dv, da; + memcpy(&du, dst + 0, 2); + memcpy(&dy, dst + 2, 2); + memcpy(&dv, dst + 4, 2); + memcpy(&da, dst + 6, 2); + + uint16_t ou = (uint16_t)(((int64_t)cb * a + (int64_t)du * inv) / 65535); + uint16_t oy = (uint16_t)(((int64_t)y * a + (int64_t)dy * inv) / 65535); + uint16_t ov = (uint16_t)(((int64_t)cr * a + (int64_t)dv * inv) / 65535); + /* Porter-Duff "over": alpha_out = alpha_src + alpha_dst*(1-alpha_src) */ + uint16_t oa = (uint16_t)(a + ((int64_t)da * inv) / 65535); + memcpy(dst + 0, &ou, 2); + memcpy(dst + 2, &oy, 2); + memcpy(dst + 4, &ov, 2); + memcpy(dst + 6, &oa, 2); + + rgba16 += 4; + dst += 8; + } +} + +/* 4:2:0 chroma sample averaged from a 2x2 RGB block. Sums are int64_t because + * a single |CB raw| can reach ~943M; four summed exceeds INT32_MAX (~2.15B). */ +struct yuv420_chroma { + uint8_t cb, cr, a; +}; + +static struct yuv420_chroma convert_quad_16bit_rgb_to_yuv420( + const uint16_t *p00, const uint16_t *p01, + const uint16_t *p10, const uint16_t *p11, + const struct color_coeffs *cfs) +{ + int64_t cb_sum = (int64_t)RGB_TO_CB(*cfs, p00[0], p00[1], p00[2]) + + RGB_TO_CB(*cfs, p01[0], p01[1], p01[2]) + + RGB_TO_CB(*cfs, p10[0], p10[1], p10[2]) + + RGB_TO_CB(*cfs, p11[0], p11[1], p11[2]); + int64_t cr_sum = (int64_t)RGB_TO_CR(*cfs, p00[0], p00[1], p00[2]) + + RGB_TO_CR(*cfs, p01[0], p01[1], p01[2]) + + RGB_TO_CR(*cfs, p10[0], p10[1], p10[2]) + + RGB_TO_CR(*cfs, p11[0], p11[1], p11[2]); + int cb = (int)((cb_sum / 4) >> COMP_OFF_8) + CBCR_OFFSET_8; + int cr = (int)((cr_sum / 4) >> COMP_OFF_8) + CBCR_OFFSET_8; + + unsigned a_sum = (unsigned)(p00[3] >> 8) + (p01[3] >> 8) + + (p10[3] >> 8) + (p11[3] >> 8); + + struct yuv420_chroma c; + c.cb = CLAMP(cb, LIMIT_LO(DEPTH8), LIMIT_HI_CBCR(DEPTH8)); + c.cr = CLAMP(cr, LIMIT_LO(DEPTH8), LIMIT_HI_CBCR(DEPTH8)); + c.a = (a_sum + 2) >> 2; + return c; +} + +/* Compute one 8-bit Y from a 16-bit RGB pixel. */ +static inline uint8_t +rgb16_to_y8(const struct color_coeffs *cfs, const uint16_t *p) +{ + int y = (RGB_TO_Y(*cfs, p[0], p[1], p[2]) >> COMP_OFF_8) + Y_OFFSET_8; + return CLAMP(y, LIMIT_LO(DEPTH8), LIMIT_HI_Y(DEPTH8)); +} + +/* + * I420: 8-bit YUV 4:2:0 planar. Y plane is full resolution; U and V planes + * each (width/2) x (height/2). width and height must be even (caller + * responsibility); odd dimensions silently truncate the last column/row. + * + * Fused 2x2-block iteration: the four overlay pixels of each chroma cell + * feed both the four Y outputs and the single CbCr pair, so each source + * pixel is read once instead of twice (vs. separate Y-plane and UV-plane + * passes — half the source DRAM traffic). + */ +void alpha_blend_i420(uint8_t * __restrict dst_y, int y_stride, + uint8_t * __restrict dst_u, + uint8_t * __restrict dst_v, int uv_stride, + const uint16_t * __restrict rgba16, int src_pixel_stride, + int width, int height) +{ + const struct color_coeffs cfs = *get_color_coeffs(CS_DFL, DEPTH8); + /* uint16_t elements per overlay row, not bytes (the pointer it + * advances is uint16_t *). Byte stride = src_row_elems * 2. */ + const size_t src_row_elems = (size_t)src_pixel_stride * 4; + const int uv_w = width / 2; + const int chroma_h = height / 2; + + for (int cy = 0; cy < chroma_h; cy++) { + const uint16_t *r0 = rgba16 + (size_t)(cy * 2) * src_row_elems; + const uint16_t *r1 = rgba16 + (size_t)(cy * 2 + 1) * src_row_elems; + uint8_t *dy0 = dst_y + (size_t)(cy * 2) * y_stride; + uint8_t *dy1 = dst_y + (size_t)(cy * 2 + 1) * y_stride; + uint8_t *du = dst_u + (size_t)cy * uv_stride; + uint8_t *dv = dst_v + (size_t)cy * uv_stride; + + for (int col = 0; col < uv_w; col++) { + const uint16_t *p00 = r0; + const uint16_t *p01 = r0 + 4; + const uint16_t *p10 = r1; + const uint16_t *p11 = r1 + 4; + const int dx0 = col * 2; + const int dx1 = dx0 + 1; + + dy0[dx0] = blend8(rgb16_to_y8(&cfs, p00), + dy0[dx0], p00[3] >> 8); + dy0[dx1] = blend8(rgb16_to_y8(&cfs, p01), + dy0[dx1], p01[3] >> 8); + dy1[dx0] = blend8(rgb16_to_y8(&cfs, p10), + dy1[dx0], p10[3] >> 8); + dy1[dx1] = blend8(rgb16_to_y8(&cfs, p11), + dy1[dx1], p11[3] >> 8); + + struct yuv420_chroma c = convert_quad_16bit_rgb_to_yuv420( + p00, p01, p10, p11, &cfs); + du[col] = blend8(c.cb, du[col], c.a); + dv[col] = blend8(c.cr, dv[col], c.a); + + r0 += 8; + r1 += 8; + } + } +} + +/* RG48: 16-bit RGB, 6 bytes per pixel, little-endian. No color conversion; + * the 16-bit overlay components write through directly. */ +void alpha_blend_rg48(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + for (int x = 0; x < width; x++) { + unsigned r = rgba16[0]; + unsigned g = rgba16[1]; + unsigned b = rgba16[2]; + unsigned a = rgba16[3]; + + uint16_t dr, dg, db; + memcpy(&dr, dst + 0, 2); + memcpy(&dg, dst + 2, 2); + memcpy(&db, dst + 4, 2); + + dr = blend16(r, dr, a); + dg = blend16(g, dg, a); + db = blend16(b, db, a); + + memcpy(dst + 0, &dr, 2); + memcpy(dst + 2, &dg, 2); + memcpy(dst + 4, &db, 2); + + rgba16 += 4; + dst += 6; + } +} + +/* v210: 10-bit YUV 4:2:2 packed; 6 pixels per 16 bytes (4 uint32_t words). + * Components occupy 10-bit slots at shifts 0/10/20; bits 30-31 are padding. + * dst is uint8_t* with no alignment guarantee, hence memcpy for word access. */ +#define V210_PIXELS_PER_GROUP 6 +#define V210_BYTES_PER_GROUP 16 +#define V210_COMP_BITS 10 +#define V210_COMP_MASK ((1u << V210_COMP_BITS) - 1) + +static inline uint32_t v210_pack3(unsigned a, unsigned b, unsigned c) +{ + return (a & V210_COMP_MASK) + | ((b & V210_COMP_MASK) << V210_COMP_BITS) + | ((c & V210_COMP_MASK) << (2 * V210_COMP_BITS)); +} + +void alpha_blend_v210(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + const struct color_coeffs cfs = *get_color_coeffs(CS_DFL, DEPTH10); + + for (int x = 0; x + V210_PIXELS_PER_GROUP <= width; + x += V210_PIXELS_PER_GROUP) { + int y[6], a_y[6]; + int cb[3], cr[3], a_c[3]; + + for (int i = 0; i < 6; i++) { + uint16_t r = rgba16[i*4 + 0]; + uint16_t g = rgba16[i*4 + 1]; + uint16_t b = rgba16[i*4 + 2]; + int yv = (RGB_TO_Y(cfs, r, g, b) >> COMP_OFF_10) + + Y_OFFSET_10; + y[i] = CLAMP(yv, LIMIT_LO(DEPTH10), LIMIT_HI_Y(DEPTH10)); + a_y[i] = rgba16[i*4 + 3] >> 6; /* 16-bit -> 10-bit */ + } + for (int p = 0; p < 3; p++) { + const uint16_t *p0 = rgba16 + (p*2) * 4; + const uint16_t *p1 = rgba16 + (p*2 + 1) * 4; + int32_t cb_sum = RGB_TO_CB(cfs, p0[0], p0[1], p0[2]) + + RGB_TO_CB(cfs, p1[0], p1[1], p1[2]); + int32_t cr_sum = RGB_TO_CR(cfs, p0[0], p0[1], p0[2]) + + RGB_TO_CR(cfs, p1[0], p1[1], p1[2]); + int cbv = ((cb_sum / 2) >> COMP_OFF_10) + CBCR_OFFSET_10; + int crv = ((cr_sum / 2) >> COMP_OFF_10) + CBCR_OFFSET_10; + cb[p] = CLAMP(cbv, LIMIT_LO(DEPTH10), LIMIT_HI_CBCR(DEPTH10)); + cr[p] = CLAMP(crv, LIMIT_LO(DEPTH10), LIMIT_HI_CBCR(DEPTH10)); + a_c[p] = (a_y[p*2] + a_y[p*2 + 1] + 1) >> 1; + } + + uint32_t d0, d1, d2, d3; + memcpy(&d0, dst + 0, 4); + memcpy(&d1, dst + 4, 4); + memcpy(&d2, dst + 8, 4); + memcpy(&d3, dst + 12, 4); + + /* Word layout: 0=Cb0|Y0|Cr0 1=Y1|Cb1|Y2 2=Cr1|Y3|Cb2 3=Y4|Cr2|Y5 */ + int dst_cb0 = (d0 >> 0) & V210_COMP_MASK; + int dst_y0 = (d0 >> 10) & V210_COMP_MASK; + int dst_cr0 = (d0 >> 20) & V210_COMP_MASK; + int dst_y1 = (d1 >> 0) & V210_COMP_MASK; + int dst_cb1 = (d1 >> 10) & V210_COMP_MASK; + int dst_y2 = (d1 >> 20) & V210_COMP_MASK; + int dst_cr1 = (d2 >> 0) & V210_COMP_MASK; + int dst_y3 = (d2 >> 10) & V210_COMP_MASK; + int dst_cb2 = (d2 >> 20) & V210_COMP_MASK; + int dst_y4 = (d3 >> 0) & V210_COMP_MASK; + int dst_cr2 = (d3 >> 10) & V210_COMP_MASK; + int dst_y5 = (d3 >> 20) & V210_COMP_MASK; + + d0 = v210_pack3(blend10(cb[0], dst_cb0, a_c[0]), + blend10(y[0], dst_y0, a_y[0]), + blend10(cr[0], dst_cr0, a_c[0])); + d1 = v210_pack3(blend10(y[1], dst_y1, a_y[1]), + blend10(cb[1], dst_cb1, a_c[1]), + blend10(y[2], dst_y2, a_y[2])); + d2 = v210_pack3(blend10(cr[1], dst_cr1, a_c[1]), + blend10(y[3], dst_y3, a_y[3]), + blend10(cb[2], dst_cb2, a_c[2])); + d3 = v210_pack3(blend10(y[4], dst_y4, a_y[4]), + blend10(cr[2], dst_cr2, a_c[2]), + blend10(y[5], dst_y5, a_y[5])); + + memcpy(dst + 0, &d0, 4); + memcpy(dst + 4, &d1, 4); + memcpy(dst + 8, &d2, 4); + memcpy(dst + 12, &d3, 4); + + rgba16 += V210_PIXELS_PER_GROUP * 4; + dst += V210_BYTES_PER_GROUP; + } +} + +/* + * R10k: 10-bit RGB packed in 4 bytes per pixel. Layout (matches + * vc_copylineRG48toR10k in pixfmt_conv.c): + * byte 0: r[9:2] + * byte 1: g[9:4] in low 6 bits, r[1:0] in high 2 bits + * byte 2: b[9:6] in low 4 bits, g[3:0] in high 4 bits + * byte 3: pad (0b11) in low 2 bits, b[5:0] in high 6 bits + */ +#define R10K_PAD 0x3u + +static inline uint32_t r10k_pack3(unsigned r, unsigned g, unsigned b) +{ + return ((r >> 2) & 0xFFu) + | (((g >> 4) & 0x3Fu) << 8) + | ((r & 0x3u) << 14) + | (((b >> 6) & 0xFu) << 16) + | ((g & 0xFu) << 20) + | (R10K_PAD << 24) + | ((b & 0x3Fu) << 26); +} + +static inline void r10k_unpack3(uint32_t d, unsigned *r, unsigned *g, unsigned *b) +{ + *r = ((d >> 0) & 0xFFu) << 2 | ((d >> 14) & 0x3u); + *g = ((d >> 8) & 0x3Fu) << 4 | ((d >> 20) & 0xFu); + *b = ((d >> 16) & 0xFu) << 6 | ((d >> 26) & 0x3Fu); +} + +void alpha_blend_r10k(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + for (int x = 0; x < width; x++) { + unsigned r = rgba16[0] >> 6; + unsigned g = rgba16[1] >> 6; + unsigned b = rgba16[2] >> 6; + unsigned a = rgba16[3] >> 6; + + uint32_t d; + memcpy(&d, dst, 4); + unsigned dr, dg, db; + r10k_unpack3(d, &dr, &dg, &db); + + d = r10k_pack3(blend10(r, dr, a), + blend10(g, dg, a), + blend10(b, db, a)); + memcpy(dst, &d, 4); + + rgba16 += 4; + dst += 4; + } +} + +/* + * R12L: 12-bit RGB packed, 8 pixels in 36 bytes (4 sub-blocks of 9 bytes, + * each holding 2 pixels = 6 components × 12 bits = 72 bits). Layout per + * 9-byte sub-block: + * byte 0: P0_R[7:0] + * byte 1: P0_G[3:0] << 4 | P0_R[11:8] + * byte 2: P0_G[11:4] + * byte 3: P0_B[7:0] + * byte 4: P1_R[3:0] << 4 | P0_B[11:8] + * byte 5: P1_R[11:4] + * byte 6: P1_G[7:0] + * byte 7: P1_B[3:0] << 4 | P1_G[11:8] + * byte 8: P1_B[11:4] + */ +#define R12L_PIXELS_PER_BLOCK 8 +#define R12L_BYTES_PER_BLOCK 36 +#define R12L_PIXELS_PER_PAIR 2 +#define R12L_BYTES_PER_PAIR 9 +#define R12L_PAIRS_PER_BLOCK (R12L_PIXELS_PER_BLOCK / R12L_PIXELS_PER_PAIR) + +static inline unsigned blend12(unsigned src, unsigned dst, unsigned a) +{ + return BLEND_N(src, dst, a, 4095u); +} + +/* Blend a single 9-byte sub-block (2 RGBA pixels). */ +static inline void r12l_blend_pair(uint8_t *dst, const uint16_t *rgba16) +{ + unsigned p0_r = rgba16[0] >> 4, p0_g = rgba16[1] >> 4; + unsigned p0_b = rgba16[2] >> 4, p0_a = rgba16[3] >> 4; + unsigned p1_r = rgba16[4] >> 4, p1_g = rgba16[5] >> 4; + unsigned p1_b = rgba16[6] >> 4, p1_a = rgba16[7] >> 4; + + unsigned d_p0_r = dst[0] | ((dst[1] & 0xFu) << 8); + unsigned d_p0_g = (dst[1] >> 4) | (dst[2] << 4); + unsigned d_p0_b = dst[3] | ((dst[4] & 0xFu) << 8); + unsigned d_p1_r = (dst[4] >> 4) | (dst[5] << 4); + unsigned d_p1_g = dst[6] | ((dst[7] & 0xFu) << 8); + unsigned d_p1_b = (dst[7] >> 4) | (dst[8] << 4); + + unsigned o_p0_r = blend12(p0_r, d_p0_r, p0_a); + unsigned o_p0_g = blend12(p0_g, d_p0_g, p0_a); + unsigned o_p0_b = blend12(p0_b, d_p0_b, p0_a); + unsigned o_p1_r = blend12(p1_r, d_p1_r, p1_a); + unsigned o_p1_g = blend12(p1_g, d_p1_g, p1_a); + unsigned o_p1_b = blend12(p1_b, d_p1_b, p1_a); + + dst[0] = o_p0_r & 0xFFu; + dst[1] = ((o_p0_r >> 8) & 0xFu) | ((o_p0_g & 0xFu) << 4); + dst[2] = (o_p0_g >> 4) & 0xFFu; + dst[3] = o_p0_b & 0xFFu; + dst[4] = ((o_p0_b >> 8) & 0xFu) | ((o_p1_r & 0xFu) << 4); + dst[5] = (o_p1_r >> 4) & 0xFFu; + dst[6] = o_p1_g & 0xFFu; + dst[7] = ((o_p1_g >> 8) & 0xFu) | ((o_p1_b & 0xFu) << 4); + dst[8] = (o_p1_b >> 4) & 0xFFu; +} + +void alpha_blend_r12l(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width) +{ + int x = 0; + for (; x + R12L_PIXELS_PER_BLOCK <= width; + x += R12L_PIXELS_PER_BLOCK) { + for (int p = 0; p < R12L_PAIRS_PER_BLOCK; p++) { + r12l_blend_pair(dst + p * R12L_BYTES_PER_PAIR, + rgba16 + p * R12L_PIXELS_PER_PAIR * 4); + } + dst += R12L_BYTES_PER_BLOCK; + rgba16 += R12L_PIXELS_PER_BLOCK * 4; + } + /* Any remaining 2-pixel pairs (R12L data is byte-aligned only at the + * pair boundary, so a single trailing pixel is silently dropped). */ + while (x + R12L_PIXELS_PER_PAIR <= width) { + r12l_blend_pair(dst, rgba16); + dst += R12L_BYTES_PER_PAIR; + rgba16 += R12L_PIXELS_PER_PAIR * 4; + x += R12L_PIXELS_PER_PAIR; + } +} diff --git a/src/utils/alpha_blend.h b/src/utils/alpha_blend.h new file mode 100644 index 0000000000..85a9c4a115 --- /dev/null +++ b/src/utils/alpha_blend.h @@ -0,0 +1,118 @@ +/** + * @file utils/alpha_blend.h + * @author Ben Roeder + * @brief Alpha blending of 16-bit RGBA overlay onto native video formats + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef UTILS_ALPHA_BLEND_H_5A8F2C1B_4E73_4D8A_B6F0_2C9E1F4A8B3D +#define UTILS_ALPHA_BLEND_H_5A8F2C1B_4E73_4D8A_B6F0_2C9E1F4A8B3D + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Blend a 16-bit RGBA overlay (uint16_t per component, range 0-65535) onto + * an 8-bit RGBA destination buffer. width is the number of pixels. + */ +void alpha_blend_rgba(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); +void alpha_blend_rgb(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); +void alpha_blend_uyvy(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); +void alpha_blend_yuyv(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); +void alpha_blend_y416(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); + +/* + * v210: 10-bit YUV 4:2:2 packed, 6 pixels per 16 bytes (4 uint32_t words). + * width must be a multiple of 6; remaining pixels are silently dropped. + */ +void alpha_blend_v210(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); + +/* + * R10k: 10-bit RGB packed in 4 bytes per pixel. No color space conversion + * (RGB destination); 16-bit input scaled to 10-bit by right-shifting 6. + */ +void alpha_blend_r10k(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); + +/* + * R12L: 12-bit RGB packed, 8 pixels in 36 bytes. No color space conversion; + * 16-bit input scaled to 12-bit by right-shifting 4. Width should be a + * multiple of 8; remaining pixels (1-7) are processed in 2-pixel pair + * sub-blocks, with any final odd pixel silently dropped. + */ +void alpha_blend_r12l(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); + +/* + * RG48: 16-bit RGB, 6 bytes per pixel little-endian. No color conversion; + * 16-bit overlay components write through directly to the destination. + * dst is uint8_t* because the destination buffer has no 2-byte alignment + * guarantee — components are loaded/stored via memcpy. + */ +void alpha_blend_rg48(uint8_t * __restrict dst, + const uint16_t * __restrict rgba16, int width); + +/* + * I420 planar 4:2:0: width must be even (odd column truncated; height likewise + * truncated to an even count for the chroma pass). + * + * Plane and overlay strides are explicit so the call can blend into a + * sub-region of a larger frame: + * - y_stride: bytes per row in the Y plane + * - uv_stride: bytes per row in each of the U and V planes + * - src_pixel_stride: pixels per row of the rgba16 overlay buffer (the + * byte advance is src_pixel_stride * 4 * sizeof(uint16_t)) + * For the simple "full-frame" case, pass y_stride=width, uv_stride=width/2, + * src_pixel_stride=width. + */ +void alpha_blend_i420(uint8_t * __restrict dst_y, int y_stride, + uint8_t * __restrict dst_u, + uint8_t * __restrict dst_v, int uv_stride, + const uint16_t * __restrict rgba16, int src_pixel_stride, + int width, int height); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/utils/overlay_config.c b/src/utils/overlay_config.c new file mode 100644 index 0000000000..40fcee9c06 --- /dev/null +++ b/src/utils/overlay_config.c @@ -0,0 +1,258 @@ +/** + * @file utils/overlay_config.c + * @author Ben Roeder + * @brief Parser for the overlay postprocessor's configuration string. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include +#include + +#include "compat/c23.h" // countof +#include "debug.h" +#include "utils/macros.h" +#include "utils/misc.h" +#include "utils/overlay_config.h" + +#define MOD_NAME "[overlay_config] " + +static const struct { + const char *kw; + enum overlay_position pos; +} POSITIONS[] = { + {"center", OVERLAY_POS_CENTER}, + {"top_left", OVERLAY_POS_TOP_LEFT}, + {"top_right", OVERLAY_POS_TOP_RIGHT}, + {"bottom_left", OVERLAY_POS_BOTTOM_LEFT}, + {"bottom_right", OVERLAY_POS_BOTTOM_RIGHT}, + {"custom", OVERLAY_POS_CUSTOM}, +}; + +static const struct { + const char *kw; + enum overlay_scale_filter filter; +} FILTERS[] = { + {"nearest", OVERLAY_SCALE_NEAREST}, + {"fast_bilinear", OVERLAY_SCALE_FAST_BILINEAR}, + {"bilinear", OVERLAY_SCALE_BILINEAR}, + {"bicubic", OVERLAY_SCALE_BICUBIC}, + {"lanczos", OVERLAY_SCALE_LANCZOS}, +}; + +static bool parse_position(const char *val, enum overlay_position *out) +{ + for (size_t i = 0; i < countof(POSITIONS); i++) { + if (strcmp(val, POSITIONS[i].kw) == 0) { + *out = POSITIONS[i].pos; + return true; + } + } + return false; +} + +static bool parse_scale_filter(const char *val, + enum overlay_scale_filter *out) +{ + for (size_t i = 0; i < countof(FILTERS); i++) { + if (strcmp(val, FILTERS[i].kw) == 0) { + *out = FILTERS[i].filter; + return true; + } + } + return false; +} + +/* Wrap parse_number() (which uses INT_MIN as its error sentinel) so the + * caller gets a plain success/fail and can still accept INT_MIN+1. */ +static bool parse_coord(const char *val, int *out) +{ + const int n = parse_number(val, INT_MIN + 1, 10); + if (n == INT_MIN) return false; + *out = n; + return true; +} + +bool overlay_config_parse(const char *opts, struct overlay_config *out) +{ + memset(out, 0, sizeof *out); + out->position = OVERLAY_POS_CENTER; + + if (opts == NULL) { + log_msg(LOG_LEVEL_ERROR, MOD_NAME "missing options\n"); + return false; + } + + /* strtok_r mutates its input — walk a heap copy. */ + char *buf = strdup(opts); + if (buf == NULL) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "out of memory parsing options\n"); + return false; + } + + bool ok = false; + char *saveptr = NULL; + for (char *tok = strtok_r(buf, ":", &saveptr); tok != NULL; + tok = strtok_r(NULL, ":", &saveptr)) { + if (strcmp(tok, "help") == 0) { + out->help = true; + ok = true; + goto out; + } + if (strcmp(tok, "perf") == 0) { + out->perf = true; + continue; + } + char *eq = strchr(tok, '='); + if (eq == NULL) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "missing '=' in '%s'\n", tok); + goto out; + } + *eq = '\0'; + const char *key = tok; + char *val = eq + 1; /* mutable: points into buf */ + if (*val == '\0') { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "empty value for '%s'\n", key); + goto out; + } + + if (strcmp(key, "file") == 0) { + const int n = snprintf(out->file, sizeof out->file, + "%s", val); + if (n < 0 || (size_t)n >= sizeof out->file) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "file path too long\n"); + goto out; + } + } else if (strcmp(key, "position") == 0) { + if (!parse_position(val, &out->position)) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "unknown position '%s'\n", val); + goto out; + } + } else if (strcmp(key, "custom_x") == 0) { + if (!parse_coord(val, &out->custom_x)) goto out; + out->position = OVERLAY_POS_CUSTOM; + } else if (strcmp(key, "custom_y") == 0) { + if (!parse_coord(val, &out->custom_y)) goto out; + out->position = OVERLAY_POS_CUSTOM; + } else if (strcmp(key, "scale_filter") == 0) { + if (!parse_scale_filter(val, &out->scale_filter)) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "unknown scale_filter '%s' " + "(try nearest, fast_bilinear, bilinear, " + "bicubic, lanczos)\n", val); + goto out; + } + } else if (strcmp(key, "blend_threads") == 0) { + if (!parse_coord(val, &out->blend_threads)) goto out; + if (out->blend_threads < 0 + || out->blend_threads > MAX_CPU_CORES) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "blend_threads must be in [0, %d]\n", + MAX_CPU_CORES); + goto out; + } + } else if (strcmp(key, "soft_edge") == 0) { + if (!parse_coord(val, &out->soft_edge)) goto out; + if (out->soft_edge < 0) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "soft_edge must be >= 0\n"); + goto out; + } + } else if (strcmp(key, "scale") == 0) { + if (strcmp(val, "frame") == 0) { + /* scale=frame: track current frame + * dimensions; clear any previous WxH + * (last-one-wins). */ + out->scale_to_frame = true; + out->scale_w = 0; + out->scale_h = 0; + continue; + } + char *x = strchr(val, 'x'); + if (x == NULL) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "scale must be WxH or 'frame' (got '%s')\n", + val); + goto out; + } + *x = '\0'; + if (!parse_coord(val, &out->scale_w)) goto out; + if (!parse_coord(x + 1, &out->scale_h)) goto out; + if (out->scale_w <= 0 || out->scale_h <= 0) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "scale dimensions must be > 0\n"); + goto out; + } + /* Explicit WxH overrides any previous scale=frame. */ + out->scale_to_frame = false; + } else { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "unknown key '%s'\n", key); + goto out; + } + } + + if (out->file[0] == '\0') { + log_msg(LOG_LEVEL_ERROR, MOD_NAME "missing 'file=' option\n"); + goto out; + } + + /* Auto-default blend_threads when unset (= 0). The work-area guard + * in vo_postprocess/overlay still routes small overlays to the + * serial path, so a generous default doesn't waste cores on tiny + * rects. min(ncpu, 8) — past 8 the dispatch overhead grows faster + * than the per-thread work shrinks for our blend sizes. Users + * who want explicit single-threaded blend pass blend_threads=1. */ + if (out->blend_threads == 0) { + int n = get_cpu_core_count(); + if (n < 1) n = 1; + if (n > 8) n = 8; + out->blend_threads = n; + } + ok = true; + +out: + free(buf); + return ok; +} diff --git a/src/utils/overlay_config.h b/src/utils/overlay_config.h new file mode 100644 index 0000000000..7fa6b86f31 --- /dev/null +++ b/src/utils/overlay_config.h @@ -0,0 +1,107 @@ +/** + * @file utils/overlay_config.h + * @author Ben Roeder + * @brief Parser for the overlay postprocessor's configuration string. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef UTILS_OVERLAY_CONFIG_H_8F2A1B6D_3E5C_4A7F_B9D2_5C8A1E4F7B3D +#define UTILS_OVERLAY_CONFIG_H_8F2A1B6D_3E5C_4A7F_B9D2_5C8A1E4F7B3D + +#include + +#include "utils/overlay_layout.h" +#include "utils/fs.h" +#include "utils/overlay_scale.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Parsed form of the postprocess option string. Owns no allocations: file + * is a fixed-size buffer so the caller can embed by value. + */ +struct overlay_config { + char file[MAX_PATH_SIZE]; + enum overlay_position position; + int custom_x; + int custom_y; + int soft_edge; + int scale_w; ///< 0 = no scaling (or scale_to_frame is set) + int scale_h; + bool scale_to_frame; ///< scale=frame: re-scale to the current + ///< frame dims on every resolution change + enum overlay_scale_filter scale_filter; ///< default BICUBIC + int blend_threads; ///< 0 = auto (min(ncpu, 8)); 1 = single-threaded; >1 = pthread workers + bool help; + bool perf; ///< periodic per-frame timing log +}; + +/* + * Parse a "key=value:key=value:..." option string into *out. Returns true on + * success, false on malformed input (with a message logged). The single + * keyword "help" is also accepted as a request for usage and produces help=1 + * with no other fields validated. + * + * Recognised keys: + * file= — path to the PAM overlay (required, except with help) + * position= — center | top_left | top_right | bottom_left + * | bottom_right | custom (default: center) + * custom_x= — absolute or negative-from-edge x offset; forces + * custom_y= position=custom + * soft_edge= — N-pixel linear alpha fade (0 = disabled, default 0) + * scale=x — resize the overlay to W x H (0x0 = no scaling, + * default) + * scale=frame — re-scale the overlay to the current frame + * dimensions; tracks resolution renegotiations + * on the decode path. Mutually exclusive with + * scale=x (last one wins). + * scale_filter= — resampling filter: nearest, fast_bilinear, + * bilinear, bicubic, lanczos (default: bicubic) + * blend_threads= — pthread workers for the per-row alpha-blend + * loop. Default is min(ncpu, 8); pass 1 for + * explicit single-threaded blend. + * perf — bare token; turns on periodic per-frame timing + * log (off by default) + * + * Unknown keys are rejected. + */ +bool overlay_config_parse(const char *opts, struct overlay_config *out); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/utils/overlay_layout.c b/src/utils/overlay_layout.c new file mode 100644 index 0000000000..2df77cc7fe --- /dev/null +++ b/src/utils/overlay_layout.c @@ -0,0 +1,143 @@ +/** + * @file utils/overlay_layout.c + * @author Ben Roeder + * @brief Overlay positioning math + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "utils/macros.h" +#include "utils/overlay_layout.h" + +/* 1D placement: the overlay's left edge wants to be at signed + * coordinate `pos` within a frame of size `frame_size`. Compute the + * intersection of [pos, pos + overlay_size] with [0, frame_size]. + * + * Returns the frame-side offset, the offset *into* the overlay buffer + * (non-zero when pos < 0 — i.e. the overlay extends past the left/top + * edge), and the visible length. When the overlay fits cleanly inside + * the frame, src_off is always 0. */ +struct axis { + int frame_off; + int src_off; + int length; +}; + +static struct axis +calc_axis(int pos, int frame_size, int overlay_size, int block) +{ + int frame_off = pos < 0 ? 0 : pos; + int end = pos + overlay_size; + if (end > frame_size) end = frame_size; + int length = end - frame_off; + if (length < 0) length = 0; + int src_off = frame_off - pos; /* 0 if pos >= 0 */ + + if (block > 1) { + /* Snap frame_off down to a block boundary. When the + * overlay extends past the left edge (src_off > 0) we + * have cushion: shift src_off back the same amount and + * gain `shift` columns of length. Without cushion the + * overlay just appears shifted-left in the frame by + * up to (block-1) pixels — a cosmetic artifact of the + * codec's pixel-block grid, same as the legacy + * behaviour before src_x/src_y were tracked. */ + int shift = frame_off % block; + if (shift > 0) { + frame_off -= shift; + if (src_off >= shift) { + src_off -= shift; + length += shift; + } + } + /* length only grows in the cushion branch, never + * shrinks; the earlier `length < 0` guard already + * pinned it to >= 0. Just snap to a block multiple. */ + length -= length % block; + } + return (struct axis){frame_off, src_off, length}; +} + +struct overlay_rect overlay_calc_rect(enum overlay_position pos, + int custom_x, int custom_y, + int frame_w, int frame_h, + int overlay_w, int overlay_h, + int block_pixels, int block_lines) +{ + int ideal_x = 0, ideal_y = 0; + switch (pos) { + case OVERLAY_POS_CENTER: + ideal_x = (frame_w - overlay_w) / 2; + ideal_y = (frame_h - overlay_h) / 2; + break; + case OVERLAY_POS_TOP_LEFT: + ideal_x = 0; ideal_y = 0; + break; + case OVERLAY_POS_TOP_RIGHT: + ideal_x = frame_w - overlay_w; ideal_y = 0; + break; + case OVERLAY_POS_BOTTOM_LEFT: + ideal_x = 0; ideal_y = frame_h - overlay_h; + break; + case OVERLAY_POS_BOTTOM_RIGHT: + ideal_x = frame_w - overlay_w; + ideal_y = frame_h - overlay_h; + break; + case OVERLAY_POS_CUSTOM: + /* Negative values count from the right/bottom edge: the + * overlay's right edge sits |custom_x| pixels from the + * frame's right edge for x = -|custom_x|. */ + ideal_x = custom_x < 0 ? frame_w + custom_x - overlay_w + : custom_x; + ideal_y = custom_y < 0 ? frame_h + custom_y - overlay_h + : custom_y; + break; + } + + const struct axis ax = calc_axis(ideal_x, frame_w, overlay_w, + block_pixels); + const struct axis ay = calc_axis(ideal_y, frame_h, overlay_h, + block_lines); + return (struct overlay_rect){ + .x = ax.frame_off, + .y = ay.frame_off, + .width = ax.length, + .height = ay.length, + .src_x = ax.src_off, + .src_y = ay.src_off, + }; +} diff --git a/src/utils/overlay_layout.h b/src/utils/overlay_layout.h new file mode 100644 index 0000000000..4be65bbf19 --- /dev/null +++ b/src/utils/overlay_layout.h @@ -0,0 +1,90 @@ +/** + * @file utils/overlay_layout.h + * @author Ben Roeder + * @brief Overlay positioning math for the overlay postprocessor. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef UTILS_OVERLAY_LAYOUT_H_5C2D8A1B_3E7F_4A9B_8D6C_2F5E1A8B7C3D +#define UTILS_OVERLAY_LAYOUT_H_5C2D8A1B_3E7F_4A9B_8D6C_2F5E1A8B7C3D + +#ifdef __cplusplus +extern "C" { +#endif + +enum overlay_position { + OVERLAY_POS_CENTER = 0, + OVERLAY_POS_TOP_LEFT, + OVERLAY_POS_TOP_RIGHT, + OVERLAY_POS_BOTTOM_LEFT, + OVERLAY_POS_BOTTOM_RIGHT, + OVERLAY_POS_CUSTOM, +}; + +struct overlay_rect { + int x, y; ///< origin within the frame + int width, height; ///< blend region (overlay clipped to frame bounds) + int src_x, src_y; ///< origin within the overlay buffer (non-zero + ///< when the overlay is larger than the frame + ///< and the visible region is the centre/right + ///< slice of the overlay) +}; + +/* + * Compute the blend rectangle for an overlay of size (overlay_w, overlay_h) + * placed onto a frame of size (frame_w, frame_h). + * + * - pos selects a preset position; OVERLAY_POS_CUSTOM uses (custom_x, custom_y) + * instead, with negative values counting from the right/bottom edges. + * - block_pixels / block_lines snap x/width and y/height down to the + * codec's pixel-block grid. Use get_pf_block_pixels(codec) for the + * horizontal block; pass block_lines=2 for chroma-vertically-subsampled + * formats (I420), 1 otherwise. Values <= 1 are a no-op for that axis. + * - The returned rect is clipped to {0..frame_w, 0..frame_h}; an overlay that + * doesn't fit returns width or height of 0. + * - When the overlay is larger than the frame, src_x/src_y describe which + * part of the overlay maps to the visible rect (left/centre/right slice + * per the chosen position). When the overlay fits, both are 0. + */ +struct overlay_rect overlay_calc_rect(enum overlay_position pos, + int custom_x, int custom_y, + int frame_w, int frame_h, + int overlay_w, int overlay_h, + int block_pixels, int block_lines); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/utils/overlay_pam.c b/src/utils/overlay_pam.c new file mode 100644 index 0000000000..61ad7450c5 --- /dev/null +++ b/src/utils/overlay_pam.c @@ -0,0 +1,103 @@ +/** + * @file utils/overlay_pam.c + * @author Ben Roeder + * @brief PAM image loader normalised to 16-bit RGBA + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include // for free, malloc + +#include "utils/overlay_pam.h" +#include "utils/pam.h" + +bool overlay_load_pam_rgba16(const char *path, uint16_t **out_data, + int *out_width, int *out_height) +{ + struct pam_metadata info; + unsigned char *raw = NULL; + if (!pam_read(path, &info, &raw, malloc)) { + return false; + } + if (info.ch_count != 3 && info.ch_count != 4) { + free(raw); + return false; + } + /* Only accept exact 8-bit (255) and 16-bit (65535) maxvals. Other + * values would need scaling to map their full range onto 0xFFFF; + * silently treating them as 16-bit produces darker overlays than the + * file authored. Reject rather than misinterpret. */ + if (info.maxval != 255 && info.maxval != 65535) { + free(raw); + return false; + } + + const size_t pixels = (size_t)info.width * info.height; + uint16_t *out = malloc(pixels * 4 * sizeof(uint16_t)); + if (!out) { + free(raw); + return false; + } + + const bool deep = info.maxval == 65535; + const size_t bytes_per_pixel = info.ch_count * (deep ? 2u : 1u); + + for (size_t i = 0; i < pixels; i++) { + const unsigned char *p = raw + i * bytes_per_pixel; + uint16_t *o = out + i * 4; + /* Default alpha to opaque; overwritten when ch_count == 4. */ + o[3] = 0xFFFFu; + if (deep) { + /* PAM 16-bit samples are big-endian. */ + for (int c = 0; c < info.ch_count; c++) { + o[c] = (uint16_t)(((unsigned)p[c*2] << 8) + | p[c*2 + 1]); + } + } else { + for (int c = 0; c < info.ch_count; c++) { + /* Bit-replicate 8-bit to 16-bit. */ + o[c] = (uint16_t)(((unsigned)p[c] << 8) | p[c]); + } + } + } + + free(raw); + *out_data = out; + *out_width = info.width; + *out_height = info.height; + return true; +} diff --git a/src/utils/overlay_pam.h b/src/utils/overlay_pam.h new file mode 100644 index 0000000000..143bd7dc9b --- /dev/null +++ b/src/utils/overlay_pam.h @@ -0,0 +1,67 @@ +/** + * @file utils/overlay_pam.h + * @author Ben Roeder + * @brief PAM image loader normalised to 16-bit RGBA, used by the overlay + * postprocessor. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef UTILS_OVERLAY_PAM_H_3F8E2A1D_6B4C_4A9F_92E0_7D5C8E3A2B6F +#define UTILS_OVERLAY_PAM_H_3F8E2A1D_6B4C_4A9F_92E0_7D5C8E3A2B6F + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Load a PAM image from path and normalise to 16-bit RGBA. Accepts 8-bit + * (maxval=255) or 16-bit (maxval=65535) PAM in RGB (3-channel) or RGBA + * (4-channel) layout. Other maxvals are rejected. 3-channel images receive + * alpha=65535. 8-bit values are bit-replicated to 16-bit (val<<8|val). + * + * On success: *out_data points to an allocated uint16_t buffer with + * width*height*4 components; *out_width and *out_height are set; the caller + * must free(*out_data). On failure returns false and leaves outputs untouched. + */ +bool overlay_load_pam_rgba16(const char *path, uint16_t **out_data, + int *out_width, int *out_height); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/utils/overlay_scale.c b/src/utils/overlay_scale.c new file mode 100644 index 0000000000..0294612ae6 --- /dev/null +++ b/src/utils/overlay_scale.c @@ -0,0 +1,161 @@ +/** + * @file utils/overlay_scale.c + * @author Ben Roeder + * @brief Resize a 16-bit RGBA overlay via libswscale. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include + +#include +#include + +#include "utils/overlay_scale.h" + +/* libavutil's RGBA64LE matches our 16-bit RGBA layout (R then G then B then + * A, native uint16_t per component) on all little-endian targets. There's + * no codec_t round-trip via get_ug_to_av_pixfmt because UltraGrid has no + * codec for 16-bit RGBA — the closest is RG48 (no alpha). */ +#define RGBA16_PIX_FMT AV_PIX_FMT_RGBA64LE + +struct overlay_scaler { + struct SwsContext *ctx; + int src_w, src_h, dst_w, dst_h; /* cache key for ctx */ + int sws_flags; /* libswscale filter flag */ +}; + +static int +filter_to_sws_flags(enum overlay_scale_filter f) +{ + switch (f) { + case OVERLAY_SCALE_NEAREST: return SWS_POINT; + case OVERLAY_SCALE_FAST_BILINEAR: return SWS_FAST_BILINEAR; + case OVERLAY_SCALE_BILINEAR: return SWS_BILINEAR; + case OVERLAY_SCALE_LANCZOS: return SWS_LANCZOS; + case OVERLAY_SCALE_BICUBIC: + default: return SWS_BICUBIC; + } +} + +struct overlay_scaler * +overlay_scaler_create(enum overlay_scale_filter filter) +{ + struct overlay_scaler *s = calloc(1, sizeof *s); + if (s == NULL) return NULL; + s->sws_flags = filter_to_sws_flags(filter); + return s; +} + +void +overlay_scaler_destroy(struct overlay_scaler *s) +{ + if (s == NULL) return; + if (s->ctx != NULL) sws_freeContext(s->ctx); + free(s); +} + +bool +overlay_scaler_scale_into(struct overlay_scaler *s, uint16_t *dst, + const uint16_t *src, + int src_w, int src_h, + int dst_w, int dst_h) +{ + if (s == NULL || dst == NULL || src == NULL + || src_w <= 0 || src_h <= 0 + || dst_w <= 0 || dst_h <= 0) { + return false; + } + + if (src_w == dst_w && src_h == dst_h) { + memcpy(dst, src, (size_t)dst_w * dst_h * 4 * sizeof *src); + return true; + } + + if (s->ctx == NULL || src_w != s->src_w || src_h != s->src_h + || dst_w != s->dst_w || dst_h != s->dst_h) { + if (s->ctx != NULL) sws_freeContext(s->ctx); + s->ctx = sws_getContext( + src_w, src_h, RGBA16_PIX_FMT, + dst_w, dst_h, RGBA16_PIX_FMT, + s->sws_flags, NULL, NULL, NULL); + if (s->ctx == NULL) return false; + s->src_w = src_w; s->src_h = src_h; + s->dst_w = dst_w; s->dst_h = dst_h; + } + + const uint8_t *src_planes[4] = { (const uint8_t *)src, NULL, NULL, NULL }; + const int src_stride[4] = { src_w * 4 * (int)sizeof *src, 0, 0, 0 }; + uint8_t *dst_planes[4] = { (uint8_t *)dst, NULL, NULL, NULL }; + const int dst_stride[4] = { dst_w * 4 * (int)sizeof *dst, 0, 0, 0 }; + + sws_scale(s->ctx, src_planes, src_stride, 0, src_h, + dst_planes, dst_stride); + return true; +} + +uint16_t * +overlay_scaler_scale(struct overlay_scaler *s, + const uint16_t *src, + int src_w, int src_h, + int dst_w, int dst_h) +{ + if (dst_w <= 0 || dst_h <= 0) return NULL; + uint16_t *dst = malloc((size_t)dst_w * dst_h * 4 * sizeof *dst); + if (dst == NULL) return NULL; + if (!overlay_scaler_scale_into(s, dst, src, src_w, src_h, dst_w, dst_h)) { + free(dst); + return NULL; + } + return dst; +} + +/* One-shot wrapper: keeps the existing simple-call API with a Lanczos + * default. Builds and frees a SwsContext on every call — fine for tests + * and infrequent reloads. Use overlay_scaler_create()+_scale_into for + * the hot-reload path. */ +uint16_t *overlay_scale_rgba16(const uint16_t *src, + int src_w, int src_h, + int dst_w, int dst_h) +{ + struct overlay_scaler s = {0}; + s.sws_flags = filter_to_sws_flags(OVERLAY_SCALE_BICUBIC); + uint16_t *dst = overlay_scaler_scale(&s, src, src_w, src_h, dst_w, dst_h); + if (s.ctx != NULL) sws_freeContext(s.ctx); + return dst; +} diff --git a/src/utils/overlay_scale.h b/src/utils/overlay_scale.h new file mode 100644 index 0000000000..a66e780234 --- /dev/null +++ b/src/utils/overlay_scale.h @@ -0,0 +1,120 @@ +/** + * @file utils/overlay_scale.h + * @author Ben Roeder + * @brief Resize a 16-bit RGBA overlay via libswscale. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef UTILS_OVERLAY_SCALE_H_5C8F2A1B_3D6E_4A8F_9C7D_2E5B1F4A8C3D +#define UTILS_OVERLAY_SCALE_H_5C8F2A1B_3D6E_4A8F_9C7D_2E5B1F4A8C3D + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Resampling filter for the upscale path. Trade-off summary: + * NEAREST — fastest, blocky, no inter-pixel mixing + * FAST_BILINEAR — very fast, mediocre quality (libswscale's quick path) + * BILINEAR — soft, smooth, half the cost of LANCZOS + * BICUBIC — sharp + smooth, balanced (default) + * LANCZOS — sharpest, can ring slightly, slowest + * + * Default is BICUBIC: it stays comfortably inside a 4K30 frame budget + * where LANCZOS does not, and the visual difference for typical overlay + * content is below threshold. Users who need sharper resampling can + * opt into LANCZOS explicitly. + */ +enum overlay_scale_filter { + OVERLAY_SCALE_BICUBIC = 0, + OVERLAY_SCALE_LANCZOS, + OVERLAY_SCALE_BILINEAR, + OVERLAY_SCALE_FAST_BILINEAR, + OVERLAY_SCALE_NEAREST, +}; + +/* + * One-shot scale: builds a fresh SwsContext, scales, frees the context. + * Convenient for tests and infrequent reloads, but rebuilds the filter + * tables on every call. Use overlay_scaler_scale() when scaling at + * video rate. + * + * Scale a 16-bit RGBA overlay (4 components per pixel, range 0-65535) from + * src_w x src_h to dst_w x dst_h via libswscale (bicubic). Returns a freshly + * malloc'd buffer of dst_w * dst_h * 4 uint16_t that the caller must free, or + * NULL on bad input or libswscale failure. The source buffer is read-only + * and unchanged on success. + */ +uint16_t *overlay_scale_rgba16(const uint16_t *src, + int src_w, int src_h, + int dst_w, int dst_h); + +/* + * Reusable scaler with a cached SwsContext. The context is rebuilt only + * when src/dst dimensions change between calls; for a steady stream of + * scale operations at the same dimensions (the postprocessor's + * hot-reload path) the per-call cost drops to one sws_scale(). + */ +struct overlay_scaler; + +struct overlay_scaler *overlay_scaler_create(enum overlay_scale_filter filter); + +/* Allocates a new dst buffer; same return contract as + * overlay_scale_rgba16(). Convenient for tests; for the postprocess + * hot path use overlay_scaler_scale_into() to avoid the per-call malloc. */ +uint16_t *overlay_scaler_scale(struct overlay_scaler *s, + const uint16_t *src, + int src_w, int src_h, + int dst_w, int dst_h); + +/* Scale into a caller-provided dst buffer of size + * dst_w * dst_h * 4 * sizeof(uint16_t). No allocation in the scaler. + * Returns true on success. The buffer reuse lets the postprocessor + * avoid a malloc(33 MB) + free(33 MB) per reload at 4K. */ +bool overlay_scaler_scale_into(struct overlay_scaler *s, uint16_t *dst, + const uint16_t *src, + int src_w, int src_h, + int dst_w, int dst_h); + +/* No-op when s is NULL. */ +void overlay_scaler_destroy(struct overlay_scaler *s); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/utils/overlay_soft_edge.c b/src/utils/overlay_soft_edge.c new file mode 100644 index 0000000000..d3d5a53935 --- /dev/null +++ b/src/utils/overlay_soft_edge.c @@ -0,0 +1,73 @@ +/** + * @file utils/overlay_soft_edge.c + * @author Ben Roeder + * @brief Linear alpha-edge fade for the overlay buffer. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "utils/macros.h" +#include "utils/overlay_soft_edge.h" + +void overlay_apply_soft_edge(uint16_t *rgba16, + int width, int height, int edge_w) +{ + if (edge_w <= 0 || width <= 0 || height <= 0) return; + + /* Clamp so the centre always keeps non-zero alpha. For a 4-wide + * buffer the deepest interior pixel is at distance 2 from each + * edge, so the maximum useful edge_w is width/2 (and height/2). */ + const int max_edge = MIN(width, height) / 2; + if (edge_w > max_edge) edge_w = max_edge; + /* Reachable for a 1xN or Nx1 buffer (max_edge=0); also prevents + * the divide-by-zero in the inner loop. */ + if (edge_w == 0) return; + + for (int y = 0; y < height; y++) { + const int dy = MIN(y, height - 1 - y); + for (int x = 0; x < width; x++) { + const int dx = MIN(x, width - 1 - x); + const int d = MIN(dx, dy); + if (d >= edge_w) continue; + uint16_t *a = &rgba16[(y * width + x) * 4 + 3]; + /* Linear ramp: alpha *= d / edge_w. uint32_t holds + * 65535 * (edge_w - 1) since edge_w is clamped to + * MIN(w, h)/2 — well below 2^16. */ + *a = (uint16_t)(((uint32_t)*a * (uint32_t)d) / (uint32_t)edge_w); + } + } +} diff --git a/src/utils/overlay_soft_edge.h b/src/utils/overlay_soft_edge.h new file mode 100644 index 0000000000..4e6f0f3fbd --- /dev/null +++ b/src/utils/overlay_soft_edge.h @@ -0,0 +1,65 @@ +/** + * @file utils/overlay_soft_edge.h + * @author Ben Roeder + * @brief Linear alpha-edge fade for the overlay buffer. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef UTILS_OVERLAY_SOFT_EDGE_H_4D2A8B7F_3E1C_4F6A_9B5D_8C2E1F4A6B3D +#define UTILS_OVERLAY_SOFT_EDGE_H_4D2A8B7F_3E1C_4F6A_9B5D_8C2E1F4A6B3D + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Apply a linear alpha ramp around the edges of a 16-bit RGBA overlay. + * For every pixel, alpha is multiplied by min(d, edge_w) / edge_w where + * d is the distance to the nearest edge — pixels on the outer row/column + * end up at alpha=0, and pixels at distance >= edge_w are unchanged. + * + * RGB components are not touched. edge_w == 0 is a no-op. edge_w larger + * than min(width, height) / 2 is silently clamped to that limit (so the + * centre still keeps non-zero alpha for pathological config values). + */ +void overlay_apply_soft_edge(uint16_t *rgba16, + int width, int height, int edge_w); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/utils/overlay_watch.c b/src/utils/overlay_watch.c new file mode 100644 index 0000000000..01d7fd56ec --- /dev/null +++ b/src/utils/overlay_watch.c @@ -0,0 +1,95 @@ +/** + * @file utils/overlay_watch.c + * @author Ben Roeder + * @brief mtime/size-based file change detection for overlay hot-reload. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include + +#include "utils/overlay_watch.h" + +/* Snapshot the file's mtime+size, returning false if the file is absent + * (transient — e.g. mid-atomic-rename). Centralises the platform-specific + * timespec spelling: detect by OS rather than _POSIX_C_SOURCE so a TU that + * hasn't opted into POSIX 2008 still gets nanosecond precision on Linux. */ +bool overlay_watch_fingerprint(const char *path, + int64_t *mtime_ns, int64_t *size) +{ + struct stat sb; + if (stat(path, &sb) != 0) return false; +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) \ + || defined(__OpenBSD__) || defined(__DragonFly__) + *mtime_ns = (int64_t)sb.st_mtimespec.tv_sec * 1000000000 + + (int64_t)sb.st_mtimespec.tv_nsec; +#elif defined(__linux__) || defined(__CYGWIN__) || defined(__sun) \ + || defined(_AIX) || defined(__GNU__) + *mtime_ns = (int64_t)sb.st_mtim.tv_sec * 1000000000 + + (int64_t)sb.st_mtim.tv_nsec; +#else + *mtime_ns = (int64_t)sb.st_mtime * 1000000000; +#endif + *size = sb.st_size; + return true; +} + +void overlay_watch_init(struct overlay_watch *w, const char *path) +{ + w->mtime_ns = 0; + w->size = 0; + w->valid = overlay_watch_fingerprint(path, &w->mtime_ns, &w->size); +} + +bool overlay_watch_changed(const struct overlay_watch *w, const char *path) +{ + int64_t mtime_ns = 0, size = 0; + if (!overlay_watch_fingerprint(path, &mtime_ns, &size)) return false; + if (!w->valid) return true; /* file was missing at init */ + return mtime_ns != w->mtime_ns || size != w->size; +} + +void overlay_watch_ack(struct overlay_watch *w, const char *path) +{ + /* Best-effort: on transient stat failure leave the baseline alone + * so a later appearance still triggers reload. */ + int64_t mtime_ns = 0, size = 0; + if (!overlay_watch_fingerprint(path, &mtime_ns, &size)) return; + w->mtime_ns = mtime_ns; + w->size = size; + w->valid = true; +} diff --git a/src/utils/overlay_watch.h b/src/utils/overlay_watch.h new file mode 100644 index 0000000000..b907febb65 --- /dev/null +++ b/src/utils/overlay_watch.h @@ -0,0 +1,97 @@ +/** + * @file utils/overlay_watch.h + * @author Ben Roeder + * @brief mtime/size-based file change detection for overlay hot-reload. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef UTILS_OVERLAY_WATCH_H_7F3D9A2B_1E5C_4A8F_B6D3_2C9E5A1F8B4D +#define UTILS_OVERLAY_WATCH_H_7F3D9A2B_1E5C_4A8F_B6D3_2C9E5A1F8B4D + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Tracks the last-seen mtime and size of a file. Designed to be polled cheaply + * once per frame from the overlay postprocessor's hot path: a single stat() + * call detects on-disk edits so the overlay can be reloaded without restart. + * + * Treated as opaque by callers; fields are exposed only so it can be embedded + * by value in callers' state structs (no heap allocation needed). + */ +struct overlay_watch { + int64_t mtime_ns; + int64_t size; + bool valid; +}; + +/* + * Capture the current mtime+size as the baseline. If the file can't be stat'd, + * the watch is left invalid and overlay_watch_changed() will return false + * until the file appears (in which case the next call detects it as a change). + */ +void overlay_watch_init(struct overlay_watch *w, const char *path); + +/* + * True if the on-disk mtime or size differs from the baseline. Does not + * mutate the watch — caller must call overlay_watch_ack() after successfully + * consuming the change (e.g. reloaded the file). If the reload fails, do + * not ack: the next poll keeps reporting the change so the caller retries. + * + * Returns false when the file is missing — a transient stat() failure + * during atomic-write doesn't trigger a spurious reload. + */ +bool overlay_watch_changed(const struct overlay_watch *w, const char *path); + +/* Commit the current on-disk mtime+size as the new baseline. No-op if the + * file is currently missing (baseline preserved). */ +void overlay_watch_ack(struct overlay_watch *w, const char *path); + +/* Read the on-disk fingerprint (mtime_ns, size) for a path. Returns true + * on success; on failure (any stat() error — caller cannot distinguish + * missing-file from transient I/O glitch) leaves *mtime_ns and *size + * untouched. Centralises the platform-specific timespec spelling so + * callers don't re-implement the same #ifdef ladder. */ +bool overlay_watch_fingerprint(const char *path, + int64_t *mtime_ns, int64_t *size); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/utils/worker.cpp b/src/utils/worker.cpp index d4891ebaa2..c44c78bb80 100644 --- a/src/utils/worker.cpp +++ b/src/utils/worker.cpp @@ -105,6 +105,7 @@ struct wp_worker { void push(wp_task_data *); void *pop(wp_task_data *); + bool is_done(wp_task_data *); queue m_data; pthread_mutex_t m_lock; @@ -176,6 +177,13 @@ void *wp_worker::pop(wp_task_data *d) { return res; } +bool wp_worker::is_done(wp_task_data *d) { + pthread_mutex_lock(&m_lock); + bool done = d->m_returned; + pthread_mutex_unlock(&m_lock); + return done; +} + static void func_delete(wp_worker *arg) { delete arg; } @@ -211,6 +219,7 @@ class worker_pool : public worker_state_observer task_result_handle_t run_async(runnable_t task, void *data, bool detached); void *wait_task(task_result_handle_t handle); + bool task_is_done(task_result_handle_t handle); private: set m_empty_workers; @@ -247,6 +256,12 @@ void *worker_pool::wait_task(task_result_handle_t handle) return w->pop(d); } +bool worker_pool::task_is_done(task_result_handle_t handle) +{ + wp_task_data *d = (wp_task_data *) handle; + return d->m_w->is_done(d); +} + static class worker_pool instance; /** @@ -282,6 +297,22 @@ void *wait_task(task_result_handle_t handle) return instance.wait_task(handle); } +/** + * @brief Non-blocking poll for completion of an async task. + * + * Returns nonzero if the task has finished — a subsequent wait_task() call on + * the same handle will return immediately. Returns zero if the task is still + * running. Either way the handle remains valid; ownership transfers only on + * the wait_task() call that consumes it. + * + * Uses int (rather than bool) so the C and C++ ABIs agree on the return + * representation across the extern "C" boundary. + */ +int task_is_done(task_result_handle_t handle) +{ + return instance.task_is_done(handle) ? 1 : 0; +} + /** * This combines task_run_async() + wait_task() * diff --git a/src/utils/worker.h b/src/utils/worker.h index 871acdc2bd..d6944381cd 100644 --- a/src/utils/worker.h +++ b/src/utils/worker.h @@ -53,6 +53,7 @@ typedef void *(*runnable_t)(void *); task_result_handle_t task_run_async(runnable_t task, void *data); void task_run_async_detached(runnable_t task, void *data); void *wait_task(task_result_handle_t handle); +int task_is_done(task_result_handle_t handle); void task_run_parallel(runnable_t task, int worker_count, void *data, size_t data_size, void **res); /** diff --git a/src/vo_postprocess/overlay.c b/src/vo_postprocess/overlay.c new file mode 100644 index 0000000000..1b8f64df89 --- /dev/null +++ b/src/vo_postprocess/overlay.c @@ -0,0 +1,899 @@ +/** + * @file vo_postprocess/overlay.c + * @author Ben Roeder + * @brief Image overlay postprocessor (native-format alpha blending). + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include +#include +#include +#include + +#include "debug.h" +#include "lib_common.h" +#include "tv.h" +#include "utils/alpha_blend.h" +#include "utils/color_out.h" +#include "utils/macros.h" +#include "utils/overlay_config.h" +#include "utils/overlay_layout.h" +#include "utils/overlay_pam.h" +#include "utils/overlay_scale.h" +#include "utils/overlay_soft_edge.h" +#include "utils/overlay_watch.h" +#include "utils/worker.h" +#include "video.h" +#include "video_codec.h" +#include "video_display.h" +#include "vo_postprocess.h" + +#define MOD_NAME "[overlay] " + +/* Below this many output pixels the per-worker wakeup cost exceeds the + * saving. ~500k chosen so a 1080p (2.07 Mpx) blend keeps fanning out, + * while a 720p (0.92 Mpx) blend at a small overlay rect falls through + * to the single-threaded path. Shared by both single-plane (blend_overlay) + * and planar (blend_i420) dispatch — the cost model is the same. */ +enum { MIN_PARALLEL_PIXELS = 500000 }; + +/* Effective worker count for a blend rect. Returns 1 (caller takes the + * serial path) when the rect is too small to amortise dispatch, or when + * the user asked for serial. Otherwise clamps requested to MAX_CPU_CORES + * so the per-thread VLAs stay bounded. */ +static inline int +parallel_workers(int requested, size_t pixels) +{ + if (pixels < MIN_PARALLEL_PIXELS) return 1; + if (requested <= 1) return 1; + return requested > MAX_CPU_CORES ? MAX_CPU_CORES : requested; +} + +typedef void (*blend_fn)(uint8_t *dst, const uint16_t *rgba16, int width); + +struct state_overlay { + struct overlay_config cfg; + struct overlay_watch watch; + struct overlay_scaler *scaler; ///< caches SwsContext across reloads + + uint16_t *overlay_rgba16; ///< active overlay (blend reads this) + int overlay_w, overlay_h; + size_t overlay_capacity; ///< pixels allocated for the active + + /* Async reload pipeline. Watcher fires -> kick a worker that + * fills `staging` -> next frame harvests, swap into active. + * Both buffers grow monotonically; a steady stream of same- + * sized reloads pays no per-reload malloc/free. */ + uint16_t *staging_rgba16; + size_t staging_capacity; + + task_result_handle_t reload_handle; + struct reload_job *reload_job; + /* (mtime, size) of the bytes the most recent failed reload + * actually loaded — captured by the worker before parsing, + * so the user "fixing" the file mid-load doesn't fool us into + * skipping a real retry. Suppresses re-kicking against the + * same broken file at frame rate; cleared on next success. */ + bool have_failed_fp; + int64_t failed_mtime_ns; + int64_t failed_size; + + struct video_desc saved_desc; + struct video_frame *in; + + /* Last codec we logged "unsupported by overlay" for. Reset to + * VIDEO_CODEC_NONE on construction so the first unsupported codec + * always warns; if the stream switches to a different unsupported + * codec mid-flight, that one warns too. */ + codec_t warned_codec; + + /* Cumulative timing; see overlay_perf_report(). */ + struct { + unsigned long frames; + unsigned long reloads; + time_ns_t blend_ns; + time_ns_t total_ns; + time_ns_t last_report_ns; + } perf; +}; + +#define PERF_REPORT_INTERVAL_NS ((time_ns_t)10 * NS_IN_SEC) + +static void +overlay_perf_report(struct state_overlay *s, time_ns_t now_ns) +{ + const unsigned long frames = s->perf.frames; + const double avg_total_ms = frames > 0 + ? (double)s->perf.total_ns / (double)frames / 1.0e6 : 0.0; + const double avg_blend_ms = frames > 0 + ? (double)s->perf.blend_ns / (double)frames / 1.0e6 : 0.0; + log_msg(LOG_LEVEL_INFO, + MOD_NAME TUNDERLINE("Overlay stats (cumulative)") + " - Frames: " TBOLD("%lu") + " / Reloads: " TBOLD("%lu") + " / Avg blend: " TBOLD("%.3f") " ms" + " / Avg total: " TBOLD("%.3f") " ms\n", + frames, s->perf.reloads, avg_blend_ms, avg_total_ms); + s->perf.last_report_ns = now_ns; +} + +static inline time_ns_t +perf_t0(const struct state_overlay *s) +{ + return s->cfg.perf ? get_time_in_ns() : 0; +} + +/* Accumulate one frame's timings and emit a periodic report if due. + * frame_t0 is the frame-start timestamp from perf_t0(); blend_ns is the + * just-measured blend duration. */ +static void +perf_tally(struct state_overlay *s, time_ns_t frame_t0, time_ns_t blend_ns) +{ + if (!s->cfg.perf) return; + const time_ns_t now = get_time_in_ns(); + s->perf.frames++; + s->perf.blend_ns += blend_ns; + s->perf.total_ns += now - frame_t0; + if (now - s->perf.last_report_ns >= PERF_REPORT_INTERVAL_NS) { + overlay_perf_report(s, now); + } +} + +/* Self-contained job for the async-reload worker. The worker writes its + * output into the caller-provided staging buffer (no per-reload malloc + * once dimensions stabilise). */ +struct reload_job { + char file[MAX_PATH_SIZE]; + int scale_w, scale_h; + int soft_edge; + struct overlay_scaler *scaler; + uint16_t *staging; /* caller-owned, sized for staging_pixels */ + size_t staging_pixels; /* worker bails if load needs more */ + int result_w, result_h; + bool success; + /* Captured by the worker at file-open time so harvest can stamp + * the *exact* fingerprint that failed/succeeded — closes a + * TOCTOU where the user could fix the file between worker start + * and harvest, fooling the failed-fingerprint guard. */ + int64_t loaded_mtime_ns; + int64_t loaded_size; + bool fingerprint_valid; +}; + +static void * +reload_worker(void *arg) +{ + struct reload_job *j = arg; + /* Capture (mtime, size) BEFORE loading so the fingerprint matches + * the bytes the worker actually saw. */ + j->fingerprint_valid = + overlay_watch_fingerprint(j->file, &j->loaded_mtime_ns, + &j->loaded_size); + uint16_t *raw = NULL; + int rw = 0, rh = 0; + if (!overlay_load_pam_rgba16(j->file, &raw, &rw, &rh)) return NULL; + + const bool scaling = j->scale_w > 0 && j->scale_h > 0; + const int out_w = scaling ? j->scale_w : rw; + const int out_h = scaling ? j->scale_h : rh; + + /* Refuse to write past the caller-provided buffer. Triggers the + * realloc-on-next-kick path so the second attempt fits. */ + if ((size_t)out_w * out_h > j->staging_pixels) { + free(raw); + return NULL; + } + + if (scaling) { + if (!overlay_scaler_scale_into(j->scaler, j->staging, raw, + rw, rh, out_w, out_h)) { + free(raw); return NULL; + } + } else { + memcpy(j->staging, raw, + (size_t)out_w * out_h * 4 * sizeof *raw); + } + free(raw); + + overlay_apply_soft_edge(j->staging, out_w, out_h, j->soft_edge); + + j->result_w = out_w; + j->result_h = out_h; + j->success = true; + return NULL; +} + +/* Drain an in-flight reload. On success: swap the staging buffer into + * the active slot and remember its dims. On failure: leave the active + * slot alone, record the failure fingerprint so we don't immediately + * re-kick against the same broken file. */ +static void +harvest_reload(struct state_overlay *s) +{ + struct reload_job *j = s->reload_job; + wait_task(s->reload_handle); + s->reload_handle = NULL; + s->reload_job = NULL; + + if (j->success) { + /* Swap active <-> staging. The old active buffer (still + * allocated) becomes the staging slot for the next reload, + * so a steady stream of same-sized reloads pays no malloc. */ + SWAP_PTR(s->overlay_rgba16, s->staging_rgba16); + SWAP(s->overlay_capacity, s->staging_capacity); + s->overlay_w = j->result_w; + s->overlay_h = j->result_h; + + s->perf.reloads++; + s->have_failed_fp = false; + overlay_watch_ack(&s->watch, s->cfg.file); + log_msg(LOG_LEVEL_VERBOSE, + MOD_NAME "loaded %s (%dx%d)\n", + s->cfg.file, j->result_w, j->result_h); + } else { + /* Use the worker's pre-load fingerprint so we stamp + * exactly the bytes that failed; if a fix raced into + * place between worker start and harvest, the next + * frame's matches_last_failure will compare against + * the now-fixed fingerprint and correctly let a retry + * fire. */ + if (j->fingerprint_valid) { + s->failed_mtime_ns = j->loaded_mtime_ns; + s->failed_size = j->loaded_size; + s->have_failed_fp = true; + } + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "async reload of %s failed\n", s->cfg.file); + } + free(j); +} + +static bool +matches_last_failure(const struct state_overlay *s) +{ + if (!s->have_failed_fp) return false; + int64_t mtime = 0, size = 0; + if (!overlay_watch_fingerprint(s->cfg.file, &mtime, &size)) return false; + return mtime == s->failed_mtime_ns && size == s->failed_size; +} + +/* Ensure *buf is at least want_pixels*4*sizeof(uint16_t) bytes; updates + * *capacity to match on success. Grows monotonically — never shrinks, + * which avoids realloc churn when a stream of similarly-sized PAMs lands. + * Returns false on allocation failure (leaving *buf and *capacity intact). */ +static bool +ensure_pixel_capacity(uint16_t **buf, size_t *capacity, size_t want_pixels) +{ + if (want_pixels <= *capacity) return true; + uint16_t *grown = realloc(*buf, want_pixels * 4 * sizeof *grown); + if (grown == NULL) return false; + *buf = grown; + *capacity = want_pixels; + return true; +} + +/* Kick an async reload with explicit scale dimensions. Pass 0/0 for + * "no scaling" (load at native PAM size). Used directly by the + * scale=frame reconfigure path; the legacy file-watcher kick is the + * thin wrapper below. */ +static void +kick_async_reload_with_dims(struct state_overlay *s, int scale_w, int scale_h) +{ + const int out_w = scale_w > 0 ? scale_w : s->overlay_w; + const int out_h = scale_h > 0 ? scale_h : s->overlay_h; + /* If we don't yet know the source dims (initial load was scaled, + * subsequent reloads use scale=). For the unscaled-source path + * the worker discovers the size — leave staging un-presized and + * the worker can grow it on first use via realloc. + * + * In practice the unscaled path is rare: users with a fixed- + * dimension stream typically pre-scale at config time. The + * realloc cost shows up only on first reload where source dim + * differs from active. */ + if (out_w > 0 && out_h > 0) { + const size_t want = (size_t)out_w * out_h; + if (!ensure_pixel_capacity(&s->staging_rgba16, + &s->staging_capacity, want)) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "out of memory growing staging " + "buffer to %dx%d; reload skipped, will " + "retry on next change\n", out_w, out_h); + return; + } + } + + struct reload_job *j = calloc(1, sizeof *j); + if (j == NULL) { + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "out of memory allocating reload job; " + "reload skipped, will retry on next change\n"); + return; + } + snprintf(j->file, sizeof j->file, "%s", s->cfg.file); + j->scale_w = scale_w; + j->scale_h = scale_h; + j->soft_edge = s->cfg.soft_edge; + j->scaler = s->scaler; + j->staging = s->staging_rgba16; + j->staging_pixels = s->staging_capacity; + + s->reload_job = j; + s->reload_handle = task_run_async(reload_worker, j); + if (s->reload_handle == NULL) { + /* Pool refused (shouldn't happen with the current pool + * impl, but keep the cleanup so a future implementation + * change doesn't leak the job). */ + free(j); + s->reload_job = NULL; + } +} + +/* File-watcher-driven kick. With scale=frame we use the current frame + * dimensions (not cfg.scale_w/h, which are 0); with scale=WxH we use + * the configured dims; otherwise no scaling. */ +static void +kick_async_reload(struct state_overlay *s) +{ + int sw = s->cfg.scale_w; + int sh = s->cfg.scale_h; + if (s->cfg.scale_to_frame) { + sw = (int)s->saved_desc.width; + sh = (int)s->saved_desc.height; + } + kick_async_reload_with_dims(s, sw, sh); +} + +/* Load (or reload) the overlay PAM. On success the persistent + * s->overlay_rgba16 buffer holds the (possibly scaled, possibly + * soft-edged) result. On failure leaves the existing buffer untouched. */ +static bool reload_overlay(struct state_overlay *s) +{ + uint16_t *raw = NULL; + int raw_w = 0, raw_h = 0; + if (!overlay_load_pam_rgba16(s->cfg.file, &raw, &raw_w, &raw_h)) { + log_msg(LOG_LEVEL_ERROR, MOD_NAME "failed to load %s\n", + s->cfg.file); + return false; + } + + const bool scaling = s->cfg.scale_w > 0 && s->cfg.scale_h > 0; + const int out_w = scaling ? s->cfg.scale_w : raw_w; + const int out_h = scaling ? s->cfg.scale_h : raw_h; + + if (!ensure_pixel_capacity(&s->overlay_rgba16, &s->overlay_capacity, + (size_t)out_w * out_h)) { + free(raw); + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "out of memory for %dx%d overlay\n", + out_w, out_h); + return false; + } + + /* Scale before soft-edge fade so the fade width is in final-buffer + * pixels, not source pixels. */ + if (scaling) { + if (!overlay_scaler_scale_into(s->scaler, s->overlay_rgba16, + raw, raw_w, raw_h, + out_w, out_h)) { + free(raw); + log_msg(LOG_LEVEL_ERROR, + MOD_NAME "failed to scale %s to %dx%d\n", + s->cfg.file, out_w, out_h); + return false; + } + free(raw); + } else { + memcpy(s->overlay_rgba16, raw, + (size_t)out_w * out_h * 4 * sizeof *raw); + free(raw); + } + overlay_apply_soft_edge(s->overlay_rgba16, out_w, out_h, + s->cfg.soft_edge); + s->overlay_w = out_w; + s->overlay_h = out_h; + /* First load is loud (NOTICE); subsequent hot-reloads are quiet + * (VERBOSE) so a fast-mtime sequence doesn't flood the terminal. + * Reload count is still surfaced via the perf stats line. */ + const int level = s->perf.reloads == 0 + ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE; + s->perf.reloads++; + if (s->cfg.soft_edge > 0) { + log_msg(level, + MOD_NAME "loaded %s (%dx%d, soft_edge=%d)\n", + s->cfg.file, out_w, out_h, s->cfg.soft_edge); + } else { + log_msg(level, + MOD_NAME "loaded %s (%dx%d)\n", + s->cfg.file, out_w, out_h); + } + return true; +} + +static void +print_help(void) +{ + printf("overlay video postprocessor — blends a 16-bit RGBA PAM " + "image onto video in its native pixel format.\n\n" + "Usage:\n" + "\t-p overlay:file=[:position=]" + "[:custom_x=:custom_y=][:soft_edge=]" + "[:scale=x[:scale_filter=]][:perf]\n\n" + "Position keywords: center (default), top_left, top_right,\n" + " bottom_left, bottom_right, custom\n\n" + "custom_x / custom_y count from the right/bottom edge when " + "negative.\n" + "soft_edge= applies an N-pixel linear alpha fade on each " + "edge (default 0).\n" + "scale=x resizes the loaded overlay to W x H pixels.\n" + "scale=frame re-scales the overlay to match the current\n" + " frame dimensions on every resolution change\n" + " (decode-path renegotiation).\n" + "scale_filter= picks the resampler: nearest, fast_bilinear,\n" + " bilinear, bicubic, lanczos (default bicubic).\n" + " Use lanczos for sharper output if budget allows;\n" + " nearest/fast_bilinear if running tight at 4K60.\n" + "blend_threads= parallelises the per-row alpha blend across\n" + " N pthread-pool workers. Default is\n" + " min(ncpu, 8); pass 1 to disable.\n" + "perf logs cumulative blend / total timing every 10 seconds " + "(off by default).\n"); +} + +static void * +overlay_init(const char *config) +{ + struct state_overlay *s = calloc(1, sizeof *s); + if (s == NULL) return NULL; + + if (!overlay_config_parse(config, &s->cfg)) { + free(s); + return NULL; + } + if (s->cfg.help) { + print_help(); + free(s); + return NULL; + } + s->scaler = overlay_scaler_create(s->cfg.scale_filter); + if (s->scaler == NULL) { + free(s); + return NULL; + } + if (!reload_overlay(s)) { + overlay_scaler_destroy(s->scaler); + free(s); + return NULL; + } + overlay_watch_init(&s->watch, s->cfg.file); + s->perf.last_report_ns = get_time_in_ns(); + return s; +} + +static bool +overlay_postprocess_reconfigure(void *state, struct video_desc desc) +{ + struct state_overlay *s = state; + s->saved_desc = desc; + vf_free(s->in); + s->in = vf_alloc_desc_data(s->saved_desc); + return s->in != NULL; +} + +static struct video_frame * +overlay_getf(void *state) +{ + struct state_overlay *s = state; + return s->in; +} + +/* Single-plane blend functions only — I420 has its own dispatch via + * blend_i420() because the planar layout takes a different signature. */ +static blend_fn +get_blend_fn(codec_t codec) +{ + switch (codec) { + case RGBA: return alpha_blend_rgba; + case RGB: return alpha_blend_rgb; + case RG48: return alpha_blend_rg48; + case UYVY: return alpha_blend_uyvy; + case YUYV: return alpha_blend_yuyv; + case v210: return alpha_blend_v210; + case R10k: return alpha_blend_r10k; + case R12L: return alpha_blend_r12l; + case Y416: return alpha_blend_y416; + default: return NULL; + } +} + +/* Per-stripe job for the parallel I420 dispatch. Each worker calls + * alpha_blend_i420 with already-offset plane pointers and a slice height, + * so workers don't coordinate (chroma row pairs split cleanly at even + * boundaries — the rect snapper guarantees this). */ +struct i420_stripe { + uint8_t *dst_y; + uint8_t *dst_u; + uint8_t *dst_v; + int y_stride; + int uv_stride; + const uint16_t *src; + int src_pixel_stride; + int width; + int height; +}; + +static void * +i420_stripe_worker(void *arg) +{ + const struct i420_stripe *j = arg; + alpha_blend_i420(j->dst_y, j->y_stride, + j->dst_u, j->dst_v, j->uv_stride, + j->src, j->src_pixel_stride, + j->width, j->height); + return NULL; +} + +/* Pointer into the overlay buffer at the visible region's origin. + * src_x/src_y are 0 in the common in-bounds case (so the result is + * just `base`); they're non-zero when the overlay is larger than the + * frame and we're showing its centre or right slice. The "* 4" is + * the four uint16_t RGBA components per overlay pixel. */ +static inline const uint16_t * +overlay_src_at(const uint16_t *base, int overlay_w, + int src_x, int src_y) +{ + return base + (size_t)src_y * overlay_w * 4 + + (size_t)src_x * 4; +} + +/* I420 needs a planar dispatch — three plane pointers and a different + * blend signature than the single-plane formats. rect.x/rect.y/rect.width/ + * rect.height are already snapped to even by overlay_calc_rect (block_lines + * and block_pixels both = 2 for I420). blend_threads matches the other + * codecs' fan-out; chroma rows split at even-luma boundaries so a 2x2 + * cell is never spanned by two workers. */ +static void +blend_i420(struct video_frame *out, const struct overlay_rect *rect, + const uint16_t *overlay_rgba16, int overlay_w, int blend_threads) +{ + assert((rect->x & 1) == 0 && (rect->y & 1) == 0); + assert((rect->width & 1) == 0 && (rect->height & 1) == 0); + + const int frame_w = (int)out->tiles[0].width; + const int frame_h = (int)out->tiles[0].height; + const int chroma_w = (frame_w + 1) / 2; + const int chroma_h_plane = (frame_h + 1) / 2; + + uint8_t *plane_y = (uint8_t *)out->tiles[0].data; + uint8_t *plane_u = plane_y + (size_t)frame_w * frame_h; + uint8_t *plane_v = plane_u + (size_t)chroma_w * chroma_h_plane; + + uint8_t *dst_y = plane_y + (size_t)rect->y * frame_w + rect->x; + uint8_t *dst_u = plane_u + (size_t)(rect->y / 2) * chroma_w + (rect->x / 2); + uint8_t *dst_v = plane_v + (size_t)(rect->y / 2) * chroma_w + (rect->x / 2); + + const uint16_t *src = overlay_src_at(overlay_rgba16, overlay_w, + rect->src_x, rect->src_y); + + const int N = parallel_workers(blend_threads, + (size_t)rect->width * rect->height); + if (N == 1) { + alpha_blend_i420(dst_y, frame_w, dst_u, dst_v, chroma_w, + src, overlay_w, + rect->width, rect->height); + return; + } + + /* Split the chroma rows evenly; last worker absorbs the remainder. + * Each stripe owns 2*rows_per luma rows + rows_per chroma rows. */ + const int total_chroma_rows = rect->height / 2; + const int rows_per = total_chroma_rows / N; + struct i420_stripe jobs[N]; + for (int i = 0; i < N; i++) { + const int cy_start = i * rows_per; + const int cy_end = (i == N - 1) ? total_chroma_rows + : (i + 1) * rows_per; + const int luma_off = cy_start * 2; + jobs[i] = (struct i420_stripe){ + .dst_y = dst_y + (size_t)luma_off * frame_w, + .dst_u = dst_u + (size_t)cy_start * chroma_w, + .dst_v = dst_v + (size_t)cy_start * chroma_w, + .y_stride = frame_w, + .uv_stride = chroma_w, + .src = overlay_src_at(src, overlay_w, + 0, luma_off), + .src_pixel_stride = overlay_w, + .width = rect->width, + .height = (cy_end - cy_start) * 2, + }; + } + task_run_parallel(i420_stripe_worker, N, jobs, sizeof jobs[0], NULL); +} + +/* Below this many bytes the per-worker dispatch cost outweighs the + * memory-bandwidth saving from threading the memcpy. Picked so a + * 1080p UYVY frame (~4 MB) still gets parallelised; smaller frames + * fall through to a single memcpy. */ +#define MIN_PARALLEL_MEMCPY_BYTES (1u << 21) /* 2 MB */ + +struct memcpy_stripe { + uint8_t *dst; + const uint8_t *src; + size_t bytes; +}; + +static void * +memcpy_stripe_worker(void *arg) +{ + const struct memcpy_stripe *j = arg; + memcpy(j->dst, j->src, j->bytes); + return NULL; +} + +static void +threaded_memcpy(uint8_t *dst, const uint8_t *src, size_t bytes, int N) +{ + if (N <= 1 || bytes < MIN_PARALLEL_MEMCPY_BYTES) { + memcpy(dst, src, bytes); + return; + } + /* VLA sized to actual N; the parser clamps N to MAX_CPU_CORES + * (256) so the stack worst case is bounded. */ + struct memcpy_stripe jobs[N]; + const size_t chunk = bytes / N; + for (int i = 0; i < N; i++) { + jobs[i].dst = dst + (size_t)i * chunk; + jobs[i].src = src + (size_t)i * chunk; + jobs[i].bytes = (i == N - 1) ? bytes - (size_t)i * chunk + : chunk; + } + task_run_parallel(memcpy_stripe_worker, N, jobs, sizeof jobs[0], NULL); +} + +/* Each thread chews through a contiguous row stripe of the rect. The + * blend functions are pure per-row (output = f(dst_row, src_row, width)), + * so independent workers cannot collide. */ +struct blend_stripe { + blend_fn fn; + uint8_t *dst_base; /* out->tiles[0].data + rect.y*pitch */ + const uint16_t *src_base; /* overlay_rgba16 row 0 */ + size_t dst_row_stride; /* req_pitch (bytes) */ + size_t src_row_stride; /* overlay_w * 4 (uint16_t elements) */ + int dst_x_byte_off; /* (rect.x / block_pixels) * block_bytes */ + int rect_width; /* pixels per row */ + int row_start; + int row_end; +}; + +static void * +blend_stripe_worker(void *arg) +{ + const struct blend_stripe *j = arg; + for (int r = j->row_start; r < j->row_end; r++) { + uint8_t *dst_row = j->dst_base + + (size_t)r * j->dst_row_stride + + j->dst_x_byte_off; + const uint16_t *src_row = + j->src_base + (size_t)r * j->src_row_stride; + j->fn(dst_row, src_row, j->rect_width); + } + return NULL; +} + +/* Run the overlay blend onto `out` (caller has already pass-through-copied + * `in`). No-op if there is no overlay loaded or the rect is empty. Logs + * once when a codec without dispatch arrives. */ +static void +blend_overlay(struct state_overlay *s, codec_t cs, + struct video_frame *out, int req_pitch) +{ + if (s->overlay_rgba16 == NULL) return; + + const int block_pixels = get_pf_block_pixels(cs); + const int block_lines = (cs == I420) ? 2 : 1; + const struct overlay_rect rect = overlay_calc_rect( + s->cfg.position, s->cfg.custom_x, s->cfg.custom_y, + (int)out->tiles[0].width, (int)out->tiles[0].height, + s->overlay_w, s->overlay_h, block_pixels, block_lines); + if (rect.width <= 0 || rect.height <= 0) return; + + if (cs == I420) { + blend_i420(out, &rect, s->overlay_rgba16, s->overlay_w, + s->cfg.blend_threads); + return; + } + + const blend_fn blend = get_blend_fn(cs); + if (blend == NULL) { + if (s->warned_codec != cs) { + log_msg(LOG_LEVEL_WARNING, + MOD_NAME "format %s unsupported by overlay\n", + get_codec_name(cs)); + s->warned_codec = cs; + } + return; + } + + const int block_bytes = get_pf_block_bytes(cs); + /* src_row_stride still walks the full overlay row even when + * src_x/src_y are non-zero (oversized overlay slice). */ + const uint16_t *src_base = overlay_src_at(s->overlay_rgba16, + s->overlay_w, + rect.src_x, rect.src_y); + struct blend_stripe base = { + .fn = blend, + .dst_base = (uint8_t *)out->tiles[0].data + + (size_t)rect.y * req_pitch, + .src_base = src_base, + .dst_row_stride = req_pitch, + .src_row_stride = (size_t)s->overlay_w * 4, + .dst_x_byte_off = (rect.x / block_pixels) * block_bytes, + .rect_width = rect.width, + }; + + const int N = parallel_workers(s->cfg.blend_threads, + (size_t)rect.width * rect.height); + if (N == 1) { + base.row_start = 0; + base.row_end = rect.height; + blend_stripe_worker(&base); + return; + } + + /* task_run_parallel reuses persistent worker-pool threads, so each + * frame pays only condvar wakeup cost rather than pthread_create. + * Each worker gets a contiguous stripe; the last absorbs the + * remainder so total rows == rect.height regardless of N. + * + * VLA sized to actual N; the parser clamps N to MAX_CPU_CORES + * (256) so the stack worst case is bounded. */ + struct blend_stripe jobs[N]; + const int rows_per = rect.height / N; + for (int i = 0; i < N; i++) { + jobs[i] = base; + jobs[i].row_start = i * rows_per; + jobs[i].row_end = (i == N - 1) ? rect.height + : (i + 1) * rows_per; + } + task_run_parallel(blend_stripe_worker, N, jobs, sizeof jobs[0], NULL); +} + +static bool +overlay_postprocess(void *state, struct video_frame *in, + struct video_frame *out, int req_pitch) +{ + struct state_overlay *s = state; + assert(in->tile_count == 1); + assert(req_pitch == vc_get_linesize(in->tiles[0].width, in->color_spec)); + + const time_ns_t frame_t0 = perf_t0(s); + + threaded_memcpy((uint8_t *)out->tiles[0].data, + (const uint8_t *)in->tiles[0].data, + in->tiles[0].data_len, + s->cfg.blend_threads); + + /* Harvest any completed async reload before checking the watcher, + * so a fresh change-detect can fire on the same frame the previous + * reload finishes. */ + if (s->reload_handle != NULL && task_is_done(s->reload_handle)) { + harvest_reload(s); + } + + /* scale=frame: re-render the overlay if the source resolution + * differs from the active overlay buffer. Detected here (per + * frame, cheap) rather than via the reconfigure callback — + * not every pipeline path reaches the postprocess via + * display_reconfigure (testcard→dummy in the e2e harness, for + * one). The kick is gated on no-reload-in-flight, same as the + * watcher path. */ + if (s->cfg.scale_to_frame + && s->reload_handle == NULL + && ((int)in->tiles[0].width != s->overlay_w + || (int)in->tiles[0].height != s->overlay_h)) { + kick_async_reload_with_dims(s, + (int)in->tiles[0].width, + (int)in->tiles[0].height); + } + /* matches_last_failure suppresses retry-on-broken-file at frame + * rate; cleared when the file actually changes again. */ + if (s->reload_handle == NULL + && overlay_watch_changed(&s->watch, s->cfg.file) + && !matches_last_failure(s)) { + kick_async_reload(s); + } + + const time_ns_t blend_t0 = perf_t0(s); + blend_overlay(s, in->color_spec, out, req_pitch); + const time_ns_t blend_ns = s->cfg.perf + ? get_time_in_ns() - blend_t0 : 0; + + perf_tally(s, frame_t0, blend_ns); + return true; +} + +static void +overlay_done(void *state) +{ + struct state_overlay *s = state; + if (s->cfg.perf && s->perf.frames > 0) { + overlay_perf_report(s, get_time_in_ns()); + } + /* Drain any in-flight async reload before tearing down. wait_task + * blocks here but we're shutting down anyway. */ + if (s->reload_handle != NULL) { + wait_task(s->reload_handle); + free(s->reload_job); + } + vf_free(s->in); + free(s->overlay_rgba16); + free(s->staging_rgba16); + overlay_scaler_destroy(s->scaler); + free(s); +} + +static void +overlay_get_out_desc(void *state, struct video_desc *out, + int *in_display_mode, int *out_frames) +{ + struct state_overlay *s = state; + *out = s->saved_desc; + *in_display_mode = DISPLAY_PROPERTY_VIDEO_MERGED; + *out_frames = 1; +} + +static bool +overlay_get_property(void *state, int property, void *val, size_t *len) +{ + UNUSED(state); UNUSED(property); UNUSED(val); UNUSED(len); + return false; +} + +static const struct vo_postprocess_info vo_pp_overlay_info = { + overlay_init, + overlay_postprocess_reconfigure, + overlay_getf, + overlay_get_out_desc, + overlay_get_property, + overlay_postprocess, + overlay_done, +}; + +REGISTER_MODULE(overlay, &vo_pp_overlay_info, + LIBRARY_CLASS_VIDEO_POSTPROCESS, VO_PP_ABI_VERSION); diff --git a/test/benchmark_overlay_matrix.sh b/test/benchmark_overlay_matrix.sh new file mode 100755 index 0000000000..f409c1d379 --- /dev/null +++ b/test/benchmark_overlay_matrix.sh @@ -0,0 +1,162 @@ +#!/bin/bash +# +# Matrix benchmark: 3 scenarios x 3 resolutions x 3 frame rates +# (and 5 filters for the scaled scenario), with per-cell frame budget +# in milliseconds and an OVER marker when avg total exceeds budget. +# +# Scenarios: +# static — file loaded once, no hot-reload (fast path) +# ball_native — hot-reload at output res, no per-reload scale +# ball_scaled — hot-reload of small PAMs, per-reload libswscale +# +# Args (env): SECS= +# +# Author: Ben Roeder +# Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + +set -e + +UV="${UV:-./bin/uv}" +[ -x "$UV" ] || { echo "uv binary not found at $UV"; exit 2; } +UV=$(cd "$(dirname "$UV")" && pwd)/$(basename "$UV") +BASE=$(cd "$(dirname "$UV")/.." && pwd) + +SECS="${SECS:-8}" +# DISPLAY=sdl shows each cell in an SDL window so you can watch it run. +# Default is "dummy" which is invisible but more representative of pure +# postprocess cost (no SDL render in the pipeline). +DISPLAY_KIND="${DISPLAY:-dummy}" +# PP_EXTRA is appended to every postprocess option string. Use it to +# probe knobs without editing the script (e.g. PP_EXTRA="scale_threads=4"). +PP_EXTRA="${PP_EXTRA:-}" +SMALL_SEQ="$BASE/../testimages" +SCRATCH=$(mktemp -d -t ovbench.XXXXXX) +LOG=$SCRATCH/uv.log +trap 'pkill -f "bin/uv" 2>/dev/null; rm -rf "$SCRATCH"' EXIT + +# 200x100 solid green static PAM for the no-reload case. +STATIC_PAM=$SCRATCH/static.pam +python3 - < "$LOG" 2>&1 & + local pid=$! + sleep 0.5 + + if [ -n "$anim_dir" ]; then + animate_loop "$anim_dir" "$current" + else + sleep "$SECS" + fi + + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + grep "Overlay stats" "$LOG" | tail -1 +} + +# Print one row of the table. Highlights cells over budget with " OVER". +# Args: scenario resolution fps filter raw_stats_line +print_row() { + local scenario=$1 res=$2 fps=$3 filter=$4 line=$5 + local frames blend total budget mark + frames=$(echo "$line" | sed -nE 's/.*Frames:[[:space:]]*([0-9]+).*/\1/p') + blend=$(echo "$line" | sed -nE 's/.*Avg blend:[[:space:]]*([0-9.]+) ms.*/\1/p') + total=$(echo "$line" | sed -nE 's/.*Avg total:[[:space:]]*([0-9.]+) ms.*/\1/p') + budget=$(awk "BEGIN { printf \"%.2f\", 1000.0 / $fps }") + if [ -n "$total" ]; then + mark=$(awk -v t="$total" -v b="$budget" \ + 'BEGIN { print (t + 0 > b + 0) ? " OVER" : "" }') + fi + printf "%-12s | %-9s | %3s | %-13s | %-7s | %7s ms | %7s ms (budget %5s ms)%s\n" \ + "$scenario" "$res" "$fps" "${filter:--}" \ + "${frames:-?}" "${blend:-?}" "${total:-?}" "$budget" "$mark" +} + +RESOLUTIONS=(${RESOLUTIONS:-1280x720 1920x1080 3840x2160}) +FPS_VALUES=(${FPS_VALUES:-24 30 60}) +FILTERS=(${FILTERS:-nearest fast_bilinear bilinear bicubic lanczos}) + +printf "Matrix bench: %d s/cell, %s display, UYVY testcard.\n\n" \ + "$SECS" "$DISPLAY_KIND" +printf "%-12s | %-9s | %3s | %-13s | %-7s | %-9s | %-29s\n" \ + "scenario" "resolution" "fps" "filter" "frames" "avg blend" "avg total" +echo "------------------------------------------------------------------------------------------------" + +CURRENT=$SCRATCH/current.pam + +# 1) Static logo +for res in "${RESOLUTIONS[@]}"; do + for fps in "${FPS_VALUES[@]}"; do + line=$(run_uv_and_animate "$res" "$fps" \ + "overlay:file=$STATIC_PAM:position=center:perf" "" "") + print_row "static" "$res" "$fps" "" "$line" + done +done +echo + +# 2) Ball at native resolution (no scaling) +for res in "${RESOLUTIONS[@]}"; do + seq_dir="$BASE/../testimages_${res}/testimages" + [ -d "$seq_dir" ] || { echo " missing $seq_dir — skipping native at $res"; continue; } + cp "$seq_dir/frame_0000.pam" "$CURRENT" + for fps in "${FPS_VALUES[@]}"; do + line=$(run_uv_and_animate "$res" "$fps" \ + "overlay:file=$CURRENT:position=top_left:perf" "$seq_dir" "$CURRENT") + print_row "ball_native" "$res" "$fps" "" "$line" + done +done +echo + +# 3) Ball scaled up (5 filters x 3 resolutions x 3 fps) +[ -d "$SMALL_SEQ" ] || { echo "missing small ball seq at $SMALL_SEQ"; exit 2; } +for res in "${RESOLUTIONS[@]}"; do + for fps in "${FPS_VALUES[@]}"; do + for filter in "${FILTERS[@]}"; do + cp "$SMALL_SEQ/frame_0000.pam" "$CURRENT" + line=$(run_uv_and_animate "$res" "$fps" \ + "overlay:file=$CURRENT:position=top_left:scale=$res:scale_filter=$filter:perf" \ + "$SMALL_SEQ" "$CURRENT") + print_row "ball_scaled" "$res" "$fps" "$filter" "$line" + done + echo + done +done diff --git a/test/benchmark_overlay_scale.sh b/test/benchmark_overlay_scale.sh new file mode 100755 index 0000000000..2e3f90a753 --- /dev/null +++ b/test/benchmark_overlay_scale.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# +# Benchmark the upscale path: 5 filters x 3 resolutions, mtime-driven +# at 30 fps reload (the bouncing ball animation), captures the +# Overlay-stats line per run and prints a summary. +# +# Author: Ben Roeder +# Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + +set -e + +UV="${UV:-./bin/uv}" +[ -x "$UV" ] || { echo "uv binary not found at $UV"; exit 2; } +UV=$(cd "$(dirname "$UV")" && pwd)/$(basename "$UV") + +SECS="${SECS:-12}" +SEQ_DIR="${SEQ_DIR:-../testimages}" +SEQ_DIR=$(cd "$SEQ_DIR" 2>/dev/null && pwd) \ + || { echo "sequence dir $SEQ_DIR not found (run generate_bouncing_ball.py first)"; exit 2; } + +CURRENT=$(mktemp -t bench-current.pam.XXXXXX) +LOG=$(mktemp -t bench-uv.log.XXXXXX) +trap 'pkill -f "bin/uv" 2>/dev/null; rm -f "$CURRENT" "$LOG"' EXIT + +cp "$SEQ_DIR/frame_0000.pam" "$CURRENT" + +FILTERS=(nearest fast_bilinear bilinear bicubic lanczos) +RESOLUTIONS=("1280x720@30" "1920x1080@60" "3840x2160@30") + +# Drives the mtime-reload loop from the foreground while uv runs in the +# background; logs go to $LOG so we can extract the stats line at the end. +run_case() { + local size=$1 fps=$2 filter=$3 + rm -f "$LOG" + cp "$SEQ_DIR/frame_0000.pam" "$CURRENT" + + "$UV" -t "testcard:size=$size:fps=$fps:codec=UYVY:pattern=ebu_bars" \ + -d "dummy:codec=UYVY" \ + --postprocess "overlay:file=$CURRENT:position=top_left:scale=$size:scale_filter=$filter:perf" \ + > "$LOG" 2>&1 & + local pid=$! + sleep 0.5 + + # Animate at ~30 fps for $SECS seconds (3 loops of 120 frames = 12s). + local end_t=$(( $(date +%s) + SECS )) + while [ $(date +%s) -lt $end_t ]; do + for f in "$SEQ_DIR"/frame_*.pam; do + cp "$f" "$CURRENT" + sleep 0.033 + [ $(date +%s) -ge $end_t ] && break + done + done + + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + grep "Overlay stats" "$LOG" | tail -1 +} + +printf "Bench: %d s per cell, dummy display, UYVY, mtime-driven 30 fps reload\n\n" "$SECS" +printf "%-18s | %-13s | %-7s | %-9s | %-9s\n" \ + "Resolution" "Filter" "Frames" "Avg blend" "Avg total" +echo "---------------------------------------------------------------------" +for res in "${RESOLUTIONS[@]}"; do + size=${res%@*} + fps=${res#*@} + for filter in "${FILTERS[@]}"; do + line=$(run_case "$size" "$fps" "$filter") + # Extract Frames / Avg blend / Avg total from the stats line. + frames=$(echo "$line" | sed -nE 's/.*Frames:[[:space:]]*([0-9]+).*/\1/p') + blend=$(echo "$line" | sed -nE 's/.*Avg blend:[[:space:]]*([0-9.]+) ms.*/\1/p') + total=$(echo "$line" | sed -nE 's/.*Avg total:[[:space:]]*([0-9.]+) ms.*/\1/p') + printf "%-18s | %-13s | %-7s | %7s ms | %7s ms\n" \ + "$size@$fps" "$filter" "${frames:-?}" "${blend:-?}" "${total:-?}" + done + echo +done diff --git a/test/run_overlay_e2e.sh b/test/run_overlay_e2e.sh new file mode 100755 index 0000000000..e5fe831fcd --- /dev/null +++ b/test/run_overlay_e2e.sh @@ -0,0 +1,420 @@ +#!/bin/bash +# +# End-to-end pixel verification for the overlay postprocessor. +# +# For each supported codec we run: +# testcard (solid colour) -> overlay (top_left) -> dummy display dump +# then read back the dumped raw frame and assert the upper-left pixels +# match the overlay colour while the area outside the overlay matches +# the testcard background. This proves the whole pipeline (PAM load, +# positioning, format dispatch, plane offsets) — not just that frames +# flow. +# +# Author: Ben Roeder +# Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + +set -e + +UV="${UV:-./bin/uv}" +[ -x "$UV" ] || { echo "uv binary not found at $UV"; exit 2; } +# Resolve to absolute path so the cd-into-workdir below still finds it. +UV=$(cd "$(dirname "$UV")" && pwd)/$(basename "$UV") + +WORK=$(mktemp -d -t ug-overlay-e2e.XXXXXX) +trap 'rm -rf "$WORK"' EXIT + +PAM=$WORK/overlay.pam +DUMP=$WORK/dummy +PASS=0 +FAIL=0 + +# 24x2 solid green PAM. Width chosen so it survives block-pixel snapping +# for every codec we test: multiple of 6 (v210), 8 (R12L), 4, 2, 1. +OVERLAY_W=24 +OVERLAY_H=2 +{ + printf 'P7\nWIDTH %d\nHEIGHT %d\nDEPTH 4\nMAXVAL 255\n' \ + "$OVERLAY_W" "$OVERLAY_H" + printf 'TUPLTYPE RGB_ALPHA\nENDHDR\n' + for _ in $(seq 1 $((OVERLAY_W * OVERLAY_H))); do + printf '\x00\xff\x00\xff' + done +} > "$PAM" + +FRAME_W=48 +FRAME_H=4 + +# Run uv once and pull a single frame to $DUMP. +# Args: codec [extra_display_opts] +run_uv() { + local codec=$1 + local display="dummy:codec=$codec:dump=oneshot:raw" + rm -f "$DUMP".* + cd "$WORK" + "$UV" -t "testcard:size=${FRAME_W}x${FRAME_H}:fps=1:codec=$codec:pattern=blank=0xFF" \ + -d "$display" \ + --postprocess "overlay:file=$PAM:position=top_left" \ + >/dev/null 2>&1 & + local pid=$! + local n=0 + while kill -0 "$pid" 2>/dev/null; do + sleep 0.2 + n=$((n + 1)) + if [ $n -gt 25 ]; then + kill "$pid" 2>/dev/null + wait "$pid" 2>/dev/null + break + fi + done + wait "$pid" 2>/dev/null || true + cd - >/dev/null +} + +# Dump the byte at offset $1 of the dump file as 2-digit hex. +hex_at() { + od -An -tx1 -N1 -j"$1" "$DUMP".* | tr -d ' \n' +} + +# Dump $2 bytes starting at offset $1 as a contiguous lowercase hex string. +hex_range() { + od -An -tx1 -N"$2" -j"$1" "$DUMP".* | tr -d ' \n' +} + +assert_eq() { + local label=$1 expected=$2 actual=$3 + if [ "$expected" = "$actual" ]; then + PASS=$((PASS + 1)) + else + echo " FAIL: $label expected=$expected actual=$actual" + FAIL=$((FAIL + 1)) + fi +} + +# Pick any dump file produced by uv (the extension varies by codec). +dump_file() { + ls "$DUMP".* 2>/dev/null | head -1 +} + +# Verify two byte ranges in the dump differ. With background = solid +# colour and overlay = different solid colour, these must always differ +# if the blend ran. We never decode codec-specific bytes here because +# the unit tests already prove the per-format math. +# Args: label overlay_off bg_off len +assert_differs() { + local label=$1 overlay_off=$2 bg_off=$3 len=$4 + local f=$(dump_file) + [ -n "$f" ] && [ -s "$f" ] || { echo " FAIL: $label no dump file"; FAIL=$((FAIL+1)); return; } + local o=$(od -An -tx1 -N"$len" -j"$overlay_off" "$f" | tr -d ' \n') + local b=$(od -An -tx1 -N"$len" -j"$bg_off" "$f" | tr -d ' \n') + if [ "$o" != "$b" ]; then + PASS=$((PASS + 1)) + else + echo " FAIL: $label overlay==bg at $overlay_off vs $bg_off ($o)" + FAIL=$((FAIL + 1)) + fi +} + +# RGBA / RGB get exact-byte assertions because the layout is trivial. +# Both codecs have no colour conversion — overlay green stays green. +test_rgba_exact() { + echo "==> RGBA exact bytes" + run_uv RGBA + local f=$(dump_file) + [ -n "$f" ] && [ -s "$f" ] || { echo " no dump file"; FAIL=$((FAIL+1)); return; } + assert_eq "RGBA pixel(0,0) is green" "00ff00ff" \ + "$(od -An -tx1 -N4 -j0 "$f" | tr -d ' \n')" + # Pixel just past the overlay (column 24) is bg red. + assert_eq "RGBA pixel(24,0) is red" "ff0000ff" \ + "$(od -An -tx1 -N4 -j$((24 * 4)) "$f" | tr -d ' \n')" +} + +test_rgb_exact() { + echo "==> RGB exact bytes" + run_uv RGB + local f=$(dump_file) + [ -n "$f" ] && [ -s "$f" ] || { echo " no dump file"; FAIL=$((FAIL+1)); return; } + assert_eq "RGB pixel(0,0) is green" "00ff00" \ + "$(od -An -tx1 -N3 -j0 "$f" | tr -d ' \n')" + assert_eq "RGB pixel(24,0) is red" "ff0000" \ + "$(od -An -tx1 -N3 -j$((24 * 3)) "$f" | tr -d ' \n')" +} + +# Codecs where we just assert "overlay region differs from bg region". +# overlay_off / bg_off are both within row 0; the overlay's first sample +# vs the row's last sample (well past the overlay's right edge). +test_differs() { + local codec=$1 overlay_off=$2 bg_off=$3 len=$4 + echo "==> $codec" + run_uv "$codec" + assert_differs "$codec overlay vs bg row0" "$overlay_off" "$bg_off" "$len" +} + +test_rgba_exact +test_rgb_exact +test_differs UYVY 0 80 4 # 48-wide UYVY = 96 B/row; bg pair near the end +test_differs YUYV 0 80 4 +test_differs v210 0 112 16 # 48-wide v210 = 128 B/row (aligned); bg at end +test_differs R10k 0 176 4 # 48-wide R10k = 192 B/row; bg near end +test_differs R12L 0 180 9 # 48-wide R12L = 6 groups * 36 = 216 B/row +test_differs Y416 0 320 8 # 48-wide Y416 = 384 B/row +# I420 Y plane is 48 B/row * 4 rows = 192 B; row 0 first vs row 0 last 4 px: +test_differs I420 0 44 4 + +# Threaded-blend parity: the same overlay through blend_threads=0 and +# blend_threads=4 must produce a byte-identical dump. Catches off-by-one +# mistakes in the row-stripe split. +test_blend_threads_parity() { + echo "==> blend_threads parity (RGBA, 0 vs 4 workers)" + rm -f "$DUMP".* + cd "$WORK" + "$UV" -t "testcard:size=64x16:fps=1:codec=RGBA:pattern=blank=0xFF" \ + -d "dummy:codec=RGBA:dump=oneshot:raw" \ + --postprocess "overlay:file=$PAM:position=top_left" \ + >/dev/null 2>&1 & + local pid=$! + local n=0 + while kill -0 "$pid" 2>/dev/null && [ $n -lt 25 ]; do sleep 0.2; n=$((n+1)); done + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + cd - >/dev/null + [ -s "$DUMP".rgba ] || { echo " FAIL: serial dump missing or empty (uv crash?)"; FAIL=$((FAIL+1)); return; } + cp "$DUMP".rgba "$WORK/serial.rgba" + + rm -f "$DUMP".* + cd "$WORK" + "$UV" -t "testcard:size=64x16:fps=1:codec=RGBA:pattern=blank=0xFF" \ + -d "dummy:dump=oneshot:raw" \ + --postprocess "overlay:file=$PAM:position=top_left:blend_threads=4" \ + >/dev/null 2>&1 & + local pid2=$! + local n=0 + while kill -0 "$pid2" 2>/dev/null && [ $n -lt 25 ]; do sleep 0.2; n=$((n+1)); done + kill "$pid2" 2>/dev/null || true + wait "$pid2" 2>/dev/null || true + cd - >/dev/null + + # cmp -s passes for two empty files; guard against a uv crash in the + # threaded run silently matching the serial dump. + [ -s "$DUMP".rgba ] || { echo " FAIL: threaded dump missing or empty (uv crash?)"; FAIL=$((FAIL+1)); return; } + if cmp -s "$WORK/serial.rgba" "$DUMP".rgba; then + PASS=$((PASS + 1)) + else + echo " FAIL: serial vs threaded output differs" + FAIL=$((FAIL + 1)) + fi +} + +test_blend_threads_parity + +# Two overlays in the same -p invocation. The framework chains +# postprocessors with `,`; each runs in sequence with the previous +# stage's output as input. Verifies that two overlay instances coexist +# (independent state, no buffer aliasing) and that top_right positions +# flush against the right edge as documented. +test_dual_overlay_chain() { + echo "==> dual overlay chain (RGBA, green top_left + red top_right)" + local ddir=$WORK/dual + mkdir -p "$ddir" + # Green and red 24x2 PAMs (same dims as the main test PAM). + local gpam=$ddir/green.pam + local rpam=$ddir/red.pam + for spec in "$gpam:00:ff:00" "$rpam:ff:00:00"; do + local file=${spec%%:*} + local rest=${spec#*:} + local r=$(echo "$rest" | cut -d: -f1) + local g=$(echo "$rest" | cut -d: -f2) + local b=$(echo "$rest" | cut -d: -f3) + { + printf 'P7\nWIDTH %d\nHEIGHT %d\nDEPTH 4\nMAXVAL 255\n' \ + "$OVERLAY_W" "$OVERLAY_H" + printf 'TUPLTYPE RGB_ALPHA\nENDHDR\n' + for _ in $(seq 1 $((OVERLAY_W * OVERLAY_H))); do + printf "\\x$r\\x$g\\x$b\\xff" + done + } > "$file" + done + + cd "$ddir" + "$UV" -t "testcard:size=${FRAME_W}x${FRAME_H}:fps=1:codec=RGBA:pattern=blank=0xFF" \ + -d "dummy:codec=RGBA:dump=oneshot:raw" \ + --postprocess "overlay:file=$gpam:position=top_left,overlay:file=$rpam:position=top_right" \ + >/dev/null 2>&1 & + local pid=$! + local n=0 + while kill -0 "$pid" 2>/dev/null && [ $n -lt 25 ]; do sleep 0.2; n=$((n+1)); done + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + cd - >/dev/null + + local f=$(ls "$ddir"/dummy.* 2>/dev/null | head -1) + [ -s "$f" ] || { echo " FAIL: no dump"; FAIL=$((FAIL+1)); return; } + + # Pixel (0,0) — green overlay's first pixel (top_left). + assert_eq "dual: pixel(0,0) is green" "00ff00ff" \ + "$(od -An -tx1 -N4 -j0 "$f" | tr -d ' \n')" + # Pixel (FRAME_W - OVERLAY_W, 0) — red overlay's first pixel + # (top_right places the overlay flush against the right edge). + local red_off=$(( (FRAME_W - OVERLAY_W) * 4 )) + assert_eq "dual: pixel(${FRAME_W}-${OVERLAY_W},0) is red" "ff0000ff" \ + "$(od -An -tx1 -N4 -j$red_off "$f" | tr -d ' \n')" +} + +test_dual_overlay_chain + +# Overlay larger than the frame, centred. Builds a 96x8 PAM split +# left-half=red / right-half=green; frame is 48x4. CENTER positioning +# slices the *middle* 48 columns out of the 96-wide overlay, which +# spans the red→green transition at overlay column 48. So the visible +# left half (frame x=0..23) shows red and the visible right half +# (x=24..47) shows green. Pre-fix this would have shown all-red +# because src_x defaulted to 0 and the blend read the overlay's +# leftmost slice instead of the centre. +test_oversized_center() { + echo "==> oversized overlay (96x8) centred on 48x4 frame" + local odir=$WORK/oversize + mkdir -p "$odir" + local pam=$odir/big.pam + local OW=96 OH=8 + { + printf 'P7\nWIDTH %d\nHEIGHT %d\nDEPTH 4\nMAXVAL 255\n' "$OW" "$OH" + printf 'TUPLTYPE RGB_ALPHA\nENDHDR\n' + for _ in $(seq 1 $OH); do + for _ in $(seq 1 $((OW / 2))); do printf '\xff\x00\x00\xff'; done + for _ in $(seq 1 $((OW / 2))); do printf '\x00\xff\x00\xff'; done + done + } > "$pam" + + cd "$odir" + "$UV" -t "testcard:size=${FRAME_W}x${FRAME_H}:fps=1:codec=RGBA:pattern=blank=0xFF" \ + -d "dummy:codec=RGBA:dump=oneshot:raw" \ + --postprocess "overlay:file=$pam:position=center" \ + >/dev/null 2>&1 & + local pid=$! + local n=0 + while kill -0 "$pid" 2>/dev/null && [ $n -lt 25 ]; do sleep 0.2; n=$((n+1)); done + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + cd - >/dev/null + + local f=$(ls "$odir"/dummy.* 2>/dev/null | head -1) + [ -s "$f" ] || { echo " FAIL: no dump"; FAIL=$((FAIL+1)); return; } + + # Visible 48 cols map to overlay cols 24..71. Cols 24..47 are red, + # 48..71 are green. So frame pixel x=0 == overlay col 24 (red); + # frame pixel x=24 == overlay col 48 (first green). + assert_eq "oversized: pixel(0,0) is red" "ff0000ff" \ + "$(od -An -tx1 -N4 -j0 "$f" | tr -d ' \n')" + assert_eq "oversized: pixel(24,0) is green" "00ff00ff" \ + "$(od -An -tx1 -N4 -j$((24 * 4)) "$f" | tr -d ' \n')" +} + +test_oversized_center + +# scale=frame: the overlay should be re-rendered at the current frame +# dimensions. Use a 4×2 native PAM (a checkerboard, so a stretch is +# detectable from the colour at known coordinates) and a 48×4 frame. +# After scale=frame, the overlay covers the whole frame; sampling +# pixel (0,0) and (47,0) — both in row 0 — should yield the colour +# of the corresponding source column after the horizontal stretch. +test_scale_frame() { + echo "==> scale=frame stretches small PAM to the full frame" + local sdir=$WORK/scaleframe + mkdir -p "$sdir" + local pam=$sdir/tiny.pam + # 4x2 PAM: row 0 = red, green, blue, white; row 1 = same. + { + printf 'P7\nWIDTH 4\nHEIGHT 2\nDEPTH 4\nMAXVAL 255\n' + printf 'TUPLTYPE RGB_ALPHA\nENDHDR\n' + for _ in 0 1; do + printf '\xff\x00\x00\xff' # red + printf '\x00\xff\x00\xff' # green + printf '\x00\x00\xff\xff' # blue + printf '\xff\xff\xff\xff' # white + done + } > "$pam" + + cd "$sdir" + # dump:skip=4 lets the async rescale (kicked from postprocess on + # the first frame) complete and harvest before we dump frame 5. + # fps=10 keeps the wait short. dummy parses these as separate + # colon-tokens — not `dump=skip=4` (that's parsed as just dump). + "$UV" -t "testcard:size=${FRAME_W}x${FRAME_H}:fps=10:codec=RGBA:pattern=blank=0xFF" \ + -d "dummy:codec=RGBA:dump:skip=4:oneshot:raw" \ + --postprocess "overlay:file=$pam:scale=frame:scale_filter=nearest" \ + >/dev/null 2>&1 & + local pid=$! + local n=0 + while kill -0 "$pid" 2>/dev/null && [ $n -lt 25 ]; do sleep 0.2; n=$((n+1)); done + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + cd - >/dev/null + + local f=$(ls "$sdir"/dummy.* 2>/dev/null | head -1) + [ -s "$f" ] || { echo " FAIL: no dump"; FAIL=$((FAIL+1)); return; } + + # 4-wide PAM stretched to 48 columns with nearest-neighbour: each + # source column owns 12 frame columns. Pixel (0,0) is red, + # pixel (47,0) is white. + assert_eq "scale=frame: pixel(0,0) is red" "ff0000ff" \ + "$(od -An -tx1 -N4 -j0 "$f" | tr -d ' \n')" + assert_eq "scale=frame: pixel(47,0) is white" "ffffffff" \ + "$(od -An -tx1 -N4 -j$((47 * 4)) "$f" | tr -d ' \n')" +} + +test_scale_frame + +# I420 parallel-vs-serial parity. The frame and overlay are sized so the +# overlay rect (1280x720 = 921k pixels) crosses the MIN_PARALLEL_PIXELS +# = 500k threshold inside blend_i420 — without this the test would fall +# through to the serial path and prove nothing. +test_i420_parallel_parity() { + echo "==> I420 parallel parity (1 vs 8 workers, 1280x720 overlay)" + local pdir=$WORK/i420parity + mkdir -p "$pdir" + local big=$pdir/big.pam + python3 - "$big" <<'PYEOF' || { echo " python3 missing — skip"; return; } +import struct, sys +W, H = 1280, 720 +with open(sys.argv[1], "wb") as f: + f.write(f'P7\nWIDTH {W}\nHEIGHT {H}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n'.encode()) + for y in range(H): + for x in range(W): + f.write(struct.pack('BBBB', x & 0xff, y & 0xff, (x ^ y) & 0xff, 0x80)) +PYEOF + run_threads() { + local label=$1 threads=$2 + mkdir -p "$pdir/$label" + cd "$pdir/$label" + "$UV" -t "testcard:size=1920x1080:fps=1:codec=I420:pattern=ebu_bars" \ + -d "dummy:codec=I420:dump=oneshot:raw" \ + --postprocess "overlay:file=$big:position=top_left:blend_threads=$threads" \ + >/dev/null 2>&1 & + local pid=$! + local n=0 + while kill -0 "$pid" 2>/dev/null && [ $n -lt 30 ]; do sleep 0.2; n=$((n+1)); done + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + cd - >/dev/null + } + run_threads t1 1 + run_threads t8 8 + # cmp -s passes for two empty files; uv crashing in both runs would + # silently pass without this guard. + if [ ! -s "$pdir/t1/dummy.yuv" ] || [ ! -s "$pdir/t8/dummy.yuv" ]; then + echo " FAIL: I420 dump missing or empty (uv crash?)" + FAIL=$((FAIL + 1)) + return + fi + if cmp -s "$pdir/t1/dummy.yuv" "$pdir/t8/dummy.yuv"; then + PASS=$((PASS + 1)) + else + echo " FAIL: I420 serial vs parallel output differs" + FAIL=$((FAIL + 1)) + fi +} + +test_i420_parallel_parity + +echo +echo "passed: $PASS failed: $FAIL" +[ $FAIL -eq 0 ] diff --git a/test/run_overlay_visual.sh b/test/run_overlay_visual.sh new file mode 100755 index 0000000000..7ba27d3732 --- /dev/null +++ b/test/run_overlay_visual.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# +# Visual demo: launches SDL with the overlay across each supported codec +# in turn so a human can confirm the overlay actually appears correctly. +# +# Args: [seconds_per_codec] default 6 +# +# Author: Ben Roeder +# Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + +set -e + +UV="${UV:-./bin/uv}" +[ -x "$UV" ] || { echo "uv binary not found at $UV"; exit 2; } +UV=$(cd "$(dirname "$UV")" && pwd)/$(basename "$UV") + +SECS="${1:-6}" +# Resolutions to walk. Format: WxH@FPS. Override via command-line args 2+. +if [ $# -gt 1 ]; then + shift + RESOLUTIONS=("$@") +else + RESOLUTIONS=("1920x1080@60" "3840x2160@30") +fi +PAM=/tmp/overlay_visual.pam + +# 200x100 solid green PAM. Soft edge is applied by the postprocessor. +python3 - </dev/null; rm -f "$PAM" "$PAM.log"; exit' INT TERM + +for res in "${RESOLUTIONS[@]}"; do + size=${res%@*} + fps=${res#*@} + echo + echo "####################################################################" + echo "# Resolution $size @ ${fps}fps" + echo "####################################################################" + + for cs in "${CODECS[@]}"; do + echo + echo "====================================================================" + echo " $cs $size@${fps} (showing for ${SECS}s)" + echo "====================================================================" + "$UV" -t "testcard:size=$size:fps=$fps:codec=$cs:pattern=ebu_bars" \ + -d sdl \ + --postprocess "overlay:file=$PAM:position=top_right:custom_x=-30:custom_y=30:soft_edge=16:perf" \ + > "$PAM.log" 2>&1 & + pid=$! + sleep "$SECS" + kill "$pid" 2>/dev/null || true + pkill -f "bin/uv" 2>/dev/null || true + wait 2>/dev/null || true + grep -iE "(loaded|reconf|stats|unsupported|error|fail)" "$PAM.log" \ + | sed 's/^/ /' | head -8 || true + done +done + +rm -f "$PAM" "$PAM.log" +echo +echo "Done — walked through ${#CODECS[@]} codecs at ${#RESOLUTIONS[@]} resolutions." diff --git a/test/run_scale_frame_visual.sh b/test/run_scale_frame_visual.sh new file mode 100755 index 0000000000..2a74071b82 --- /dev/null +++ b/test/run_scale_frame_visual.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# +# Visual demo of scale=frame. +# +# Builds a 480x270 PAM with eight vertical colour bars +# (red/green/blue/white × 2) and shows it through SDL twice: first +# WITHOUT scale=frame (so the user sees the PAM at native 480x270 — +# a clearly-recognisable badge in the centre, a quarter of a 1080p +# frame), then WITH scale=frame (the same PAM stretched across the +# full window). The eight colour bars make the horizontal stretch +# unambiguous: the bar boundaries land at frame_w * k/8 for each k. +# +# Then it walks several resolutions back-to-back to show that +# scale=frame produces a correctly-sized overlay at each, without +# the user having to recompute scale=WxH. +# +# Args: [seconds_per_window] default 4 +# +# Author: Ben Roeder +# Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + +set -e + +UV="${UV:-./bin/uv}" +[ -x "$UV" ] || { echo "uv binary not found at $UV"; exit 2; } +UV=$(cd "$(dirname "$UV")" && pwd)/$(basename "$UV") + +SECS="${1:-4}" +RESOLUTIONS=("1280x720@30" "1920x1080@30" "3840x2160@30") + +PAM=/tmp/overlay_scale_frame_visual.pam +LOG=/tmp/overlay_scale_frame_visual.log + +# 480x270 PAM (1/4 of a 1080p frame, ~6% of area at 4K). Eight vertical +# colour bars: red, green, blue, white repeated twice. At native size +# you see a small recognisable badge in the centre; with scale=frame +# the same eight bars cover the full window so the stretch is obvious. +python3 - </dev/null || true + rm -f "$PAM" "$LOG" +} +trap cleanup INT TERM EXIT + +run_one() { + local label=$1 size=$2 fps=$3 pp=$4 + echo + echo "=====================================================================" + echo " $label $size@${fps}fps (${SECS}s)" + echo "=====================================================================" + "$UV" -t "testcard:size=$size:fps=$fps:codec=RGBA:pattern=ebu_bars" \ + -d sdl \ + --postprocess "$pp" \ + > "$LOG" 2>&1 & + local pid=$! + sleep "$SECS" + kill "$pid" 2>/dev/null || true + pkill -f "bin/uv" 2>/dev/null || true + wait 2>/dev/null || true + grep -iE "(loaded|overlay stats|unsupported|fail)" "$LOG" \ + | sed 's/^/ /' | head -6 || true +} + +# Comparison at 1080p: native vs scale=frame. +echo +echo "#####################################################################" +echo "# Comparison: same 480x270 PAM at 1920x1080" +echo "#####################################################################" +run_one "Native 480x270 (no scale)" 1920x1080 30 \ + "overlay:file=$PAM:position=center" +run_one "scale=frame (stretched)" 1920x1080 30 \ + "overlay:file=$PAM:scale=frame:scale_filter=nearest:perf" + +# Walk resolutions with scale=frame: each window should show the +# overlay covering the full frame regardless of source resolution. +echo +echo "#####################################################################" +echo "# scale=frame across multiple source resolutions" +echo "#####################################################################" +for res in "${RESOLUTIONS[@]}"; do + size=${res%@*} + fps=${res#*@} + run_one "scale=frame" "$size" "$fps" \ + "overlay:file=$PAM:scale=frame:scale_filter=nearest:perf" +done + +echo +echo "Done — walked native and scale=frame at ${#RESOLUTIONS[@]} resolutions." diff --git a/test/run_tests.c b/test/run_tests.c index 646fb7b7db..4c4e30dbee 100644 --- a/test/run_tests.c +++ b/test/run_tests.c @@ -47,6 +47,13 @@ #include "test_host.h" #include "test_aes.h" +#include "test_alpha_blend.h" +#include "test_overlay_config.h" +#include "test_overlay_layout.h" +#include "test_overlay_pam.h" +#include "test_overlay_scale.h" +#include "test_overlay_soft_edge.h" +#include "test_overlay_watch.h" #include "test_bitstream.h" #include "test_des.h" #include "test_md5.h" @@ -90,6 +97,93 @@ DECLARE_TEST(misc_test_net_sockaddr_compare_v4_mapped); DECLARE_TEST(misc_test_replace_all); DECLARE_TEST(misc_test_unit_evaluate); DECLARE_TEST(misc_test_video_desc_io_op_symmetry); +DECLARE_TEST(alpha_blend_test_rgba_alpha_zero); +DECLARE_TEST(alpha_blend_test_rgba_alpha_max); +DECLARE_TEST(alpha_blend_test_rgba_half_alpha); +DECLARE_TEST(alpha_blend_test_rgb_alpha_zero); +DECLARE_TEST(alpha_blend_test_rgb_alpha_max); +DECLARE_TEST(alpha_blend_test_uyvy_alpha_zero); +DECLARE_TEST(alpha_blend_test_uyvy_alpha_max_white); +DECLARE_TEST(alpha_blend_test_uyvy_alpha_max_red); +DECLARE_TEST(alpha_blend_test_yuyv_alpha_zero); +DECLARE_TEST(alpha_blend_test_yuyv_alpha_max_white); +DECLARE_TEST(alpha_blend_test_yuyv_alpha_max_red); +DECLARE_TEST(alpha_blend_test_y416_alpha_zero); +DECLARE_TEST(alpha_blend_test_y416_alpha_max_white); +DECLARE_TEST(alpha_blend_test_i420_alpha_zero); +DECLARE_TEST(alpha_blend_test_i420_alpha_max_white); +DECLARE_TEST(alpha_blend_test_i420_chroma_alpha_averaging); +DECLARE_TEST(alpha_blend_test_i420_subregion_strides); +DECLARE_TEST(alpha_blend_test_rg48_alpha_zero); +DECLARE_TEST(alpha_blend_test_rg48_alpha_max_white); +DECLARE_TEST(alpha_blend_test_v210_alpha_zero); +DECLARE_TEST(alpha_blend_test_v210_alpha_max_white); +DECLARE_TEST(alpha_blend_test_r10k_alpha_zero); +DECLARE_TEST(alpha_blend_test_r10k_alpha_max_white); +DECLARE_TEST(alpha_blend_test_r12l_alpha_zero); +DECLARE_TEST(alpha_blend_test_r12l_alpha_max_white); +DECLARE_TEST(overlay_pam_test_load_8bit_rgba); +DECLARE_TEST(overlay_pam_test_load_8bit_rgb_adds_alpha); +DECLARE_TEST(overlay_pam_test_load_16bit_rgba); +DECLARE_TEST(overlay_pam_test_rejects_missing_file); +DECLARE_TEST(overlay_pam_test_rejects_grayscale); +DECLARE_TEST(overlay_pam_test_rejects_intermediate_maxval); +DECLARE_TEST(overlay_layout_test_center); +DECLARE_TEST(overlay_layout_test_corners); +DECLARE_TEST(overlay_layout_test_custom_negative_from_edge); +DECLARE_TEST(overlay_layout_test_block_pixel_alignment); +DECLARE_TEST(overlay_layout_test_overlay_larger_than_frame); +DECLARE_TEST(overlay_layout_test_oversized_center); +DECLARE_TEST(overlay_layout_test_oversized_right); +DECLARE_TEST(overlay_layout_test_oversized_custom_positive); +DECLARE_TEST(overlay_layout_test_block_lines_alignment); +DECLARE_TEST(overlay_scale_test_identity); +DECLARE_TEST(overlay_scale_test_upscale_solid_colour); +DECLARE_TEST(overlay_scale_test_downscale_average); +DECLARE_TEST(overlay_scale_test_returns_null_on_bad_dims); +DECLARE_TEST(overlay_scale_test_source_buffer_unchanged); +DECLARE_TEST(overlay_scaler_test_create_destroy); +DECLARE_TEST(overlay_scaler_test_reuses_context_same_dims); +DECLARE_TEST(overlay_scaler_test_rebuilds_context_on_dim_change); +DECLARE_TEST(overlay_scaler_test_scale_into_no_alloc); +DECLARE_TEST(overlay_scaler_test_filter_nearest); +DECLARE_TEST(overlay_scaler_test_filter_bilinear); +DECLARE_TEST(overlay_soft_edge_test_zero_width_is_noop); +DECLARE_TEST(overlay_soft_edge_test_edge_pixel_zeroed); +DECLARE_TEST(overlay_soft_edge_test_linear_ramp); +DECLARE_TEST(overlay_soft_edge_test_centre_untouched); +DECLARE_TEST(overlay_soft_edge_test_rgb_components_unchanged); +DECLARE_TEST(overlay_soft_edge_test_oversized_width_clamps); +DECLARE_TEST(overlay_soft_edge_test_non_square); +DECLARE_TEST(overlay_soft_edge_test_exact_half_dimension); +DECLARE_TEST(overlay_soft_edge_test_scales_existing_alpha); +DECLARE_TEST(overlay_soft_edge_test_degenerate_one_row); +DECLARE_TEST(overlay_watch_test_init_no_change); +DECLARE_TEST(overlay_watch_test_detects_size_change); +DECLARE_TEST(overlay_watch_test_detects_mtime_change); +DECLARE_TEST(overlay_watch_test_ack_on_missing_file_preserves_baseline); +DECLARE_TEST(overlay_watch_test_missing_file_no_change); +DECLARE_TEST(overlay_watch_test_file_appears_after_init); +DECLARE_TEST(overlay_watch_test_detects_atomic_rename); +DECLARE_TEST(overlay_watch_test_changed_does_not_consume_baseline); +DECLARE_TEST(overlay_watch_test_ack_commits_baseline); +DECLARE_TEST(overlay_config_test_minimal_file_only); +DECLARE_TEST(overlay_config_test_position_keywords); +DECLARE_TEST(overlay_config_test_custom_xy); +DECLARE_TEST(overlay_config_test_help); +DECLARE_TEST(overlay_config_test_rejects_missing_file); +DECLARE_TEST(overlay_config_test_rejects_unknown_key); +DECLARE_TEST(overlay_config_test_rejects_bad_position); +DECLARE_TEST(overlay_config_test_rejects_non_integer_xy); +DECLARE_TEST(overlay_config_test_rejects_null_and_empty_value); +DECLARE_TEST(overlay_config_test_soft_edge); +DECLARE_TEST(overlay_config_test_scale); +DECLARE_TEST(overlay_config_test_scale_frame); +DECLARE_TEST(overlay_config_test_scale_frame_overrides_wxh); +DECLARE_TEST(overlay_config_test_perf); +DECLARE_TEST(overlay_config_test_scale_filter); +DECLARE_TEST(overlay_config_test_blend_threads); +DECLARE_TEST(overlay_config_test_rejects_oversize_options); struct { const char *name; @@ -127,6 +221,93 @@ struct { DEFINE_TEST(misc_test_unit_evaluate), DEFINE_TEST(misc_test_video_desc_io_op_symmetry), DEFINE_TEST(test_sdp_parser), + DEFINE_TEST(alpha_blend_test_rgba_alpha_zero), + DEFINE_TEST(alpha_blend_test_rgba_alpha_max), + DEFINE_TEST(alpha_blend_test_rgba_half_alpha), + DEFINE_TEST(alpha_blend_test_rgb_alpha_zero), + DEFINE_TEST(alpha_blend_test_rgb_alpha_max), + DEFINE_TEST(alpha_blend_test_uyvy_alpha_zero), + DEFINE_TEST(alpha_blend_test_uyvy_alpha_max_white), + DEFINE_TEST(alpha_blend_test_uyvy_alpha_max_red), + DEFINE_TEST(alpha_blend_test_yuyv_alpha_zero), + DEFINE_TEST(alpha_blend_test_yuyv_alpha_max_white), + DEFINE_TEST(alpha_blend_test_yuyv_alpha_max_red), + DEFINE_TEST(alpha_blend_test_y416_alpha_zero), + DEFINE_TEST(alpha_blend_test_y416_alpha_max_white), + DEFINE_TEST(alpha_blend_test_i420_alpha_zero), + DEFINE_TEST(alpha_blend_test_i420_alpha_max_white), + DEFINE_TEST(alpha_blend_test_i420_chroma_alpha_averaging), + DEFINE_TEST(alpha_blend_test_i420_subregion_strides), + DEFINE_TEST(alpha_blend_test_rg48_alpha_zero), + DEFINE_TEST(alpha_blend_test_rg48_alpha_max_white), + DEFINE_TEST(alpha_blend_test_v210_alpha_zero), + DEFINE_TEST(alpha_blend_test_v210_alpha_max_white), + DEFINE_TEST(alpha_blend_test_r10k_alpha_zero), + DEFINE_TEST(alpha_blend_test_r10k_alpha_max_white), + DEFINE_TEST(alpha_blend_test_r12l_alpha_zero), + DEFINE_TEST(alpha_blend_test_r12l_alpha_max_white), + DEFINE_TEST(overlay_pam_test_load_8bit_rgba), + DEFINE_TEST(overlay_pam_test_load_8bit_rgb_adds_alpha), + DEFINE_TEST(overlay_pam_test_load_16bit_rgba), + DEFINE_TEST(overlay_pam_test_rejects_missing_file), + DEFINE_TEST(overlay_pam_test_rejects_grayscale), + DEFINE_TEST(overlay_pam_test_rejects_intermediate_maxval), + DEFINE_TEST(overlay_layout_test_center), + DEFINE_TEST(overlay_layout_test_corners), + DEFINE_TEST(overlay_layout_test_custom_negative_from_edge), + DEFINE_TEST(overlay_layout_test_block_pixel_alignment), + DEFINE_TEST(overlay_layout_test_overlay_larger_than_frame), + DEFINE_TEST(overlay_layout_test_oversized_center), + DEFINE_TEST(overlay_layout_test_oversized_right), + DEFINE_TEST(overlay_layout_test_oversized_custom_positive), + DEFINE_TEST(overlay_layout_test_block_lines_alignment), + DEFINE_TEST(overlay_scale_test_identity), + DEFINE_TEST(overlay_scale_test_upscale_solid_colour), + DEFINE_TEST(overlay_scale_test_downscale_average), + DEFINE_TEST(overlay_scale_test_returns_null_on_bad_dims), + DEFINE_TEST(overlay_scale_test_source_buffer_unchanged), + DEFINE_TEST(overlay_scaler_test_create_destroy), + DEFINE_TEST(overlay_scaler_test_reuses_context_same_dims), + DEFINE_TEST(overlay_scaler_test_rebuilds_context_on_dim_change), + DEFINE_TEST(overlay_scaler_test_scale_into_no_alloc), + DEFINE_TEST(overlay_scaler_test_filter_nearest), + DEFINE_TEST(overlay_scaler_test_filter_bilinear), + DEFINE_TEST(overlay_soft_edge_test_zero_width_is_noop), + DEFINE_TEST(overlay_soft_edge_test_edge_pixel_zeroed), + DEFINE_TEST(overlay_soft_edge_test_linear_ramp), + DEFINE_TEST(overlay_soft_edge_test_centre_untouched), + DEFINE_TEST(overlay_soft_edge_test_rgb_components_unchanged), + DEFINE_TEST(overlay_soft_edge_test_oversized_width_clamps), + DEFINE_TEST(overlay_soft_edge_test_non_square), + DEFINE_TEST(overlay_soft_edge_test_exact_half_dimension), + DEFINE_TEST(overlay_soft_edge_test_scales_existing_alpha), + DEFINE_TEST(overlay_soft_edge_test_degenerate_one_row), + DEFINE_TEST(overlay_watch_test_init_no_change), + DEFINE_TEST(overlay_watch_test_detects_size_change), + DEFINE_TEST(overlay_watch_test_detects_mtime_change), + DEFINE_TEST(overlay_watch_test_ack_on_missing_file_preserves_baseline), + DEFINE_TEST(overlay_watch_test_missing_file_no_change), + DEFINE_TEST(overlay_watch_test_file_appears_after_init), + DEFINE_TEST(overlay_watch_test_detects_atomic_rename), + DEFINE_TEST(overlay_watch_test_changed_does_not_consume_baseline), + DEFINE_TEST(overlay_watch_test_ack_commits_baseline), + DEFINE_TEST(overlay_config_test_minimal_file_only), + DEFINE_TEST(overlay_config_test_position_keywords), + DEFINE_TEST(overlay_config_test_custom_xy), + DEFINE_TEST(overlay_config_test_help), + DEFINE_TEST(overlay_config_test_rejects_missing_file), + DEFINE_TEST(overlay_config_test_rejects_unknown_key), + DEFINE_TEST(overlay_config_test_rejects_bad_position), + DEFINE_TEST(overlay_config_test_rejects_non_integer_xy), + DEFINE_TEST(overlay_config_test_rejects_null_and_empty_value), + DEFINE_TEST(overlay_config_test_soft_edge), + DEFINE_TEST(overlay_config_test_scale), + DEFINE_TEST(overlay_config_test_scale_frame), + DEFINE_TEST(overlay_config_test_scale_frame_overrides_wxh), + DEFINE_TEST(overlay_config_test_perf), + DEFINE_TEST(overlay_config_test_scale_filter), + DEFINE_TEST(overlay_config_test_blend_threads), + DEFINE_TEST(overlay_config_test_rejects_oversize_options), }; static bool test_helper(const char *name, int (*func)(), bool quiet) { diff --git a/test/test_alpha_blend.c b/test/test_alpha_blend.c new file mode 100644 index 0000000000..9ecd7f04bf --- /dev/null +++ b/test/test_alpha_blend.c @@ -0,0 +1,618 @@ +/** + * @file test/test_alpha_blend.c + * @author Ben Roeder + * @brief Unit tests for alpha_blend.c, registered via run_tests.c + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include + +#include "test_alpha_blend.h" +#include "unit_common.h" +#include "utils/alpha_blend.h" + +static void mk_pixel16(uint16_t *p, int r, int g, int b, int a) +{ + p[0] = r; p[1] = g; p[2] = b; p[3] = a; +} + +/* + * Property: alpha=0 in the overlay leaves dst exactly unchanged. + */ +int alpha_blend_test_rgba_alpha_zero(void) +{ + uint8_t dst[12] = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120}; + uint8_t orig[12]; + memcpy(orig, dst, sizeof dst); + + uint16_t src[12]; + for (int i = 0; i < 3; i++) { + /* white overlay, alpha=0 - should not affect dst */ + mk_pixel16(src + i*4, 65535, 65535, 65535, 0); + } + alpha_blend_rgba(dst, src, 3); + + for (size_t i = 0; i < sizeof dst; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + } + return 0; +} + +/* + * Property: alpha=max replaces dst with the source RGB. Alpha channel becomes + * fully opaque (Porter-Duff "over" with src.a=1 -> out.a=1). + */ +int alpha_blend_test_rgba_alpha_max(void) +{ + uint8_t dst[4] = {0, 0, 0, 0}; + uint16_t src[4]; + mk_pixel16(src, 65535, 65535, 65535, 65535); /* white opaque */ + alpha_blend_rgba(dst, src, 1); + ASSERT_EQUAL_MESSAGE("R", 255, dst[0]); + ASSERT_EQUAL_MESSAGE("G", 255, dst[1]); + ASSERT_EQUAL_MESSAGE("B", 255, dst[2]); + ASSERT_EQUAL_MESSAGE("A", 255, dst[3]); + return 0; +} + +/* + * White overlay onto black dst at alpha~50% -> RGB ~128. + * alpha8 = 32768>>8 = 128, out = (255*128 + 0*127)/255 = 128. + */ +int alpha_blend_test_rgba_half_alpha(void) +{ + uint8_t dst[4] = {0, 0, 0, 0}; + uint16_t src[4]; + mk_pixel16(src, 65535, 65535, 65535, 32768); + alpha_blend_rgba(dst, src, 1); + ASSERT_EQUAL_MESSAGE("R", 128, dst[0]); + ASSERT_EQUAL_MESSAGE("G", 128, dst[1]); + ASSERT_EQUAL_MESSAGE("B", 128, dst[2]); + return 0; +} + +/* RGB destination: 3 bytes per pixel, no alpha channel in dst. */ + +int alpha_blend_test_rgb_alpha_zero(void) +{ + uint8_t dst[9] = {10, 20, 30, 40, 50, 60, 70, 80, 90}; + uint8_t orig[9]; memcpy(orig, dst, sizeof dst); + uint16_t src[12]; + for (int i = 0; i < 3; i++) { + mk_pixel16(src + i*4, 65535, 65535, 65535, 0); + } + alpha_blend_rgb(dst, src, 3); + for (size_t i = 0; i < sizeof dst; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + } + return 0; +} + +int alpha_blend_test_rgb_alpha_max(void) +{ + uint8_t dst[3] = {0, 0, 0}; + uint16_t src[4]; + mk_pixel16(src, 65535, 32768, 0, 65535); + alpha_blend_rgb(dst, src, 1); + ASSERT_EQUAL_MESSAGE("R", 255, dst[0]); + ASSERT_EQUAL_MESSAGE("G", 128, dst[1]); + ASSERT_EQUAL_MESSAGE("B", 0, dst[2]); + return 0; +} + +/* + * UYVY: 8-bit YUV 4:2:2, byte order U Y0 V Y1 (2 pixels in 4 bytes). + */ + +int alpha_blend_test_uyvy_alpha_zero(void) +{ + uint8_t dst[8] = {100, 110, 120, 130, 140, 150, 160, 170}; + uint8_t orig[8]; memcpy(orig, dst, sizeof dst); + uint16_t src[8]; + mk_pixel16(src + 0, 65535, 65535, 65535, 0); + mk_pixel16(src + 4, 65535, 65535, 65535, 0); + alpha_blend_uyvy(dst, src, 2); + for (size_t i = 0; i < sizeof dst; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + } + return 0; +} + +/* + * White overlay onto black dst at alpha=max -> BT.709 white limited-range: + * Y=235, Cb=Cr=128. + */ +int alpha_blend_test_uyvy_alpha_max_white(void) +{ + uint8_t dst[4] = {0, 0, 0, 0}; + uint16_t src[8]; + mk_pixel16(src + 0, 65535, 65535, 65535, 65535); + mk_pixel16(src + 4, 65535, 65535, 65535, 65535); + alpha_blend_uyvy(dst, src, 2); + ASSERT_EQUAL_MESSAGE("U", 128, dst[0]); + ASSERT_EQUAL_MESSAGE("Y0", 235, dst[1]); + ASSERT_EQUAL_MESSAGE("V", 128, dst[2]); + ASSERT_EQUAL_MESSAGE("Y1", 235, dst[3]); + return 0; +} + +/* + * Pure red overlay onto black at alpha=max. BT.709 red as 16-bit input through + * DEPTH8 coefficients with COMP_OFF=22 produces Y=62 Cb=102 Cr=240. The Y + * value is 62 (not 63) because UltraGrid's RGB_TO_Y uses a truncating + * right-shift, not rounding division. + */ +int alpha_blend_test_uyvy_alpha_max_red(void) +{ + uint8_t dst[4] = {0, 0, 0, 0}; + uint16_t src[8]; + mk_pixel16(src + 0, 65535, 0, 0, 65535); + mk_pixel16(src + 4, 65535, 0, 0, 65535); + alpha_blend_uyvy(dst, src, 2); + ASSERT_EQUAL_MESSAGE("U=Cb", 102, dst[0]); + ASSERT_EQUAL_MESSAGE("Y0", 62, dst[1]); + ASSERT_EQUAL_MESSAGE("V=Cr", 240, dst[2]); + ASSERT_EQUAL_MESSAGE("Y1", 62, dst[3]); + return 0; +} + +/* + * YUYV: 8-bit YUV 4:2:2, byte order Y0 U Y1 V (same conversion as UYVY, + * different pack). + */ + +int alpha_blend_test_yuyv_alpha_zero(void) +{ + uint8_t dst[8] = {100, 110, 120, 130, 140, 150, 160, 170}; + uint8_t orig[8]; memcpy(orig, dst, sizeof dst); + uint16_t src[8]; + mk_pixel16(src + 0, 65535, 65535, 65535, 0); + mk_pixel16(src + 4, 65535, 65535, 65535, 0); + alpha_blend_yuyv(dst, src, 2); + for (size_t i = 0; i < sizeof dst; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + } + return 0; +} + +int alpha_blend_test_yuyv_alpha_max_white(void) +{ + uint8_t dst[4] = {0, 0, 0, 0}; + uint16_t src[8]; + mk_pixel16(src + 0, 65535, 65535, 65535, 65535); + mk_pixel16(src + 4, 65535, 65535, 65535, 65535); + alpha_blend_yuyv(dst, src, 2); + ASSERT_EQUAL_MESSAGE("Y0", 235, dst[0]); + ASSERT_EQUAL_MESSAGE("U", 128, dst[1]); + ASSERT_EQUAL_MESSAGE("Y1", 235, dst[2]); + ASSERT_EQUAL_MESSAGE("V", 128, dst[3]); + return 0; +} + +int alpha_blend_test_yuyv_alpha_max_red(void) +{ + uint8_t dst[4] = {0, 0, 0, 0}; + uint16_t src[8]; + mk_pixel16(src + 0, 65535, 0, 0, 65535); + mk_pixel16(src + 4, 65535, 0, 0, 65535); + alpha_blend_yuyv(dst, src, 2); + ASSERT_EQUAL_MESSAGE("Y0", 62, dst[0]); + ASSERT_EQUAL_MESSAGE("U=Cb", 102, dst[1]); + ASSERT_EQUAL_MESSAGE("Y1", 62, dst[2]); + ASSERT_EQUAL_MESSAGE("V=Cr", 240, dst[3]); + return 0; +} + +/* + * Y416: 16-bit YUV 4:4:4 with alpha, byte order U(2) Y(2) V(2) A(2) + * little-endian, 8 bytes per pixel. Full chroma resolution (no subsampling). + */ + +int alpha_blend_test_y416_alpha_zero(void) +{ + uint8_t dst[16]; + for (size_t i = 0; i < sizeof dst; i++) dst[i] = (uint8_t)(i * 17); + uint8_t orig[16]; memcpy(orig, dst, sizeof dst); + uint16_t src[8]; + mk_pixel16(src + 0, 65535, 65535, 65535, 0); + mk_pixel16(src + 4, 65535, 65535, 65535, 0); + alpha_blend_y416(dst, src, 2); + for (size_t i = 0; i < sizeof dst; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + } + return 0; +} + +/* + * White overlay onto zero dst at alpha=max -> BT.709 white at 16-bit limited. + * Y nominally 235*256 = 60160 but integer truncation produces 60159. + * Cb = Cr = 128*256 = 32768. Alpha = full opacity (65535). + */ +int alpha_blend_test_y416_alpha_max_white(void) +{ + uint8_t dst[8] = {0,0, 0,0, 0,0, 0,0}; + uint16_t src[4]; + mk_pixel16(src, 65535, 65535, 65535, 65535); + alpha_blend_y416(dst, src, 1); + uint16_t u, y, v, a; + memcpy(&u, dst + 0, 2); + memcpy(&y, dst + 2, 2); + memcpy(&v, dst + 4, 2); + memcpy(&a, dst + 6, 2); + ASSERT_EQUAL_MESSAGE("U", 32768, u); + ASSERT_EQUAL_MESSAGE("Y", 60159, y); + ASSERT_EQUAL_MESSAGE("V", 32768, v); + ASSERT_EQUAL_MESSAGE("A", 65535, a); + return 0; +} + +/* + * I420: 8-bit YUV 4:2:0 planar. Y plane full resolution, U and V planes + * each at half resolution (one chroma sample per 2x2 Y block). Chroma + * alpha is the average of the 4 source pixels' alphas in each 2x2 block. + */ + +int alpha_blend_test_i420_alpha_zero(void) +{ + enum { W = 4, H = 2 }; + uint8_t dst_y[W * H]; uint8_t dst_u[W / 2 * H / 2]; uint8_t dst_v[W / 2 * H / 2]; + uint8_t orig_y[W * H], orig_u[W / 2 * H / 2], orig_v[W / 2 * H / 2]; + for (int i = 0; i < W * H; i++) dst_y[i] = orig_y[i] = (uint8_t)(100 + i); + for (int i = 0; i < W / 2 * H / 2; i++) { + dst_u[i] = orig_u[i] = 50; + dst_v[i] = orig_v[i] = 200; + } + uint16_t src[W * H * 4]; + for (int i = 0; i < W * H; i++) { + mk_pixel16(src + i*4, 65535, 0, 0, 0); /* red, alpha=0 */ + } + alpha_blend_i420(dst_y, W, dst_u, dst_v, W / 2, src, W, W, H); + for (int i = 0; i < W * H; i++) ASSERT_EQUAL_MESSAGE("Y unchanged", orig_y[i], dst_y[i]); + for (int i = 0; i < W / 2 * H / 2; i++) ASSERT_EQUAL_MESSAGE("U unchanged", orig_u[i], dst_u[i]); + for (int i = 0; i < W / 2 * H / 2; i++) ASSERT_EQUAL_MESSAGE("V unchanged", orig_v[i], dst_v[i]); + return 0; +} + +int alpha_blend_test_i420_alpha_max_white(void) +{ + enum { W = 2, H = 2 }; + uint8_t dst_y[W * H] = {0}; + uint8_t dst_u[1] = {0}; + uint8_t dst_v[1] = {0}; + uint16_t src[W * H * 4]; + for (int i = 0; i < W * H; i++) { + mk_pixel16(src + i*4, 65535, 65535, 65535, 65535); + } + alpha_blend_i420(dst_y, W, dst_u, dst_v, W / 2, src, W, W, H); + ASSERT_EQUAL_MESSAGE("Y[0]", 235, dst_y[0]); + ASSERT_EQUAL_MESSAGE("Y[1]", 235, dst_y[1]); + ASSERT_EQUAL_MESSAGE("Y[2]", 235, dst_y[2]); + ASSERT_EQUAL_MESSAGE("Y[3]", 235, dst_y[3]); + ASSERT_EQUAL_MESSAGE("U", 128, dst_u[0]); + ASSERT_EQUAL_MESSAGE("V", 128, dst_v[0]); + return 0; +} + +/* + * Validates the 2x2 chroma alpha averaging. Two pixels in the 2x2 block have + * alpha=255 (full source), the other two have alpha=0 (full dst). The chroma + * sample's averaged alpha is (255 + 255 + 0 + 0 + 2) >> 2 = 128. Y values use + * per-pixel alpha, so two Y pixels stay at dst (=10) and the other two + * become 235 (white). Chroma is averaged by alpha=128: + * U_out = (128 * 128 + 50 * 127) / 255 = 89 + * V_out = (128 * 128 + 200 * 127) / 255 = 163 + */ +int alpha_blend_test_i420_chroma_alpha_averaging(void) +{ + enum { W = 2, H = 2 }; + uint8_t dst_y[W * H] = {10, 10, 10, 10}; + uint8_t dst_u[1] = {50}; + uint8_t dst_v[1] = {200}; + uint16_t src[W * H * 4]; + /* 2x2 block: top-left and bottom-right opaque white, others transparent */ + mk_pixel16(src + 0*4, 65535, 65535, 65535, 65535); /* top-left */ + mk_pixel16(src + 1*4, 65535, 65535, 65535, 0); /* top-right */ + mk_pixel16(src + 2*4, 65535, 65535, 65535, 0); /* bot-left */ + mk_pixel16(src + 3*4, 65535, 65535, 65535, 65535); /* bot-right */ + alpha_blend_i420(dst_y, W, dst_u, dst_v, W / 2, src, W, W, H); + ASSERT_EQUAL_MESSAGE("Y top-left (alpha=max)", 235, dst_y[0]); + ASSERT_EQUAL_MESSAGE("Y top-right (alpha=0)", 10, dst_y[1]); + ASSERT_EQUAL_MESSAGE("Y bot-left (alpha=0)", 10, dst_y[2]); + ASSERT_EQUAL_MESSAGE("Y bot-right (alpha=max)", 235, dst_y[3]); + ASSERT_EQUAL_MESSAGE("U (averaged alpha)", 89, dst_u[0]); + ASSERT_EQUAL_MESSAGE("V (averaged alpha)", 163, dst_v[0]); + return 0; +} + +/* + * Sub-region: a 2x2 overlay blended into the upper-left quadrant of a 4x4 + * destination. Outside the blended region every byte must remain untouched, + * which exercises the new y_stride / uv_stride / src_stride parameters. + */ +int alpha_blend_test_i420_subregion_strides(void) +{ + enum { DW = 4, DH = 4, OW = 2, OH = 2 }; + uint8_t dst_y[DW * DH]; + uint8_t dst_u[(DW / 2) * (DH / 2)]; + uint8_t dst_v[(DW / 2) * (DH / 2)]; + for (int i = 0; i < DW * DH; i++) dst_y[i] = (uint8_t)(50 + i); + for (int i = 0; i < (DW/2) * (DH/2); i++) { dst_u[i] = 70; dst_v[i] = 180; } + + /* 2x2 opaque-white overlay laid out at OW pixel stride. */ + uint16_t src[OW * OH * 4]; + for (int i = 0; i < OW * OH; i++) + mk_pixel16(src + i * 4, 65535, 65535, 65535, 65535); + + alpha_blend_i420(dst_y + 0, DW, /* upper-left corner of Y plane */ + dst_u + 0, dst_v + 0, DW / 2, + src, OW, + OW, OH); + + /* Top half of Y plane: opaque white -> 235. Bottom half untouched. */ + for (int x = 0; x < OW; x++) { + ASSERT_EQUAL_MESSAGE("Y top-row blended", 235, dst_y[x]); + ASSERT_EQUAL_MESSAGE("Y second-row blended", 235, dst_y[DW + x]); + } + for (int x = OW; x < DW; x++) { + ASSERT_EQUAL_MESSAGE("Y row 0 right untouched", (uint8_t)(50 + x), dst_y[x]); + ASSERT_EQUAL_MESSAGE("Y row 1 right untouched", (uint8_t)(50 + DW + x), dst_y[DW + x]); + } + for (int x = 0; x < DW; x++) { + ASSERT_EQUAL_MESSAGE("Y row 2 untouched", (uint8_t)(50 + 2*DW + x), dst_y[2*DW + x]); + ASSERT_EQUAL_MESSAGE("Y row 3 untouched", (uint8_t)(50 + 3*DW + x), dst_y[3*DW + x]); + } + + /* U/V: only the (0,0) chroma sample (covering the blended 2x2 block) + * is touched. The (1,0), (0,1), (1,1) samples must stay at baseline. */ + ASSERT_EQUAL_MESSAGE("U(0,0) blended", 128, dst_u[0]); + ASSERT_EQUAL_MESSAGE("V(0,0) blended", 128, dst_v[0]); + ASSERT_EQUAL_MESSAGE("U(1,0) untouched", 70, dst_u[1]); + ASSERT_EQUAL_MESSAGE("V(1,0) untouched", 180, dst_v[1]); + ASSERT_EQUAL_MESSAGE("U(0,1) untouched", 70, dst_u[2]); + ASSERT_EQUAL_MESSAGE("V(0,1) untouched", 180, dst_v[2]); + ASSERT_EQUAL_MESSAGE("U(1,1) untouched", 70, dst_u[3]); + ASSERT_EQUAL_MESSAGE("V(1,1) untouched", 180, dst_v[3]); + return 0; +} + +/* + * RG48: 16-bit RGB, 6 bytes per pixel little-endian, no color conversion. + * 16-bit overlay components map directly to dst components (just truncate + * 16->16 = pass-through), alpha-blended at full 16-bit precision. + */ +int alpha_blend_test_rg48_alpha_zero(void) +{ + uint8_t dst[6 * 2]; + for (size_t i = 0; i < sizeof dst; i++) dst[i] = (uint8_t)(0x10 + i); + uint8_t orig[6 * 2]; + memcpy(orig, dst, sizeof dst); + + uint16_t src[2 * 4]; + mk_pixel16(src + 0*4, 65535, 0, 65535, 0); /* magenta, alpha=0 */ + mk_pixel16(src + 1*4, 12345, 6789, 1111, 0); /* arbitrary, alpha=0 */ + + alpha_blend_rg48(dst, src, 2); + for (size_t i = 0; i < sizeof dst; i++) + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + return 0; +} + +int alpha_blend_test_rg48_alpha_max_white(void) +{ + uint8_t dst[6 * 2] = {0}; + uint16_t src[2 * 4]; + mk_pixel16(src + 0*4, 65535, 65535, 65535, 65535); + mk_pixel16(src + 1*4, 65535, 0, 0, 65535); + + alpha_blend_rg48(dst, src, 2); + + uint16_t r0, g0, b0, r1, g1, b1; + memcpy(&r0, dst + 0, 2); memcpy(&g0, dst + 2, 2); memcpy(&b0, dst + 4, 2); + memcpy(&r1, dst + 6, 2); memcpy(&g1, dst + 8, 2); memcpy(&b1, dst + 10, 2); + ASSERT_EQUAL_MESSAGE("p0 R", 65535, r0); + ASSERT_EQUAL_MESSAGE("p0 G", 65535, g0); + ASSERT_EQUAL_MESSAGE("p0 B", 65535, b0); + ASSERT_EQUAL_MESSAGE("p1 R", 65535, r1); + ASSERT_EQUAL_MESSAGE("p1 G", 0, g1); + ASSERT_EQUAL_MESSAGE("p1 B", 0, b1); + return 0; +} + +/* v210 component extraction at 10-bit, low-bit shift positions 0/10/20. */ +#define V210_GET(w, shift) (((w) >> (shift)) & 0x3FFu) + +int alpha_blend_test_v210_alpha_zero(void) +{ + /* alpha_blend_v210 strips bits 30-31 (v210 padding) on round-trip, + * so dst must be initialised with valid v210 (zero padding) for the + * "byte unchanged" assertion to be meaningful. */ + uint8_t dst[16]; + uint32_t w[4] = { + (100u << 0) | (200u << 10) | (300u << 20), + (400u << 0) | (500u << 10) | (600u << 20), + (700u << 0) | (800u << 10) | (900u << 20), + (150u << 0) | (250u << 10) | (350u << 20), + }; + memcpy(dst, w, sizeof dst); + uint8_t orig[16]; memcpy(orig, dst, sizeof dst); + uint16_t src[24]; + for (int i = 0; i < 6; i++) { + mk_pixel16(src + i*4, 65535, 65535, 65535, 0); + } + alpha_blend_v210(dst, src, 6); + for (size_t i = 0; i < sizeof dst; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + } + return 0; +} + +/* White overlay at alpha=max -> 10-bit limited Y=940, Cb=Cr=512. */ +int alpha_blend_test_v210_alpha_max_white(void) +{ + uint8_t dst[16] = {0}; + uint16_t src[24]; + for (int i = 0; i < 6; i++) { + mk_pixel16(src + i*4, 65535, 65535, 65535, 65535); + } + alpha_blend_v210(dst, src, 6); + + uint32_t w[4]; + memcpy(w, dst, sizeof w); + + /* Word layout: 0=Cb0 Y0 Cr0 1=Y1 Cb1 Y2 2=Cr1 Y3 Cb2 3=Y4 Cr2 Y5 */ + int y[6] = { V210_GET(w[0], 10), V210_GET(w[1], 0), V210_GET(w[1], 20), + V210_GET(w[2], 10), V210_GET(w[3], 0), V210_GET(w[3], 20) }; + int cb[3] = { V210_GET(w[0], 0), V210_GET(w[1], 10), V210_GET(w[2], 20) }; + int cr[3] = { V210_GET(w[0], 20), V210_GET(w[2], 0), V210_GET(w[3], 10) }; + + for (int i = 0; i < 6; i++) ASSERT_EQUAL_MESSAGE("Y", 940, y[i]); + for (int i = 0; i < 3; i++) ASSERT_EQUAL_MESSAGE("Cb", 512, cb[i]); + for (int i = 0; i < 3; i++) ASSERT_EQUAL_MESSAGE("Cr", 512, cr[i]); + for (int i = 0; i < 4; i++) ASSERT_EQUAL_MESSAGE("padding", 0u, w[i] >> 30); + return 0; +} + +/* R10k: see alpha_blend_r10k in src/utils/alpha_blend.c for layout. */ + +int alpha_blend_test_r10k_alpha_zero(void) +{ + /* Pad bits (24-25) must be 0b11 for valid R10k; the rest is arbitrary. */ + uint8_t dst[8] = {0xAA, 0xBB, 0xCC, 0x03, 0x11, 0x22, 0x33, 0x03}; + uint8_t orig[8]; memcpy(orig, dst, sizeof dst); + uint16_t src[8]; + mk_pixel16(src + 0, 65535, 65535, 65535, 0); + mk_pixel16(src + 4, 65535, 0, 0, 0); + alpha_blend_r10k(dst, src, 2); + for (size_t i = 0; i < sizeof dst; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + } + return 0; +} + +/* White overlay at alpha=max -> r=g=b=1023, padding=0x3. All 4 bytes 0xFF. */ +int alpha_blend_test_r10k_alpha_max_white(void) +{ + uint8_t dst[4] = {0, 0, 0, 0}; + uint16_t src[4]; + mk_pixel16(src, 65535, 65535, 65535, 65535); + alpha_blend_r10k(dst, src, 1); + + uint32_t w; memcpy(&w, dst, 4); + unsigned r = ((w >> 0) & 0xFFu) << 2 | ((w >> 14) & 0x3u); + unsigned g = ((w >> 8) & 0x3Fu) << 4 | ((w >> 20) & 0xFu); + unsigned b = ((w >> 16) & 0xFu) << 6 | ((w >> 26) & 0x3Fu); + unsigned pad = (w >> 24) & 0x3u; + ASSERT_EQUAL_MESSAGE("R", 1023u, r); + ASSERT_EQUAL_MESSAGE("G", 1023u, g); + ASSERT_EQUAL_MESSAGE("B", 1023u, b); + ASSERT_EQUAL_MESSAGE("pad", 0x3u, pad); + return 0; +} + +/* R12L: see alpha_blend_r12l in src/utils/alpha_blend.c for layout. */ + +/* Unpack a 12-bit pixel from a 9-byte R12L sub-block. */ +static void r12l_unpack_pixel(const uint8_t *block, int pixel, + unsigned *r, unsigned *g, unsigned *b) +{ + if (pixel == 0) { + *r = block[0] | ((block[1] & 0xFu) << 8); + *g = (block[1] >> 4) | (block[2] << 4); + *b = block[3] | ((block[4] & 0xFu) << 8); + } else { + *r = (block[4] >> 4) | (block[5] << 4); + *g = block[6] | ((block[7] & 0xFu) << 8); + *b = (block[7] >> 4) | (block[8] << 4); + } +} + +int alpha_blend_test_r12l_alpha_zero(void) +{ + /* R12L has no padding bits; arbitrary fill is valid. */ + uint8_t dst[36]; + for (size_t i = 0; i < sizeof dst; i++) dst[i] = 0xAAu; + uint8_t orig[36]; memcpy(orig, dst, sizeof dst); + uint16_t src[8 * 4]; + for (int i = 0; i < 8; i++) { + mk_pixel16(src + i*4, 65535, 65535, 65535, 0); + } + alpha_blend_r12l(dst, src, 8); + for (size_t i = 0; i < sizeof dst; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], dst[i]); + } + return 0; +} + +/* + * Asymmetric per-pixel values verify the byte layout: each pixel's r/g/b + * differ from neighbours, so a stride or pair-order bug would mis-place + * a value. Spot-checks block 0 P0+P1 and block 3 P1 (last pixel). + */ +int alpha_blend_test_r12l_alpha_max_white(void) +{ + uint8_t dst[36] = {0}; + uint16_t src[8 * 4]; + for (int i = 0; i < 8; i++) { + /* 16-bit values that down-shift by 4 to recognisable 12-bit + * triples: pixel i gets r=0x100*(i+1), g=0x010*(i+1), b=0x001*(i+1). */ + mk_pixel16(src + i*4, + (0x100u * (i + 1)) << 4, + (0x010u * (i + 1)) << 4, + (0x001u * (i + 1)) << 4, + 65535); + } + alpha_blend_r12l(dst, src, 8); + + unsigned r, g, b; + r12l_unpack_pixel(dst + 0, 0, &r, &g, &b); + ASSERT_EQUAL_MESSAGE("blk0 P0 R", 0x100u, r); + ASSERT_EQUAL_MESSAGE("blk0 P0 G", 0x010u, g); + ASSERT_EQUAL_MESSAGE("blk0 P0 B", 0x001u, b); + + r12l_unpack_pixel(dst + 0, 1, &r, &g, &b); + ASSERT_EQUAL_MESSAGE("blk0 P1 R", 0x200u, r); + ASSERT_EQUAL_MESSAGE("blk0 P1 G", 0x020u, g); + ASSERT_EQUAL_MESSAGE("blk0 P1 B", 0x002u, b); + + r12l_unpack_pixel(dst + 27, 1, &r, &g, &b); /* last pixel of block 3 */ + ASSERT_EQUAL_MESSAGE("blk3 P1 R", 0x800u, r); + ASSERT_EQUAL_MESSAGE("blk3 P1 G", 0x080u, g); + ASSERT_EQUAL_MESSAGE("blk3 P1 B", 0x008u, b); + return 0; +} diff --git a/test/test_alpha_blend.h b/test/test_alpha_blend.h new file mode 100644 index 0000000000..d7e5e25e85 --- /dev/null +++ b/test/test_alpha_blend.h @@ -0,0 +1,67 @@ +/** + * @file test/test_alpha_blend.h + * @author Ben Roeder + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef TEST_ALPHA_BLEND_H_98D2F4A0_3E81_4B6F_9C2A_5D8E1B7F0A12 +#define TEST_ALPHA_BLEND_H_98D2F4A0_3E81_4B6F_9C2A_5D8E1B7F0A12 + +/* Returns 0 on success, negative on failure. Matches the run_tests.c contract. */ +int alpha_blend_test_rgba_alpha_zero(void); +int alpha_blend_test_rgba_alpha_max(void); +int alpha_blend_test_rgba_half_alpha(void); +int alpha_blend_test_rgb_alpha_zero(void); +int alpha_blend_test_rgb_alpha_max(void); +int alpha_blend_test_uyvy_alpha_zero(void); +int alpha_blend_test_uyvy_alpha_max_white(void); +int alpha_blend_test_uyvy_alpha_max_red(void); +int alpha_blend_test_yuyv_alpha_zero(void); +int alpha_blend_test_yuyv_alpha_max_white(void); +int alpha_blend_test_yuyv_alpha_max_red(void); +int alpha_blend_test_y416_alpha_zero(void); +int alpha_blend_test_y416_alpha_max_white(void); +int alpha_blend_test_i420_alpha_zero(void); +int alpha_blend_test_i420_alpha_max_white(void); +int alpha_blend_test_i420_chroma_alpha_averaging(void); +int alpha_blend_test_i420_subregion_strides(void); +int alpha_blend_test_rg48_alpha_zero(void); +int alpha_blend_test_rg48_alpha_max_white(void); +int alpha_blend_test_v210_alpha_zero(void); +int alpha_blend_test_v210_alpha_max_white(void); +int alpha_blend_test_r10k_alpha_zero(void); +int alpha_blend_test_r10k_alpha_max_white(void); +int alpha_blend_test_r12l_alpha_zero(void); +int alpha_blend_test_r12l_alpha_max_white(void); + +#endif // defined TEST_ALPHA_BLEND_H_* diff --git a/test/test_alpha_blend_rapidcheck.cpp b/test/test_alpha_blend_rapidcheck.cpp new file mode 100644 index 0000000000..f1d04ef09d --- /dev/null +++ b/test/test_alpha_blend_rapidcheck.cpp @@ -0,0 +1,338 @@ +/** + * @file test/test_alpha_blend_rapidcheck.cpp + * @author Ben Roeder + * @brief Property-based tests for utils/alpha_blend.c — every codec. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#include +#include +#include + +extern "C" { +#include "utils/alpha_blend.h" +} + +namespace { + +/* Generate a 16-bit RGBA overlay row with all alphas forced to a chosen + * value. Component layout matches alpha_blend.h: 4 uint16_t per pixel + * (R, G, B, A). */ +std::vector rgba16_with_alpha(int width, uint16_t alpha) +{ + auto pixels = *rc::gen::container>( + width * 4U, rc::gen::arbitrary()); + for (int i = 0; i < width; i++) pixels[i * 4 + 3] = alpha; + return pixels; +} + +std::vector dst_bytes(size_t n) +{ + return *rc::gen::container>( + n, rc::gen::arbitrary()); +} + +/* Place the dst buffer between two pages of guard bytes; assert the + * guards are untouched after the blend. Catches off-by-one writes that + * a strict bounds check would miss. */ +struct guarded_buffer { + static constexpr size_t GUARD = 32; + std::vector buf; /* size = GUARD + payload + GUARD */ + size_t payload_size; + + explicit guarded_buffer(size_t payload) + : buf(GUARD + payload + GUARD, 0xAB) + , payload_size(payload) + { + /* Random payload bytes; guards stay 0xAB. */ + auto fill = *rc::gen::container>( + payload, rc::gen::arbitrary()); + std::memcpy(buf.data() + GUARD, fill.data(), payload); + } + + uint8_t *dst() { return buf.data() + GUARD; } + + bool guards_intact() const + { + for (size_t i = 0; i < GUARD; i++) { + if (buf[i] != 0xAB) return false; + if (buf[GUARD + payload_size + i] != 0xAB) return false; + } + return true; + } +}; + +/* width range used by the per-codec generators. Wide enough to exercise + * group packing (R12L 8-pixel groups, v210 6-pixel groups) without + * blowing test time. */ +auto gen_width() { return rc::gen::inRange(1, 256); } +auto gen_width_even() { return rc::gen::map(rc::gen::inRange(1, 128), [](int n){ return n*2; }); } +auto gen_width_v210() { return rc::gen::map(rc::gen::inRange(1, 32), [](int n){ return n*6; }); } +auto gen_width_r12l() { return rc::gen::map(rc::gen::inRange(1, 32), [](int n){ return n*8; }); } +auto gen_width_i420() { return rc::gen::map(rc::gen::inRange(1, 64), [](int n){ return n*2; }); } +auto gen_height_i420() { return rc::gen::map(rc::gen::inRange(1, 64), [](int n){ return n*2; }); } + +/* Bytes per pixel for each packed codec. */ +constexpr int BPP_RGBA = 4; +constexpr int BPP_RGB = 3; +constexpr int BPP_UYVY = 2; +constexpr int BPP_YUYV = 2; +constexpr int BPP_Y416 = 8; +constexpr int BPP_R10K = 4; +constexpr int BPP_RG48 = 6; + +inline int bytes_v210(int width) { return (width / 6) * 16; } +inline int bytes_r12l(int width) { return (width / 8) * 36; } + +/* Run an alpha=0 identity check for a single-plane blend. + * + * For codecs without format-reserved bits the property is "byte-identical + * dst, including the very first call". This is the property the original + * RapidCheck integration found (and fixed) the RGBA dst[3] clobber with; + * the canonicalize-then-no-op variant alone would not have caught it. + * + * For codecs with reserved bits (R10k pad, v210 bits 30-31) the first + * call legally canonicalises those bits — pixel data is preserved but + * byte representation isn't. The two-call variant verifies a converged + * dst stays converged. */ +template +bool check_alpha_zero(const char *label, int bytes_per_row, + Blend blend, decltype(gen_width()) width_gen) +{ + return rc::check(label, [=]() { + const int w = *width_gen; + const auto src = rgba16_with_alpha(w, 0); + guarded_buffer g(w * bytes_per_row); + std::vector before(g.dst(), + g.dst() + g.payload_size); + blend(g.dst(), src.data(), w); + std::vector after(g.dst(), + g.dst() + g.payload_size); + RC_ASSERT(before == after); + RC_ASSERT(g.guards_intact()); + }); +} + +/* Two-call variant for codecs with format-reserved bits. */ +template +bool check_alpha_zero_canonical(const char *label, int bytes_per_row, + Blend blend, + decltype(gen_width()) width_gen) +{ + return rc::check(label, [=]() { + const int w = *width_gen; + const auto src = rgba16_with_alpha(w, 0); + guarded_buffer g(w * bytes_per_row); + blend(g.dst(), src.data(), w); + std::vector canonical(g.dst(), + g.dst() + g.payload_size); + blend(g.dst(), src.data(), w); + std::vector after(g.dst(), + g.dst() + g.payload_size); + RC_ASSERT(canonical == after); + RC_ASSERT(g.guards_intact()); + }); +} + +/* No-overrun + bounds check (every output byte stays in-range, which + * for uint8_t is automatic — the meaningful check is the guard pages). */ +template +bool check_no_overrun(const char *label, int bytes_per_row, + Blend blend, decltype(gen_width()) width_gen) +{ + return rc::check(label, [=]() { + const int w = *width_gen; + const auto src = rgba16_with_alpha(w, + *rc::gen::arbitrary()); + guarded_buffer g(w * bytes_per_row); + blend(g.dst(), src.data(), w); + RC_ASSERT(g.guards_intact()); + }); +} + +/* Monotonicity in alpha: increasing the source alpha can only move dst + * monotonically toward the (codec-specific) src representation. For all + * codecs this means: with low alpha, |dst - src_repr| >= |dst' - src_repr| + * where dst' is the result with higher alpha. We don't have src_repr + * cheaply for converted codecs; instead check the RGB-only codecs where + * dst byte is a direct blend of src component and original dst. */ +bool check_rgba_monotonicity() +{ + return rc::check("alpha_blend_rgba: dst moves monotonically with alpha", + []() { + const int w = *gen_width(); + /* Independent RGB; alpha bumped from a_lo to a_hi. */ + auto src = rgba16_with_alpha(w, 0); + for (int i = 0; i < w; i++) { + src[i*4+0] = *rc::gen::arbitrary(); + src[i*4+1] = *rc::gen::arbitrary(); + src[i*4+2] = *rc::gen::arbitrary(); + } + const uint16_t a_lo = *rc::gen::inRange(0, 32768); + const uint16_t a_hi = *rc::gen::inRange(32768, 65536); + const auto dst_orig = dst_bytes(w * BPP_RGBA); + + std::vector dst_lo = dst_orig; + std::vector dst_hi = dst_orig; + for (int i = 0; i < w; i++) src[i*4+3] = a_lo; + alpha_blend_rgba(dst_lo.data(), src.data(), w); + for (int i = 0; i < w; i++) src[i*4+3] = a_hi; + alpha_blend_rgba(dst_hi.data(), src.data(), w); + + /* d_lo and d_hi both lie on the segment [d_orig, s_byte]; + * d_hi must be at least as close to s_byte as d_lo. */ + for (int i = 0; i < w; i++) { + for (int c = 0; c < 3; c++) { + const int s_byte = src[i*4+c] >> 8; + const int dist_lo = + std::abs(int(dst_lo[i*4+c]) - s_byte); + const int dist_hi = + std::abs(int(dst_hi[i*4+c]) - s_byte); + RC_ASSERT(dist_hi <= dist_lo); + } + } + }); +} + +} // namespace + +bool test_alpha_blend_properties() +{ + bool ok = true; + + /* alpha=0 identity — the bug-finder per the original RapidCheck + * integration (it caught dst[3] being clobbered by RGBA blend). */ + ok &= check_alpha_zero("alpha_blend_rgba: alpha=0 preserves dst", + BPP_RGBA, alpha_blend_rgba, gen_width()); + ok &= check_alpha_zero("alpha_blend_rgb: alpha=0 preserves dst", + BPP_RGB, alpha_blend_rgb, gen_width()); + ok &= check_alpha_zero("alpha_blend_uyvy: alpha=0 preserves dst", + BPP_UYVY, alpha_blend_uyvy, gen_width_even()); + ok &= check_alpha_zero("alpha_blend_yuyv: alpha=0 preserves dst", + BPP_YUYV, alpha_blend_yuyv, gen_width_even()); + ok &= check_alpha_zero("alpha_blend_y416: alpha=0 preserves dst", + BPP_Y416, alpha_blend_y416, gen_width()); + /* R10k always packs the 0x3 reserved pad bits even with alpha=0 + * — use the canonicalize-then-no-op variant. */ + ok &= check_alpha_zero_canonical( + "alpha_blend_r10k: alpha=0 preserves dst (canonical)", + BPP_R10K, alpha_blend_r10k, gen_width()); + ok &= check_alpha_zero("alpha_blend_rg48: alpha=0 preserves dst", + BPP_RG48, alpha_blend_rg48, gen_width()); + + /* v210: 6-pixel groups (16 bytes/group); reserved bits 30-31 + * always pack to zero, so use the canonicalize-then-no-op + * pattern. Inline because the helper templates assume + * bytes-per-pixel rather than bytes-per-group. */ + ok &= rc::check("alpha_blend_v210: alpha=0 preserves dst (canonical)", + []() { + const int w = *gen_width_v210(); + const auto src = rgba16_with_alpha(w, 0); + guarded_buffer g(bytes_v210(w)); + alpha_blend_v210(g.dst(), src.data(), w); + std::vector canonical(g.dst(), + g.dst() + g.payload_size); + alpha_blend_v210(g.dst(), src.data(), w); + std::vector after(g.dst(), + g.dst() + g.payload_size); + RC_ASSERT(canonical == after); + RC_ASSERT(g.guards_intact()); + }); + + /* R12L: 8-pixel groups (36 bytes), partial trailing pairs OK. No + * reserved bits — strict byte identity holds. */ + ok &= rc::check("alpha_blend_r12l: alpha=0 preserves dst", []() { + const int w = *gen_width_r12l(); + const auto src = rgba16_with_alpha(w, 0); + guarded_buffer g(bytes_r12l(w)); + std::vector before(g.dst(), + g.dst() + g.payload_size); + alpha_blend_r12l(g.dst(), src.data(), w); + std::vector after(g.dst(), + g.dst() + g.payload_size); + RC_ASSERT(before == after); + RC_ASSERT(g.guards_intact()); + }); + + /* I420 alpha=0 identity across all three planes. */ + ok &= rc::check("alpha_blend_i420: alpha=0 preserves all planes", []() { + const int w = *gen_width_i420(); + const int h = *gen_height_i420(); + const auto src = [&]() { + auto v = *rc::gen::container>( + static_cast(w) * h * 4, + rc::gen::arbitrary()); + for (int i = 0; i < w * h; i++) v[i * 4 + 3] = 0; + return v; + }(); + guarded_buffer y_g(static_cast(w) * h); + guarded_buffer u_g(static_cast(w / 2) * (h / 2)); + guarded_buffer v_g(static_cast(w / 2) * (h / 2)); + std::vector y0(y_g.dst(), y_g.dst() + y_g.payload_size); + std::vector u0(u_g.dst(), u_g.dst() + u_g.payload_size); + std::vector v0(v_g.dst(), v_g.dst() + v_g.payload_size); + alpha_blend_i420(y_g.dst(), w, u_g.dst(), v_g.dst(), w / 2, + src.data(), w, w, h); + std::vector y1(y_g.dst(), y_g.dst() + y_g.payload_size); + std::vector u1(u_g.dst(), u_g.dst() + u_g.payload_size); + std::vector v1(v_g.dst(), v_g.dst() + v_g.payload_size); + RC_ASSERT(y0 == y1); + RC_ASSERT(u0 == u1); + RC_ASSERT(v0 == v1); + RC_ASSERT(y_g.guards_intact() && u_g.guards_intact() + && v_g.guards_intact()); + }); + + /* Per-codec no-overrun. */ + ok &= check_no_overrun("alpha_blend_rgba: no buffer overrun", + BPP_RGBA, alpha_blend_rgba, gen_width()); + ok &= check_no_overrun("alpha_blend_rgb: no buffer overrun", + BPP_RGB, alpha_blend_rgb, gen_width()); + ok &= check_no_overrun("alpha_blend_uyvy: no buffer overrun", + BPP_UYVY, alpha_blend_uyvy, gen_width_even()); + ok &= check_no_overrun("alpha_blend_yuyv: no buffer overrun", + BPP_YUYV, alpha_blend_yuyv, gen_width_even()); + ok &= check_no_overrun("alpha_blend_y416: no buffer overrun", + BPP_Y416, alpha_blend_y416, gen_width()); + ok &= check_no_overrun("alpha_blend_r10k: no buffer overrun", + BPP_R10K, alpha_blend_r10k, gen_width()); + ok &= check_no_overrun("alpha_blend_rg48: no buffer overrun", + BPP_RG48, alpha_blend_rg48, gen_width()); + + ok &= check_rgba_monotonicity(); + + return ok; +} diff --git a/test/test_file_monitoring_rapidcheck.cpp b/test/test_file_monitoring_rapidcheck.cpp new file mode 100644 index 0000000000..808ce4e0f7 --- /dev/null +++ b/test/test_file_monitoring_rapidcheck.cpp @@ -0,0 +1,177 @@ +/** + * @file test/test_file_monitoring_rapidcheck.cpp + * @author Ben Roeder + * @brief Property-based tests for utils/overlay_watch.c. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "utils/overlay_watch.h" +} + +namespace { + +/* Local tempfile RAII. The project's get_temp_file() in utils/fs.h is + * the canonical helper (and is used by the unit-test sibling + * test_overlay_watch.c), but linking fs.o pulls in transitive deps + * (log_msg, strrpbrk, get_ug_data_path) that would balloon the test + * binary. Test runs on POSIX dev machines only, so mkstemp suffices. */ +struct tempfile { + std::string path; + tempfile() + : path("/tmp/ug-overlay-watch-XXXXXX") + { + std::vector buf(path.begin(), path.end()); + buf.push_back('\0'); + int fd = mkstemp(buf.data()); + if (fd < 0) { + path.clear(); + return; + } + close(fd); + path = buf.data(); + } + ~tempfile() { if (!path.empty()) unlink(path.c_str()); } + tempfile(const tempfile &) = delete; + tempfile &operator=(const tempfile &) = delete; + + void write_bytes(const std::vector &bytes) const + { + FILE *f = fopen(path.c_str(), "wb"); + RC_ASSERT(f != nullptr); + const size_t n = fwrite(bytes.data(), 1, bytes.size(), f); + fclose(f); + RC_ASSERT(n == bytes.size()); + } +}; + +std::vector gen_bytes(size_t min_n = 1, size_t max_n = 64) +{ + const size_t n = static_cast(*rc::gen::inRange( + static_cast(min_n), static_cast(max_n + 1))); + return *rc::gen::container>( + n, rc::gen::arbitrary()); +} + +} // namespace + +bool test_overlay_watch_properties() +{ + bool ok = true; + + /* Init + immediate change-check on the same file: nothing changed, + * so changed() must be false. */ + ok &= rc::check("watch: init then changed() == false", []() { + tempfile f; + RC_PRE(!f.path.empty()); + const auto bytes = gen_bytes(1, 32); + f.write_bytes(bytes); + + struct overlay_watch w; + overlay_watch_init(&w, f.path.c_str()); + RC_ASSERT(!overlay_watch_changed(&w, f.path.c_str())); + }); + + /* Size change is detected. We rewrite with a different number of + * bytes; mtime may or may not tick (filesystem resolution), but + * size always differs. */ + ok &= rc::check("watch: size change detected", []() { + tempfile f; + RC_PRE(!f.path.empty()); + const auto a = gen_bytes(1, 16); + const auto b = gen_bytes(17, 64); + f.write_bytes(a); + + struct overlay_watch w; + overlay_watch_init(&w, f.path.c_str()); + f.write_bytes(b); + RC_ASSERT(overlay_watch_changed(&w, f.path.c_str())); + }); + + /* ack() commits the new fingerprint, so a subsequent changed() + * with no further edit returns false. */ + ok &= rc::check("watch: ack clears the change", []() { + tempfile f; + RC_PRE(!f.path.empty()); + f.write_bytes(gen_bytes(1, 8)); + + struct overlay_watch w; + overlay_watch_init(&w, f.path.c_str()); + f.write_bytes(gen_bytes(9, 32)); + RC_ASSERT(overlay_watch_changed(&w, f.path.c_str())); + overlay_watch_ack(&w, f.path.c_str()); + RC_ASSERT(!overlay_watch_changed(&w, f.path.c_str())); + }); + + /* Missing file at init: watch is invalid; the first time the file + * appears, changed() returns true. */ + ok &= rc::check("watch: missing-then-appears triggers change", []() { + tempfile f; + RC_PRE(!f.path.empty()); + /* Delete it before init so the watch starts invalid. */ + unlink(f.path.c_str()); + + struct overlay_watch w; + overlay_watch_init(&w, f.path.c_str()); + /* No file: changed() returns false (transient, per docs). */ + RC_ASSERT(!overlay_watch_changed(&w, f.path.c_str())); + f.write_bytes(gen_bytes(1, 16)); + RC_ASSERT(overlay_watch_changed(&w, f.path.c_str())); + }); + + /* fingerprint() gives a stable answer for an unchanged file. */ + ok &= rc::check("watch: fingerprint stable when file unchanged", []() { + tempfile f; + RC_PRE(!f.path.empty()); + f.write_bytes(gen_bytes(1, 16)); + + int64_t m1 = 0, s1 = 0, m2 = 0, s2 = 0; + RC_ASSERT(overlay_watch_fingerprint(f.path.c_str(), &m1, &s1)); + RC_ASSERT(overlay_watch_fingerprint(f.path.c_str(), &m2, &s2)); + RC_ASSERT(m1 == m2); + RC_ASSERT(s1 == s2); + }); + + return ok; +} diff --git a/test/test_overlay_config.c b/test/test_overlay_config.c new file mode 100644 index 0000000000..b8a3375aea --- /dev/null +++ b/test/test_overlay_config.c @@ -0,0 +1,310 @@ +/** + * @file test/test_overlay_config.c + * @author Ben Roeder + * @brief Unit tests for utils/overlay_config.c + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include + +#include "test_overlay_config.h" +#include "unit_common.h" +#include "utils/overlay_config.h" + +/* file=PATH alone: position defaults to center; help is false. */ +int overlay_config_test_minimal_file_only(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("parse", + overlay_config_parse("file=/tmp/x.pam", &c)); + ASSERT_MESSAGE("file matches", + strcmp(c.file, "/tmp/x.pam") == 0); + ASSERT_EQUAL_MESSAGE("position default", + OVERLAY_POS_CENTER, (int)c.position); + ASSERT_MESSAGE("not help", !c.help); + return 0; +} + +/* All five preset position keywords map to their enum values. */ +int overlay_config_test_position_keywords(void) +{ + struct overlay_config c; + const struct { + const char *kw; + enum overlay_position pos; + } cases[] = { + {"center", OVERLAY_POS_CENTER}, + {"top_left", OVERLAY_POS_TOP_LEFT}, + {"top_right", OVERLAY_POS_TOP_RIGHT}, + {"bottom_left", OVERLAY_POS_BOTTOM_LEFT}, + {"bottom_right", OVERLAY_POS_BOTTOM_RIGHT}, + }; + for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) { + char buf[64]; + snprintf(buf, sizeof buf, "file=a.pam:position=%s", + cases[i].kw); + ASSERT_MESSAGE(cases[i].kw, overlay_config_parse(buf, &c)); + ASSERT_EQUAL_MESSAGE(cases[i].kw, (int)cases[i].pos, + (int)c.position); + } + return 0; +} + +/* custom_x / custom_y promote position to OVERLAY_POS_CUSTOM and parse signed + * integers (negative = from-edge). */ +int overlay_config_test_custom_xy(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("parse", + overlay_config_parse( + "file=a.pam:custom_x=10:custom_y=-20", &c)); + ASSERT_EQUAL_MESSAGE("position", OVERLAY_POS_CUSTOM, (int)c.position); + ASSERT_EQUAL_MESSAGE("x", 10, c.custom_x); + ASSERT_EQUAL_MESSAGE("y", -20, c.custom_y); + return 0; +} + +/* "help" alone is allowed with no file (the postprocessor prints usage). */ +int overlay_config_test_help(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("parse help", overlay_config_parse("help", &c)); + ASSERT_MESSAGE("help flag", c.help); + return 0; +} + +int overlay_config_test_rejects_missing_file(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("no file: reject", + !overlay_config_parse("position=center", &c)); + return 0; +} + +int overlay_config_test_rejects_unknown_key(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("typo: reject", + !overlay_config_parse("file=a.pam:positon=center", &c)); + return 0; +} + +int overlay_config_test_rejects_bad_position(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("middle: reject", + !overlay_config_parse("file=a.pam:position=middle", &c)); + return 0; +} + +int overlay_config_test_rejects_non_integer_xy(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("alpha x: reject", + !overlay_config_parse("file=a.pam:custom_x=abc", &c)); + ASSERT_MESSAGE("trailing junk: reject", + !overlay_config_parse("file=a.pam:custom_x=10x", &c)); + return 0; +} + +int overlay_config_test_rejects_null_and_empty_value(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("NULL opts: reject", !overlay_config_parse(NULL, &c)); + ASSERT_MESSAGE("empty position value: reject", + !overlay_config_parse("file=a.pam:position=", &c)); + ASSERT_MESSAGE("empty file value: reject", + !overlay_config_parse("file=", &c)); + return 0; +} + +int overlay_config_test_soft_edge(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("parse", + overlay_config_parse("file=a.pam:soft_edge=12", &c)); + ASSERT_EQUAL_MESSAGE("soft_edge", 12, c.soft_edge); + + ASSERT_MESSAGE("default zero", + overlay_config_parse("file=a.pam", &c)); + ASSERT_EQUAL_MESSAGE("default", 0, c.soft_edge); + + ASSERT_MESSAGE("negative rejected", + !overlay_config_parse("file=a.pam:soft_edge=-3", &c)); + return 0; +} + +int overlay_config_test_scale(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("parse 320x240", + overlay_config_parse("file=a.pam:scale=320x240", &c)); + ASSERT_EQUAL_MESSAGE("scale_w", 320, c.scale_w); + ASSERT_EQUAL_MESSAGE("scale_h", 240, c.scale_h); + + ASSERT_MESSAGE("default zero", + overlay_config_parse("file=a.pam", &c)); + ASSERT_EQUAL_MESSAGE("default w", 0, c.scale_w); + ASSERT_EQUAL_MESSAGE("default h", 0, c.scale_h); + + ASSERT_MESSAGE("missing 'x' rejected", + !overlay_config_parse("file=a.pam:scale=320", &c)); + ASSERT_MESSAGE("zero rejected", + !overlay_config_parse("file=a.pam:scale=0x240", &c)); + ASSERT_MESSAGE("negative rejected", + !overlay_config_parse("file=a.pam:scale=-10x240", &c)); + return 0; +} + +/* scale=frame parses, sets scale_to_frame=true, and zeroes the + * numeric scale dimensions. */ +int overlay_config_test_scale_frame(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("parse scale=frame", + overlay_config_parse("file=a.pam:scale=frame", &c)); + ASSERT_MESSAGE("scale_to_frame set", c.scale_to_frame); + ASSERT_EQUAL_MESSAGE("scale_w zero", 0, c.scale_w); + ASSERT_EQUAL_MESSAGE("scale_h zero", 0, c.scale_h); + + ASSERT_MESSAGE("default off", + overlay_config_parse("file=a.pam", &c)); + ASSERT_MESSAGE("scale_to_frame default false", !c.scale_to_frame); + return 0; +} + +/* Last-one-wins between scale=WxH and scale=frame. */ +int overlay_config_test_scale_frame_overrides_wxh(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("frame after WxH", + overlay_config_parse( + "file=a.pam:scale=320x240:scale=frame", &c)); + ASSERT_MESSAGE("scale_to_frame set", c.scale_to_frame); + ASSERT_EQUAL_MESSAGE("scale_w cleared", 0, c.scale_w); + ASSERT_EQUAL_MESSAGE("scale_h cleared", 0, c.scale_h); + + ASSERT_MESSAGE("WxH after frame", + overlay_config_parse( + "file=a.pam:scale=frame:scale=320x240", &c)); + ASSERT_MESSAGE("scale_to_frame cleared", !c.scale_to_frame); + ASSERT_EQUAL_MESSAGE("scale_w set", 320, c.scale_w); + ASSERT_EQUAL_MESSAGE("scale_h set", 240, c.scale_h); + return 0; +} + +int overlay_config_test_perf(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("parse perf flag", + overlay_config_parse("file=a.pam:perf", &c)); + ASSERT_MESSAGE("perf set", c.perf); + + ASSERT_MESSAGE("default off", + overlay_config_parse("file=a.pam", &c)); + ASSERT_MESSAGE("perf default false", !c.perf); + return 0; +} + +int overlay_config_test_scale_filter(void) +{ + struct overlay_config c; + ASSERT_MESSAGE("default bicubic", + overlay_config_parse("file=a.pam", &c)); + ASSERT_EQUAL_MESSAGE("default", OVERLAY_SCALE_BICUBIC, (int)c.scale_filter); + + const struct { + const char *name; + enum overlay_scale_filter expected; + } cases[] = { + {"nearest", OVERLAY_SCALE_NEAREST}, + {"fast_bilinear", OVERLAY_SCALE_FAST_BILINEAR}, + {"bilinear", OVERLAY_SCALE_BILINEAR}, + {"bicubic", OVERLAY_SCALE_BICUBIC}, + {"lanczos", OVERLAY_SCALE_LANCZOS}, + }; + for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) { + char buf[64]; + snprintf(buf, sizeof buf, "file=a.pam:scale_filter=%s", + cases[i].name); + ASSERT_MESSAGE(cases[i].name, overlay_config_parse(buf, &c)); + ASSERT_EQUAL_MESSAGE(cases[i].name, (int)cases[i].expected, + (int)c.scale_filter); + } + ASSERT_MESSAGE("unknown filter rejected", + !overlay_config_parse("file=a.pam:scale_filter=bogus", &c)); + return 0; +} + +int overlay_config_test_blend_threads(void) +{ + struct overlay_config c; + + /* Unset -> auto-default of min(ncpu, 8). On any host that's >= 1. */ + ASSERT_MESSAGE("default auto", + overlay_config_parse("file=a.pam", &c)); + ASSERT_MESSAGE("auto >= 1", c.blend_threads >= 1); + ASSERT_MESSAGE("auto <= 8", c.blend_threads <= 8); + + ASSERT_MESSAGE("explicit 4", + overlay_config_parse("file=a.pam:blend_threads=4", &c)); + ASSERT_EQUAL_MESSAGE("4", 4, c.blend_threads); + + /* User who wants single-threaded passes 1 explicitly. */ + ASSERT_MESSAGE("explicit 1", + overlay_config_parse("file=a.pam:blend_threads=1", &c)); + ASSERT_EQUAL_MESSAGE("1", 1, c.blend_threads); + + ASSERT_MESSAGE("negative rejected", + !overlay_config_parse("file=a.pam:blend_threads=-1", &c)); + return 0; +} + +int overlay_config_test_rejects_oversize_options(void) +{ + char giant[MAX_PATH_SIZE + 512]; + memset(giant, 'a', sizeof giant - 1); + giant[sizeof giant - 1] = '\0'; + memcpy(giant, "file=", 5); + + struct overlay_config c; + ASSERT_MESSAGE("options too long: reject", + !overlay_config_parse(giant, &c)); + return 0; +} diff --git a/test/test_overlay_config.h b/test/test_overlay_config.h new file mode 100644 index 0000000000..19507f5897 --- /dev/null +++ b/test/test_overlay_config.h @@ -0,0 +1,58 @@ +/** + * @file test/test_overlay_config.h + * @author Ben Roeder + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef TEST_OVERLAY_CONFIG_H_3B7E8C2A_5D9F_4A1E_8B6D_4C2F7A3E1D9B +#define TEST_OVERLAY_CONFIG_H_3B7E8C2A_5D9F_4A1E_8B6D_4C2F7A3E1D9B + +int overlay_config_test_minimal_file_only(void); +int overlay_config_test_position_keywords(void); +int overlay_config_test_custom_xy(void); +int overlay_config_test_help(void); +int overlay_config_test_rejects_missing_file(void); +int overlay_config_test_rejects_unknown_key(void); +int overlay_config_test_rejects_bad_position(void); +int overlay_config_test_rejects_non_integer_xy(void); +int overlay_config_test_rejects_null_and_empty_value(void); +int overlay_config_test_rejects_oversize_options(void); +int overlay_config_test_soft_edge(void); +int overlay_config_test_scale(void); +int overlay_config_test_scale_frame(void); +int overlay_config_test_scale_frame_overrides_wxh(void); +int overlay_config_test_perf(void); +int overlay_config_test_scale_filter(void); +int overlay_config_test_blend_threads(void); + +#endif diff --git a/test/test_overlay_layout.c b/test/test_overlay_layout.c new file mode 100644 index 0000000000..d9236a5cea --- /dev/null +++ b/test/test_overlay_layout.c @@ -0,0 +1,200 @@ +/** + * @file test/test_overlay_layout.c + * @author Ben Roeder + * @brief Unit tests for utils/overlay_layout.c + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "test_overlay_layout.h" +#include "unit_common.h" +#include "utils/overlay_layout.h" + +/* Center: a 200x100 overlay in 1920x1080 frame -> (860, 490, 200, 100). */ +int overlay_layout_test_center(void) +{ + struct overlay_rect r = overlay_calc_rect(OVERLAY_POS_CENTER, + 0, 0, 1920, 1080, + 200, 100, 1, 1); + ASSERT_EQUAL_MESSAGE("x", 860, r.x); + ASSERT_EQUAL_MESSAGE("y", 490, r.y); + ASSERT_EQUAL_MESSAGE("width", 200, r.width); + ASSERT_EQUAL_MESSAGE("height", 100, r.height); + return 0; +} + +int overlay_layout_test_corners(void) +{ + struct overlay_rect r; + + r = overlay_calc_rect(OVERLAY_POS_TOP_LEFT, 0, 0, + 1920, 1080, 200, 100, 1, 1); + ASSERT_EQUAL_MESSAGE("TL x", 0, r.x); + ASSERT_EQUAL_MESSAGE("TL y", 0, r.y); + + r = overlay_calc_rect(OVERLAY_POS_TOP_RIGHT, 0, 0, + 1920, 1080, 200, 100, 1, 1); + ASSERT_EQUAL_MESSAGE("TR x", 1720, r.x); + ASSERT_EQUAL_MESSAGE("TR y", 0, r.y); + + r = overlay_calc_rect(OVERLAY_POS_BOTTOM_LEFT, 0, 0, + 1920, 1080, 200, 100, 1, 1); + ASSERT_EQUAL_MESSAGE("BL x", 0, r.x); + ASSERT_EQUAL_MESSAGE("BL y", 980, r.y); + + r = overlay_calc_rect(OVERLAY_POS_BOTTOM_RIGHT, 0, 0, + 1920, 1080, 200, 100, 1, 1); + ASSERT_EQUAL_MESSAGE("BR x", 1720, r.x); + ASSERT_EQUAL_MESSAGE("BR y", 980, r.y); + return 0; +} + +/* + * Custom: positive values are absolute; negative values count from the + * right/bottom edges (so the overlay's right edge sits N pixels from the + * frame's right edge for x = -N). + */ +int overlay_layout_test_custom_negative_from_edge(void) +{ + /* x=-10, y=-10 with overlay 200x100 in 1920x1080: + * x = 1920 + (-10) - 200 = 1710 + * y = 1080 + (-10) - 100 = 970 */ + struct overlay_rect r = overlay_calc_rect(OVERLAY_POS_CUSTOM, + -10, -10, 1920, 1080, + 200, 100, 1, 1); + ASSERT_EQUAL_MESSAGE("x", 1710, r.x); + ASSERT_EQUAL_MESSAGE("y", 970, r.y); + return 0; +} + +/* + * Block alignment: x and width are snapped down to a multiple of block_pixels. + * v210's block is 6 pixels; an overlay at x=863 / width=200 should snap to + * x=858 / width=198. + */ +int overlay_layout_test_block_pixel_alignment(void) +{ + struct overlay_rect r = overlay_calc_rect(OVERLAY_POS_CUSTOM, + 863, 100, 1920, 1080, + 200, 100, 6, 1); + ASSERT_EQUAL_MESSAGE("x snapped", 858, r.x); + ASSERT_EQUAL_MESSAGE("width snapped", 198, r.width); + ASSERT_EQUAL_MESSAGE("y unchanged", 100, r.y); + ASSERT_EQUAL_MESSAGE("height", 100, r.height); + return 0; +} + +/* Overlay larger than frame, top-left positioning: visible rect is the + * overlay's top-left corner; src_x/src_y stay at 0. */ +int overlay_layout_test_overlay_larger_than_frame(void) +{ + struct overlay_rect r = overlay_calc_rect(OVERLAY_POS_TOP_LEFT, + 0, 0, 100, 50, + 200, 200, 1, 1); + ASSERT_EQUAL_MESSAGE("x", 0, r.x); + ASSERT_EQUAL_MESSAGE("y", 0, r.y); + ASSERT_EQUAL_MESSAGE("width", 100, r.width); + ASSERT_EQUAL_MESSAGE("height", 50, r.height); + ASSERT_EQUAL_MESSAGE("src_x", 0, r.src_x); + ASSERT_EQUAL_MESSAGE("src_y", 0, r.src_y); + return 0; +} + +/* Centred 200x200 overlay on a 100x50 frame: visible region maps to the + * centre slice of the overlay, not the top-left corner. */ +int overlay_layout_test_oversized_center(void) +{ + struct overlay_rect r = overlay_calc_rect(OVERLAY_POS_CENTER, + 0, 0, 100, 50, + 200, 200, 1, 1); + ASSERT_EQUAL_MESSAGE("x", 0, r.x); + ASSERT_EQUAL_MESSAGE("y", 0, r.y); + ASSERT_EQUAL_MESSAGE("width", 100, r.width); + ASSERT_EQUAL_MESSAGE("height", 50, r.height); + ASSERT_EQUAL_MESSAGE("src_x", 50, r.src_x); /* (200-100)/2 */ + ASSERT_EQUAL_MESSAGE("src_y", 75, r.src_y); /* (200-50)/2 */ + return 0; +} + +/* Right-aligned 200x200 overlay on a 100x50 frame: visible region is + * the right slice (last 100 columns) and bottom slice (last 50 rows). */ +int overlay_layout_test_oversized_right(void) +{ + struct overlay_rect r = overlay_calc_rect(OVERLAY_POS_BOTTOM_RIGHT, + 0, 0, 100, 50, + 200, 200, 1, 1); + ASSERT_EQUAL_MESSAGE("x", 0, r.x); + ASSERT_EQUAL_MESSAGE("y", 0, r.y); + ASSERT_EQUAL_MESSAGE("width", 100, r.width); + ASSERT_EQUAL_MESSAGE("height", 50, r.height); + ASSERT_EQUAL_MESSAGE("src_x", 100, r.src_x); /* 200-100 */ + ASSERT_EQUAL_MESSAGE("src_y", 150, r.src_y); /* 200-50 */ + return 0; +} + +/* Custom-positive offset on an oversized overlay: the right portion of + * the overlay is clipped, but the visible portion still starts at the + * overlay's column 0 (no slicing on the left). */ +int overlay_layout_test_oversized_custom_positive(void) +{ + struct overlay_rect r = overlay_calc_rect(OVERLAY_POS_CUSTOM, + 20, 10, 100, 50, + 200, 200, 1, 1); + ASSERT_EQUAL_MESSAGE("x", 20, r.x); + ASSERT_EQUAL_MESSAGE("y", 10, r.y); + ASSERT_EQUAL_MESSAGE("width", 80, r.width); /* 100 - 20 */ + ASSERT_EQUAL_MESSAGE("height", 40, r.height); /* 50 - 10 */ + ASSERT_EQUAL_MESSAGE("src_x", 0, r.src_x); + ASSERT_EQUAL_MESSAGE("src_y", 0, r.src_y); + return 0; +} + +/* Vertical block alignment for chroma-subsampled formats: an odd custom_y + * snaps the rect origin down, and an odd remaining height snaps the height + * down too, so the chroma sample 2x2 grid is preserved. */ +int overlay_layout_test_block_lines_alignment(void) +{ + /* y=3 with block_lines=2 -> y=2; height=5 -> 4. */ + struct overlay_rect r = overlay_calc_rect(OVERLAY_POS_CUSTOM, + 10, 3, 1920, 1080, + 100, 5, 2, 2); + ASSERT_EQUAL_MESSAGE("x snapped", 10, r.x); + ASSERT_EQUAL_MESSAGE("y snapped", 2, r.y); + ASSERT_EQUAL_MESSAGE("width snapped", 100, r.width); + ASSERT_EQUAL_MESSAGE("height snapped", 4, r.height); + return 0; +} diff --git a/test/test_overlay_layout.h b/test/test_overlay_layout.h new file mode 100644 index 0000000000..4470a5b797 --- /dev/null +++ b/test/test_overlay_layout.h @@ -0,0 +1,50 @@ +/** + * @file test/test_overlay_layout.h + * @author Ben Roeder + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef TEST_OVERLAY_LAYOUT_H_4D7B2E1A_8C5F_4B3E_A6D9_3F2E5C8A1B4D +#define TEST_OVERLAY_LAYOUT_H_4D7B2E1A_8C5F_4B3E_A6D9_3F2E5C8A1B4D + +int overlay_layout_test_center(void); +int overlay_layout_test_corners(void); +int overlay_layout_test_custom_negative_from_edge(void); +int overlay_layout_test_block_pixel_alignment(void); +int overlay_layout_test_overlay_larger_than_frame(void); +int overlay_layout_test_oversized_center(void); +int overlay_layout_test_oversized_right(void); +int overlay_layout_test_oversized_custom_positive(void); +int overlay_layout_test_block_lines_alignment(void); + +#endif diff --git a/test/test_overlay_pam.c b/test/test_overlay_pam.c new file mode 100644 index 0000000000..084a85d674 --- /dev/null +++ b/test/test_overlay_pam.c @@ -0,0 +1,248 @@ +/** + * @file test/test_overlay_pam.c + * @author Ben Roeder + * @brief Unit tests for utils/overlay_pam.c, registered via run_tests.c + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include +#include +#include +#include + +#include "test_overlay_pam.h" +#include "unit_common.h" +#include "utils/fs.h" +#include "utils/overlay_pam.h" + +/* Write a temp PAM file with the given header + data and return its path via + * *path_out (caller will unlink). Returns true on success. */ +static bool write_temp_pam(const char *header, const unsigned char *data, + size_t data_len, const char **path_out) +{ + FILE *f = get_temp_file(path_out); + if (!f) return false; + const size_t header_len = strlen(header); + if (fwrite(header, 1, header_len, f) != header_len || + fwrite(data, 1, data_len, f) != data_len) { + fclose(f); + unlink(*path_out); + return false; + } + if (fclose(f) != 0) { + unlink(*path_out); + return false; + } + return true; +} + +/* + * 8-bit RGBA PAM (maxval=255) loads with 8-bit values bit-replicated to 16-bit: + * value 0xAB at 8-bit becomes 0xABAB at 16-bit. + */ +int overlay_pam_test_load_8bit_rgba(void) +{ + const char *header = + "P7\n" + "WIDTH 2\n" + "HEIGHT 1\n" + "DEPTH 4\n" + "MAXVAL 255\n" + "TUPLTYPE RGB_ALPHA\n" + "ENDHDR\n"; + const unsigned char data[] = { + 0xFF, 0x00, 0x00, 0xFF, /* opaque red */ + 0xAB, 0xCD, 0xEF, 0x80, /* arbitrary, half-alpha */ + }; + const char *path = NULL; + ASSERT_MESSAGE("write temp file", + write_temp_pam(header, data, sizeof data, &path)); + + uint16_t *out = NULL; + int w = 0, h = 0; + bool ok = overlay_load_pam_rgba16(path, &out, &w, &h); + unlink(path); + + ASSERT_MESSAGE("load returned true", ok); + ASSERT_EQUAL_MESSAGE("width", 2, w); + ASSERT_EQUAL_MESSAGE("height", 1, h); + + ASSERT_EQUAL_MESSAGE("p0 R", 0xFFFFu, (unsigned)out[0]); + ASSERT_EQUAL_MESSAGE("p0 G", 0x0000u, (unsigned)out[1]); + ASSERT_EQUAL_MESSAGE("p0 B", 0x0000u, (unsigned)out[2]); + ASSERT_EQUAL_MESSAGE("p0 A", 0xFFFFu, (unsigned)out[3]); + ASSERT_EQUAL_MESSAGE("p1 R", 0xABABu, (unsigned)out[4]); + ASSERT_EQUAL_MESSAGE("p1 G", 0xCDCDu, (unsigned)out[5]); + ASSERT_EQUAL_MESSAGE("p1 B", 0xEFEFu, (unsigned)out[6]); + ASSERT_EQUAL_MESSAGE("p1 A", 0x8080u, (unsigned)out[7]); + + free(out); + return 0; +} + +/* 3-channel RGB PAM (DEPTH 3, no alpha) gets alpha=65535 added. */ +int overlay_pam_test_load_8bit_rgb_adds_alpha(void) +{ + const char *header = + "P7\n" + "WIDTH 1\n" + "HEIGHT 1\n" + "DEPTH 3\n" + "MAXVAL 255\n" + "TUPLTYPE RGB\n" + "ENDHDR\n"; + const unsigned char data[] = { 0x12, 0x34, 0x56 }; + const char *path = NULL; + ASSERT_MESSAGE("write temp file", + write_temp_pam(header, data, sizeof data, &path)); + + uint16_t *out = NULL; + int w = 0, h = 0; + bool ok = overlay_load_pam_rgba16(path, &out, &w, &h); + unlink(path); + + ASSERT_MESSAGE("load returned true", ok); + ASSERT_EQUAL_MESSAGE("R", 0x1212u, (unsigned)out[0]); + ASSERT_EQUAL_MESSAGE("G", 0x3434u, (unsigned)out[1]); + ASSERT_EQUAL_MESSAGE("B", 0x5656u, (unsigned)out[2]); + ASSERT_EQUAL_MESSAGE("A defaults to opaque", 0xFFFFu, (unsigned)out[3]); + free(out); + return 0; +} + +/* 16-bit RGBA PAM (maxval=65535): samples are 2 bytes big-endian. Data is read + * verbatim into the uint16_t buffer with byte-swap on little-endian hosts. */ +int overlay_pam_test_load_16bit_rgba(void) +{ + const char *header = + "P7\n" + "WIDTH 1\n" + "HEIGHT 1\n" + "DEPTH 4\n" + "MAXVAL 65535\n" + "TUPLTYPE RGB_ALPHA\n" + "ENDHDR\n"; + /* Big-endian 16-bit samples: R=0x1234, G=0x5678, B=0x9ABC, A=0xDEF0 */ + const unsigned char data[] = { + 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, + }; + const char *path = NULL; + ASSERT_MESSAGE("write temp file", + write_temp_pam(header, data, sizeof data, &path)); + + uint16_t *out = NULL; + int w = 0, h = 0; + bool ok = overlay_load_pam_rgba16(path, &out, &w, &h); + unlink(path); + + ASSERT_MESSAGE("load returned true", ok); + ASSERT_EQUAL_MESSAGE("R", 0x1234u, (unsigned)out[0]); + ASSERT_EQUAL_MESSAGE("G", 0x5678u, (unsigned)out[1]); + ASSERT_EQUAL_MESSAGE("B", 0x9ABCu, (unsigned)out[2]); + ASSERT_EQUAL_MESSAGE("A", 0xDEF0u, (unsigned)out[3]); + free(out); + return 0; +} + +int overlay_pam_test_rejects_missing_file(void) +{ + uint16_t *out = (uint16_t *)0xDEADBEEF; + int w = 999, h = 999; + bool ok = overlay_load_pam_rgba16("/nonexistent/path/no.pam", + &out, &w, &h); + ASSERT_MESSAGE("load returned false", !ok); + /* outputs untouched per contract */ + ASSERT_EQUAL_MESSAGE("out untouched", (uintptr_t)0xDEADBEEF, (uintptr_t)out); + ASSERT_EQUAL_MESSAGE("w untouched", 999, w); + ASSERT_EQUAL_MESSAGE("h untouched", 999, h); + return 0; +} + +int overlay_pam_test_rejects_grayscale(void) +{ + const char *header = + "P7\n" + "WIDTH 1\n" + "HEIGHT 1\n" + "DEPTH 1\n" + "MAXVAL 255\n" + "TUPLTYPE GRAYSCALE\n" + "ENDHDR\n"; + const unsigned char data[] = { 0x80 }; + const char *path = NULL; + ASSERT_MESSAGE("write temp file", + write_temp_pam(header, data, sizeof data, &path)); + + uint16_t *out = NULL; + int w = 0, h = 0; + bool ok = overlay_load_pam_rgba16(path, &out, &w, &h); + unlink(path); + + ASSERT_MESSAGE("load returned false", !ok); + return 0; +} + +int overlay_pam_test_rejects_intermediate_maxval(void) +{ + /* 10-bit (maxval=1023) is rejected; values would otherwise be + * silently treated as raw 16-bit, producing a darker overlay. */ + const char *header = + "P7\n" + "WIDTH 1\n" + "HEIGHT 1\n" + "DEPTH 4\n" + "MAXVAL 1023\n" + "TUPLTYPE RGB_ALPHA\n" + "ENDHDR\n"; + const unsigned char data[] = { + 0x03, 0xFF, 0x03, 0xFF, 0x03, 0xFF, 0x03, 0xFF, + }; + const char *path = NULL; + ASSERT_MESSAGE("write temp file", + write_temp_pam(header, data, sizeof data, &path)); + + uint16_t *out = NULL; + int w = 0, h = 0; + bool ok = overlay_load_pam_rgba16(path, &out, &w, &h); + unlink(path); + + ASSERT_MESSAGE("load returned false", !ok); + return 0; +} diff --git a/test/test_overlay_pam.h b/test/test_overlay_pam.h new file mode 100644 index 0000000000..c348fbc6a6 --- /dev/null +++ b/test/test_overlay_pam.h @@ -0,0 +1,47 @@ +/** + * @file test/test_overlay_pam.h + * @author Ben Roeder + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef TEST_OVERLAY_PAM_H_2A8E1F4B_5C9D_4F7E_B6A1_9D3C2E5F8A1B +#define TEST_OVERLAY_PAM_H_2A8E1F4B_5C9D_4F7E_B6A1_9D3C2E5F8A1B + +int overlay_pam_test_load_8bit_rgba(void); +int overlay_pam_test_load_8bit_rgb_adds_alpha(void); +int overlay_pam_test_load_16bit_rgba(void); +int overlay_pam_test_rejects_missing_file(void); +int overlay_pam_test_rejects_grayscale(void); +int overlay_pam_test_rejects_intermediate_maxval(void); + +#endif diff --git a/test/test_overlay_rapidcheck.cpp b/test/test_overlay_rapidcheck.cpp new file mode 100644 index 0000000000..ab1cb0cd02 --- /dev/null +++ b/test/test_overlay_rapidcheck.cpp @@ -0,0 +1,197 @@ +/** + * @file test/test_overlay_rapidcheck.cpp + * @author Ben Roeder + * @brief Property-based tests for utils/overlay_layout.c. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +extern "C" { +#include "utils/overlay_layout.h" +} + +namespace { + +/* Frame and overlay dimensions in a range that exercises both + * "overlay fits" and "overlay larger than frame" cases. */ +auto gen_frame_dim() { return rc::gen::inRange(2, 4096); } +auto gen_overlay_dim() { return rc::gen::inRange(1, 4096); } +auto gen_block() { return rc::gen::elementOf(std::vector{1, 2, 4, 6, 8}); } + +auto gen_position() +{ + return rc::gen::elementOf(std::vector{ + OVERLAY_POS_CENTER, + OVERLAY_POS_TOP_LEFT, + OVERLAY_POS_TOP_RIGHT, + OVERLAY_POS_BOTTOM_LEFT, + OVERLAY_POS_BOTTOM_RIGHT, + }); +} + +} // namespace + +bool test_overlay_layout_properties() +{ + bool ok = true; + + /* Preset positions never produce a rect that escapes the frame. */ + ok &= rc::check("overlay_calc_rect: preset stays inside frame", []() { + const int fw = *gen_frame_dim(); + const int fh = *gen_frame_dim(); + const int ow = *gen_overlay_dim(); + const int oh = *gen_overlay_dim(); + const int bp = *gen_block(); + const int bl = *gen_block(); + const auto pos = *gen_position(); + + struct overlay_rect r = overlay_calc_rect( + pos, 0, 0, fw, fh, ow, oh, bp, bl); + + RC_ASSERT(r.x >= 0); + RC_ASSERT(r.y >= 0); + RC_ASSERT(r.width >= 0); + RC_ASSERT(r.height >= 0); + RC_ASSERT(r.x + r.width <= fw); + RC_ASSERT(r.y + r.height <= fh); + }); + + /* Block-grid snapping: the rect's x/y/width/height must align to + * the block grid that the caller declared. */ + ok &= rc::check("overlay_calc_rect: snaps to block grid", []() { + const int fw = *gen_frame_dim(); + const int fh = *gen_frame_dim(); + const int ow = *gen_overlay_dim(); + const int oh = *gen_overlay_dim(); + const int bp = *gen_block(); + const int bl = *gen_block(); + const auto pos = *gen_position(); + + struct overlay_rect r = overlay_calc_rect( + pos, 0, 0, fw, fh, ow, oh, bp, bl); + + if (bp > 1) { + RC_ASSERT(r.x % bp == 0); + RC_ASSERT(r.width % bp == 0); + } + if (bl > 1) { + RC_ASSERT(r.y % bl == 0); + RC_ASSERT(r.height % bl == 0); + } + }); + + /* Custom position with non-negative offsets: the rect's origin is + * the snapped offset (or zero if it would push past the frame). */ + ok &= rc::check("overlay_calc_rect: custom positive offset honoured", + []() { + const int fw = *gen_frame_dim(); + const int fh = *gen_frame_dim(); + const int ow = *gen_overlay_dim(); + const int oh = *gen_overlay_dim(); + const int cx = *rc::gen::inRange(0, 4096); + const int cy = *rc::gen::inRange(0, 4096); + + struct overlay_rect r = overlay_calc_rect( + OVERLAY_POS_CUSTOM, cx, cy, fw, fh, ow, oh, 1, 1); + + RC_ASSERT(r.x >= 0 && r.y >= 0); + RC_ASSERT(r.x + r.width <= fw); + RC_ASSERT(r.y + r.height <= fh); + if (cx < fw) RC_ASSERT(r.x == cx); + if (cy < fh) RC_ASSERT(r.y == cy); + }); + + /* Negative custom offset counts from the opposite edge. Per + * overlay_layout.c the rule is x = fw + cx - ow, then clamp to + * [0, fw]. With block_pixels=block_lines=1 no snapping happens, + * so we can verify the formula exactly. */ + ok &= rc::check("overlay_calc_rect: negative custom offset from edge", + []() { + const int fw = *gen_frame_dim(); + const int fh = *gen_frame_dim(); + const int ow = *rc::gen::inRange(1, 256); + const int oh = *rc::gen::inRange(1, 256); + const int cx = *rc::gen::inRange(-256, 0); + const int cy = *rc::gen::inRange(-256, 0); + + struct overlay_rect r = overlay_calc_rect( + OVERLAY_POS_CUSTOM, cx, cy, fw, fh, ow, oh, 1, 1); + + RC_ASSERT(r.x >= 0 && r.y >= 0); + RC_ASSERT(r.x + r.width <= fw); + RC_ASSERT(r.y + r.height <= fh); + + /* Exact formula: when (fw + cx - ow) is in-range, that's + * the placement; otherwise it clamps to 0. */ + const int ideal_x = fw + cx - ow; + const int ideal_y = fh + cy - oh; + RC_ASSERT(r.x == (ideal_x > 0 ? ideal_x : 0)); + RC_ASSERT(r.y == (ideal_y > 0 ? ideal_y : 0)); + }); + + /* Center positioning is symmetric: x*2 + width is roughly fw + * (within one block_pixels of slack from the snap). Skip when + * the rect collapses to zero — block-snap can push a small + * overlay past zero, in which case "centered" is meaningless. */ + ok &= rc::check("overlay_calc_rect: CENTER places near middle", []() { + const int fw = *rc::gen::inRange(64, 4096); + const int fh = *rc::gen::inRange(64, 4096); + const int ow = *rc::gen::inRange(2, fw); + const int oh = *rc::gen::inRange(2, fh); + const int bp = *gen_block(); + const int bl = *gen_block(); + + struct overlay_rect r = overlay_calc_rect( + OVERLAY_POS_CENTER, 0, 0, fw, fh, ow, oh, bp, bl); + + RC_PRE(r.width > 0); + RC_PRE(r.height > 0); + /* Centre placement combines three error sources, each + * bounded by < block: + * 1. width snap drops up to (bp-1) from overlay_w + * 2. y = (fh - overlay_h)/2 truncation loses 0.5 px + * when fh - overlay_h is odd (counted as 1) + * 3. y snap drops up to (bp-1) from the ideal y + * Total asymmetry magnitude: |a + p + 2q| < 3*block, + * where a = width snap loss, p = parity loss, q = y + * snap loss. */ + const int slack_x = 3 * bp; + const int slack_y = 3 * bl; + RC_ASSERT(std::abs((fw - r.width) - 2 * r.x) <= slack_x); + RC_ASSERT(std::abs((fh - r.height) - 2 * r.y) <= slack_y); + }); + + return ok; +} diff --git a/test/test_overlay_scale.c b/test/test_overlay_scale.c new file mode 100644 index 0000000000..9ba46a8f51 --- /dev/null +++ b/test/test_overlay_scale.c @@ -0,0 +1,333 @@ +/** + * @file test/test_overlay_scale.c + * @author Ben Roeder + * @brief Unit tests for utils/overlay_scale.c + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include + +#include "test_overlay_scale.h" +#include "unit_common.h" +#include "utils/overlay_scale.h" + +static void +fill_solid_red(uint16_t *buf, int w, int h) +{ + for (int i = 0; i < w * h; i++) { + buf[i*4 + 0] = 65535; /* R */ + buf[i*4 + 1] = 0; /* G */ + buf[i*4 + 2] = 0; /* B */ + buf[i*4 + 3] = 65535; /* A */ + } +} + +/* Identity scale (same dims) should preserve every pixel exactly. */ +int overlay_scale_test_identity(void) +{ + enum { W = 4, H = 2 }; + uint16_t src[W * H * 4]; + for (int i = 0; i < W * H * 4; i++) src[i] = (uint16_t)(i * 257); + + uint16_t *dst = overlay_scale_rgba16(src, W, H, W, H); + ASSERT_MESSAGE("not NULL", dst != NULL); + for (int i = 0; i < W * H * 4; i++) { + ASSERT_EQUAL_MESSAGE("byte equal", src[i], dst[i]); + } + free(dst); + return 0; +} + +/* Upscale a solid-colour overlay: every output pixel is still that colour + * (within +/- 64 LSB for libswscale filter rounding). */ +int overlay_scale_test_upscale_solid_colour(void) +{ + enum { SW = 4, SH = 4, DW = 16, DH = 16 }; + uint16_t src[SW * SH * 4]; + fill_solid_red(src, SW, SH); + + uint16_t *dst = overlay_scale_rgba16(src, SW, SH, DW, DH); + ASSERT_MESSAGE("not NULL", dst != NULL); + /* libswscale bilinear has up to a few LSB of filter rounding even + * on constant-colour input. Allow a small tolerance — the real + * contract is "the colour stays close to red", not "no rounding". */ + const int TOL = 64; /* ~0.1% of full range */ + for (int i = 0; i < DW * DH; i++) { + ASSERT_MESSAGE("R close to max", + dst[i*4 + 0] >= 65535 - TOL); + ASSERT_MESSAGE("G close to 0", + dst[i*4 + 1] <= TOL); + ASSERT_MESSAGE("B close to 0", + dst[i*4 + 2] <= TOL); + ASSERT_MESSAGE("A close to max", + dst[i*4 + 3] >= 65535 - TOL); + } + free(dst); + return 0; +} + +/* Downscale a 4x4 half-red half-blue checkerboard to 2x2: each output + * sample averages a 2x2 input block, so the corners come out as a + * mid-tone purple. We only sanity-check that the channels mix instead + * of asserting a precise filter output. */ +int overlay_scale_test_downscale_average(void) +{ + enum { SW = 4, SH = 4, DW = 2, DH = 2 }; + uint16_t src[SW * SH * 4]; + for (int y = 0; y < SH; y++) { + for (int x = 0; x < SW; x++) { + const int even = ((x ^ y) & 1) == 0; + const int idx = (y * SW + x) * 4; + src[idx + 0] = even ? 65535 : 0; /* R */ + src[idx + 1] = 0; /* G */ + src[idx + 2] = even ? 0 : 65535; /* B */ + src[idx + 3] = 65535; + } + } + + uint16_t *dst = overlay_scale_rgba16(src, SW, SH, DW, DH); + ASSERT_MESSAGE("not NULL", dst != NULL); + for (int i = 0; i < DW * DH; i++) { + /* Each channel should be in the mixed range, not pure + * 0 or 65535. The exact value depends on the libswscale + * filter taps; we just assert "neither original colour". */ + ASSERT_MESSAGE("R between extremes", + dst[i*4 + 0] > 0 && dst[i*4 + 0] < 65535); + ASSERT_MESSAGE("B between extremes", + dst[i*4 + 2] > 0 && dst[i*4 + 2] < 65535); + ASSERT_EQUAL_MESSAGE("A still max", 65535, dst[i*4 + 3]); + } + free(dst); + return 0; +} + +int overlay_scale_test_returns_null_on_bad_dims(void) +{ + enum { W = 4, H = 4 }; + uint16_t src[W * H * 4]; + fill_solid_red(src, W, H); + + ASSERT_MESSAGE("zero dst_w", overlay_scale_rgba16(src, W, H, 0, 4) == NULL); + ASSERT_MESSAGE("zero dst_h", overlay_scale_rgba16(src, W, H, 4, 0) == NULL); + ASSERT_MESSAGE("neg dst_w", overlay_scale_rgba16(src, W, H, -1, 4) == NULL); + ASSERT_MESSAGE("zero src_w", overlay_scale_rgba16(src, 0, H, 4, 4) == NULL); + ASSERT_MESSAGE("NULL src", overlay_scale_rgba16(NULL, W, H, 4, 4) == NULL); + return 0; +} + +int overlay_scale_test_source_buffer_unchanged(void) +{ + enum { W = 4, H = 4 }; + uint16_t src[W * H * 4]; + fill_solid_red(src, W, H); + uint16_t orig[W * H * 4]; + memcpy(orig, src, sizeof src); + + uint16_t *dst = overlay_scale_rgba16(src, W, H, 16, 16); + ASSERT_MESSAGE("not NULL", dst != NULL); + for (int i = 0; i < W * H * 4; i++) { + ASSERT_EQUAL_MESSAGE("src untouched", orig[i], src[i]); + } + free(dst); + return 0; +} + +/* + * The opaque scaler holds a cached SwsContext across calls. The cache + * effect itself isn't observable from the API surface — output is always + * correct regardless — so these tests prove the contract of the new API + * (create/destroy, dimensions can change between calls) and rely on the + * implementation review for the cache hit. + */ + +int overlay_scaler_test_create_destroy(void) +{ + struct overlay_scaler *s = overlay_scaler_create(OVERLAY_SCALE_LANCZOS); + ASSERT_MESSAGE("create non-NULL", s != NULL); + overlay_scaler_destroy(s); + /* destroy(NULL) is a documented no-op so callers don't need + * to guard their cleanup paths. */ + overlay_scaler_destroy(NULL); + return 0; +} + +int overlay_scaler_test_reuses_context_same_dims(void) +{ + enum { W = 4, H = 4, DW = 16, DH = 16 }; + uint16_t src[W * H * 4]; + fill_solid_red(src, W, H); + + struct overlay_scaler *sc = overlay_scaler_create(OVERLAY_SCALE_LANCZOS); + ASSERT_MESSAGE("create", sc != NULL); + + uint16_t *out1 = overlay_scaler_scale(sc, src, W, H, DW, DH); + uint16_t *out2 = overlay_scaler_scale(sc, src, W, H, DW, DH); + ASSERT_MESSAGE("call 1 ok", out1 != NULL); + ASSERT_MESSAGE("call 2 ok", out2 != NULL); + for (int i = 0; i < DW * DH * 4; i++) { + ASSERT_EQUAL_MESSAGE("identical output", out1[i], out2[i]); + } + free(out1); free(out2); + overlay_scaler_destroy(sc); + return 0; +} + +int overlay_scaler_test_rebuilds_context_on_dim_change(void) +{ + enum { W = 4, H = 4 }; + uint16_t src[W * H * 4]; + fill_solid_red(src, W, H); + + struct overlay_scaler *sc = overlay_scaler_create(OVERLAY_SCALE_LANCZOS); + uint16_t *small = overlay_scaler_scale(sc, src, W, H, 8, 8); + uint16_t *big = overlay_scaler_scale(sc, src, W, H, 16, 16); + ASSERT_MESSAGE("8x8 ok", small != NULL); + ASSERT_MESSAGE("16x16 ok", big != NULL); + const int TOL = 64; + for (int i = 0; i < 8 * 8 * 4; i += 4) { + ASSERT_MESSAGE("8x8 R near max", small[i + 0] >= 65535 - TOL); + ASSERT_MESSAGE("8x8 G near 0", small[i + 1] <= TOL); + } + for (int i = 0; i < 16 * 16 * 4; i += 4) { + ASSERT_MESSAGE("16x16 R near max", big[i + 0] >= 65535 - TOL); + ASSERT_MESSAGE("16x16 G near 0", big[i + 1] <= TOL); + } + free(small); free(big); + overlay_scaler_destroy(sc); + return 0; +} + +/* scale_into writes through caller-provided dst buffer; no allocation + * happens on the scaler side, so the same dst pointer is filled by + * successive calls. */ +int overlay_scaler_test_scale_into_no_alloc(void) +{ + enum { W = 4, H = 4, DW = 16, DH = 16 }; + uint16_t src[W * H * 4]; + fill_solid_red(src, W, H); + + uint16_t *dst = malloc(DW * DH * 4 * sizeof *dst); + ASSERT_MESSAGE("dst allocated", dst != NULL); + memset(dst, 0xAA, DW * DH * 4 * sizeof *dst); + + struct overlay_scaler *sc = overlay_scaler_create(OVERLAY_SCALE_LANCZOS); + ASSERT_MESSAGE("call ok", overlay_scaler_scale_into(sc, dst, src, W, H, DW, DH)); + + /* Output must have replaced the 0xAA pattern with red. */ + const int TOL = 64; + for (int i = 0; i < DW * DH; i++) { + ASSERT_MESSAGE("R near max", dst[i*4 + 0] >= 65535 - TOL); + ASSERT_MESSAGE("G near 0", dst[i*4 + 1] <= TOL); + ASSERT_MESSAGE("B near 0", dst[i*4 + 2] <= TOL); + ASSERT_MESSAGE("A near max", dst[i*4 + 3] >= 65535 - TOL); + } + + /* Second call into the same buffer with new content still works. */ + for (int i = 0; i < W * H; i++) { + src[i*4 + 0] = 0; + src[i*4 + 1] = 65535; /* now green */ + src[i*4 + 2] = 0; + src[i*4 + 3] = 65535; + } + ASSERT_MESSAGE("second call ok", + overlay_scaler_scale_into(sc, dst, src, W, H, DW, DH)); + for (int i = 0; i < DW * DH; i++) { + ASSERT_MESSAGE("R near 0", dst[i*4 + 0] <= TOL); + ASSERT_MESSAGE("G near max", dst[i*4 + 1] >= 65535 - TOL); + } + + free(dst); + overlay_scaler_destroy(sc); + return 0; +} + +/* Nearest-neighbour upscale of a 2x2 distinct-colour block must produce + * sharp 8x8 output (no inter-pixel mixing): the top-left quadrant is + * pure red, top-right pure green, etc. Bilinear/Lanczos would smear. */ +int overlay_scaler_test_filter_nearest(void) +{ + enum { W = 2, H = 2, DW = 8, DH = 8 }; + uint16_t src[W * H * 4] = { + 65535, 0, 0, 65535, /* (0,0) red */ + 0, 65535, 0, 65535, /* (1,0) green */ + 0, 0, 65535, 65535, /* (0,1) blue */ + 65535, 65535, 65535, 65535, /* (1,1) white */ + }; + + struct overlay_scaler *sc = overlay_scaler_create(OVERLAY_SCALE_NEAREST); + uint16_t *dst = overlay_scaler_scale(sc, src, W, H, DW, DH); + ASSERT_MESSAGE("call ok", dst != NULL); + + /* SWS_POINT preserves the per-pixel source colour but adds a few + * LSB of rounding at component boundaries — much sharper than + * bilinear/lanczos but not bit-exact. Tolerance reflects that. */ + const int TOL = 64; + ASSERT_MESSAGE("(0,0) R near max", dst[0] >= 65535 - TOL); + ASSERT_MESSAGE("(0,0) G near 0", dst[1] <= TOL); + ASSERT_MESSAGE("(0,0) B near 0", dst[2] <= TOL); + ASSERT_MESSAGE("(DW-1,0) R near 0", dst[(DW - 1) * 4 + 0] <= TOL); + ASSERT_MESSAGE("(DW-1,0) G near max", dst[(DW - 1) * 4 + 1] >= 65535 - TOL); + + free(dst); + overlay_scaler_destroy(sc); + return 0; +} + +/* Bilinear upscale of solid-colour input still gives that solid colour + * (within filter tolerance). Just proves the bilinear path works. */ +int overlay_scaler_test_filter_bilinear(void) +{ + enum { W = 4, H = 4, DW = 16, DH = 16 }; + uint16_t src[W * H * 4]; + fill_solid_red(src, W, H); + + struct overlay_scaler *sc = overlay_scaler_create(OVERLAY_SCALE_BILINEAR); + uint16_t *dst = overlay_scaler_scale(sc, src, W, H, DW, DH); + ASSERT_MESSAGE("call ok", dst != NULL); + + const int TOL = 64; + for (int i = 0; i < DW * DH; i++) { + ASSERT_MESSAGE("R near max", dst[i*4 + 0] >= 65535 - TOL); + ASSERT_MESSAGE("G near 0", dst[i*4 + 1] <= TOL); + } + free(dst); + overlay_scaler_destroy(sc); + return 0; +} diff --git a/test/test_overlay_scale.h b/test/test_overlay_scale.h new file mode 100644 index 0000000000..3d9c7d9772 --- /dev/null +++ b/test/test_overlay_scale.h @@ -0,0 +1,52 @@ +/** + * @file test/test_overlay_scale.h + * @author Ben Roeder + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef TEST_OVERLAY_SCALE_H_2A1F8C7B_4E5D_4F3A_9B2D_5E8A1F7B4C3D +#define TEST_OVERLAY_SCALE_H_2A1F8C7B_4E5D_4F3A_9B2D_5E8A1F7B4C3D + +int overlay_scale_test_identity(void); +int overlay_scale_test_upscale_solid_colour(void); +int overlay_scale_test_downscale_average(void); +int overlay_scale_test_returns_null_on_bad_dims(void); +int overlay_scale_test_source_buffer_unchanged(void); +int overlay_scaler_test_create_destroy(void); +int overlay_scaler_test_reuses_context_same_dims(void); +int overlay_scaler_test_rebuilds_context_on_dim_change(void); +int overlay_scaler_test_scale_into_no_alloc(void); +int overlay_scaler_test_filter_nearest(void); +int overlay_scaler_test_filter_bilinear(void); + +#endif diff --git a/test/test_overlay_soft_edge.c b/test/test_overlay_soft_edge.c new file mode 100644 index 0000000000..4f692fd453 --- /dev/null +++ b/test/test_overlay_soft_edge.c @@ -0,0 +1,253 @@ +/** + * @file test/test_overlay_soft_edge.c + * @author Ben Roeder + * @brief Unit tests for utils/overlay_soft_edge.c + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include + +#include "test_overlay_soft_edge.h" +#include "unit_common.h" +#include "utils/overlay_soft_edge.h" + +/* Fill an opaque-white WxH RGBA16 buffer (alpha = 65535). */ +static void +fill_opaque_white(uint16_t *buf, int w, int h) +{ + for (int i = 0; i < w * h; i++) { + buf[i*4 + 0] = 65535; + buf[i*4 + 1] = 65535; + buf[i*4 + 2] = 65535; + buf[i*4 + 3] = 65535; + } +} + +/* edge_w=0: function leaves the buffer untouched. */ +int overlay_soft_edge_test_zero_width_is_noop(void) +{ + enum { W = 8, H = 8 }; + uint16_t buf[W * H * 4]; + fill_opaque_white(buf, W, H); + uint16_t orig[W * H * 4]; + memcpy(orig, buf, sizeof buf); + + overlay_apply_soft_edge(buf, W, H, 0); + for (int i = 0; i < W * H * 4; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], buf[i]); + } + return 0; +} + +/* The outermost row/column has distance 0 from an edge: alpha -> 0. */ +int overlay_soft_edge_test_edge_pixel_zeroed(void) +{ + enum { W = 8, H = 8 }; + uint16_t buf[W * H * 4]; + fill_opaque_white(buf, W, H); + + overlay_apply_soft_edge(buf, W, H, 3); + + ASSERT_EQUAL_MESSAGE("top-left", 0, buf[(0 * W + 0) * 4 + 3]); + ASSERT_EQUAL_MESSAGE("top-right", 0, buf[(0 * W + W - 1) * 4 + 3]); + ASSERT_EQUAL_MESSAGE("bot-left", 0, buf[((H - 1) * W + 0) * 4 + 3]); + ASSERT_EQUAL_MESSAGE("bot-right", 0, buf[((H - 1) * W + W - 1) * 4 + 3]); + ASSERT_EQUAL_MESSAGE("top edge mid", 0, buf[(0 * W + W / 2) * 4 + 3]); + return 0; +} + +/* For edge_w=4 on a wide-enough buffer, alpha at distance d from the nearest + * edge is round(65535 * d / edge_w) for d in 1..edge_w-1, clamped at 65535. */ +int overlay_soft_edge_test_linear_ramp(void) +{ + enum { W = 16, H = 16, EDGE = 4 }; + uint16_t buf[W * H * 4]; + fill_opaque_white(buf, W, H); + + overlay_apply_soft_edge(buf, W, H, EDGE); + + /* Row of pixels stepping inward from the left edge, on a row that's + * far from the top/bottom (so vertical distance is >= EDGE). */ + const int y = H / 2; + const int expected[] = { 0, 16383, 32767, 49151, 65535, 65535 }; + for (int x = 0; x < (int)(sizeof expected / sizeof expected[0]); x++) { + char msg[32]; + snprintf(msg, sizeof msg, "x=%d", x); + ASSERT_EQUAL_MESSAGE(msg, expected[x], + buf[(y * W + x) * 4 + 3]); + } + return 0; +} + +/* Pixels at distance >= edge_w from every edge keep alpha = 65535. */ +int overlay_soft_edge_test_centre_untouched(void) +{ + enum { W = 20, H = 20, EDGE = 4 }; + uint16_t buf[W * H * 4]; + fill_opaque_white(buf, W, H); + + overlay_apply_soft_edge(buf, W, H, EDGE); + + for (int y = EDGE; y < H - EDGE; y++) { + for (int x = EDGE; x < W - EDGE; x++) { + ASSERT_EQUAL_MESSAGE("centre alpha unchanged", + 65535, buf[(y * W + x) * 4 + 3]); + } + } + return 0; +} + +/* Only alpha is modified — RGB components stay put. */ +int overlay_soft_edge_test_rgb_components_unchanged(void) +{ + enum { W = 8, H = 8 }; + uint16_t buf[W * H * 4]; + for (int i = 0; i < W * H; i++) { + buf[i*4 + 0] = (uint16_t)(1000 + i); + buf[i*4 + 1] = (uint16_t)(2000 + i); + buf[i*4 + 2] = (uint16_t)(3000 + i); + buf[i*4 + 3] = 65535; + } + + overlay_apply_soft_edge(buf, W, H, 3); + + for (int i = 0; i < W * H; i++) { + ASSERT_EQUAL_MESSAGE("R untouched", (uint16_t)(1000 + i), buf[i*4 + 0]); + ASSERT_EQUAL_MESSAGE("G untouched", (uint16_t)(2000 + i), buf[i*4 + 1]); + ASSERT_EQUAL_MESSAGE("B untouched", (uint16_t)(3000 + i), buf[i*4 + 2]); + } + return 0; +} + +/* edge_w larger than min(W,H)/2 must not over-attenuate the centre below + * what it would be at edge_w = min(W,H)/2 (centre stays > 0). */ +int overlay_soft_edge_test_oversized_width_clamps(void) +{ + enum { W = 4, H = 4 }; + uint16_t buf[W * H * 4]; + fill_opaque_white(buf, W, H); + + /* edge_w=100 on a 4x4 buffer would over-shoot if no clamp. */ + overlay_apply_soft_edge(buf, W, H, 100); + + /* The four corner-adjacent inner pixels (x=1,y=1 etc) have distance + * 1 from the nearest edge, so they should be > 0 (not negative or + * wrapped). */ + ASSERT_MESSAGE("inner pixel > 0", buf[(1 * W + 1) * 4 + 3] > 0); + ASSERT_MESSAGE("inner pixel <= max", buf[(1 * W + 1) * 4 + 3] <= 65535); + return 0; +} + +/* Non-square buffer: clamp must be on the SHORT axis, not just width. A + * 4x20 buffer with edge_w=10 must clamp to MIN(4,20)/2 = 2, not 10. */ +int overlay_soft_edge_test_non_square(void) +{ + enum { W = 4, H = 20 }; + uint16_t buf[W * H * 4]; + fill_opaque_white(buf, W, H); + + overlay_apply_soft_edge(buf, W, H, 10); + + /* Centre column (x=2) at y=10 has dx=1 (clamped edge_w=2) so + * alpha = 65535*1/2 = 32767. If clamp had used width instead of + * MIN(w,h) we'd see 65535*1/4 = 16383. */ + ASSERT_EQUAL_MESSAGE("centre col mid-row", + 32767, buf[(10 * W + 2) * 4 + 3]); + return 0; +} + +/* edge_w exactly at the clamp boundary (= min(w,h)/2): the clamp is a + * no-op, the deepest interior pixel sits at d = edge_w - 1, and the + * d == edge_w branch is unreachable. */ +int overlay_soft_edge_test_exact_half_dimension(void) +{ + enum { W = 8, H = 8, EDGE = 4 }; + uint16_t buf[W * H * 4]; + fill_opaque_white(buf, W, H); + + overlay_apply_soft_edge(buf, W, H, EDGE); + + /* (3,3) and (4,4) are the deepest pixels: dx=3, dy=3, d=3. */ + ASSERT_EQUAL_MESSAGE("(3,3) deepest", 49151, + buf[(3 * W + 3) * 4 + 3]); + ASSERT_EQUAL_MESSAGE("(4,4) deepest", 49151, + buf[(4 * W + 4) * 4 + 3]); + return 0; +} + +/* Already-translucent input: the ramp must SCALE existing alpha, not + * clobber it to a fixed ramp value. alpha=32768 with d=1, edge_w=4 gives + * floor(32768 * 1 / 4) = 8192 — not 16384 (which is what 65535*1/4 / 2 + * would give if the function rebuilt alpha from scratch). */ +int overlay_soft_edge_test_scales_existing_alpha(void) +{ + enum { W = 8, H = 8, EDGE = 4 }; + uint16_t buf[W * H * 4]; + for (int i = 0; i < W * H; i++) { + buf[i*4 + 0] = 0; + buf[i*4 + 1] = 0; + buf[i*4 + 2] = 0; + buf[i*4 + 3] = 32768; + } + + overlay_apply_soft_edge(buf, W, H, EDGE); + + ASSERT_EQUAL_MESSAGE("d=0 -> 0", 0, buf[(W/2 * W + 0) * 4 + 3]); + ASSERT_EQUAL_MESSAGE("d=1 -> 8192", 8192, buf[(W/2 * W + 1) * 4 + 3]); + ASSERT_EQUAL_MESSAGE("d=2 -> 16384", 16384, buf[(W/2 * W + 2) * 4 + 3]); + ASSERT_EQUAL_MESSAGE("d=3 -> 24576", 24576, buf[(W/2 * W + 3) * 4 + 3]); + return 0; +} + +/* 1xN buffer: max_edge = MIN(1, N)/2 = 0, so the function returns without + * touching the buffer (and without dividing by zero). */ +int overlay_soft_edge_test_degenerate_one_row(void) +{ + enum { W = 8, H = 1 }; + uint16_t buf[W * H * 4]; + fill_opaque_white(buf, W, H); + uint16_t orig[W * H * 4]; + memcpy(orig, buf, sizeof buf); + + overlay_apply_soft_edge(buf, W, H, 4); + for (int i = 0; i < W * H * 4; i++) { + ASSERT_EQUAL_MESSAGE("byte unchanged", orig[i], buf[i]); + } + return 0; +} diff --git a/test/test_overlay_soft_edge.h b/test/test_overlay_soft_edge.h new file mode 100644 index 0000000000..a171c9696f --- /dev/null +++ b/test/test_overlay_soft_edge.h @@ -0,0 +1,51 @@ +/** + * @file test/test_overlay_soft_edge.h + * @author Ben Roeder + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef TEST_OVERLAY_SOFT_EDGE_H_8C2A1F6D_4B5E_4E3F_9A7C_3D8B5E1F2A4C +#define TEST_OVERLAY_SOFT_EDGE_H_8C2A1F6D_4B5E_4E3F_9A7C_3D8B5E1F2A4C + +int overlay_soft_edge_test_zero_width_is_noop(void); +int overlay_soft_edge_test_edge_pixel_zeroed(void); +int overlay_soft_edge_test_linear_ramp(void); +int overlay_soft_edge_test_centre_untouched(void); +int overlay_soft_edge_test_rgb_components_unchanged(void); +int overlay_soft_edge_test_oversized_width_clamps(void); +int overlay_soft_edge_test_non_square(void); +int overlay_soft_edge_test_exact_half_dimension(void); +int overlay_soft_edge_test_scales_existing_alpha(void); +int overlay_soft_edge_test_degenerate_one_row(void); + +#endif diff --git a/test/test_overlay_watch.c b/test/test_overlay_watch.c new file mode 100644 index 0000000000..3a183fb643 --- /dev/null +++ b/test/test_overlay_watch.c @@ -0,0 +1,258 @@ +/** + * @file test/test_overlay_watch.c + * @author Ben Roeder + * @brief Unit tests for utils/overlay_watch.c + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include "test_overlay_watch.h" +#include "unit_common.h" +#include "utils/fs.h" +#include "utils/overlay_watch.h" + +static bool write_file(const char **path_out, const char *body, size_t len) +{ + FILE *f = get_temp_file(path_out); + if (!f) return false; + bool ok = fwrite(body, 1, len, f) == len; + if (fclose(f) != 0) ok = false; + if (!ok) unlink(*path_out); + return ok; +} + +static bool overwrite_file(const char *path, const char *body, size_t len) +{ + FILE *f = fopen(path, "wb"); + if (!f) return false; + bool ok = fwrite(body, 1, len, f) == len; + if (fclose(f) != 0) ok = false; + return ok; +} + +/* Stable file: init then immediate poll reports no change. */ +int overlay_watch_test_init_no_change(void) +{ + const char *path = NULL; + ASSERT_MESSAGE("write file", write_file(&path, "hello", 5)); + + struct overlay_watch w; + overlay_watch_init(&w, path); + ASSERT_MESSAGE("not changed", !overlay_watch_changed(&w, path)); + + unlink(path); + return 0; +} + +/* File grows -> change detected (size differs even if mtime resolution is coarse). */ +int overlay_watch_test_detects_size_change(void) +{ + const char *path = NULL; + ASSERT_MESSAGE("write file", write_file(&path, "hello", 5)); + + struct overlay_watch w; + overlay_watch_init(&w, path); + ASSERT_MESSAGE("rewrite bigger", + overwrite_file(path, "hello world", 11)); + ASSERT_MESSAGE("changed", overlay_watch_changed(&w, path)); + + unlink(path); + return 0; +} + +/* Same size, different mtime -> change detected. Force the baseline mtime + * into the past via utimes() so we don't depend on timing resolution. */ +int overlay_watch_test_detects_mtime_change(void) +{ + const char *path = NULL; + ASSERT_MESSAGE("write file", write_file(&path, "hello", 5)); + + struct overlay_watch w; + overlay_watch_init(&w, path); + + struct timeval tv[2]; + tv[0].tv_sec = 1000000; tv[0].tv_usec = 0; /* atime */ + tv[1].tv_sec = 1000000; tv[1].tv_usec = 0; /* mtime: well in the past */ + ASSERT_EQUAL_MESSAGE("utimes", 0, utimes(path, tv)); + + ASSERT_MESSAGE("changed", overlay_watch_changed(&w, path)); + + unlink(path); + return 0; +} + +/* Acking against a missing file is a no-op: baseline survives so the next + * appearance still triggers reload. Avoids dropping a real edit if the file + * is briefly absent (e.g. atomic-write rename) at ack time. */ +int overlay_watch_test_ack_on_missing_file_preserves_baseline(void) +{ + const char *path = NULL; + ASSERT_MESSAGE("write file", write_file(&path, "hello", 5)); + + struct overlay_watch w; + overlay_watch_init(&w, path); + + ASSERT_EQUAL_MESSAGE("unlink", 0, unlink(path)); + overlay_watch_ack(&w, path); + + ASSERT_MESSAGE("recreate", + overwrite_file(path, "different content", 17)); + ASSERT_MESSAGE("appearance still triggers", + overlay_watch_changed(&w, path)); + + unlink(path); + return 0; +} + +/* + * The baseline must NOT advance until the caller explicitly acknowledges + * the change (after a successful reload). Otherwise a failed reload would + * eat the trigger and a later valid mutation would go undetected. + */ +int overlay_watch_test_changed_does_not_consume_baseline(void) +{ + const char *path = NULL; + ASSERT_MESSAGE("write file", write_file(&path, "hello", 5)); + + struct overlay_watch w; + overlay_watch_init(&w, path); + ASSERT_MESSAGE("rewrite #1", + overwrite_file(path, "broken!!!", 9)); + + /* First poll: change detected. Caller's reload (simulated) fails, + * so it does NOT call overlay_watch_ack. */ + ASSERT_MESSAGE("first poll changed", + overlay_watch_changed(&w, path)); + + /* Second poll without ack: still reports change so the caller can + * retry, even though mtime/size haven't moved further. */ + ASSERT_MESSAGE("second poll still changed (not acked)", + overlay_watch_changed(&w, path)); + + /* And after a fresh mutation, change is still reported. */ + ASSERT_MESSAGE("rewrite #2", + overwrite_file(path, "fixed content version 2", 23)); + ASSERT_MESSAGE("after mutation: changed", + overlay_watch_changed(&w, path)); + + unlink(path); + return 0; +} + +int overlay_watch_test_ack_commits_baseline(void) +{ + const char *path = NULL; + ASSERT_MESSAGE("write file", write_file(&path, "hello", 5)); + + struct overlay_watch w; + overlay_watch_init(&w, path); + ASSERT_MESSAGE("rewrite", + overwrite_file(path, "version 2 here", 14)); + ASSERT_MESSAGE("changed", overlay_watch_changed(&w, path)); + + overlay_watch_ack(&w, path); + ASSERT_MESSAGE("after ack: quiet", + !overlay_watch_changed(&w, path)); + + unlink(path); + return 0; +} + +/* Missing file (transiently absent during atomic-write) must not trigger a + * spurious reload — caller would crash trying to load nothing. */ +int overlay_watch_test_missing_file_no_change(void) +{ + struct overlay_watch w; + overlay_watch_init(&w, "/nonexistent/path/no.pam"); + ASSERT_MESSAGE("missing file: no change", + !overlay_watch_changed(&w, "/nonexistent/path/no.pam")); + return 0; +} + +/* File initially missing, then created -> first poll after creation reports + * change (so the caller's reload kicks in once the file appears). */ +int overlay_watch_test_file_appears_after_init(void) +{ + const char *path = NULL; + ASSERT_MESSAGE("write+remove", write_file(&path, "x", 1)); + ASSERT_EQUAL_MESSAGE("unlink", 0, unlink(path)); + + struct overlay_watch w; + overlay_watch_init(&w, path); + ASSERT_MESSAGE("missing while absent", + !overlay_watch_changed(&w, path)); + + ASSERT_MESSAGE("recreate", overwrite_file(path, "appeared", 8)); + ASSERT_MESSAGE("change reported on appearance", + overlay_watch_changed(&w, path)); + overlay_watch_ack(&w, path); + ASSERT_MESSAGE("then quiet", + !overlay_watch_changed(&w, path)); + + unlink(path); + return 0; +} + +/* Atomic-write replacement: write a sibling temp file then rename() over the + * watched path. The new inode has a different mtime/size and the watcher + * must detect it. */ +int overlay_watch_test_detects_atomic_rename(void) +{ + const char *path = NULL; + ASSERT_MESSAGE("write file", write_file(&path, "v1", 2)); + + struct overlay_watch w; + overlay_watch_init(&w, path); + + const char *staging = NULL; + ASSERT_MESSAGE("write staging", write_file(&staging, "version2", 8)); + ASSERT_EQUAL_MESSAGE("rename", 0, rename(staging, path)); + + ASSERT_MESSAGE("change detected after rename", + overlay_watch_changed(&w, path)); + + unlink(path); + return 0; +} diff --git a/test/test_overlay_watch.h b/test/test_overlay_watch.h new file mode 100644 index 0000000000..99fa9ad5c2 --- /dev/null +++ b/test/test_overlay_watch.h @@ -0,0 +1,50 @@ +/** + * @file test/test_overlay_watch.h + * @author Ben Roeder + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef TEST_OVERLAY_WATCH_H_2A9D8F1C_4B6E_4D7A_9F3B_5E8C2A1D7B4F +#define TEST_OVERLAY_WATCH_H_2A9D8F1C_4B6E_4D7A_9F3B_5E8C2A1D7B4F + +int overlay_watch_test_init_no_change(void); +int overlay_watch_test_detects_size_change(void); +int overlay_watch_test_detects_mtime_change(void); +int overlay_watch_test_ack_on_missing_file_preserves_baseline(void); +int overlay_watch_test_missing_file_no_change(void); +int overlay_watch_test_file_appears_after_init(void); +int overlay_watch_test_detects_atomic_rename(void); +int overlay_watch_test_changed_does_not_consume_baseline(void); +int overlay_watch_test_ack_commits_baseline(void); + +#endif diff --git a/test/test_rapidcheck_main.cpp b/test/test_rapidcheck_main.cpp new file mode 100644 index 0000000000..d82bcbfc79 --- /dev/null +++ b/test/test_rapidcheck_main.cpp @@ -0,0 +1,71 @@ +/** + * @file test/test_rapidcheck_main.cpp + * @author Ben Roeder + * @brief Runner for the property-based test suites. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +extern bool test_alpha_blend_properties(); +extern bool test_overlay_layout_properties(); +extern bool test_soft_edge_properties(); +extern bool test_overlay_watch_properties(); + +/* color_space.c calls get_commandline_param("color-601"); register_param + * runs at static-init time. Linking host.cpp into the test binary would + * drag in most of UltraGrid, so stub these two — the tests never set + * either flag, and a NULL return from get_commandline_param falls back + * to the codebase's BT.709 default. */ +extern "C" const char *get_commandline_param(const char *) { return nullptr; } +extern "C" void register_param(const char *, const char *) {} + +int main() +{ + std::cout << "UltraGrid property-based tests (RapidCheck)\n"; + + bool ok = true; + std::cout << "\n=== Alpha blend ===\n"; + ok &= test_alpha_blend_properties(); + std::cout << "\n=== Overlay layout ===\n"; + ok &= test_overlay_layout_properties(); + std::cout << "\n=== Soft edges ===\n"; + ok &= test_soft_edge_properties(); + std::cout << "\n=== Overlay watch ===\n"; + ok &= test_overlay_watch_properties(); + + std::cout << (ok ? "\nAll properties passed.\n" + : "\nFAILURES — see output above.\n"); + return ok ? 0 : 1; +} diff --git a/test/test_soft_edges_rapidcheck.cpp b/test/test_soft_edges_rapidcheck.cpp new file mode 100644 index 0000000000..43d4e6c693 --- /dev/null +++ b/test/test_soft_edges_rapidcheck.cpp @@ -0,0 +1,151 @@ +/** + * @file test/test_soft_edges_rapidcheck.cpp + * @author Ben Roeder + * @brief Property-based tests for utils/overlay_soft_edge.c. + */ +/* + * Copyright (c) 2026 CESNET, zájmové sdružení právnických osob + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, is permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of CESNET nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHORS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, + * BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#include +#include + +extern "C" { +#include "utils/overlay_soft_edge.h" +} + +namespace { + +auto gen_dim() { return rc::gen::inRange(1, 256); } +auto gen_edge_w() { return rc::gen::inRange(0, 64); } + +std::vector gen_overlay(int w, int h) +{ + return *rc::gen::container>( + static_cast(w) * h * 4, + rc::gen::arbitrary()); +} + +inline uint16_t alpha_at(const std::vector &v, + int w, int x, int y) +{ + return v[(static_cast(y) * w + x) * 4 + 3]; +} + +} // namespace + +bool test_soft_edge_properties() +{ + bool ok = true; + + /* edge_w == 0 is documented as a no-op. */ + ok &= rc::check("soft_edge: edge_w=0 is a no-op", []() { + const int w = *gen_dim(); + const int h = *gen_dim(); + auto buf = gen_overlay(w, h); + auto before = buf; + overlay_apply_soft_edge(buf.data(), w, h, 0); + RC_ASSERT(buf == before); + }); + + /* RGB channels are never touched. */ + ok &= rc::check("soft_edge: RGB channels untouched", []() { + const int w = *gen_dim(); + const int h = *gen_dim(); + const int e = *gen_edge_w(); + auto buf = gen_overlay(w, h); + auto before = buf; + overlay_apply_soft_edge(buf.data(), w, h, e); + for (size_t i = 0; i + 3 < buf.size(); i += 4) { + RC_ASSERT(buf[i + 0] == before[i + 0]); + RC_ASSERT(buf[i + 1] == before[i + 1]); + RC_ASSERT(buf[i + 2] == before[i + 2]); + } + }); + + /* Alpha never increases. The fade only attenuates. */ + ok &= rc::check("soft_edge: alpha never increases", []() { + const int w = *gen_dim(); + const int h = *gen_dim(); + const int e = *gen_edge_w(); + auto buf = gen_overlay(w, h); + auto before = buf; + overlay_apply_soft_edge(buf.data(), w, h, e); + for (size_t i = 3; i < buf.size(); i += 4) { + RC_ASSERT(buf[i] <= before[i]); + } + }); + + /* Pixels at distance >= edge_w are unchanged. Distance d to the + * nearest edge of an int x in [0, w-1] is min(x, w-1-x); same for y. */ + ok &= rc::check("soft_edge: interior pixels unchanged", []() { + const int w = *rc::gen::inRange(8, 256); + const int h = *rc::gen::inRange(8, 256); + /* Pick edge_w small enough that an interior region exists. */ + const int e = *rc::gen::inRange(1, std::min(w, h) / 2); + auto buf = gen_overlay(w, h); + auto before = buf; + overlay_apply_soft_edge(buf.data(), w, h, e); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + const int dx = std::min(x, w - 1 - x); + const int dy = std::min(y, h - 1 - y); + if (std::min(dx, dy) >= e) { + RC_ASSERT(alpha_at(buf, w, x, y) + == alpha_at(before, w, x, y)); + } + } + } + }); + + /* Outer row/column ends up at alpha = 0 when edge_w > 0 and the + * overlay is large enough that the ramp actually fires. */ + ok &= rc::check("soft_edge: outer ring is zero", []() { + const int w = *rc::gen::inRange(4, 256); + const int h = *rc::gen::inRange(4, 256); + const int e = *rc::gen::inRange(1, std::min(w, h) / 2); + auto buf = gen_overlay(w, h); + overlay_apply_soft_edge(buf.data(), w, h, e); + for (int x = 0; x < w; x++) { + RC_ASSERT(alpha_at(buf, w, x, 0) == 0); + RC_ASSERT(alpha_at(buf, w, x, h - 1) == 0); + } + for (int y = 0; y < h; y++) { + RC_ASSERT(alpha_at(buf, w, 0, y) == 0); + RC_ASSERT(alpha_at(buf, w, w - 1, y) == 0); + } + }); + + return ok; +}