From 35689a8844ae6b5fccdefcb5c499fadbb713bd74 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Mon, 26 Oct 2026 20:02:03 +0000 Subject: [PATCH 1/2] feat: Rgb24 JPEG decode/encode in LibJpegWrap and JpegCodec Native (LibJpegWrap.cpp, non-WASM): DecodeJpegToRgb24 (JCS_RGB, full resolution, libjpeg-turbo default upsampling) and EncodeRgb24ToJpeg (jpeg_set_defaults + jpeg_set_quality baseline, 4:2:0 default or 4:4:4, row stride honoured); errors recover through setjmp/longjmp. linux-x64 LibJpegWrap.so rebuilt in ubuntu:22.04 (GLIBC <= 2.14 symbols, NEEDED libjpeg.so.8). linux-arm64 and win-x64 binaries are unchanged (no cross toolchain); the managed Rgb24 calls throw NotSupportedException there. Managed: IJpegCodec/JpegCodec gain DecodeRgb24 (buffer + pooled overloads) and EncodeRgb24(frame, quality, [output], JpegSubsampling); new JpegSubsampling enum. Tests: decode of a cjpeg reference equals djpeg's Rgb24 bytes exactly; round trip PSNR (4:2:0 q90 > 30 dB, 4:4:4 q92 > 40 dB); 4:4:4 beats 4:2:0 on a chroma checker; padded stride, buffer-too-small, corrupt input, format/quality validation; Gray8/I420 decode of the reference unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8 --- README.md | 32 +- src/LibJpegWrap/LibJpegWrap.cpp | 1681 +++++++++-------- .../JpegCodecRgb24Tests.cs | 224 +++ .../MjpegHdrEngineTests.cs | 6 + .../ModelingEvolution.Mjpeg.Tests.csproj | 4 + .../TestData/rgb24-reference.jpg | Bin 0 -> 1215 bytes .../TestData/rgb24-reference.rgb | Bin 0 -> 4608 bytes src/ModelingEvolution.Mjpeg/IJpegCodec.cs | 46 + src/ModelingEvolution.Mjpeg/JpegCodec.cs | 135 ++ .../JpegTurboNative.cs | 7 + .../libs/linux-x64/LibJpegWrap.so | Bin 27072 -> 27112 bytes 11 files changed, 1355 insertions(+), 780 deletions(-) create mode 100644 src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs create mode 100644 src/ModelingEvolution.Mjpeg.Tests/TestData/rgb24-reference.jpg create mode 100644 src/ModelingEvolution.Mjpeg.Tests/TestData/rgb24-reference.rgb diff --git a/README.md b/README.md index 12f311d..19a0e67 100644 --- a/README.md +++ b/README.md @@ -221,10 +221,29 @@ codec.DctMethod = DctMethod.Float; // Faster encoding var header = new FrameHeader(1920, 1080, 1920 * 3, PixelFormat.Rgb24, 1920 * 1080 * 3); var frame = new FrameImage(header, rawPixelData); -byte[] jpegBuffer = new byte[header.Length]; -int jpegLength = codec.Encode(frame, jpegBuffer); +using var jpeg = codec.EncodeRgb24(frame, quality: 90); // Encode() takes I420 or Gray8 only ``` +### Rgb24 Decode/Encode + +`Decode`/`Encode` work on Gray8 and I420. For packed Rgb24 (R, G, B) use: + +| Method | Result | +|--------|--------| +| `FrameHeader DecodeRgb24(ReadOnlyMemory jpeg, Memory output)` | Full-resolution Rgb24, stride = width * 3; output must hold width * height * 3 bytes | +| `FrameImage DecodeRgb24(ReadOnlyMemory jpeg)` | Same, buffer rented from `MemoryPool.Shared` (dispose it) | +| `int EncodeRgb24(in FrameImage frame, int quality, Memory output, JpegSubsampling subsampling = Ratio420)` | Baseline JPEG; returns bytes written | +| `FrameImage EncodeRgb24(in FrameImage frame, int quality, JpegSubsampling subsampling = Ratio420)` | Same, buffer rented from the shared pool (worst-case `tjBufSize` bound) | + +- Decoding uses libjpeg-turbo's default (fancy) chroma upsampling; output is bit-identical to `djpeg -ppm`. +- Encoding uses `jpeg_set_defaults` + `jpeg_set_quality(quality, force_baseline)` and the integer DCT. The default + subsampling is libjpeg's default, 4:2:0 (`JpegSubsampling.Ratio420`); pass `JpegSubsampling.Ratio444` to keep + chroma at full resolution (sharp coloured edges, larger files). `FrameHeader.Stride` may include row padding. +- The Rgb24 calls are stateless native calls: they ignore the codec's `Quality`/`DctMethod` and are safe to call + concurrently on one `JpegCodec`. +- Platforms: the native Rgb24 entry points ship in the **linux-x64** `LibJpegWrap.so`. On linux-arm64 and win-x64 the + bundled binaries predate them, and the Rgb24 methods throw `NotSupportedException`. + --- ## HDR Blending API @@ -716,6 +735,15 @@ cmake --build build --config Release ### Dependencies No external NuGet dependencies for JPEG codec - native binaries are bundled. +On Linux the bundled `LibJpegWrap.so` links `libjpeg.so.8` dynamically: install `libjpeg-turbo8` (Ubuntu/Debian). + +The linux-x64 binary is built in an `ubuntu:22.04` container (glibc 2.35 baseline) against `libjpeg-turbo8-dev`: + +```bash +docker run --rm -v $PWD/src/LibJpegWrap:/src:ro -v $PWD/out:/out ubuntu:22.04 bash -c \ + 'apt-get update && apt-get install -y g++ cmake libjpeg-turbo8-dev && cmake -S /src -B /b -DCMAKE_BUILD_TYPE=Release \ + && cmake --build /b && strip --strip-unneeded /b/libLibJpegWrap.so && cp /b/libLibJpegWrap.so /out/LibJpegWrap.so' +``` | Component | Purpose | Platforms | |-----------|---------|-----------| diff --git a/src/LibJpegWrap/LibJpegWrap.cpp b/src/LibJpegWrap/LibJpegWrap.cpp index 25d7bde..5442fff 100644 --- a/src/LibJpegWrap/LibJpegWrap.cpp +++ b/src/LibJpegWrap/LibJpegWrap.cpp @@ -1,779 +1,904 @@ -// LibJpegWrap.cpp : Defines the functions for the static library. -// -#ifdef _WIN32 -#define EXPORT __declspec(dllexport) -//#include -//#include -//#include -//#include -// -//long long GetPrvMem() { -// PROCESS_MEMORY_COUNTERS_EX pmc; -// if (GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS*)&pmc, sizeof(pmc))){ -// return pmc.PrivateUsage; -// } -// return 0; -//} -// -//// A class to manage memory allocations -//class AllocationBlock { -//public: -// AllocationBlock(const std::string &location, int lineNumber) -// : location_(location), lineNumber_(lineNumber), workingSet_(GetPrvMem()) {} -// -// ~AllocationBlock() { -// long long w = GetPrvMem(); -// long long d = w - workingSet_; -// if (d > 16 * 1024) { -// std::cout << "+" << d << "B of allocated: "<< w << "B in " << location_ << " at " << lineNumber_ << std::endl; -// } -// -// } -// -//private: -// std::string location_; -// int lineNumber_; -// long long workingSet_; -//}; -//#define CHECK_ALLOCATION() AllocationBlock allocationBlock(__FILE__, __LINE__) - -#endif - -#ifndef _WIN32 -#define EXPORT // Linux doesn't require a special export keyword -#endif - -#include -#include -#include -#include -#include - -typedef unsigned char byte; -typedef unsigned long ulong; - -typedef struct { - struct jpeg_destination_mgr pub; /* Public fields */ - byte* buffer; /* Start of the buffer */ - ulong buffer_size; /* Buffer size */ - ulong data_size; /* Final data size */ -} memory_destination_mgr; - -void init_destination(j_compress_ptr cinfo) { - memory_destination_mgr* dest = (memory_destination_mgr*)cinfo->dest; - dest->pub.next_output_byte = dest->buffer; - dest->pub.free_in_buffer = dest->buffer_size; - dest->data_size = 0; // No data written yet -} - -boolean empty_output_buffer(j_compress_ptr cinfo) { - // Handle buffer overflow. This could involve reallocating the buffer - // and updating the relevant fields in the destination manager. - // For simplicity, this example just prints an error and stops. - - std::cerr << "Buffer overflow in custom destination manager\n"; - return FALSE; // Causes the library to terminate with an error. -} - -void term_destination(j_compress_ptr cinfo) { - memory_destination_mgr* dest = (memory_destination_mgr*)cinfo->dest; - dest->data_size = dest->buffer_size - dest->pub.free_in_buffer; - // At this point, dest->data_size contains the size of the JPEG data. -} -memory_destination_mgr* jpeg_memory_dest(j_compress_ptr cinfo, byte* buffer, ulong size) { - if (cinfo->dest == nullptr) { // Allocate memory for the custom manager if necessary - cinfo->dest = (struct jpeg_destination_mgr*) - (*cinfo->mem->alloc_small) ((j_common_ptr)cinfo, JPOOL_PERMANENT, - sizeof(memory_destination_mgr)); - //std::cout << "\n\rAllocating " << sizeof(memory_destination_mgr) << "B for libjpeg buffer.\n\r"; - memory_destination_mgr* dest = (memory_destination_mgr*)cinfo->dest; - dest->pub.init_destination = init_destination; - dest->pub.empty_output_buffer = empty_output_buffer; - dest->pub.term_destination = term_destination; - dest->buffer = buffer; - dest->buffer_size = size; - dest->data_size = 0; // No data written yet - return dest; - } - else - { - memory_destination_mgr* dest = (memory_destination_mgr*)cinfo->dest; - - dest->buffer = buffer; - dest->buffer_size = size; - dest->data_size = 0; // No data written yet - return dest; - } -} - -class YuvEncoder { -public: - - struct jpeg_compress_struct cinfo; - struct jpeg_error_mgr jerr; - - YuvEncoder(const int width, const int height, const int quality, const int bufferSize) - { - cinfo.err = jpeg_std_error(&jerr); - jpeg_create_compress(&cinfo); - cinfo.image_width = width; - cinfo.image_height = height; - cinfo.input_components = 3; - cinfo.in_color_space = JCS_YCbCr; - - - jpeg_set_defaults(&cinfo); - jpeg_set_quality(&cinfo, quality, FALSE); - - cinfo.raw_data_in = TRUE; // Supply downsampled data - cinfo.comp_info[0].h_samp_factor = 2; - cinfo.comp_info[0].v_samp_factor = 2; - cinfo.comp_info[1].h_samp_factor = 1; - cinfo.comp_info[1].v_samp_factor = 1; - cinfo.comp_info[2].h_samp_factor = 1; - cinfo.comp_info[2].v_samp_factor = 1; - //cinfo.dct_method = JDCT_FASTEST; - jpeg_memory_dest(&cinfo, nullptr, bufferSize); - } - void SetQuality(int quality) - { - jpeg_set_quality(&cinfo, quality, FALSE); - } - // 0 - int - // 1 - fast - void SetMode(int mode) - { - if(mode == 0) - cinfo.dct_method = JDCT_ISLOW; - else - cinfo.dct_method = JDCT_FASTEST; - } - ulong Encode(byte* data, byte* dstBuffer, ulong dstBufferSize) - { - //CHECK_ALLOCATION(); - - jpeg_memory_dest(&cinfo, dstBuffer, dstBufferSize); - jpeg_start_compress(&cinfo, TRUE); - - auto width = cinfo.image_width; - auto height = cinfo.image_height; - - // Calculate the sizes of the Y, U, and V planes - size_t sizeY = width * height; - size_t sizeU = sizeY / 4; - //size_t sizeV = sizeU; // Same as sizeU - - // Split yuv420Data into Y, U, and V components - byte* Y = data; - byte* U = data + sizeY; - byte* V = data + sizeY + sizeU; - - while (cinfo.next_scanline < cinfo.image_height) { - JSAMPROW y[16], cb[8], cr[8]; - for (int i = 0; i < 16 && cinfo.next_scanline + i < cinfo.image_height; i++) { - y[i] = &Y[(cinfo.next_scanline + i) * width]; - if (i % 2 == 0) { - cb[i / 2] = &U[((cinfo.next_scanline + i) / 2) * (width / 2)]; - cr[i / 2] = &V[((cinfo.next_scanline + i) / 2) * (width / 2)]; - } - } - JSAMPARRAY planes[3] = { y, cb, cr }; - //{ - //CHECK_ALLOCATION(); - jpeg_write_raw_data(&cinfo, planes, 16); - //} - } - - jpeg_finish_compress(&cinfo); - - memory_destination_mgr* dest = (memory_destination_mgr*)cinfo.dest; - return dest->data_size; - } - ~YuvEncoder() - { - jpeg_destroy_compress(&cinfo); - } -}; -typedef struct YuvEncoder YuvEncoder; - -// Memory source manager for decompression -typedef struct { - struct jpeg_source_mgr pub; - const byte* buffer; - ulong buffer_size; -} memory_source_mgr; - -void init_source(j_decompress_ptr cinfo) { - // No initialization needed -} - -boolean fill_input_buffer(j_decompress_ptr cinfo) { - // Should not be called since we provide all data upfront - return FALSE; -} - -void skip_input_data(j_decompress_ptr cinfo, long num_bytes) { - memory_source_mgr* src = (memory_source_mgr*)cinfo->src; - if (num_bytes > 0) { - src->pub.next_input_byte += num_bytes; - src->pub.bytes_in_buffer -= num_bytes; - } -} - -void term_source(j_decompress_ptr cinfo) { - // No cleanup needed -} - -void jpeg_memory_src(j_decompress_ptr cinfo, const byte* buffer, ulong size) { - memory_source_mgr* src; - - if (cinfo->src == nullptr) { - cinfo->src = (struct jpeg_source_mgr*) - (*cinfo->mem->alloc_small)((j_common_ptr)cinfo, JPOOL_PERMANENT, - sizeof(memory_source_mgr)); - } - - src = (memory_source_mgr*)cinfo->src; - src->pub.init_source = init_source; - src->pub.fill_input_buffer = fill_input_buffer; - src->pub.skip_input_data = skip_input_data; - src->pub.resync_to_restart = jpeg_resync_to_restart; - src->pub.term_source = term_source; - src->buffer = buffer; - src->buffer_size = size; - src->pub.next_input_byte = buffer; - src->pub.bytes_in_buffer = size; -} - -// Decoder result structure -typedef struct { - int width; - int height; - int components; - int colorSpace; -} DecodeInfo; - -// Pooled I420 Decoder - reuses jpeg_decompress_struct across calls -class I420Decoder { -public: - struct jpeg_decompress_struct cinfo; - struct jpeg_error_mgr jerr; - int max_width; - int max_height; - bool initialized; - - I420Decoder(int maxWidth, int maxHeight) - : max_width(maxWidth), max_height(maxHeight), initialized(false) - { - cinfo.err = jpeg_std_error(&jerr); - jpeg_create_decompress(&cinfo); - initialized = true; - } - - ~I420Decoder() - { - if (initialized) { - jpeg_destroy_decompress(&cinfo); - initialized = false; - } - } - - ulong DecodeI420(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) - { - // Set up memory source - if (cinfo.src == nullptr) { - cinfo.src = (struct jpeg_source_mgr*) - (*cinfo.mem->alloc_small)((j_common_ptr)&cinfo, JPOOL_PERMANENT, - sizeof(memory_source_mgr)); - } - - memory_source_mgr* src = (memory_source_mgr*)cinfo.src; - src->pub.init_source = init_source; - src->pub.fill_input_buffer = fill_input_buffer; - src->pub.skip_input_data = skip_input_data; - src->pub.resync_to_restart = jpeg_resync_to_restart; - src->pub.term_source = term_source; - src->buffer = jpegData; - src->buffer_size = jpegSize; - src->pub.next_input_byte = jpegData; - src->pub.bytes_in_buffer = jpegSize; - - if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { - return 0; - } - - // Request raw YUV output - cinfo.raw_data_out = TRUE; - cinfo.out_color_space = JCS_YCbCr; - - jpeg_start_decompress(&cinfo); - - int width = cinfo.output_width; - int height = cinfo.output_height; - - info->width = width; - info->height = height; - info->components = 3; - info->colorSpace = cinfo.out_color_space; - - // I420 size - ulong sizeY = width * height; - ulong sizeU = sizeY / 4; - ulong sizeV = sizeU; - ulong totalSize = sizeY + sizeU + sizeV; - - if (totalSize > outputSize) { - jpeg_abort_decompress(&cinfo); - return 0; - } - - byte* Y = output; - byte* U = output + sizeY; - byte* V = output + sizeY + sizeU; - - JSAMPROW y_rows[16]; - JSAMPROW u_rows[8]; - JSAMPROW v_rows[8]; - JSAMPARRAY planes[3] = { y_rows, u_rows, v_rows }; - - int y_stride = width; - int uv_stride = width / 2; - - while (cinfo.output_scanline < cinfo.output_height) { - for (int i = 0; i < 16; i++) { - int y_line = cinfo.output_scanline + i; - if (y_line < height) { - y_rows[i] = Y + y_line * y_stride; - } else { - y_rows[i] = Y + (height - 1) * y_stride; - } - - if (i < 8) { - int uv_line = (cinfo.output_scanline / 2) + i; - if (uv_line < height / 2) { - u_rows[i] = U + uv_line * uv_stride; - v_rows[i] = V + uv_line * uv_stride; - } else { - u_rows[i] = U + (height / 2 - 1) * uv_stride; - v_rows[i] = V + (height / 2 - 1) * uv_stride; - } - } - } - - jpeg_read_raw_data(&cinfo, planes, 16); - } - - jpeg_finish_decompress(&cinfo); - - return totalSize; - } - - ulong DecodeGray(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) - { - // Set up memory source - if (cinfo.src == nullptr) { - cinfo.src = (struct jpeg_source_mgr*) - (*cinfo.mem->alloc_small)((j_common_ptr)&cinfo, JPOOL_PERMANENT, - sizeof(memory_source_mgr)); - } - - memory_source_mgr* src = (memory_source_mgr*)cinfo.src; - src->pub.init_source = init_source; - src->pub.fill_input_buffer = fill_input_buffer; - src->pub.skip_input_data = skip_input_data; - src->pub.resync_to_restart = jpeg_resync_to_restart; - src->pub.term_source = term_source; - src->buffer = jpegData; - src->buffer_size = jpegSize; - src->pub.next_input_byte = jpegData; - src->pub.bytes_in_buffer = jpegSize; - - if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { - return 0; - } - - // Request grayscale output - cinfo.out_color_space = JCS_GRAYSCALE; - cinfo.raw_data_out = FALSE; - - jpeg_start_decompress(&cinfo); - - info->width = cinfo.output_width; - info->height = cinfo.output_height; - info->components = cinfo.output_components; - info->colorSpace = cinfo.out_color_space; - - ulong rowStride = cinfo.output_width * cinfo.output_components; - ulong totalSize = rowStride * cinfo.output_height; - - if (totalSize > outputSize) { - jpeg_abort_decompress(&cinfo); - return 0; - } - - while (cinfo.output_scanline < cinfo.output_height) { - byte* rowPtr = output + cinfo.output_scanline * rowStride; - jpeg_read_scanlines(&cinfo, &rowPtr, 1); - } - - jpeg_finish_decompress(&cinfo); - - return totalSize; - } -}; -typedef struct I420Decoder I420Decoder; - -// Custom error handler — sets flag instead of calling exit() -// CRITICAL for WASM: default jpeg_error_exit calls exit() which kills the entire app. -// NOTE: setjmp/longjmp is not used because the .NET WASM runtime's linker -// (-mllvm -wasm-enable-sjlj) is incompatible with Emscripten's JS-based -// setjmp/longjmp emulation. Flag-based error recovery avoids this issue. -struct safe_error_mgr { - struct jpeg_error_mgr pub; - bool has_error; -}; - -static void safe_error_exit(j_common_ptr cinfo) { - safe_error_mgr* myerr = (safe_error_mgr*)cinfo->err; - myerr->has_error = true; - // Do NOT call exit() — just set the flag and return. - // The caller checks has_error after each libjpeg call. -} - -// BGRA Decoder — reuses jpeg_decompress_struct, safe error handling -class BgraDecoder { -public: - struct jpeg_decompress_struct cinfo; - safe_error_mgr jerr; - int max_width; - int max_height; - - BgraDecoder(int maxWidth, int maxHeight) - : max_width(maxWidth), max_height(maxHeight) - { - cinfo.err = jpeg_std_error(&jerr.pub); - jerr.pub.error_exit = safe_error_exit; - jerr.has_error = false; - jpeg_create_decompress(&cinfo); - } - - ~BgraDecoder() { jpeg_destroy_decompress(&cinfo); } - - ulong DecodeBGRA(const byte* jpegData, ulong jpegSize, - byte* output, ulong outputSize, DecodeInfo* info) - { - jerr.has_error = false; - - jpeg_memory_src(&cinfo, jpegData, jpegSize); - if (jerr.has_error) { jpeg_abort_decompress(&cinfo); return 0; } - - if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK || jerr.has_error) { - jpeg_abort_decompress(&cinfo); - return 0; - } - - cinfo.out_color_space = JCS_EXT_BGRA; // Direct BGRA — libjpeg-turbo extension - cinfo.raw_data_out = FALSE; - - jpeg_start_decompress(&cinfo); - if (jerr.has_error) { jpeg_abort_decompress(&cinfo); return 0; } - - int width = cinfo.output_width; - int height = cinfo.output_height; - int rowBytes = width * 4; - ulong totalSize = (ulong)rowBytes * height; - - info->width = width; - info->height = height; - info->components = 4; - info->colorSpace = cinfo.out_color_space; - - if (totalSize > outputSize) { - jpeg_abort_decompress(&cinfo); - return 0; - } - - // Read scanlines directly into caller's buffer - while (cinfo.output_scanline < cinfo.output_height) { - byte* rowPtr = output + cinfo.output_scanline * rowBytes; - jpeg_read_scanlines(&cinfo, &rowPtr, 1); - if (jerr.has_error) { jpeg_abort_decompress(&cinfo); return 0; } - } - - jpeg_finish_decompress(&cinfo); - if (jerr.has_error) { - // Struct may be in a dirty state — abort defensively before reuse - jpeg_abort_decompress(&cinfo); - return 0; - } - return totalSize; - } -}; - -// Decode JPEG to grayscale (for HDR blending) -// Returns: bytes written to output, or 0 on error -// Output format: 8-bit grayscale, row-major -ulong DecodeToGray(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { - struct jpeg_decompress_struct cinfo; - struct jpeg_error_mgr jerr; - - cinfo.err = jpeg_std_error(&jerr); - jpeg_create_decompress(&cinfo); - - jpeg_memory_src(&cinfo, jpegData, jpegSize); - - if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { - jpeg_destroy_decompress(&cinfo); - return 0; - } - - // Request grayscale output - cinfo.out_color_space = JCS_GRAYSCALE; - - jpeg_start_decompress(&cinfo); - - info->width = cinfo.output_width; - info->height = cinfo.output_height; - info->components = cinfo.output_components; - info->colorSpace = cinfo.out_color_space; - - ulong rowStride = cinfo.output_width * cinfo.output_components; - ulong totalSize = rowStride * cinfo.output_height; - - if (totalSize > outputSize) { - jpeg_abort_decompress(&cinfo); - jpeg_destroy_decompress(&cinfo); - return 0; - } - - while (cinfo.output_scanline < cinfo.output_height) { - byte* rowPtr = output + cinfo.output_scanline * rowStride; - jpeg_read_scanlines(&cinfo, &rowPtr, 1); - } - - jpeg_finish_decompress(&cinfo); - jpeg_destroy_decompress(&cinfo); - - return totalSize; -} - -// Decode JPEG to I420 (YUV 4:2:0 planar) for HDR blending with color -// Returns: bytes written to output, or 0 on error -// Output format: Y plane (width*height), U plane (width*height/4), V plane (width*height/4) -ulong DecodeToI420(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { - struct jpeg_decompress_struct cinfo; - struct jpeg_error_mgr jerr; - - cinfo.err = jpeg_std_error(&jerr); - jpeg_create_decompress(&cinfo); - - jpeg_memory_src(&cinfo, jpegData, jpegSize); - - if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { - jpeg_destroy_decompress(&cinfo); - return 0; - } - - // Request raw YUV output (planar, subsampled) - cinfo.raw_data_out = TRUE; - cinfo.out_color_space = JCS_YCbCr; - - jpeg_start_decompress(&cinfo); - - int width = cinfo.output_width; - int height = cinfo.output_height; - - info->width = width; - info->height = height; - info->components = 3; - info->colorSpace = cinfo.out_color_space; - - // I420 size: Y (width*height) + U (width*height/4) + V (width*height/4) - ulong sizeY = width * height; - ulong sizeU = sizeY / 4; - ulong sizeV = sizeU; - ulong totalSize = sizeY + sizeU + sizeV; - - if (totalSize > outputSize) { - jpeg_abort_decompress(&cinfo); - jpeg_destroy_decompress(&cinfo); - return 0; - } - - byte* Y = output; - byte* U = output + sizeY; - byte* V = output + sizeY + sizeU; - - // Read raw data in MCU rows (typically 16 lines for Y, 8 for U/V with 4:2:0) - JSAMPROW y_rows[16]; - JSAMPROW u_rows[8]; - JSAMPROW v_rows[8]; - JSAMPARRAY planes[3] = { y_rows, u_rows, v_rows }; - - int y_stride = width; - int uv_stride = width / 2; - - while (cinfo.output_scanline < cinfo.output_height) { - int lines_to_read = cinfo.output_height - cinfo.output_scanline; - if (lines_to_read > 16) lines_to_read = 16; - - for (int i = 0; i < 16; i++) { - int y_line = cinfo.output_scanline + i; - if (y_line < height) { - y_rows[i] = Y + y_line * y_stride; - } else { - y_rows[i] = Y + (height - 1) * y_stride; // Pad with last line - } - - if (i < 8) { - int uv_line = (cinfo.output_scanline / 2) + i; - if (uv_line < height / 2) { - u_rows[i] = U + uv_line * uv_stride; - v_rows[i] = V + uv_line * uv_stride; - } else { - u_rows[i] = U + (height / 2 - 1) * uv_stride; - v_rows[i] = V + (height / 2 - 1) * uv_stride; - } - } - } - - jpeg_read_raw_data(&cinfo, planes, 16); - } - - jpeg_finish_decompress(&cinfo); - jpeg_destroy_decompress(&cinfo); - - return totalSize; -} - -// Encode grayscale (Gray8) to JPEG -// Returns: bytes written to output, or 0 on error -ulong EncodeGray8(const byte* grayData, int width, int height, int quality, byte* output, ulong outputSize) { - struct jpeg_compress_struct cinfo; - struct jpeg_error_mgr jerr; - - cinfo.err = jpeg_std_error(&jerr); - jpeg_create_compress(&cinfo); - - // Set up memory destination - memory_destination_mgr* dest = jpeg_memory_dest(&cinfo, output, outputSize); - - cinfo.image_width = width; - cinfo.image_height = height; - cinfo.input_components = 1; - cinfo.in_color_space = JCS_GRAYSCALE; - - jpeg_set_defaults(&cinfo); - jpeg_set_quality(&cinfo, quality, TRUE); - - jpeg_start_compress(&cinfo, TRUE); - - JSAMPROW row_pointer[1]; - int row_stride = width; - - while (cinfo.next_scanline < cinfo.image_height) { - row_pointer[0] = (JSAMPROW)&grayData[cinfo.next_scanline * row_stride]; - jpeg_write_scanlines(&cinfo, row_pointer, 1); - } - - jpeg_finish_compress(&cinfo); - - ulong dataSize = dest->data_size; - jpeg_destroy_compress(&cinfo); - - return dataSize; -} - -// Get JPEG dimensions without full decode -int GetJpegInfo(const byte* jpegData, ulong jpegSize, DecodeInfo* info) { - struct jpeg_decompress_struct cinfo; - struct jpeg_error_mgr jerr; - - cinfo.err = jpeg_std_error(&jerr); - jpeg_create_decompress(&cinfo); - - jpeg_memory_src(&cinfo, jpegData, jpegSize); - - if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { - jpeg_destroy_decompress(&cinfo); - return 0; - } - - info->width = cinfo.image_width; - info->height = cinfo.image_height; - info->components = cinfo.num_components; - info->colorSpace = cinfo.jpeg_color_space; - - jpeg_destroy_decompress(&cinfo); - return 1; -} - -extern "C" { - EXPORT YuvEncoder* Create(int width, int height, int quality, ulong size) { - YuvEncoder* enc = new YuvEncoder(width, height, quality, size); - return enc; - } - EXPORT ulong Encode(YuvEncoder* encoder, byte* data, byte* dstBuffer, ulong dstBufferSize) { - return encoder->Encode(data, dstBuffer, dstBufferSize); - } - EXPORT void SetQuality(YuvEncoder* encoder, int quality) { - encoder->SetQuality(quality); - } - EXPORT void SetMode(YuvEncoder* encoder, int mode) { - encoder->SetMode(mode); - } - - EXPORT void Close(YuvEncoder* encoder) - { - delete encoder; - } - - // Decode functions - EXPORT ulong DecodeJpegToGray(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { - return DecodeToGray(jpegData, jpegSize, output, outputSize, info); - } - - EXPORT int GetJpegImageInfo(const byte* jpegData, ulong jpegSize, DecodeInfo* info) { - return GetJpegInfo(jpegData, jpegSize, info); - } - - EXPORT ulong EncodeGray8ToJpeg(const byte* grayData, int width, int height, int quality, byte* output, ulong outputSize) { - return EncodeGray8(grayData, width, height, quality, output, outputSize); - } - - EXPORT ulong DecodeJpegToI420(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { - return DecodeToI420(jpegData, jpegSize, output, outputSize, info); - } - - // Pooled decoder functions - EXPORT I420Decoder* CreateDecoder(int maxWidth, int maxHeight) { - return new I420Decoder(maxWidth, maxHeight); - } - - EXPORT ulong DecoderDecodeI420(I420Decoder* decoder, const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { - return decoder->DecodeI420(jpegData, jpegSize, output, outputSize, info); - } - - EXPORT ulong DecoderDecodeGray(I420Decoder* decoder, const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { - return decoder->DecodeGray(jpegData, jpegSize, output, outputSize, info); - } - - EXPORT void CloseDecoder(I420Decoder* decoder) { - delete decoder; - } - - // BGRA decoder functions (safe error handling for WASM) - EXPORT BgraDecoder* CreateBgraDecoder(int maxWidth, int maxHeight) { - return new BgraDecoder(maxWidth, maxHeight); - } - - EXPORT void CloseBgraDecoder(BgraDecoder* decoder) { - delete decoder; - } - - EXPORT ulong DecoderDecodeBGRA(BgraDecoder* decoder, - const byte* jpegData, ulong jpegSize, - byte* output, ulong outputSize, DecodeInfo* info) { - return decoder->DecodeBGRA(jpegData, jpegSize, output, outputSize, info); - } +// LibJpegWrap.cpp : Defines the functions for the static library. +// +#ifdef _WIN32 +#define EXPORT __declspec(dllexport) +//#include +//#include +//#include +//#include +// +//long long GetPrvMem() { +// PROCESS_MEMORY_COUNTERS_EX pmc; +// if (GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS*)&pmc, sizeof(pmc))){ +// return pmc.PrivateUsage; +// } +// return 0; +//} +// +//// A class to manage memory allocations +//class AllocationBlock { +//public: +// AllocationBlock(const std::string &location, int lineNumber) +// : location_(location), lineNumber_(lineNumber), workingSet_(GetPrvMem()) {} +// +// ~AllocationBlock() { +// long long w = GetPrvMem(); +// long long d = w - workingSet_; +// if (d > 16 * 1024) { +// std::cout << "+" << d << "B of allocated: "<< w << "B in " << location_ << " at " << lineNumber_ << std::endl; +// } +// +// } +// +//private: +// std::string location_; +// int lineNumber_; +// long long workingSet_; +//}; +//#define CHECK_ALLOCATION() AllocationBlock allocationBlock(__FILE__, __LINE__) + +#endif + +#ifndef _WIN32 +#define EXPORT // Linux doesn't require a special export keyword +#endif + +#include +#include +#include +#include +#include +#include + +typedef unsigned char byte; +typedef unsigned long ulong; + +typedef struct { + struct jpeg_destination_mgr pub; /* Public fields */ + byte* buffer; /* Start of the buffer */ + ulong buffer_size; /* Buffer size */ + ulong data_size; /* Final data size */ +} memory_destination_mgr; + +void init_destination(j_compress_ptr cinfo) { + memory_destination_mgr* dest = (memory_destination_mgr*)cinfo->dest; + dest->pub.next_output_byte = dest->buffer; + dest->pub.free_in_buffer = dest->buffer_size; + dest->data_size = 0; // No data written yet +} + +boolean empty_output_buffer(j_compress_ptr cinfo) { + // Handle buffer overflow. This could involve reallocating the buffer + // and updating the relevant fields in the destination manager. + // For simplicity, this example just prints an error and stops. + + std::cerr << "Buffer overflow in custom destination manager\n"; + return FALSE; // Causes the library to terminate with an error. +} + +void term_destination(j_compress_ptr cinfo) { + memory_destination_mgr* dest = (memory_destination_mgr*)cinfo->dest; + dest->data_size = dest->buffer_size - dest->pub.free_in_buffer; + // At this point, dest->data_size contains the size of the JPEG data. +} +memory_destination_mgr* jpeg_memory_dest(j_compress_ptr cinfo, byte* buffer, ulong size) { + if (cinfo->dest == nullptr) { // Allocate memory for the custom manager if necessary + cinfo->dest = (struct jpeg_destination_mgr*) + (*cinfo->mem->alloc_small) ((j_common_ptr)cinfo, JPOOL_PERMANENT, + sizeof(memory_destination_mgr)); + //std::cout << "\n\rAllocating " << sizeof(memory_destination_mgr) << "B for libjpeg buffer.\n\r"; + memory_destination_mgr* dest = (memory_destination_mgr*)cinfo->dest; + dest->pub.init_destination = init_destination; + dest->pub.empty_output_buffer = empty_output_buffer; + dest->pub.term_destination = term_destination; + dest->buffer = buffer; + dest->buffer_size = size; + dest->data_size = 0; // No data written yet + return dest; + } + else + { + memory_destination_mgr* dest = (memory_destination_mgr*)cinfo->dest; + + dest->buffer = buffer; + dest->buffer_size = size; + dest->data_size = 0; // No data written yet + return dest; + } +} + +class YuvEncoder { +public: + + struct jpeg_compress_struct cinfo; + struct jpeg_error_mgr jerr; + + YuvEncoder(const int width, const int height, const int quality, const int bufferSize) + { + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_compress(&cinfo); + cinfo.image_width = width; + cinfo.image_height = height; + cinfo.input_components = 3; + cinfo.in_color_space = JCS_YCbCr; + + + jpeg_set_defaults(&cinfo); + jpeg_set_quality(&cinfo, quality, FALSE); + + cinfo.raw_data_in = TRUE; // Supply downsampled data + cinfo.comp_info[0].h_samp_factor = 2; + cinfo.comp_info[0].v_samp_factor = 2; + cinfo.comp_info[1].h_samp_factor = 1; + cinfo.comp_info[1].v_samp_factor = 1; + cinfo.comp_info[2].h_samp_factor = 1; + cinfo.comp_info[2].v_samp_factor = 1; + //cinfo.dct_method = JDCT_FASTEST; + jpeg_memory_dest(&cinfo, nullptr, bufferSize); + } + void SetQuality(int quality) + { + jpeg_set_quality(&cinfo, quality, FALSE); + } + // 0 - int + // 1 - fast + void SetMode(int mode) + { + if(mode == 0) + cinfo.dct_method = JDCT_ISLOW; + else + cinfo.dct_method = JDCT_FASTEST; + } + ulong Encode(byte* data, byte* dstBuffer, ulong dstBufferSize) + { + //CHECK_ALLOCATION(); + + jpeg_memory_dest(&cinfo, dstBuffer, dstBufferSize); + jpeg_start_compress(&cinfo, TRUE); + + auto width = cinfo.image_width; + auto height = cinfo.image_height; + + // Calculate the sizes of the Y, U, and V planes + size_t sizeY = width * height; + size_t sizeU = sizeY / 4; + //size_t sizeV = sizeU; // Same as sizeU + + // Split yuv420Data into Y, U, and V components + byte* Y = data; + byte* U = data + sizeY; + byte* V = data + sizeY + sizeU; + + while (cinfo.next_scanline < cinfo.image_height) { + JSAMPROW y[16], cb[8], cr[8]; + for (int i = 0; i < 16 && cinfo.next_scanline + i < cinfo.image_height; i++) { + y[i] = &Y[(cinfo.next_scanline + i) * width]; + if (i % 2 == 0) { + cb[i / 2] = &U[((cinfo.next_scanline + i) / 2) * (width / 2)]; + cr[i / 2] = &V[((cinfo.next_scanline + i) / 2) * (width / 2)]; + } + } + JSAMPARRAY planes[3] = { y, cb, cr }; + //{ + //CHECK_ALLOCATION(); + jpeg_write_raw_data(&cinfo, planes, 16); + //} + } + + jpeg_finish_compress(&cinfo); + + memory_destination_mgr* dest = (memory_destination_mgr*)cinfo.dest; + return dest->data_size; + } + ~YuvEncoder() + { + jpeg_destroy_compress(&cinfo); + } +}; +typedef struct YuvEncoder YuvEncoder; + +// Memory source manager for decompression +typedef struct { + struct jpeg_source_mgr pub; + const byte* buffer; + ulong buffer_size; +} memory_source_mgr; + +void init_source(j_decompress_ptr cinfo) { + // No initialization needed +} + +boolean fill_input_buffer(j_decompress_ptr cinfo) { + // Should not be called since we provide all data upfront + return FALSE; +} + +void skip_input_data(j_decompress_ptr cinfo, long num_bytes) { + memory_source_mgr* src = (memory_source_mgr*)cinfo->src; + if (num_bytes > 0) { + src->pub.next_input_byte += num_bytes; + src->pub.bytes_in_buffer -= num_bytes; + } +} + +void term_source(j_decompress_ptr cinfo) { + // No cleanup needed +} + +void jpeg_memory_src(j_decompress_ptr cinfo, const byte* buffer, ulong size) { + memory_source_mgr* src; + + if (cinfo->src == nullptr) { + cinfo->src = (struct jpeg_source_mgr*) + (*cinfo->mem->alloc_small)((j_common_ptr)cinfo, JPOOL_PERMANENT, + sizeof(memory_source_mgr)); + } + + src = (memory_source_mgr*)cinfo->src; + src->pub.init_source = init_source; + src->pub.fill_input_buffer = fill_input_buffer; + src->pub.skip_input_data = skip_input_data; + src->pub.resync_to_restart = jpeg_resync_to_restart; + src->pub.term_source = term_source; + src->buffer = buffer; + src->buffer_size = size; + src->pub.next_input_byte = buffer; + src->pub.bytes_in_buffer = size; +} + +// Decoder result structure +typedef struct { + int width; + int height; + int components; + int colorSpace; +} DecodeInfo; + +// Pooled I420 Decoder - reuses jpeg_decompress_struct across calls +class I420Decoder { +public: + struct jpeg_decompress_struct cinfo; + struct jpeg_error_mgr jerr; + int max_width; + int max_height; + bool initialized; + + I420Decoder(int maxWidth, int maxHeight) + : max_width(maxWidth), max_height(maxHeight), initialized(false) + { + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_decompress(&cinfo); + initialized = true; + } + + ~I420Decoder() + { + if (initialized) { + jpeg_destroy_decompress(&cinfo); + initialized = false; + } + } + + ulong DecodeI420(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) + { + // Set up memory source + if (cinfo.src == nullptr) { + cinfo.src = (struct jpeg_source_mgr*) + (*cinfo.mem->alloc_small)((j_common_ptr)&cinfo, JPOOL_PERMANENT, + sizeof(memory_source_mgr)); + } + + memory_source_mgr* src = (memory_source_mgr*)cinfo.src; + src->pub.init_source = init_source; + src->pub.fill_input_buffer = fill_input_buffer; + src->pub.skip_input_data = skip_input_data; + src->pub.resync_to_restart = jpeg_resync_to_restart; + src->pub.term_source = term_source; + src->buffer = jpegData; + src->buffer_size = jpegSize; + src->pub.next_input_byte = jpegData; + src->pub.bytes_in_buffer = jpegSize; + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + return 0; + } + + // Request raw YUV output + cinfo.raw_data_out = TRUE; + cinfo.out_color_space = JCS_YCbCr; + + jpeg_start_decompress(&cinfo); + + int width = cinfo.output_width; + int height = cinfo.output_height; + + info->width = width; + info->height = height; + info->components = 3; + info->colorSpace = cinfo.out_color_space; + + // I420 size + ulong sizeY = width * height; + ulong sizeU = sizeY / 4; + ulong sizeV = sizeU; + ulong totalSize = sizeY + sizeU + sizeV; + + if (totalSize > outputSize) { + jpeg_abort_decompress(&cinfo); + return 0; + } + + byte* Y = output; + byte* U = output + sizeY; + byte* V = output + sizeY + sizeU; + + JSAMPROW y_rows[16]; + JSAMPROW u_rows[8]; + JSAMPROW v_rows[8]; + JSAMPARRAY planes[3] = { y_rows, u_rows, v_rows }; + + int y_stride = width; + int uv_stride = width / 2; + + while (cinfo.output_scanline < cinfo.output_height) { + for (int i = 0; i < 16; i++) { + int y_line = cinfo.output_scanline + i; + if (y_line < height) { + y_rows[i] = Y + y_line * y_stride; + } else { + y_rows[i] = Y + (height - 1) * y_stride; + } + + if (i < 8) { + int uv_line = (cinfo.output_scanline / 2) + i; + if (uv_line < height / 2) { + u_rows[i] = U + uv_line * uv_stride; + v_rows[i] = V + uv_line * uv_stride; + } else { + u_rows[i] = U + (height / 2 - 1) * uv_stride; + v_rows[i] = V + (height / 2 - 1) * uv_stride; + } + } + } + + jpeg_read_raw_data(&cinfo, planes, 16); + } + + jpeg_finish_decompress(&cinfo); + + return totalSize; + } + + ulong DecodeGray(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) + { + // Set up memory source + if (cinfo.src == nullptr) { + cinfo.src = (struct jpeg_source_mgr*) + (*cinfo.mem->alloc_small)((j_common_ptr)&cinfo, JPOOL_PERMANENT, + sizeof(memory_source_mgr)); + } + + memory_source_mgr* src = (memory_source_mgr*)cinfo.src; + src->pub.init_source = init_source; + src->pub.fill_input_buffer = fill_input_buffer; + src->pub.skip_input_data = skip_input_data; + src->pub.resync_to_restart = jpeg_resync_to_restart; + src->pub.term_source = term_source; + src->buffer = jpegData; + src->buffer_size = jpegSize; + src->pub.next_input_byte = jpegData; + src->pub.bytes_in_buffer = jpegSize; + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + return 0; + } + + // Request grayscale output + cinfo.out_color_space = JCS_GRAYSCALE; + cinfo.raw_data_out = FALSE; + + jpeg_start_decompress(&cinfo); + + info->width = cinfo.output_width; + info->height = cinfo.output_height; + info->components = cinfo.output_components; + info->colorSpace = cinfo.out_color_space; + + ulong rowStride = cinfo.output_width * cinfo.output_components; + ulong totalSize = rowStride * cinfo.output_height; + + if (totalSize > outputSize) { + jpeg_abort_decompress(&cinfo); + return 0; + } + + while (cinfo.output_scanline < cinfo.output_height) { + byte* rowPtr = output + cinfo.output_scanline * rowStride; + jpeg_read_scanlines(&cinfo, &rowPtr, 1); + } + + jpeg_finish_decompress(&cinfo); + + return totalSize; + } +}; +typedef struct I420Decoder I420Decoder; + +// Custom error handler — sets flag instead of calling exit() +// CRITICAL for WASM: default jpeg_error_exit calls exit() which kills the entire app. +// NOTE: setjmp/longjmp is not used because the .NET WASM runtime's linker +// (-mllvm -wasm-enable-sjlj) is incompatible with Emscripten's JS-based +// setjmp/longjmp emulation. Flag-based error recovery avoids this issue. +struct safe_error_mgr { + struct jpeg_error_mgr pub; + bool has_error; +}; + +static void safe_error_exit(j_common_ptr cinfo) { + safe_error_mgr* myerr = (safe_error_mgr*)cinfo->err; + myerr->has_error = true; + // Do NOT call exit() — just set the flag and return. + // The caller checks has_error after each libjpeg call. +} + +// BGRA Decoder — reuses jpeg_decompress_struct, safe error handling +class BgraDecoder { +public: + struct jpeg_decompress_struct cinfo; + safe_error_mgr jerr; + int max_width; + int max_height; + + BgraDecoder(int maxWidth, int maxHeight) + : max_width(maxWidth), max_height(maxHeight) + { + cinfo.err = jpeg_std_error(&jerr.pub); + jerr.pub.error_exit = safe_error_exit; + jerr.has_error = false; + jpeg_create_decompress(&cinfo); + } + + ~BgraDecoder() { jpeg_destroy_decompress(&cinfo); } + + ulong DecodeBGRA(const byte* jpegData, ulong jpegSize, + byte* output, ulong outputSize, DecodeInfo* info) + { + jerr.has_error = false; + + jpeg_memory_src(&cinfo, jpegData, jpegSize); + if (jerr.has_error) { jpeg_abort_decompress(&cinfo); return 0; } + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK || jerr.has_error) { + jpeg_abort_decompress(&cinfo); + return 0; + } + + cinfo.out_color_space = JCS_EXT_BGRA; // Direct BGRA — libjpeg-turbo extension + cinfo.raw_data_out = FALSE; + + jpeg_start_decompress(&cinfo); + if (jerr.has_error) { jpeg_abort_decompress(&cinfo); return 0; } + + int width = cinfo.output_width; + int height = cinfo.output_height; + int rowBytes = width * 4; + ulong totalSize = (ulong)rowBytes * height; + + info->width = width; + info->height = height; + info->components = 4; + info->colorSpace = cinfo.out_color_space; + + if (totalSize > outputSize) { + jpeg_abort_decompress(&cinfo); + return 0; + } + + // Read scanlines directly into caller's buffer + while (cinfo.output_scanline < cinfo.output_height) { + byte* rowPtr = output + cinfo.output_scanline * rowBytes; + jpeg_read_scanlines(&cinfo, &rowPtr, 1); + if (jerr.has_error) { jpeg_abort_decompress(&cinfo); return 0; } + } + + jpeg_finish_decompress(&cinfo); + if (jerr.has_error) { + // Struct may be in a dirty state — abort defensively before reuse + jpeg_abort_decompress(&cinfo); + return 0; + } + return totalSize; + } +}; + +// Decode JPEG to grayscale (for HDR blending) +// Returns: bytes written to output, or 0 on error +// Output format: 8-bit grayscale, row-major +ulong DecodeToGray(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { + struct jpeg_decompress_struct cinfo; + struct jpeg_error_mgr jerr; + + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_decompress(&cinfo); + + jpeg_memory_src(&cinfo, jpegData, jpegSize); + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + jpeg_destroy_decompress(&cinfo); + return 0; + } + + // Request grayscale output + cinfo.out_color_space = JCS_GRAYSCALE; + + jpeg_start_decompress(&cinfo); + + info->width = cinfo.output_width; + info->height = cinfo.output_height; + info->components = cinfo.output_components; + info->colorSpace = cinfo.out_color_space; + + ulong rowStride = cinfo.output_width * cinfo.output_components; + ulong totalSize = rowStride * cinfo.output_height; + + if (totalSize > outputSize) { + jpeg_abort_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + return 0; + } + + while (cinfo.output_scanline < cinfo.output_height) { + byte* rowPtr = output + cinfo.output_scanline * rowStride; + jpeg_read_scanlines(&cinfo, &rowPtr, 1); + } + + jpeg_finish_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + + return totalSize; +} + +// Decode JPEG to I420 (YUV 4:2:0 planar) for HDR blending with color +// Returns: bytes written to output, or 0 on error +// Output format: Y plane (width*height), U plane (width*height/4), V plane (width*height/4) +ulong DecodeToI420(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { + struct jpeg_decompress_struct cinfo; + struct jpeg_error_mgr jerr; + + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_decompress(&cinfo); + + jpeg_memory_src(&cinfo, jpegData, jpegSize); + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + jpeg_destroy_decompress(&cinfo); + return 0; + } + + // Request raw YUV output (planar, subsampled) + cinfo.raw_data_out = TRUE; + cinfo.out_color_space = JCS_YCbCr; + + jpeg_start_decompress(&cinfo); + + int width = cinfo.output_width; + int height = cinfo.output_height; + + info->width = width; + info->height = height; + info->components = 3; + info->colorSpace = cinfo.out_color_space; + + // I420 size: Y (width*height) + U (width*height/4) + V (width*height/4) + ulong sizeY = width * height; + ulong sizeU = sizeY / 4; + ulong sizeV = sizeU; + ulong totalSize = sizeY + sizeU + sizeV; + + if (totalSize > outputSize) { + jpeg_abort_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + return 0; + } + + byte* Y = output; + byte* U = output + sizeY; + byte* V = output + sizeY + sizeU; + + // Read raw data in MCU rows (typically 16 lines for Y, 8 for U/V with 4:2:0) + JSAMPROW y_rows[16]; + JSAMPROW u_rows[8]; + JSAMPROW v_rows[8]; + JSAMPARRAY planes[3] = { y_rows, u_rows, v_rows }; + + int y_stride = width; + int uv_stride = width / 2; + + while (cinfo.output_scanline < cinfo.output_height) { + int lines_to_read = cinfo.output_height - cinfo.output_scanline; + if (lines_to_read > 16) lines_to_read = 16; + + for (int i = 0; i < 16; i++) { + int y_line = cinfo.output_scanline + i; + if (y_line < height) { + y_rows[i] = Y + y_line * y_stride; + } else { + y_rows[i] = Y + (height - 1) * y_stride; // Pad with last line + } + + if (i < 8) { + int uv_line = (cinfo.output_scanline / 2) + i; + if (uv_line < height / 2) { + u_rows[i] = U + uv_line * uv_stride; + v_rows[i] = V + uv_line * uv_stride; + } else { + u_rows[i] = U + (height / 2 - 1) * uv_stride; + v_rows[i] = V + (height / 2 - 1) * uv_stride; + } + } + } + + jpeg_read_raw_data(&cinfo, planes, 16); + } + + jpeg_finish_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + + return totalSize; +} + +// Encode grayscale (Gray8) to JPEG +// Returns: bytes written to output, or 0 on error +ulong EncodeGray8(const byte* grayData, int width, int height, int quality, byte* output, ulong outputSize) { + struct jpeg_compress_struct cinfo; + struct jpeg_error_mgr jerr; + + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_compress(&cinfo); + + // Set up memory destination + memory_destination_mgr* dest = jpeg_memory_dest(&cinfo, output, outputSize); + + cinfo.image_width = width; + cinfo.image_height = height; + cinfo.input_components = 1; + cinfo.in_color_space = JCS_GRAYSCALE; + + jpeg_set_defaults(&cinfo); + jpeg_set_quality(&cinfo, quality, TRUE); + + jpeg_start_compress(&cinfo, TRUE); + + JSAMPROW row_pointer[1]; + int row_stride = width; + + while (cinfo.next_scanline < cinfo.image_height) { + row_pointer[0] = (JSAMPROW)&grayData[cinfo.next_scanline * row_stride]; + jpeg_write_scanlines(&cinfo, row_pointer, 1); + } + + jpeg_finish_compress(&cinfo); + + ulong dataSize = dest->data_size; + jpeg_destroy_compress(&cinfo); + + return dataSize; +} + +#ifndef __EMSCRIPTEN__ +// Rgb24 decode/encode (native targets only; the WASM build exposes BGRA decode only). +// Errors use setjmp/longjmp, the libjpeg-documented recovery: error_exit must not return. +struct jump_error_mgr { + struct jpeg_error_mgr pub; + jmp_buf jump; +}; + +static void jump_error_exit(j_common_ptr cinfo) { + jump_error_mgr* err = (jump_error_mgr*)cinfo->err; + longjmp(err->jump, 1); +} + +static void silent_output_message(j_common_ptr) {} + +// Decode a JPEG to packed Rgb24 (R, G, B; stride = width * 3) at full resolution. +// Chroma is upsampled by libjpeg-turbo's default (fancy) upsampling. +// Returns: bytes written (width * height * 3), or 0 on error / output too small. +ulong DecodeToRgb24(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { + struct jpeg_decompress_struct cinfo; + jump_error_mgr jerr; + + cinfo.err = jpeg_std_error(&jerr.pub); + jerr.pub.error_exit = jump_error_exit; + jerr.pub.output_message = silent_output_message; + if (setjmp(jerr.jump)) { + jpeg_destroy_decompress(&cinfo); + return 0; + } + jpeg_create_decompress(&cinfo); + jpeg_mem_src(&cinfo, jpegData, jpegSize); + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + jpeg_destroy_decompress(&cinfo); + return 0; + } + + cinfo.out_color_space = JCS_RGB; + cinfo.raw_data_out = FALSE; + jpeg_start_decompress(&cinfo); + + ulong rowStride = (ulong)cinfo.output_width * 3; + ulong totalSize = rowStride * cinfo.output_height; + + info->width = cinfo.output_width; + info->height = cinfo.output_height; + info->components = cinfo.output_components; + info->colorSpace = cinfo.out_color_space; + + if (totalSize > outputSize) { + jpeg_destroy_decompress(&cinfo); + return 0; + } + + while (cinfo.output_scanline < cinfo.output_height) { + byte* rowPtr = output + cinfo.output_scanline * rowStride; + jpeg_read_scanlines(&cinfo, &rowPtr, 1); + } + + jpeg_finish_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + return totalSize; +} + +// Encode packed Rgb24 (R, G, B; rows of `stride` bytes) to a baseline JPEG. +// subsampling: 0 = 4:2:0 (libjpeg's jpeg_set_defaults), 1 = 4:4:4 (no chroma subsampling). +// Returns: bytes written to output, or 0 on error / output too small. +ulong EncodeRgb24(const byte* rgbData, int width, int height, int stride, int quality, int subsampling, + byte* output, ulong outputSize) { + struct jpeg_compress_struct cinfo; + jump_error_mgr jerr; + unsigned char* mem = nullptr; + unsigned long memSize = 0; + + cinfo.err = jpeg_std_error(&jerr.pub); + jerr.pub.error_exit = jump_error_exit; + jerr.pub.output_message = silent_output_message; + if (setjmp(jerr.jump)) { + jpeg_destroy_compress(&cinfo); + free(*(unsigned char* volatile*)&mem); // re-read from memory after longjmp + return 0; + } + jpeg_create_compress(&cinfo); + jpeg_mem_dest(&cinfo, &mem, &memSize); + + cinfo.image_width = width; + cinfo.image_height = height; + cinfo.input_components = 3; + cinfo.in_color_space = JCS_RGB; + + jpeg_set_defaults(&cinfo); + jpeg_set_quality(&cinfo, quality, TRUE); + if (subsampling == 1) { + cinfo.comp_info[0].h_samp_factor = 1; + cinfo.comp_info[0].v_samp_factor = 1; + } + + jpeg_start_compress(&cinfo, TRUE); + while (cinfo.next_scanline < cinfo.image_height) { + JSAMPROW row = (JSAMPROW)(rgbData + (ulong)cinfo.next_scanline * stride); + jpeg_write_scanlines(&cinfo, &row, 1); + } + jpeg_finish_compress(&cinfo); + jpeg_destroy_compress(&cinfo); + + ulong written = 0; + if (memSize <= outputSize) { + memcpy(output, mem, memSize); + written = memSize; + } + free(mem); + return written; +} +#endif + +// Get JPEG dimensions without full decode +int GetJpegInfo(const byte* jpegData, ulong jpegSize, DecodeInfo* info) { + struct jpeg_decompress_struct cinfo; + struct jpeg_error_mgr jerr; + + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_decompress(&cinfo); + + jpeg_memory_src(&cinfo, jpegData, jpegSize); + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + jpeg_destroy_decompress(&cinfo); + return 0; + } + + info->width = cinfo.image_width; + info->height = cinfo.image_height; + info->components = cinfo.num_components; + info->colorSpace = cinfo.jpeg_color_space; + + jpeg_destroy_decompress(&cinfo); + return 1; +} + +extern "C" { + EXPORT YuvEncoder* Create(int width, int height, int quality, ulong size) { + YuvEncoder* enc = new YuvEncoder(width, height, quality, size); + return enc; + } + EXPORT ulong Encode(YuvEncoder* encoder, byte* data, byte* dstBuffer, ulong dstBufferSize) { + return encoder->Encode(data, dstBuffer, dstBufferSize); + } + EXPORT void SetQuality(YuvEncoder* encoder, int quality) { + encoder->SetQuality(quality); + } + EXPORT void SetMode(YuvEncoder* encoder, int mode) { + encoder->SetMode(mode); + } + + EXPORT void Close(YuvEncoder* encoder) + { + delete encoder; + } + + // Decode functions + EXPORT ulong DecodeJpegToGray(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { + return DecodeToGray(jpegData, jpegSize, output, outputSize, info); + } + + EXPORT int GetJpegImageInfo(const byte* jpegData, ulong jpegSize, DecodeInfo* info) { + return GetJpegInfo(jpegData, jpegSize, info); + } + + EXPORT ulong EncodeGray8ToJpeg(const byte* grayData, int width, int height, int quality, byte* output, ulong outputSize) { + return EncodeGray8(grayData, width, height, quality, output, outputSize); + } + +#ifndef __EMSCRIPTEN__ + EXPORT ulong DecodeJpegToRgb24(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { + return DecodeToRgb24(jpegData, jpegSize, output, outputSize, info); + } + EXPORT ulong EncodeRgb24ToJpeg(const byte* rgbData, int width, int height, int stride, int quality, int subsampling, + byte* output, ulong outputSize) { + return EncodeRgb24(rgbData, width, height, stride, quality, subsampling, output, outputSize); + } +#endif + EXPORT ulong DecodeJpegToI420(const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { + return DecodeToI420(jpegData, jpegSize, output, outputSize, info); + } + + // Pooled decoder functions + EXPORT I420Decoder* CreateDecoder(int maxWidth, int maxHeight) { + return new I420Decoder(maxWidth, maxHeight); + } + + EXPORT ulong DecoderDecodeI420(I420Decoder* decoder, const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { + return decoder->DecodeI420(jpegData, jpegSize, output, outputSize, info); + } + + EXPORT ulong DecoderDecodeGray(I420Decoder* decoder, const byte* jpegData, ulong jpegSize, byte* output, ulong outputSize, DecodeInfo* info) { + return decoder->DecodeGray(jpegData, jpegSize, output, outputSize, info); + } + + EXPORT void CloseDecoder(I420Decoder* decoder) { + delete decoder; + } + + // BGRA decoder functions (safe error handling for WASM) + EXPORT BgraDecoder* CreateBgraDecoder(int maxWidth, int maxHeight) { + return new BgraDecoder(maxWidth, maxHeight); + } + + EXPORT void CloseBgraDecoder(BgraDecoder* decoder) { + delete decoder; + } + + EXPORT ulong DecoderDecodeBGRA(BgraDecoder* decoder, + const byte* jpegData, ulong jpegSize, + byte* output, ulong outputSize, DecodeInfo* info) { + return decoder->DecodeBGRA(jpegData, jpegSize, output, outputSize, info); + } } \ No newline at end of file diff --git a/src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs b/src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs new file mode 100644 index 0000000..ffff31d --- /dev/null +++ b/src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs @@ -0,0 +1,224 @@ +using FluentAssertions; +using Xunit; + +namespace ModelingEvolution.Mjpeg.Tests; + +/// +/// Rgb24 decode/encode through . +/// The reference pair in TestData was produced by libjpeg-turbo's own tools (2.1.2): +/// cjpeg -quality 90 of a 48x32 synthetic image (gradients, a red/blue checker, a pattern), +/// and djpeg -ppm of that JPEG, stripped of its PPM header (rgb24-reference.rgb). +/// +public class JpegCodecRgb24Tests +{ + private const int RefWidth = 48; + private const int RefHeight = 32; + + private static byte[] ReferenceJpeg => File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "TestData", "rgb24-reference.jpg")); + private static byte[] ReferenceRgb => File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "TestData", "rgb24-reference.rgb")); + + [Fact] + public void DecodeRgb24_ReferenceJpeg_MatchesDjpegOutputExactly() + { + using var codec = new JpegCodec(); + + using var frame = codec.DecodeRgb24(ReferenceJpeg); + + frame.Header.Should().Be(new FrameHeader(RefWidth, RefHeight, RefWidth * 3, PixelFormat.Rgb24, RefWidth * RefHeight * 3)); + frame.Data.ToArray().Should().Equal(ReferenceRgb); + } + + [Fact] + public void DecodeRgb24_ToBuffer_FillsBufferAndReturnsHeader() + { + using var codec = new JpegCodec(); + var buffer = new byte[RefWidth * RefHeight * 3 + 100]; + + var header = codec.DecodeRgb24(ReferenceJpeg, buffer); + + header.Should().Be(new FrameHeader(RefWidth, RefHeight, RefWidth * 3, PixelFormat.Rgb24, RefWidth * RefHeight * 3)); + buffer.AsSpan(0, header.Length).ToArray().Should().Equal(ReferenceRgb); + } + + [Fact] + public void DecodeRgb24_OutputBufferTooSmall_Throws() + { + using var codec = new JpegCodec(); + var buffer = new byte[RefWidth * RefHeight * 3 - 1]; + + var act = () => codec.DecodeRgb24(ReferenceJpeg, buffer); + + act.Should().Throw(); + } + + [Fact] + public void DecodeRgb24_CorruptJpeg_ThrowsAndCodecStaysUsable() + { + using var codec = new JpegCodec(); + + // Garbage after SOI: libjpeg raises a fatal error; the native side recovers via longjmp. + var act = () => codec.DecodeRgb24(new byte[] { 0xFF, 0xD8, 0x00, 0x01, 0x02 }, new byte[RefWidth * RefHeight * 3]); + act.Should().Throw(); + + using var frame = codec.DecodeRgb24(ReferenceJpeg); + frame.Data.ToArray().Should().Equal(ReferenceRgb); + } + + [Theory] + [InlineData(JpegSubsampling.Ratio420, 90, 30.0)] + [InlineData(JpegSubsampling.Ratio444, 92, 40.0)] + public void EncodeRgb24_ThenDecode_RoundTripsAbovePsnrBound(JpegSubsampling subsampling, int quality, double minPsnr) + { + using var codec = new JpegCodec(); + var (w, h, rgb) = SyntheticImage(checker: false); + using var source = new FrameImage(new FrameHeader(w, h, w * 3, PixelFormat.Rgb24, rgb.Length), rgb); + + using var jpeg = codec.EncodeRgb24(source, quality, subsampling); + using var decoded = codec.DecodeRgb24(jpeg.Data); + + jpeg.Data.Span[0].Should().Be(0xFF); + jpeg.Data.Span[1].Should().Be(0xD8); + decoded.Header.Width.Should().Be(w); + decoded.Header.Height.Should().Be(h); + var psnr = Psnr(rgb, decoded.Data.Span); + Console.WriteLine($"PSNR {subsampling} q{quality}: {psnr:F2} dB"); + psnr.Should().BeGreaterThan(minPsnr); + } + + [Fact] + public void EncodeRgb24_Ratio444_KeepsSharpChromaEdgesBetterThanRatio420() + { + using var codec = new JpegCodec(); + var (w, h, rgb) = SyntheticImage(); + using var source = new FrameImage(new FrameHeader(w, h, w * 3, PixelFormat.Rgb24, rgb.Length), rgb); + + using var jpeg420 = codec.EncodeRgb24(source, 92, JpegSubsampling.Ratio420); + using var jpeg444 = codec.EncodeRgb24(source, 92, JpegSubsampling.Ratio444); + using var decoded420 = codec.DecodeRgb24(jpeg420.Data); + using var decoded444 = codec.DecodeRgb24(jpeg444.Data); + + Psnr(rgb, decoded444.Data.Span).Should().BeGreaterThan(Psnr(rgb, decoded420.Data.Span)); + } + + [Fact] + public void EncodeRgb24_PaddedStride_IgnoresPaddingBytes() + { + using var codec = new JpegCodec(); + var (w, h, rgb) = SyntheticImage(); + int stride = w * 3 + 16; + var padded = new byte[stride * h]; + for (int y = 0; y < h; y++) + { + rgb.AsSpan(y * w * 3, w * 3).CopyTo(padded.AsSpan(y * stride)); + padded.AsSpan(y * stride + w * 3, 16).Fill(0xAB); + } + using var tight = new FrameImage(new FrameHeader(w, h, w * 3, PixelFormat.Rgb24, rgb.Length), rgb); + using var loose = new FrameImage(new FrameHeader(w, h, stride, PixelFormat.Rgb24, padded.Length), padded); + + using var a = codec.EncodeRgb24(tight, 90); + using var b = codec.EncodeRgb24(loose, 90); + + b.Data.ToArray().Should().Equal(a.Data.ToArray()); + } + + [Fact] + public void EncodeRgb24_ToBuffer_ReturnsBytesWritten() + { + using var codec = new JpegCodec(); + var (w, h, rgb) = SyntheticImage(); + using var source = new FrameImage(new FrameHeader(w, h, w * 3, PixelFormat.Rgb24, rgb.Length), rgb); + var output = new byte[rgb.Length * 2]; + + int written = codec.EncodeRgb24(source, 90, output); + + using var viaPool = codec.EncodeRgb24(source, 90); + output.AsSpan(0, written).ToArray().Should().Equal(viaPool.Data.ToArray()); + } + + [Fact] + public void EncodeRgb24_OutputBufferTooSmall_Throws() + { + using var codec = new JpegCodec(); + var (w, h, rgb) = SyntheticImage(); + using var source = new FrameImage(new FrameHeader(w, h, w * 3, PixelFormat.Rgb24, rgb.Length), rgb); + + var act = () => codec.EncodeRgb24(source, 90, new byte[64]); + + act.Should().Throw(); + } + + [Fact] + public void EncodeRgb24_NonRgb24Frame_ThrowsNotSupported() + { + using var codec = new JpegCodec(); + var gray = new byte[16 * 16]; + using var frame = new FrameImage(new FrameHeader(16, 16, 16, PixelFormat.Gray8, gray.Length), gray); + + var act = () => codec.EncodeRgb24(frame, 90); + + act.Should().Throw(); + } + + [Theory] + [InlineData(0)] + [InlineData(101)] + public void EncodeRgb24_QualityOutOfRange_Throws(int quality) + { + using var codec = new JpegCodec(); + var (w, h, rgb) = SyntheticImage(); + using var source = new FrameImage(new FrameHeader(w, h, w * 3, PixelFormat.Rgb24, rgb.Length), rgb); + + var act = () => codec.EncodeRgb24(source, quality); + + act.Should().Throw(); + } + + [Fact] + public void Gray8AndI420Decode_OfReferenceJpeg_AreUnchanged() + { + using var codec = new JpegCodec(); + + using var gray = codec.Decode(ReferenceJpeg); + using var i420 = codec.DecodeI420(ReferenceJpeg); + + gray.Header.Should().Be(new FrameHeader(RefWidth, RefHeight, RefWidth, PixelFormat.Gray8, RefWidth * RefHeight)); + i420.Header.Format.Should().Be(PixelFormat.I420); + i420.Header.Length.Should().Be(RefWidth * RefHeight * 3 / 2); + // Gray8 is the JPEG's luma plane, which is also the Y plane of the I420 decode. + gray.Data.ToArray().Should().Equal(i420.Data.Slice(0, RefWidth * RefHeight).ToArray()); + } + + // 96x64: smooth gradients (left), a saturated red/blue checker with 4-px cells (middle, or a third gradient + // when checker is false), a smooth pattern (right). The checker is the worst case for 4:2:0 chroma. + private static (int Width, int Height, byte[] Rgb) SyntheticImage(bool checker = true) + { + const int w = 96, h = 64; + var rgb = new byte[w * h * 3]; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + int i = (y * w + x) * 3; + (byte r, byte g, byte b) = x switch + { + < 32 => ((byte)(x * 8), (byte)(y * 4), (byte)128), + < 64 when !checker => ((byte)(255 - x * 2), (byte)(x * 3), (byte)(y * 3)), + < 64 => ((x / 4 + y / 4) % 2 == 0) ? ((byte)255, (byte)0, (byte)0) : ((byte)0, (byte)0, (byte)255), + _ => ((byte)(x * 2), (byte)(255 - y * 3), (byte)((x + y) * 2)) + }; + rgb[i] = r; rgb[i + 1] = g; rgb[i + 2] = b; + } + return (w, h, rgb); + } + + private static double Psnr(ReadOnlySpan expected, ReadOnlySpan actual) + { + double sum = 0; + for (int i = 0; i < expected.Length; i++) + { + double d = expected[i] - actual[i]; + sum += d * d; + } + double mse = sum / expected.Length; + return mse == 0 ? double.PositiveInfinity : 10 * Math.Log10(255.0 * 255.0 / mse); + } +} diff --git a/src/ModelingEvolution.Mjpeg.Tests/MjpegHdrEngineTests.cs b/src/ModelingEvolution.Mjpeg.Tests/MjpegHdrEngineTests.cs index e05fff3..02fe436 100644 --- a/src/ModelingEvolution.Mjpeg.Tests/MjpegHdrEngineTests.cs +++ b/src/ModelingEvolution.Mjpeg.Tests/MjpegHdrEngineTests.cs @@ -182,6 +182,12 @@ public FrameImage DecodeI420(ReadOnlyMemory jpegData) return new FrameImage(header, data); } + // The HDR engine never uses Rgb24; these members exist only to satisfy IJpegCodec. + public FrameHeader DecodeRgb24(ReadOnlyMemory jpegData, Memory outputBuffer) => throw new NotSupportedException(); + public FrameImage DecodeRgb24(ReadOnlyMemory jpegData) => throw new NotSupportedException(); + public int EncodeRgb24(in FrameImage frame, int quality, Memory outputBuffer, JpegSubsampling subsampling = JpegSubsampling.Ratio420) => throw new NotSupportedException(); + public FrameImage EncodeRgb24(in FrameImage frame, int quality, JpegSubsampling subsampling = JpegSubsampling.Ratio420) => throw new NotSupportedException(); + public int Encode(in FrameImage frame, Memory outputBuffer) { EncodeCallCount++; diff --git a/src/ModelingEvolution.Mjpeg.Tests/ModelingEvolution.Mjpeg.Tests.csproj b/src/ModelingEvolution.Mjpeg.Tests/ModelingEvolution.Mjpeg.Tests.csproj index f149831..5e3aac0 100644 --- a/src/ModelingEvolution.Mjpeg.Tests/ModelingEvolution.Mjpeg.Tests.csproj +++ b/src/ModelingEvolution.Mjpeg.Tests/ModelingEvolution.Mjpeg.Tests.csproj @@ -32,6 +32,10 @@ + + + + diff --git a/src/ModelingEvolution.Mjpeg.Tests/TestData/rgb24-reference.jpg b/src/ModelingEvolution.Mjpeg.Tests/TestData/rgb24-reference.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fd5a2a1fa1c17a248ea4722422a9213fd82fe185 GIT binary patch literal 1215 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 literal 0 HcmV?d00001 diff --git a/src/ModelingEvolution.Mjpeg/IJpegCodec.cs b/src/ModelingEvolution.Mjpeg/IJpegCodec.cs index 1d16d90..1051e9b 100644 --- a/src/ModelingEvolution.Mjpeg/IJpegCodec.cs +++ b/src/ModelingEvolution.Mjpeg/IJpegCodec.cs @@ -12,6 +12,18 @@ public enum DctMethod : byte Float = 1 } +/// +/// Chroma subsampling used when encoding Rgb24 to JPEG. +/// +public enum JpegSubsampling : byte +{ + /// 4:2:0 - chroma at half resolution in both directions; libjpeg's default (jpeg_set_defaults). + Ratio420 = 0, + + /// 4:4:4 - chroma at full resolution; larger files, no colour bleeding at sharp edges. + Ratio444 = 1 +} + /// /// JPEG encoding and decoding interface. /// Thread-safe for concurrent encoding/decoding operations. @@ -70,6 +82,40 @@ public interface IJpegCodec : IDisposable /// FrameImage containing the JPEG data. Caller must dispose. FrameImage Encode(in FrameImage frame); + /// + /// Decodes JPEG data to packed Rgb24 (R, G, B; stride = width * 3) at full resolution. + /// + /// The JPEG compressed data. + /// Buffer to receive decoded pixels. Must hold at least width * height * 3 bytes. + /// Frame header describing the decoded image (). + FrameHeader DecodeRgb24(ReadOnlyMemory jpegData, Memory outputBuffer); + + /// + /// Decodes JPEG data to packed Rgb24 (R, G, B; stride = width * 3), renting the output buffer from the shared pool. + /// + /// The JPEG compressed data. + /// FrameImage containing the decoded pixels. Caller must dispose. + FrameImage DecodeRgb24(ReadOnlyMemory jpegData); + + /// + /// Encodes an Rgb24 frame to a baseline JPEG. + /// + /// The frame to encode. Its format must be ; rows are read with . + /// JPEG quality (1-100), applied with libjpeg's jpeg_set_quality (baseline tables). + /// Buffer to receive JPEG data. Must be large enough. + /// Chroma subsampling; (libjpeg's default) unless specified. + /// Number of bytes written to outputBuffer. + int EncodeRgb24(in FrameImage frame, int quality, Memory outputBuffer, JpegSubsampling subsampling = JpegSubsampling.Ratio420); + + /// + /// Encodes an Rgb24 frame to a baseline JPEG, renting the output buffer from the shared pool. + /// + /// The frame to encode. Its format must be . + /// JPEG quality (1-100). + /// Chroma subsampling; (libjpeg's default) unless specified. + /// FrameImage containing the JPEG data (Length = encoded size). Caller must dispose. + FrameImage EncodeRgb24(in FrameImage frame, int quality, JpegSubsampling subsampling = JpegSubsampling.Ratio420); + /// /// Default JPEG quality (1-100). Higher values produce better quality but larger files. /// diff --git a/src/ModelingEvolution.Mjpeg/JpegCodec.cs b/src/ModelingEvolution.Mjpeg/JpegCodec.cs index eb15c42..cc38d14 100644 --- a/src/ModelingEvolution.Mjpeg/JpegCodec.cs +++ b/src/ModelingEvolution.Mjpeg/JpegCodec.cs @@ -298,6 +298,141 @@ public FrameImage Encode(in FrameImage frame) return new FrameImage(header, owner); } + /// + /// Stateless native call: safe to use concurrently on one codec instance. + public unsafe FrameHeader DecodeRgb24(ReadOnlyMemory jpegData, Memory outputBuffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + using var inputHandle = jpegData.Pin(); + using var outputHandle = outputBuffer.Pin(); + + nint input = (nint)inputHandle.Pointer; + nint output = (nint)outputHandle.Pointer; + ulong inputLength = (ulong)jpegData.Length; + ulong outputLength = (ulong)outputBuffer.Length; + + var (bytesWritten, info) = RequireRgb24Support(() => + { + var n = JpegTurboNative.DecodeJpegToRgb24(input, inputLength, output, outputLength, out var decoded); + return (n, decoded); + }); + + if (bytesWritten == 0) + throw new InvalidOperationException("Failed to decode JPEG image to Rgb24 (corrupt data or output buffer too small)."); + + return new FrameHeader(info.Width, info.Height, info.Width * 3, PixelFormat.Rgb24, (int)bytesWritten); + } + + /// + /// Stateless native call: safe to use concurrently on one codec instance. + public unsafe FrameImage DecodeRgb24(ReadOnlyMemory jpegData) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + FrameHeader info; + using (var inputHandle = jpegData.Pin()) + { + if (JpegTurboNative.GetJpegImageInfo((nint)inputHandle.Pointer, (ulong)jpegData.Length, out var header) == 0) + throw new InvalidOperationException("Failed to read JPEG header."); + info = new FrameHeader(header.Width, header.Height, header.Width * 3, PixelFormat.Rgb24, header.Width * header.Height * 3); + } + + var owner = MemoryPool.Shared.Rent(info.Length); + try + { + var decoded = DecodeRgb24(jpegData, owner.Memory); + return new FrameImage(decoded, owner); + } + catch + { + owner.Dispose(); + throw; + } + } + + /// + /// Stateless native call: safe to use concurrently on one codec instance. Uses the integer DCT. + public unsafe int EncodeRgb24(in FrameImage frame, int quality, Memory outputBuffer, JpegSubsampling subsampling = JpegSubsampling.Ratio420) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ValidateRgb24Encode(frame.Header, quality, subsampling); + + using var inputHandle = frame.Data.Pin(); + using var outputHandle = outputBuffer.Pin(); + var header = frame.Header; + nint input = (nint)inputHandle.Pointer; + nint output = (nint)outputHandle.Pointer; + ulong outputLength = (ulong)outputBuffer.Length; + + var bytesWritten = RequireRgb24Support(() => JpegTurboNative.EncodeRgb24ToJpeg( + input, header.Width, header.Height, header.Stride, quality, (int)subsampling, output, outputLength)); + + if (bytesWritten == 0) + throw new InvalidOperationException("Failed to encode Rgb24 image to JPEG (output buffer too small or encoder error)."); + + return (int)bytesWritten; + } + + /// + /// + /// Rents a worst-case buffer (libjpeg-turbo's tjBufSize bound for the chosen subsampling) from the shared pool. + /// + public FrameImage EncodeRgb24(in FrameImage frame, int quality, JpegSubsampling subsampling = JpegSubsampling.Ratio420) + { + ValidateRgb24Encode(frame.Header, quality, subsampling); + + var owner = MemoryPool.Shared.Rent(MaxJpegSize(frame.Header.Width, frame.Header.Height, subsampling)); + try + { + int length = EncodeRgb24(frame, quality, owner.Memory, subsampling); + var header = new FrameHeader(frame.Header.Width, frame.Header.Height, length, frame.Header.Format, length); + return new FrameImage(header, owner); + } + catch + { + owner.Dispose(); + throw; + } + } + + private static void ValidateRgb24Encode(in FrameHeader header, int quality, JpegSubsampling subsampling) + { + if (header.Format != PixelFormat.Rgb24) + throw new NotSupportedException($"EncodeRgb24 requires PixelFormat.Rgb24. Got: {header.Format}"); + if (header.Width <= 0 || header.Height <= 0 || header.Stride < header.Width * 3) + throw new ArgumentException($"Invalid Rgb24 frame header: {header}", nameof(header)); + if (quality < 1 || quality > 100) + throw new ArgumentOutOfRangeException(nameof(quality), "Quality must be between 1 and 100."); + if (!Enum.IsDefined(subsampling)) + throw new ArgumentOutOfRangeException(nameof(subsampling)); + } + + // libjpeg-turbo tjBufSize: PAD(w, mcuw) * PAD(h, mcuh) * (2 + chromasf) + 2048, chromasf = 4 * 64 / (mcuw * mcuh). + private static int MaxJpegSize(int width, int height, JpegSubsampling subsampling) + { + int mcu = subsampling == JpegSubsampling.Ratio444 ? 8 : 16; + long chroma = 4L * 64 / (mcu * mcu); + long size = (long)Pad(width, mcu) * Pad(height, mcu) * (2 + chroma) + 2048; + return checked((int)size); + + static int Pad(int v, int p) => (v + p - 1) / p * p; + } + + private static T RequireRgb24Support(Func call) + { + try + { + return call(); + } + catch (EntryPointNotFoundException e) + { + throw new NotSupportedException( + "Rgb24 JPEG decode/encode is not available in the LibJpegWrap native library for this platform " + + $"({System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier}); it ships for linux-x64.", e); + } + } + /// /// Disposes the native encoder and decoder resources. /// diff --git a/src/ModelingEvolution.Mjpeg/JpegTurboNative.cs b/src/ModelingEvolution.Mjpeg/JpegTurboNative.cs index 99f61e6..02685ec 100644 --- a/src/ModelingEvolution.Mjpeg/JpegTurboNative.cs +++ b/src/ModelingEvolution.Mjpeg/JpegTurboNative.cs @@ -54,6 +54,13 @@ internal static class JpegTurboNative [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] internal static extern int GetJpegImageInfo(nint jpegData, ulong jpegSize, out DecodeInfo info); + // Rgb24 decode/encode (stateless; linux-x64 native only in this release) + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + internal static extern ulong DecodeJpegToRgb24(nint jpegData, ulong jpegSize, nint output, ulong outputSize, out DecodeInfo info); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + internal static extern ulong EncodeRgb24ToJpeg(nint rgbData, int width, int height, int stride, int quality, int subsampling, nint output, ulong outputSize); + /// /// Decode result info from native library. /// diff --git a/src/ModelingEvolution.Mjpeg/libs/linux-x64/LibJpegWrap.so b/src/ModelingEvolution.Mjpeg/libs/linux-x64/LibJpegWrap.so index 332e395d7f12400c508cead11a2fbe65c6128c5a..19c821c56b7d14a462ea4c871d01a3261bcba9cd 100644 GIT binary patch literal 27112 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 ZNRZ`9zNv_0%YV$zIp`G=V=$r0{|Dx|uuA{{ From 4c4e8d12714637de65c8b6d9f45ba09901968269 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Mon, 26 Oct 2026 20:08:11 +0000 Subject: [PATCH 2/2] fix: review of #2 - GetJpegInfo no longer exits the process; EncodeRgb24 checks data length - GetJpegInfo (behind GetJpegImageInfo, used by every pooled decode) now uses the longjmp error handler: a malformed header (e.g. SOF precision 12) returns 0 and the managed side throws InvalidOperationException instead of libjpeg calling exit(). Test: pooled DecodeRgb24 of a JPEG with SOF precision 12 throws; with the previous .so the same test aborts the test host ("Unsupported JPEG data precision 12"). - EncodeRgb24 throws ArgumentException before the P/Invoke when frame data is shorter than Stride * (Height - 1) + Width * 3 (both overloads; test). - Gray8/I420 test compares against outputs stored from master add0195. - Doc comments name NotSupportedException / InvalidOperationException / ArgumentException; README states the four new IJpegCodec members are breaking for custom implementations. - linux-x64 LibJpegWrap.so rebuilt the same way (ubuntu:22.04); exports, NEEDED and GLIBC floor (2.14) unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8 --- README.md | 5 +- src/LibJpegWrap/LibJpegWrap.cpp | 13 ++++- .../JpegCodecRgb24Tests.cs | 46 ++++++++++++++++-- .../TestData/gray8-reference.gray | 1 + .../TestData/i420-reference.yuv | 1 + src/ModelingEvolution.Mjpeg/IJpegCodec.cs | 12 +++++ src/ModelingEvolution.Mjpeg/JpegCodec.cs | 13 +++-- .../libs/linux-x64/LibJpegWrap.so | Bin 27112 -> 27112 bytes 8 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 src/ModelingEvolution.Mjpeg.Tests/TestData/gray8-reference.gray create mode 100644 src/ModelingEvolution.Mjpeg.Tests/TestData/i420-reference.yuv diff --git a/README.md b/README.md index 19a0e67..4e5e123 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,8 @@ using var jpeg = codec.EncodeRgb24(frame, quality: 90); // Encode() takes I420 ### Rgb24 Decode/Encode -`Decode`/`Encode` work on Gray8 and I420. For packed Rgb24 (R, G, B) use: +`Decode`/`Encode` work on Gray8 and I420. For packed Rgb24 (R, G, B) use the four `IJpegCodec` members below +(added in 1.5.0). **Breaking change for custom `IJpegCodec` implementations:** they must add these four members. | Method | Result | |--------|--------| @@ -241,6 +242,8 @@ using var jpeg = codec.EncodeRgb24(frame, quality: 90); // Encode() takes I420 chroma at full resolution (sharp coloured edges, larger files). `FrameHeader.Stride` may include row padding. - The Rgb24 calls are stateless native calls: they ignore the codec's `Quality`/`DctMethod` and are safe to call concurrently on one `JpegCodec`. +- Errors: `InvalidOperationException` for undecodable data or a too-small output buffer; `ArgumentException` when the + frame data is shorter than `Stride * (Height - 1) + Width * 3`; `ArgumentOutOfRangeException` for quality outside 1-100. - Platforms: the native Rgb24 entry points ship in the **linux-x64** `LibJpegWrap.so`. On linux-arm64 and win-x64 the bundled binaries predate them, and the Rgb24 methods throw `NotSupportedException`. diff --git a/src/LibJpegWrap/LibJpegWrap.cpp b/src/LibJpegWrap/LibJpegWrap.cpp index 5442fff..10b32a3 100644 --- a/src/LibJpegWrap/LibJpegWrap.cpp +++ b/src/LibJpegWrap/LibJpegWrap.cpp @@ -803,9 +803,20 @@ ulong EncodeRgb24(const byte* rgbData, int width, int height, int stride, int qu // Get JPEG dimensions without full decode int GetJpegInfo(const byte* jpegData, ulong jpegSize, DecodeInfo* info) { struct jpeg_decompress_struct cinfo; +#ifndef __EMSCRIPTEN__ + // A malformed header (e.g. bad SOF precision) is a fatal libjpeg error; the default handler calls exit(). + jump_error_mgr jerr; + cinfo.err = jpeg_std_error(&jerr.pub); + jerr.pub.error_exit = jump_error_exit; + jerr.pub.output_message = silent_output_message; + if (setjmp(jerr.jump)) { + jpeg_destroy_decompress(&cinfo); + return 0; + } +#else struct jpeg_error_mgr jerr; - cinfo.err = jpeg_std_error(&jerr); +#endif jpeg_create_decompress(&cinfo); jpeg_memory_src(&cinfo, jpegData, jpegSize); diff --git a/src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs b/src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs index ffff31d..fcccac9 100644 --- a/src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs +++ b/src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs @@ -174,18 +174,56 @@ public void EncodeRgb24_QualityOutOfRange_Throws(int quality) } [Fact] - public void Gray8AndI420Decode_OfReferenceJpeg_AreUnchanged() + public void Gray8AndI420Decode_OfReferenceJpeg_MatchOutputOfPreviousRelease() { + // gray8-reference.gray / i420-reference.yuv: Decode / DecodeI420 of rgb24-reference.jpg on master add0195 (v1.4.1 natives). using var codec = new JpegCodec(); using var gray = codec.Decode(ReferenceJpeg); using var i420 = codec.DecodeI420(ReferenceJpeg); gray.Header.Should().Be(new FrameHeader(RefWidth, RefHeight, RefWidth, PixelFormat.Gray8, RefWidth * RefHeight)); + gray.Data.ToArray().Should().Equal(File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "TestData", "gray8-reference.gray"))); i420.Header.Format.Should().Be(PixelFormat.I420); - i420.Header.Length.Should().Be(RefWidth * RefHeight * 3 / 2); - // Gray8 is the JPEG's luma plane, which is also the Y plane of the I420 decode. - gray.Data.ToArray().Should().Equal(i420.Data.Slice(0, RefWidth * RefHeight).ToArray()); + i420.Data.ToArray().Should().Equal(File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "TestData", "i420-reference.yuv"))); + } + + [Fact] + public void DecodeRgb24_Pooled_BadSofPrecision_ThrowsInsteadOfExitingProcess() + { + using var codec = new JpegCodec(); + var jpeg = ReferenceJpeg; + int sof = IndexOfMarker(jpeg, 0xC0); + jpeg[sof + 4] = 12; // SOF0 sample precision: baseline libjpeg-turbo supports 8 only -> fatal error in jpeg_read_header + + var act = () => codec.DecodeRgb24(jpeg); + + act.Should().Throw(); + using var frame = codec.DecodeRgb24(ReferenceJpeg); + frame.Data.ToArray().Should().Equal(ReferenceRgb); + } + + [Fact] + public void EncodeRgb24_DataShorterThanHeader_ThrowsBeforeNativeCall() + { + using var codec = new JpegCodec(); + var (w, h, rgb) = SyntheticImage(); + // Header claims a padded stride the data does not have. + using var frame = new FrameImage(new FrameHeader(w, h, w * 3 + 16, PixelFormat.Rgb24, rgb.Length), rgb); + + var pooled = () => codec.EncodeRgb24(frame, 90); + var buffered = () => codec.EncodeRgb24(frame, 90, new byte[rgb.Length * 2]); + + pooled.Should().Throw(); + buffered.Should().Throw(); + } + + private static int IndexOfMarker(byte[] jpeg, byte marker) + { + for (int i = 0; i < jpeg.Length - 1; i++) + if (jpeg[i] == 0xFF && jpeg[i + 1] == marker) + return i; + throw new InvalidOperationException($"Marker FF{marker:X2} not found."); } // 96x64: smooth gradients (left), a saturated red/blue checker with 4-px cells (middle, or a third gradient diff --git a/src/ModelingEvolution.Mjpeg.Tests/TestData/gray8-reference.gray b/src/ModelingEvolution.Mjpeg.Tests/TestData/gray8-reference.gray new file mode 100644 index 0000000..fc94cbf --- /dev/null +++ b/src/ModelingEvolution.Mjpeg.Tests/TestData/gray8-reference.gray @@ -0,0 +1 @@ +!&+/26;@DHNQJLMKJLMKÆÆÉÍÍËÌÏÒÓÔÖØÙÛÜ $).26:?DGLQUPMMLPMMLÄÅÇÊÌÎÑÔÏÐÔ×ÙÚÛÛ %(-26;?DILQVZ !NIJM !NIJMÆÇÇÇÈÊÌÌÖÖ×××ÙÛÝ$)-17:?CHMQUZ^!HQOJ!HQOJÁÄÇÉÌÏÐÏÍÏÒÓÖÚÝß#(-15:>CFLPTY^bJOQH!JOQH!ÀÂÅÇÉÍÎÎÑÖÚÛÛÜÜÚ#',159?CGKPTX]bfMJIN! MJIN! ÂÂÄÇÈÉÍÐÐÔÖÔÖÛàâ(,159>CGLPUY]bgkLMMPLMMP¿¿ÄËËÈËÑÓÓͼ¾ÁÂ+/49CFLPTY^bJOQH!JOQH!ÀÂÅÇÉÍÎÎÑÖÚÛÛÜÜÚ#',159?CGKPTX]bfMJIN! MJIN! ÂÂÄÇÈÉÍÐÐÔÖÔÖÛàâ(,159>CGLPUY]bgkLMMPLMMP¿¿ÄËËÈËÑÓÓͼ¾ÁÂ+/49The JPEG compressed data. /// Buffer to receive decoded pixels. Must hold at least width * height * 3 bytes. /// Frame header describing the decoded image (). + /// The data is not a decodable JPEG, or the output buffer is too small. + /// The bundled native library has no Rgb24 support on this platform (linux-arm64, win-x64); it ships for linux-x64. FrameHeader DecodeRgb24(ReadOnlyMemory jpegData, Memory outputBuffer); /// @@ -95,6 +97,8 @@ public interface IJpegCodec : IDisposable /// /// The JPEG compressed data. /// FrameImage containing the decoded pixels. Caller must dispose. + /// The data is not a decodable JPEG. + /// The bundled native library has no Rgb24 support on this platform (linux-arm64, win-x64); it ships for linux-x64. FrameImage DecodeRgb24(ReadOnlyMemory jpegData); /// @@ -105,6 +109,10 @@ public interface IJpegCodec : IDisposable /// Buffer to receive JPEG data. Must be large enough. /// Chroma subsampling; (libjpeg's default) unless specified. /// Number of bytes written to outputBuffer. + /// The frame is not , or the platform has no native Rgb24 support (linux-arm64, win-x64). + /// The header is invalid or the frame data is shorter than Stride * (Height - 1) + Width * 3. + /// is outside 1-100. + /// The encoder failed or the output buffer is too small. int EncodeRgb24(in FrameImage frame, int quality, Memory outputBuffer, JpegSubsampling subsampling = JpegSubsampling.Ratio420); /// @@ -114,6 +122,10 @@ public interface IJpegCodec : IDisposable /// JPEG quality (1-100). /// Chroma subsampling; (libjpeg's default) unless specified. /// FrameImage containing the JPEG data (Length = encoded size). Caller must dispose. + /// The frame is not , or the platform has no native Rgb24 support (linux-arm64, win-x64). + /// The header is invalid or the frame data is shorter than Stride * (Height - 1) + Width * 3. + /// is outside 1-100. + /// The encoder failed. FrameImage EncodeRgb24(in FrameImage frame, int quality, JpegSubsampling subsampling = JpegSubsampling.Ratio420); /// diff --git a/src/ModelingEvolution.Mjpeg/JpegCodec.cs b/src/ModelingEvolution.Mjpeg/JpegCodec.cs index cc38d14..e26a3ff 100644 --- a/src/ModelingEvolution.Mjpeg/JpegCodec.cs +++ b/src/ModelingEvolution.Mjpeg/JpegCodec.cs @@ -356,7 +356,7 @@ public unsafe FrameImage DecodeRgb24(ReadOnlyMemory jpegData) public unsafe int EncodeRgb24(in FrameImage frame, int quality, Memory outputBuffer, JpegSubsampling subsampling = JpegSubsampling.Ratio420) { ObjectDisposedException.ThrowIf(_disposed, this); - ValidateRgb24Encode(frame.Header, quality, subsampling); + ValidateRgb24Encode(frame, quality, subsampling); using var inputHandle = frame.Data.Pin(); using var outputHandle = outputBuffer.Pin(); @@ -380,7 +380,7 @@ public unsafe int EncodeRgb24(in FrameImage frame, int quality, Memory out /// public FrameImage EncodeRgb24(in FrameImage frame, int quality, JpegSubsampling subsampling = JpegSubsampling.Ratio420) { - ValidateRgb24Encode(frame.Header, quality, subsampling); + ValidateRgb24Encode(frame, quality, subsampling); var owner = MemoryPool.Shared.Rent(MaxJpegSize(frame.Header.Width, frame.Header.Height, subsampling)); try @@ -396,12 +396,17 @@ public FrameImage EncodeRgb24(in FrameImage frame, int quality, JpegSubsampling } } - private static void ValidateRgb24Encode(in FrameHeader header, int quality, JpegSubsampling subsampling) + private static void ValidateRgb24Encode(in FrameImage frame, int quality, JpegSubsampling subsampling) { + var header = frame.Header; if (header.Format != PixelFormat.Rgb24) throw new NotSupportedException($"EncodeRgb24 requires PixelFormat.Rgb24. Got: {header.Format}"); if (header.Width <= 0 || header.Height <= 0 || header.Stride < header.Width * 3) - throw new ArgumentException($"Invalid Rgb24 frame header: {header}", nameof(header)); + throw new ArgumentException($"Invalid Rgb24 frame header: {header}", nameof(frame)); + long required = (long)header.Stride * (header.Height - 1) + (long)header.Width * 3; + if (frame.Data.Length < required) + throw new ArgumentException( + $"Frame data holds {frame.Data.Length} bytes; {header} needs at least {required}.", nameof(frame)); if (quality < 1 || quality > 100) throw new ArgumentOutOfRangeException(nameof(quality), "Quality must be between 1 and 100."); if (!Enum.IsDefined(subsampling)) diff --git a/src/ModelingEvolution.Mjpeg/libs/linux-x64/LibJpegWrap.so b/src/ModelingEvolution.Mjpeg/libs/linux-x64/LibJpegWrap.so index 19c821c56b7d14a462ea4c871d01a3261bcba9cd..f149055589b864042ca7df0eb6745c1658ae5064 100644 GIT binary patch delta 2105 zcmZ8ieM}Q)7=Q05wngm~EEa?b##0eckT9Wu*fNmAFzv7c%fzX}!ZI|Nu+1n#n1$H^ zN#~=BiDuDk$y}xm(q$o=x*9P$qYQN)x|oShZW>AU)Od&a zZ#A5c_G!(r#+;z`5iW?1aK@sTfpyV2t(mivUz@-ea1^1-=ZzLxteeYQEwo8DH+CQ| zXs{||_-e>-FCEjRab0v*XXeu_6rq0)URw0#3>J!{58AP&kU^p)NId1E#|1M;%%ju# zm9h09aZAXoCS*21wum%t6>W$x^VWR&IAU&We{N8xB&7OgNY&>(7x8(@mgQ2*aJq&u z$(<)kosD9mEX9-gtI_CU-L`6k>-?U|y~?ocxwk=MfIQMKagW(No(`EvYsbpR?0@%@*^T)lAJ}HL_2dfbEy=W zYvv!wl$={Mex0l+{y+akKZrKvPMu}KUP!PqY3p!|g-X649#;2zcpK-CpS`LUZUY09JK^5fGU-nXkp&uq+@{y&nhjo zDh=l4lKY9g znO;L5CRQDP_ejw^MV>=l3sNJk&;2x<9y%f?((MHmhNSDE(O0J^#aotd51X3vm!jD4 zemPJ+TYH0;X}KqSc;m66s5%qLXUpKG(0yCBK5|a5`m-ssAaj}tJIwh7bl_YnlAUc5 z16|0%)q}CTkxK4@nf&dm)Ll@dL5yx0u+yt#oy$`LBiTD|>(dp#{ zTq#W~Pq3}fFt+WQqCCC*jZmlu=K?n#YxPk%7r16{Q2~ZAHJEx?bkMr;xVL*C-806^ z8F0JCa0B3WjNu-D^NitiXh6ppE*{)Ax?4Vpb5VFj+I&l&qC_JDO)Ji3NN)@4-~*X9 zLhsZIwi0wa;CjIw;izxfG-|Jy@lrO%S^ZFHB4J)_NUSvI7-o?rp#^nxwjzydqbC({ z_>#s}#)Vg_9VJU;@whImOlQ4qmG?Ac=VNGNRWs-CKB`)(ZNc1?3APo45>Es{J{(H<9L1tn#8?%CL_u_x>w8QQ(om$u5@1y_&T=6QfL#0Ed@4l zq;Q!ab_=yb#eNi3E;Op9N5eu88+ZXkQk$^r7~eX|3`p+|$?^{_hN8~dN12C;nj(~J z4`exC>=(p``QkmP6ME;AW?(O|5Y<1Ut5UJaP3o1xArxvFz5dIESZ}}$D}>s1B?YV4 zv!cYZ(o%BK5zj?5zzIb;xb;oEd?mQRd zC!H9S<-xtu3&eE1_4kw`7g9olRw3YWMA=T-*foCOI_WboqmZiI-BRzaZIrdycHYL;#83b`FuaVXapaYFqom0-C zP~UFwyeAPZBRTvzB#3Y2H5i_98ZkTvcgvX#J?RQronc~sL}@mhO+_kQBfB#uM+c$6 zG2Ici%T@Y9L3}A4>i4!<2~*;6lB*fEuv_4qXU)n1;I?1K^UZ0VD3RHjGZ|AqDb4&O z^q;;rdR5mPT!a;wDyLkQKHi%Z7vy^DrmAD`tW-%dvx?YFy}q|YIYe9BN3!gG;XCij zvZb+^FF^M!ja7Vd>JzT6Z$x>i2BZ?A%b87oK<4F`wX0`&o4=Vfd$H~K1d?d9@3pTrw&U{=a#NOv;CUwSq?)3#ff=OMPHGY{ z5+^it7^4I<0Y`v|KpiaXKpyA>T7fQLG4LzkI$$f%32Xy41G|7nfjz)8z)B2HgNROG zm=YN$axKsWY*167lnL)f7YIA>5wP_>62S1=9=pF^@;OZ(Be~1!X|wy6We4dRK{H&~Lhswf6*Zb$OPBl{(z(#|zu!bB~m&0ln28Jp6xaewo z{!)tGq*EXKE|j7|Lno>=E(|XlG9LF)`2H2B>O>as|C!={mtQVxY9gJ5F?2E+E{vE} zr9!Xn$nxtCz77_jf(ru|h_!MlTnjh@u`Hid!$T4YMwJP_d8}>+v*c$Mzd1##3tGwJ zy!C=h9LGHa7dMWZh-pQS<4oX`cw@4gLM1EX9Ye+;u=3*zBlnjl(2-1!0ow6}H z0(6doTS*obEo8dxk^@CkXcakClrVMuRap*E>bZ#ro4CWTiJ_DpSD`$C(8ZztTQUD) z5?Q=p!ey*RuDI71KT(lQB~Du9eqORz!(5m~@*MG6mG4idBDIc7bT6@0BxqZT;R;ua JySAc&`48&L&j|nk