diff --git a/README.md b/README.md index 12f311d..4e5e123 100644 --- a/README.md +++ b/README.md @@ -221,10 +221,32 @@ 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 the four `IJpegCodec` members below +(added in 1.5.0). **Breaking change for custom `IJpegCodec` implementations:** they must add these four members. + +| 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`. +- 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`. + --- ## HDR Blending API @@ -716,6 +738,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..10b32a3 100644 --- a/src/LibJpegWrap/LibJpegWrap.cpp +++ b/src/LibJpegWrap/LibJpegWrap.cpp @@ -1,779 +1,915 @@ -// 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; +#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); + + 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..fcccac9 --- /dev/null +++ b/src/ModelingEvolution.Mjpeg.Tests/JpegCodecRgb24Tests.cs @@ -0,0 +1,262 @@ +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_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.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 + // 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/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¿¿ÄËËÈËÑÓÓͼ¾ÁÂ+/49 +/// 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,52 @@ 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 (). + /// 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); + + /// + /// 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. + /// 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); + + /// + /// 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. + /// 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); + + /// + /// 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. + /// 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); + /// /// 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..e26a3ff 100644 --- a/src/ModelingEvolution.Mjpeg/JpegCodec.cs +++ b/src/ModelingEvolution.Mjpeg/JpegCodec.cs @@ -298,6 +298,146 @@ 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, 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, 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 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(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)) + 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 332e395..f149055 100644 Binary files a/src/ModelingEvolution.Mjpeg/libs/linux-x64/LibJpegWrap.so and b/src/ModelingEvolution.Mjpeg/libs/linux-x64/LibJpegWrap.so differ