diff --git a/.github/workflows/test-webp.yml b/.github/workflows/test-webp.yml
new file mode 100644
index 0000000..04395ef
--- /dev/null
+++ b/.github/workflows/test-webp.yml
@@ -0,0 +1,57 @@
+name: Test WebP
+
+on:
+ push:
+ branches:
+ - 'main'
+ paths:
+ - 'webp/**'
+ - '.github/workflows/test-webp.yml'
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ test-webp:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ name: Linux (libwebp)
+ install: sudo apt-get update && sudo apt-get install -y libwebp-dev
+ expected-decode: libwebp
+ expected-encode: libwebp
+ - os: ubuntu-latest
+ name: Linux (NgEngine)
+ install: sudo find /usr -name 'libwebp*.so*' -exec mv {} {}.disabled \;
+ expected-decode: Java (ngengine)
+ - os: macos-latest
+ name: macOS (ImageIO)
+ expected-decode: macOS ImageIO
+ - os: windows-latest
+ name: Windows (WIC)
+ install: winget install --id 9PG2DK419DRG --accept-package-agreements --accept-source-agreements --source msstore --silent
+ expected-decode: Windows WIC
+
+ name: ${{ matrix.name }}
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 25
+
+ - name: Setup native libraries
+ if: matrix.install
+ run: ${{ matrix.install }}
+
+ - name: Make gradlew executable
+ if: runner.os != 'Windows'
+ run: chmod +x ./gradlew
+
+ - name: Run WebP tests
+ shell: bash
+ run: ./gradlew :webp:test -Dexpected.decode="${{ matrix.expected-decode }}" -Dexpected.encode="${{ matrix.expected-encode }}"
\ No newline at end of file
diff --git a/.run/TestingApp.run.xml b/.run/TestingApp.run.xml
new file mode 100644
index 0000000..5982232
--- /dev/null
+++ b/.run/TestingApp.run.xml
@@ -0,0 +1,12 @@
+
Automatically selects the best available backend: + *
All methods throw if no backend is available; check {@link #isAvailable()} first + * or handle {@link UnsupportedOperationException}. + */ +@SuppressWarnings("unused") // API +public interface PlatformWebPDecoder { + PlatformWebPDecoder INSTANCE = new PlatformWebPDecoderImpl(); + + /** + * Returns the name of the active backend (e.g. "libwebp", "macOS ImageIO", "Windows WIC"). + * + * @throws UnsupportedOperationException if no backend is available + */ + String backendName(); + + /** + * Decodes a WebP image into packed ARGB pixels. + * + * @param webpData raw WebP file bytes + * @return decoded image with ARGB pixel data + * @throws IllegalStateException if the data is invalid or decoding fails + * @throws UnsupportedOperationException if no backend is available + */ + DecodedImage decode(byte[] webpData); + + /** + * Returns the dimensions of a WebP image without fully decoding it. + * + * @param webpData raw WebP file bytes + * @return {@code int[]{width, height}} + * @throws IllegalStateException if the data is invalid + * @throws UnsupportedOperationException if no backend is available + */ + int[] getInfo(byte[] webpData); + + /** + * Returns {@code true} if the given data is a valid WebP image. + * + * @param data raw bytes to check + */ + default boolean isWebP(byte[] data) { + if (data == null || data.length == 0) return false; + + try { + int[] info = getInfo(data); + return info[0] > 0 && info[1] > 0; + } catch (Exception e) { + return false; + } + } + + boolean isAvailable(); +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/encoder/PlatformWebPEncoder.java b/webp/src/main/java/org/redlance/platformtools/webp/encoder/PlatformWebPEncoder.java new file mode 100644 index 0000000..3001fd5 --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/encoder/PlatformWebPEncoder.java @@ -0,0 +1,57 @@ +package org.redlance.platformtools.webp.encoder; + +import org.redlance.platformtools.webp.impl.PlatformWebPEncoderImpl; + +/** + * Platform-native WebP image encoder. + * + *
Automatically selects the best available backend: + *
All methods throw if no backend is available; check {@link #isAvailable()} first + * or handle {@link UnsupportedOperationException}. + */ +@SuppressWarnings("unused") // API +public interface PlatformWebPEncoder { + PlatformWebPEncoder INSTANCE = new PlatformWebPEncoderImpl(); + + /** + * Returns the name of the active backend (e.g. "libwebp", "macOS ImageIO", "Windows WIC"). + * + * @throws UnsupportedOperationException if no backend is available + */ + String backendName(); + + /** + * Encodes ARGB pixels into a lossless WebP image. + * + * @param argb pixel data as packed ARGB integers, + * length must be {@code width * height} + * @param width image width in pixels + * @param height image height in pixels + * @return WebP file bytes + * @throws IllegalStateException if encoding fails + * @throws UnsupportedOperationException if no backend is available + */ + byte[] encodeLossless(int[] argb, int width, int height); + + /** + * Encodes ARGB pixels into a lossy WebP image. + * + * @param argb pixel data as packed ARGB integers, + * length must be {@code width * height} + * @param width image width in pixels + * @param height image height in pixels + * @param quality compression quality, {@code 0.0f} (smallest) to {@code 1.0f} (best) + * @return WebP file bytes + * @throws IllegalStateException if encoding fails + * @throws UnsupportedOperationException if no backend is available + */ + byte[] encodeLossy(int[] argb, int width, int height, float quality); + + boolean isAvailable(); +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/PlatformWebPDecoderImpl.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/PlatformWebPDecoderImpl.java new file mode 100644 index 0000000..dbc258f --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/PlatformWebPDecoderImpl.java @@ -0,0 +1,73 @@ +package org.redlance.platformtools.webp.impl; + +import org.jetbrains.annotations.Nullable; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; +import org.redlance.platformtools.webp.impl.ngengine.NgEngineDecoder; +import org.redlance.platformtools.webp.impl.libwebp.LibWebPDecoder; +import org.redlance.platformtools.webp.impl.macos.MacOSImageIODecoder; +import org.redlance.platformtools.webp.impl.windows.WindowsCodecsDecoder; + +public final class PlatformWebPDecoderImpl implements PlatformWebPDecoder { + private final @Nullable PlatformWebPDecoder delegate = createBackend(); + + private PlatformWebPDecoder requireDelegate() { + if (this.delegate == null) throw new UnsupportedOperationException("No WebP decoder backend available"); + return this.delegate; + } + + @Override + public String backendName() { + return requireDelegate().backendName(); + } + + @Override + public DecodedImage decode(byte[] webpData) { + return requireDelegate().decode(webpData); + } + + @Override + public int[] getInfo(byte[] webpData) { + return requireDelegate().getInfo(webpData); + } + + @Override + public boolean isAvailable() { + return this.delegate != null && this.delegate.isAvailable(); + } + + private static @Nullable PlatformWebPDecoder createBackend() { + // 1. libwebp (all platforms) + try { + PlatformWebPDecoder backend = LibWebPDecoder.tryCreate(); + if (backend != null) return backend; + } catch (Throwable ignored) { + } + + // 2. System frameworks + String os = System.getProperty("os.name", "").toLowerCase(); + + if (os.contains("mac")) { + try { + PlatformWebPDecoder backend = MacOSImageIODecoder.tryCreate(); + if (backend != null) return backend; + } catch (Throwable ignored) { + } + } else if (os.contains("win")) { + try { + PlatformWebPDecoder backend = WindowsCodecsDecoder.tryCreate(); + if (backend != null) return backend; + } catch (Throwable ignored) { + } + } + + // 3. Pure-Java fallback (ngengine image-webp-java) + try { + PlatformWebPDecoder backend = NgEngineDecoder.tryCreate(); + if (backend != null) return backend; + } catch (Throwable ignored) { + } + + return null; + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/PlatformWebPEncoderImpl.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/PlatformWebPEncoderImpl.java new file mode 100644 index 0000000..2dbf633 --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/PlatformWebPEncoderImpl.java @@ -0,0 +1,63 @@ +package org.redlance.platformtools.webp.impl; + +import org.jetbrains.annotations.Nullable; +import org.redlance.platformtools.webp.encoder.PlatformWebPEncoder; +import org.redlance.platformtools.webp.impl.libwebp.LibWebPEncoder; + +public class PlatformWebPEncoderImpl implements PlatformWebPEncoder { + private final @Nullable PlatformWebPEncoder delegate = createBackend(); + + private PlatformWebPEncoder requireDelegate() { + if (this.delegate == null) throw new UnsupportedOperationException("No WebP encoder backend available"); + return this.delegate; + } + + @Override + public String backendName() { + return requireDelegate().backendName(); + } + + @Override + public byte[] encodeLossless(int[] argb, int width, int height) { + return requireDelegate().encodeLossless(argb, width, height); + } + + @Override + public byte[] encodeLossy(int[] argb, int width, int height, float quality) { + return requireDelegate().encodeLossy(argb, width, height, quality); + } + + @Override + public boolean isAvailable() { + return this.delegate != null && this.delegate.isAvailable(); + } + + private static @Nullable PlatformWebPEncoder createBackend() { + // 1. libwebp (all platforms) + try { + PlatformWebPEncoder backend = LibWebPEncoder.tryCreate(); + if (backend != null) return backend; + } catch (Throwable ignored) { + } + + // 2. System frameworks + /*String os = System.getProperty("os.name", "").toLowerCase(); + + if (os.contains("mac")) { + try { + PlatformWebPEncoder backend = MacOSImageIOEncoder.tryCreate(); + if (backend != null) return backend; + } catch (Throwable ignored) { + } + } else if (os.contains("win")) { + try { + PlatformWebPEncoder backend = WindowsCodecsEncoder.tryCreate(); + if (backend != null) return backend; + } catch (Throwable ignored) { + } + }*/ + + // No pure-Java fallback encoder available + return null; + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPDecoder.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPDecoder.java new file mode 100644 index 0000000..8e2aabe --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPDecoder.java @@ -0,0 +1,127 @@ +package org.redlance.platformtools.webp.impl.libwebp; + +import org.jetbrains.annotations.Nullable; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; +import java.nio.ByteOrder; + +public final class LibWebPDecoder implements PlatformWebPDecoder { + private final LibWebPLibrary lib; + + // uint8_t* WebPDecodeARGB(const uint8_t* data, size_t size, int* w, int* h) + private final MethodHandle webPDecodeARGB; + // int WebPGetInfo(const uint8_t* data, size_t size, int* w, int* h) + private final MethodHandle webPGetInfo; + + private LibWebPDecoder(LibWebPLibrary lib) { + this.lib = lib; + + Linker linker = Linker.nativeLinker(); + + this.webPDecodeARGB = linker.downcallHandle( + lib.lookup.find("WebPDecodeARGB").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, + ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + this.webPGetInfo = linker.downcallHandle( + lib.lookup.find("WebPGetInfo").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_INT, ValueLayout.ADDRESS, + ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + } + + public static @Nullable LibWebPDecoder tryCreate() { + LibWebPLibrary lib = LibWebPLibrary.getInstance(); + return lib != null ? new LibWebPDecoder(lib) : null; + } + + @Override + public String backendName() { + return "libwebp"; + } + + @Override + public DecodedImage decode(byte[] webpData) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment wPtr = arena.allocate(ValueLayout.JAVA_INT); + MemorySegment hPtr = arena.allocate(ValueLayout.JAVA_INT); + + MemorySegment dataSeg = arena.allocate(webpData.length); + dataSeg.copyFrom(MemorySegment.ofArray(webpData)); + + MemorySegment result = (MemorySegment) this.webPDecodeARGB.invokeExact( + dataSeg, (long) webpData.length, wPtr, hPtr + ); + if (result.equals(MemorySegment.NULL)) { + throw new IllegalStateException("WebPDecodeARGB failed: invalid or unsupported WebP data"); + } + + int w = wPtr.get(ValueLayout.JAVA_INT, 0); + int h = hPtr.get(ValueLayout.JAVA_INT, 0); + + int[] pixels = result.reinterpret((long) w * h * 4).toArray( + ValueLayout.JAVA_INT.withOrder(ByteOrder.BIG_ENDIAN) + ); + this.lib.webPFree.invokeExact(result); + + return new DecodedImage(pixels, w, h); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("WebP decode failed", t); + } + } + + @Override + public int[] getInfo(byte[] webpData) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment wPtr = arena.allocate(ValueLayout.JAVA_INT); + MemorySegment hPtr = arena.allocate(ValueLayout.JAVA_INT); + + MemorySegment dataSeg = arena.allocate(webpData.length); + dataSeg.copyFrom(MemorySegment.ofArray(webpData)); + + int ok = (int) this.webPGetInfo.invokeExact( + dataSeg, (long) webpData.length, wPtr, hPtr + ); + if (ok == 0) { + throw new IllegalStateException("WebPGetInfo failed: invalid or unsupported WebP data"); + } + + return new int[] { + wPtr.get(ValueLayout.JAVA_INT, 0), + hPtr.get(ValueLayout.JAVA_INT, 0) + }; + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("WebP getInfo failed", t); + } + } + + @Override + public boolean isWebP(byte[] data) { + if (data == null || data.length == 0) return false; + + try (Arena arena = Arena.ofConfined()) { + MemorySegment dataSeg = arena.allocate(data.length); + dataSeg.copyFrom(MemorySegment.ofArray(data)); + + return (int) this.webPGetInfo.invokeExact(dataSeg, (long) data.length, MemorySegment.NULL, MemorySegment.NULL) != 0; + } catch (Throwable t) { + return false; + } + } + + @Override + public boolean isAvailable() { + return true; + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPEncoder.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPEncoder.java new file mode 100644 index 0000000..6c86b6d --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPEncoder.java @@ -0,0 +1,108 @@ +package org.redlance.platformtools.webp.impl.libwebp; + +import org.jetbrains.annotations.Nullable; +import org.redlance.platformtools.webp.encoder.PlatformWebPEncoder; + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; + +public final class LibWebPEncoder implements PlatformWebPEncoder { + private final LibWebPLibrary lib; + + // size_t WebPEncodeLosslessBGRA(const uint8_t* bgra, int w, int h, int stride, uint8_t** output) + private final MethodHandle webPEncodeLosslessBGRA; + // size_t WebPEncodeBGRA(const uint8_t* bgra, int w, int h, int stride, float quality, uint8_t** output) + private final MethodHandle webPEncodeBGRA; + + private LibWebPEncoder(LibWebPLibrary lib) { + this.lib = lib; + + Linker linker = Linker.nativeLinker(); + + this.webPEncodeLosslessBGRA = linker.downcallHandle( + lib.lookup.find("WebPEncodeLosslessBGRA").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, + ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, + ValueLayout.ADDRESS + ) + ); + this.webPEncodeBGRA = linker.downcallHandle( + lib.lookup.find("WebPEncodeBGRA").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, + ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, + ValueLayout.JAVA_FLOAT, ValueLayout.ADDRESS + ) + ); + } + + public static @Nullable LibWebPEncoder tryCreate() { + LibWebPLibrary lib = LibWebPLibrary.getInstance(); + return lib != null ? new LibWebPEncoder(lib) : null; + } + + @Override + public String backendName() { + return "libwebp"; + } + + @Override + public byte[] encodeLossless(int[] argb, int width, int height) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outputPtr = arena.allocate(ValueLayout.ADDRESS); + + MemorySegment argbSeg = arena.allocate((long) argb.length * 4); + argbSeg.copyFrom(MemorySegment.ofArray(argb)); + + long size = (long) this.webPEncodeLosslessBGRA.invokeExact( + argbSeg, width, height, width * 4, outputPtr + ); + if (size == 0) { + throw new IllegalStateException("WebPEncodeLosslessBGRA failed"); + } + + MemorySegment encoded = outputPtr.get(ValueLayout.ADDRESS, 0).reinterpret(size); + byte[] result = encoded.toArray(ValueLayout.JAVA_BYTE); + + this.lib.webPFree.invokeExact(outputPtr.get(ValueLayout.ADDRESS, 0)); + return result; + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("WebP lossless encode failed", t); + } + } + + @Override + public byte[] encodeLossy(int[] argb, int width, int height, float quality) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outputPtr = arena.allocate(ValueLayout.ADDRESS); + + MemorySegment argbSeg = arena.allocate((long) argb.length * 4); + argbSeg.copyFrom(MemorySegment.ofArray(argb)); + + long size = (long) this.webPEncodeBGRA.invokeExact( + argbSeg, width, height, width * 4, quality * 100.0f, outputPtr + ); + if (size == 0) { + throw new IllegalStateException("WebPEncodeBGRA failed"); + } + + MemorySegment encoded = outputPtr.get(ValueLayout.ADDRESS, 0).reinterpret(size); + byte[] result = encoded.toArray(ValueLayout.JAVA_BYTE); + + this.lib.webPFree.invokeExact(outputPtr.get(ValueLayout.ADDRESS, 0)); + return result; + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("WebP lossy encode failed", t); + } + } + + @Override + public boolean isAvailable() { + return true; + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPLibrary.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPLibrary.java new file mode 100644 index 0000000..40a4b8d --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/libwebp/LibWebPLibrary.java @@ -0,0 +1,74 @@ +package org.redlance.platformtools.webp.impl.libwebp; + +import org.jetbrains.annotations.Nullable; + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; +import java.nio.file.Path; + +public final class LibWebPLibrary { + final SymbolLookup lookup; + final MethodHandle webPFree; + + private LibWebPLibrary(SymbolLookup lookup) { + this.lookup = lookup; + + this.webPFree = Linker.nativeLinker().downcallHandle( + lookup.find("WebPFree").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.ADDRESS) + ); + } + + private static class Holder { + static final @Nullable LibWebPLibrary INSTANCE = tryLoad(); + } + + public static @Nullable LibWebPLibrary getInstance() { + return Holder.INSTANCE; + } + + private static @Nullable LibWebPLibrary tryLoad() { + for (String name : new String[]{"webp", "libwebp", "libwebp-7", "libwebp.so.7"}) { + try { + return new LibWebPLibrary(SymbolLookup.libraryLookup(name, Arena.global())); + } catch (Throwable ignored) { + } + } + + String os = System.getProperty("os.name", "").toLowerCase(); + String[] paths; + + if (os.contains("mac")) { + paths = new String[]{ + "/opt/homebrew/lib/libwebp.dylib", + "/usr/local/lib/libwebp.dylib", + "/opt/homebrew/lib/libwebp.7.dylib", + "/usr/local/lib/libwebp.7.dylib" + }; + } else if (os.contains("win")) { + String programFiles = System.getenv("ProgramFiles"); + String localAppData = System.getenv("LOCALAPPDATA"); + paths = new String[]{ + "libwebp.dll", + programFiles + "\\libwebp\\bin\\libwebp.dll", + localAppData + "\\libwebp\\bin\\libwebp.dll" + }; + } else { + paths = new String[]{ + "/usr/lib/libwebp.so", + "/usr/lib/x86_64-linux-gnu/libwebp.so", + "/usr/lib/aarch64-linux-gnu/libwebp.so", + "/usr/local/lib/libwebp.so" + }; + } + + for (String path : paths) { + try { + return new LibWebPLibrary(SymbolLookup.libraryLookup(Path.of(path), Arena.global())); + } catch (Throwable ignored) { + } + } + + return null; + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSFrameworks.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSFrameworks.java new file mode 100644 index 0000000..c61c9d5 --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSFrameworks.java @@ -0,0 +1,119 @@ +package org.redlance.platformtools.webp.impl.macos; + +import org.jetbrains.annotations.Nullable; + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; + +public final class MacOSFrameworks { + static final int kCFStringEncodingUTF8 = 0x08000100; + static final int kCGImageAlphaLast = 3; + static final int kCGImageAlphaFirst = 4; + static final int kCGImageAlphaNoneSkipLast = 5; + static final int kCGImageAlphaNoneSkipFirst = 6; + static final int kCGImageAlphaPremultipliedFirst = 2; + static final int kCGImageAlphaPremultipliedLast = 1; + static final int kCGBitmapByteOrder32Little = 2 << 12; + static final int kCFNumberFloat64Type = 13; + + final SymbolLookup lookup; + final SymbolLookup imageIO; + + final MethodHandle cfRelease; + final MethodHandle cgColorSpaceCreateDeviceRGB; + final MethodHandle cgColorSpaceRelease; + final MethodHandle cgImageRelease; + /*private final MethodHandle cfStringCreateWithCString; + + final MethodHandle cfDataCreateMutable; + final MethodHandle cgImageDestCreateWithData;*/ + + private MacOSFrameworks(SymbolLookup combined, SymbolLookup imageIO) { + this.lookup = combined; + this.imageIO = imageIO; + + Linker linker = Linker.nativeLinker(); + + this.cfRelease = linker.downcallHandle( + combined.find("CFRelease").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.ADDRESS) + ); + /*this.cfStringCreateWithCString = linker.downcallHandle( + combined.find("CFStringCreateWithCString").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT + ) + );*/ + this.cgColorSpaceCreateDeviceRGB = linker.downcallHandle( + combined.find("CGColorSpaceCreateDeviceRGB").orElseThrow(), + FunctionDescriptor.of(ValueLayout.ADDRESS) + ); + this.cgColorSpaceRelease = linker.downcallHandle( + combined.find("CGColorSpaceRelease").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.ADDRESS) + ); + this.cgImageRelease = linker.downcallHandle( + combined.find("CGImageRelease").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.ADDRESS) + ); + + /*this.cfDataCreateMutable = linker.downcallHandle( + combined.find("CFDataCreateMutable").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG + ) + ); + this.cgImageDestCreateWithData = linker.downcallHandle( + imageIO.find("CGImageDestinationCreateWithData").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, ValueLayout.ADDRESS + ) + );*/ + } + + private static class Holder { + static final @Nullable MacOSFrameworks INSTANCE = tryCreate(); + } + + public static @Nullable MacOSFrameworks getInstance() { + return Holder.INSTANCE; + } + + private static @Nullable MacOSFrameworks tryCreate() { + try { + return create(); + } catch (Throwable t) { + return null; + } + } + + public static MacOSFrameworks create() { + SymbolLookup defaultLookup = Linker.nativeLinker().defaultLookup(); + SymbolLookup io = SymbolLookup.libraryLookup( + "/System/Library/Frameworks/ImageIO.framework/ImageIO", Arena.global() + ); + SymbolLookup combined = name -> io.find(name).or(() -> defaultLookup.find(name)); + return new MacOSFrameworks(combined, io); + } + + /*MemorySegment createCFString(Arena arena, String s) throws Throwable { + return (MemorySegment) cfStringCreateWithCString.invokeExact( + MemorySegment.NULL, arena.allocateFrom(s), kCFStringEncodingUTF8 + ); + }*/ + + static void unpremultiplyAlpha(int[] argb) { + for (int i = 0; i < argb.length; i++) { + int pixel = argb[i]; + int a = (pixel >> 24) & 0xFF; + if (a == 0) { + argb[i] = 0; + } else if (a < 255) { + int r = Math.min(255, (((pixel >> 16) & 0xFF) * 255 + a / 2) / a); + int g = Math.min(255, (((pixel >> 8) & 0xFF) * 255 + a / 2) / a); + int b = Math.min(255, ((pixel & 0xFF) * 255 + a / 2) / a); + argb[i] = (a << 24) | (r << 16) | (g << 8) | b; + } + } + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSImageIODecoder.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSImageIODecoder.java new file mode 100644 index 0000000..30274c6 --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSImageIODecoder.java @@ -0,0 +1,229 @@ +package org.redlance.platformtools.webp.impl.macos; + +import org.jetbrains.annotations.Nullable; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; +import java.nio.ByteOrder; + +public final class MacOSImageIODecoder implements PlatformWebPDecoder { + private final MacOSFrameworks fw; + + private final MethodHandle cfDataCreate; + private final MethodHandle cgImageSourceCreateWithData; + private final MethodHandle cgImageSourceCreateImageAtIndex; + private final MethodHandle cgImageGetWidth; + private final MethodHandle cgImageGetHeight; + private final MethodHandle cgImageGetBitmapInfo; + private final MethodHandle cgImageGetDataProvider; + private final MethodHandle cgDataProviderCopyData; + private final MethodHandle cfDataGetBytePtr; + private final MethodHandle cfDataGetLength; + + private MacOSImageIODecoder(MacOSFrameworks fw) { + this.fw = fw; + + Linker linker = Linker.nativeLinker(); + + this.cfDataCreate = linker.downcallHandle( + fw.lookup.find("CFDataCreate").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG + ) + ); + this.cgImageSourceCreateWithData = linker.downcallHandle( + fw.imageIO.find("CGImageSourceCreateWithData").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + this.cgImageSourceCreateImageAtIndex = linker.downcallHandle( + fw.imageIO.find("CGImageSourceCreateImageAtIndex").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, ValueLayout.ADDRESS + ) + ); + this.cgImageGetWidth = linker.downcallHandle( + fw.lookup.find("CGImageGetWidth").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS) + ); + this.cgImageGetHeight = linker.downcallHandle( + fw.lookup.find("CGImageGetHeight").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS) + ); + this.cgImageGetBitmapInfo = linker.downcallHandle( + fw.lookup.find("CGImageGetBitmapInfo").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS) + ); + this.cgImageGetDataProvider = linker.downcallHandle( + fw.lookup.find("CGImageGetDataProvider").orElseThrow(), + FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS) + ); + this.cgDataProviderCopyData = linker.downcallHandle( + fw.lookup.find("CGDataProviderCopyData").orElseThrow(), + FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS) + ); + this.cfDataGetBytePtr = linker.downcallHandle( + fw.lookup.find("CFDataGetBytePtr").orElseThrow(), + FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS) + ); + this.cfDataGetLength = linker.downcallHandle( + fw.lookup.find("CFDataGetLength").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS) + ); + } + + public static @Nullable MacOSImageIODecoder tryCreate() { + MacOSFrameworks fw = MacOSFrameworks.getInstance(); + return fw != null ? new MacOSImageIODecoder(fw) : null; + } + + public static MacOSImageIODecoder create() throws Throwable { + return new MacOSImageIODecoder(MacOSFrameworks.create()); + } + + @Override + public String backendName() { + return "macOS ImageIO"; + } + + @Override + public DecodedImage decode(byte[] webpData) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment webpSeg = arena.allocate(webpData.length); + webpSeg.copyFrom(MemorySegment.ofArray(webpData)); + + MemorySegment cfData = (MemorySegment) this.cfDataCreate.invokeExact( + MemorySegment.NULL, webpSeg, (long) webpData.length + ); + + MemorySegment source = (MemorySegment) this.cgImageSourceCreateWithData.invokeExact(cfData, MemorySegment.NULL); + if (source.address() == 0) { + this.fw.cfRelease.invokeExact(cfData); + throw new IllegalStateException("CGImageSourceCreateWithData failed"); + } + + MemorySegment cgImage = (MemorySegment) this.cgImageSourceCreateImageAtIndex.invokeExact( + source, 0L, MemorySegment.NULL + ); + if (cgImage.address() == 0) { + this.fw.cfRelease.invokeExact(source); + this.fw.cfRelease.invokeExact(cfData); + throw new IllegalStateException("CGImageSourceCreateImageAtIndex failed"); + } + + long w = (long) this.cgImageGetWidth.invokeExact(cgImage); + long h = (long) this.cgImageGetHeight.invokeExact(cgImage); + int bitmapInfo = (int) this.cgImageGetBitmapInfo.invokeExact(cgImage); + + // Read raw pixel data from CGImage + MemorySegment provider = (MemorySegment) this.cgImageGetDataProvider.invokeExact(cgImage); + MemorySegment rawData = (MemorySegment) this.cgDataProviderCopyData.invokeExact(provider); + if (rawData.address() == 0) { + this.fw.cgImageRelease.invokeExact(cgImage); + this.fw.cfRelease.invokeExact(source); + this.fw.cfRelease.invokeExact(cfData); + throw new IllegalStateException("CGDataProviderCopyData failed"); + } + + long len = (long) this.cfDataGetLength.invokeExact(rawData); + MemorySegment ptr = (MemorySegment) this.cfDataGetBytePtr.invokeExact(rawData); + MemorySegment pixels = ptr.reinterpret(len); + + int[] argb = readPixels(pixels, (int) w, (int) h, bitmapInfo); + + this.fw.cfRelease.invokeExact(rawData); + this.fw.cgImageRelease.invokeExact(cgImage); + this.fw.cfRelease.invokeExact(source); + this.fw.cfRelease.invokeExact(cfData); + + return new DecodedImage(argb, (int) w, (int) h); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("macOS ImageIO decode failed", t); + } + } + + private static int[] readPixels(MemorySegment pixels, int w, int h, int bitmapInfo) { + int alphaInfo = bitmapInfo & 0x1F; // kCGBitmapAlphaInfoMask + int byteOrder = bitmapInfo & 0x7000; // kCGBitmapByteOrderMask + boolean premultiplied = alphaInfo == MacOSFrameworks.kCGImageAlphaPremultipliedFirst + || alphaInfo == MacOSFrameworks.kCGImageAlphaPremultipliedLast; + + int[] argb; + if (byteOrder == MacOSFrameworks.kCGBitmapByteOrder32Little) { + // Native LE int = BGRA bytes = ARGB int + argb = pixels.toArray(ValueLayout.JAVA_INT); + } else { + // Big-endian / default: ARGB bytes → read as BE int + argb = pixels.toArray(ValueLayout.JAVA_INT.withOrder(ByteOrder.BIG_ENDIAN)); + } + + if (premultiplied) { + MacOSFrameworks.unpremultiplyAlpha(argb); + } + + // Handle AlphaLast / SkipLast formats (RGBA/RGBX) → convert to ARGB + if (alphaInfo == MacOSFrameworks.kCGImageAlphaPremultipliedLast + || alphaInfo == MacOSFrameworks.kCGImageAlphaLast + || alphaInfo == MacOSFrameworks.kCGImageAlphaNoneSkipLast) { + for (int i = 0; i < argb.length; i++) { + int px = argb[i]; + // RGBA → ARGB: rotate right by 8 + argb[i] = (px << 24) | (px >>> 8); + } + } + + return argb; + } + + @Override + public int[] getInfo(byte[] webpData) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment webpSeg = arena.allocate(webpData.length); + webpSeg.copyFrom(MemorySegment.ofArray(webpData)); + + MemorySegment cfData = (MemorySegment) this.cfDataCreate.invokeExact( + MemorySegment.NULL, webpSeg, (long) webpData.length + ); + + MemorySegment source = (MemorySegment) this.cgImageSourceCreateWithData.invokeExact( + cfData, MemorySegment.NULL + ); + if (source.address() == 0) { + this.fw.cfRelease.invokeExact(cfData); + throw new IllegalStateException("CGImageSourceCreateWithData failed"); + } + + MemorySegment cgImage = (MemorySegment) this.cgImageSourceCreateImageAtIndex.invokeExact( + source, 0L, MemorySegment.NULL + ); + if (cgImage.address() == 0) { + this.fw.cfRelease.invokeExact(source); + this.fw.cfRelease.invokeExact(cfData); + throw new IllegalStateException("CGImageSourceCreateImageAtIndex failed"); + } + + long w = (long) this.cgImageGetWidth.invokeExact(cgImage); + long h = (long) this.cgImageGetHeight.invokeExact(cgImage); + + this.fw.cgImageRelease.invokeExact(cgImage); + this.fw.cfRelease.invokeExact(source); + this.fw.cfRelease.invokeExact(cfData); + + return new int[] {(int) w, (int) h}; + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("macOS ImageIO getInfo failed", t); + } + } + + @Override + public boolean isAvailable() { + return true; + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSImageIOEncoder.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSImageIOEncoder.java new file mode 100644 index 0000000..834a807 --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/macos/MacOSImageIOEncoder.java @@ -0,0 +1,222 @@ +/*package org.redlance.platformtools.webp.impl.macos; + +import org.jetbrains.annotations.Nullable; +import org.redlance.platformtools.webp.encoder.PlatformWebPEncoder; + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; + +public final class MacOSImageIOEncoder implements PlatformWebPEncoder { + private final MacOSFrameworks fw; + + private final MethodHandle cfDataGetLength; + private final MethodHandle cfDataGetBytePtr; + private final MethodHandle cfNumberCreate; + private final MethodHandle cfDictionaryCreate; + private final MethodHandle cgImageCreate; + private final MethodHandle cgDataProviderCreateWithData; + private final MethodHandle cgDataProviderRelease; + private final MethodHandle cgImageDestAddImage; + private final MethodHandle cgImageDestFinalize; + private final MemorySegment qualityPropertyKey; + + private MacOSImageIOEncoder(MacOSFrameworks fw) { + this.fw = fw; + + Linker linker = Linker.nativeLinker(); + + this.cfDataGetLength = linker.downcallHandle( + fw.lookup.find("CFDataGetLength").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS) + ); + this.cfDataGetBytePtr = linker.downcallHandle( + fw.lookup.find("CFDataGetBytePtr").orElseThrow(), + FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS) + ); + this.cfNumberCreate = linker.downcallHandle( + fw.lookup.find("CFNumberCreate").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ) + ); + this.cfDictionaryCreate = linker.downcallHandle( + fw.lookup.find("CFDictionaryCreate").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + this.cgImageCreate = linker.downcallHandle( + fw.lookup.find("CGImageCreate").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS, ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, + ValueLayout.JAVA_BOOLEAN, ValueLayout.JAVA_INT + ) + ); + this.cgDataProviderCreateWithData = linker.downcallHandle( + fw.lookup.find("CGDataProviderCreateWithData").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, ValueLayout.ADDRESS + ) + ); + this.cgDataProviderRelease = linker.downcallHandle( + fw.lookup.find("CGDataProviderRelease").orElseThrow(), + FunctionDescriptor.ofVoid(ValueLayout.ADDRESS) + ); + this.cgImageDestAddImage = linker.downcallHandle( + fw.imageIO.find("CGImageDestinationAddImage").orElseThrow(), + FunctionDescriptor.ofVoid( + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + this.cgImageDestFinalize = linker.downcallHandle( + fw.imageIO.find("CGImageDestinationFinalize").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_BOOLEAN, ValueLayout.ADDRESS) + ); + this.qualityPropertyKey = fw.imageIO.find("kCGImageDestinationLossyCompressionQuality").orElseThrow() + .reinterpret(ValueLayout.ADDRESS.byteSize()) + .get(ValueLayout.ADDRESS, 0); + } + + public static @Nullable MacOSImageIOEncoder tryCreate() { + MacOSFrameworks fw = MacOSFrameworks.getInstance(); + if (fw == null || !testCanEncode(fw)) return null; + return new MacOSImageIOEncoder(fw); + } + + private static boolean testCanEncode(MacOSFrameworks fw) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment uti = fw.createCFString(arena, "org.webmproject.webp"); + if (uti.address() == 0) return false; + + MemorySegment data = (MemorySegment) fw.cfDataCreateMutable.invokeExact(MemorySegment.NULL, 0L); + MemorySegment dest = (MemorySegment) fw.cgImageDestCreateWithData.invokeExact( + data, uti, 1L, MemorySegment.NULL + ); + boolean result = dest.address() != 0; + if (result) fw.cfRelease.invokeExact(dest); + fw.cfRelease.invokeExact(data); + fw.cfRelease.invokeExact(uti); + return result; + } catch (Throwable t) { + return false; + } + } + + @Override + public String backendName() { + return "macOS ImageIO"; + } + + @Override + public byte[] encodeLossless(int[] argb, int width, int height) { + // ImageIO doesn't support true lossless WebP; use max quality lossy + return encodeLossy(argb, width, height, 1.0f); + } + + @Override + public byte[] encodeLossy(int[] argb, int width, int height, float quality) { + try (Arena arena = Arena.ofConfined()) { + // CGDataProviderCreateWithData stores the pointer — must use arena-allocated memory + int bufSize = argb.length * 4; + MemorySegment argbSeg = arena.allocate(bufSize); + argbSeg.copyFrom(MemorySegment.ofArray(argb)); + + MemorySegment colorSpace = (MemorySegment) this.fw.cgColorSpaceCreateDeviceRGB.invokeExact(); + MemorySegment provider = (MemorySegment) this.cgDataProviderCreateWithData.invokeExact( + MemorySegment.NULL, argbSeg, (long) bufSize, MemorySegment.NULL + ); + + MemorySegment cgImage = (MemorySegment) this.cgImageCreate.invokeExact( + (long) width, (long) height, + 8L, 32L, (long) width * 4, + colorSpace, MacOSFrameworks.kCGBitmapByteOrder32Little | MacOSFrameworks.kCGImageAlphaFirst, + provider, MemorySegment.NULL, + false, 0 + ); + + if (cgImage.address() == 0) { + this.cgDataProviderRelease.invokeExact(provider); + this.fw.cgColorSpaceRelease.invokeExact(colorSpace); + throw new IllegalStateException("CGImageCreate failed"); + } + + MemorySegment outputData = (MemorySegment) this.fw.cfDataCreateMutable.invokeExact( + MemorySegment.NULL, 0L + ); + + MemorySegment uti = this.fw.createCFString(arena, "org.webmproject.webp"); + MemorySegment dest = (MemorySegment) this.fw.cgImageDestCreateWithData.invokeExact( + outputData, uti, 1L, MemorySegment.NULL + ); + + if (dest.address() == 0) { + this.fw.cfRelease.invokeExact(uti); + this.fw.cfRelease.invokeExact(outputData); + this.fw.cgImageRelease.invokeExact(cgImage); + this.cgDataProviderRelease.invokeExact(provider); + this.fw.cgColorSpaceRelease.invokeExact(colorSpace); + throw new IllegalStateException("CGImageDestinationCreateWithData failed"); + } + + MemorySegment props = createQualityProperties(arena, quality); + this.cgImageDestAddImage.invokeExact(dest, cgImage, props); + boolean ok = (boolean) this.cgImageDestFinalize.invokeExact(dest); + + byte[] result = null; + if (ok) { + long len = (long) this.cfDataGetLength.invokeExact(outputData); + MemorySegment ptr = (MemorySegment) this.cfDataGetBytePtr.invokeExact(outputData); + result = ptr.reinterpret(len).toArray(ValueLayout.JAVA_BYTE); + } + + this.fw.cfRelease.invokeExact(props); + this.fw.cfRelease.invokeExact(dest); + this.fw.cfRelease.invokeExact(uti); + this.fw.cfRelease.invokeExact(outputData); + this.fw.cgImageRelease.invokeExact(cgImage); + this.cgDataProviderRelease.invokeExact(provider); + this.fw.cgColorSpaceRelease.invokeExact(colorSpace); + + if (result == null) throw new IllegalStateException("CGImageDestinationFinalize failed"); + return result; + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("macOS ImageIO encode failed", t); + } + } + + private MemorySegment createQualityProperties(Arena arena, float quality) throws Throwable { + MemorySegment doubleVal = arena.allocate(ValueLayout.JAVA_DOUBLE); + doubleVal.set(ValueLayout.JAVA_DOUBLE, 0, quality); + MemorySegment qualityNum = (MemorySegment) this.cfNumberCreate.invokeExact( + MemorySegment.NULL, MacOSFrameworks.kCFNumberFloat64Type, doubleVal + ); + + MemorySegment keys = arena.allocate(ValueLayout.ADDRESS); + keys.set(ValueLayout.ADDRESS, 0, this.qualityPropertyKey); + + MemorySegment values = arena.allocate(ValueLayout.ADDRESS); + values.set(ValueLayout.ADDRESS, 0, qualityNum); + + MemorySegment dict = (MemorySegment) this.cfDictionaryCreate.invokeExact( + MemorySegment.NULL, keys, values, 1L, + MemorySegment.NULL, MemorySegment.NULL + ); + + this.fw.cfRelease.invokeExact(qualityNum); + return dict; + } + + @Override + public boolean isAvailable() { + return true; + } +}*/ diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/ngengine/NgEngineDecoder.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/ngengine/NgEngineDecoder.java new file mode 100644 index 0000000..960add9 --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/ngengine/NgEngineDecoder.java @@ -0,0 +1,70 @@ +package org.redlance.platformtools.webp.impl.ngengine; + +import org.jetbrains.annotations.Nullable; +import org.ngengine.webp.decoder.DecodedWebP; +import org.ngengine.webp.decoder.WebPDecoder; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; + +import java.nio.ByteBuffer; + +/** + * Fallback WebP decoder using ngengine image-webp-java (pure-Java). + */ +public final class NgEngineDecoder implements PlatformWebPDecoder { + private NgEngineDecoder() { + } + + public static @Nullable NgEngineDecoder tryCreate() { + try { + // Verify the library is on the classpath + Class.forName("org.ngengine.webp.decoder.WebPDecoder"); + return new NgEngineDecoder(); + } catch (ClassNotFoundException e) { + return null; + } + } + + @Override + public String backendName() { + return "Java (ngengine)"; + } + + @Override + public DecodedImage decode(byte[] webpData) { + try { + DecodedWebP decoded = WebPDecoder.decode(webpData); + ByteBuffer rgba = decoded.rgba; + rgba.rewind(); + + int pixelCount = decoded.width * decoded.height; + int[] argb = new int[pixelCount]; + for (int i = 0; i < pixelCount; i++) { + int r = rgba.get() & 0xFF; + int g = rgba.get() & 0xFF; + int b = rgba.get() & 0xFF; + int a = rgba.get() & 0xFF; + argb[i] = (a << 24) | (r << 16) | (g << 8) | b; + } + + return new DecodedImage(argb, decoded.width, decoded.height); + } catch (Exception e) { + throw new IllegalStateException("ngengine WebP decode failed", e); + } + } + + @Override + public int[] getInfo(byte[] webpData) { + try { + DecodedWebP decoded = WebPDecoder.decode(webpData); + return new int[] {decoded.width, decoded.height}; + } catch (Exception e) { + throw new IllegalStateException("ngengine WebP getInfo failed", e); + } + } + + @Override + public boolean isAvailable() { + return true; + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsCodecsDecoder.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsCodecsDecoder.java new file mode 100644 index 0000000..9b6c82c --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsCodecsDecoder.java @@ -0,0 +1,241 @@ +package org.redlance.platformtools.webp.impl.windows; + +import org.jetbrains.annotations.Nullable; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; + +import java.lang.foreign.*; + +import static org.redlance.platformtools.webp.impl.windows.WindowsComHelper.*; + +public final class WindowsCodecsDecoder implements PlatformWebPDecoder { + private final WindowsComHelper com; + + private WindowsCodecsDecoder(WindowsComHelper com) { + this.com = com; + } + + public static @Nullable WindowsCodecsDecoder tryCreate() { + WindowsComHelper com = WindowsComHelper.getInstance(); + if (com == null) return null; + + // Verify WebP decode support (codec may not be installed) + try (Arena arena = Arena.ofConfined()) { + MemorySegment factory = com.createFactory(arena); + if (factory == null) return null; + + try { + MemorySegment decoder = comCreateObj( + arena, factory, FACTORY_CREATE_DECODER, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ), + guidWebpContainer(arena), MemorySegment.NULL + ); + comRelease(decoder); + } finally { + comRelease(factory); + } + } catch (Throwable t) { + return null; + } + + return new WindowsCodecsDecoder(com); + } + + @Override + public String backendName() { + return "Windows WIC"; + } + + @Override + public DecodedImage decode(byte[] webpData) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment factory = this.com.createFactory(arena); + if (factory == null) throw new IllegalStateException("Failed to create WIC factory"); + + MemorySegment wicStream = null, decoder = null, frame = null, converter = null; + try { + wicStream = comCreateObj( + arena, factory, FACTORY_CREATE_STREAM, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + + // InitializeFromMemory stores the pointer — must use arena-allocated memory + MemorySegment dataSeg = arena.allocate(webpData.length); + dataSeg.copyFrom(MemorySegment.ofArray(webpData)); + + checkHr(comCallInt( + wicStream, WIC_STREAM_INITIALIZE_FROM_MEMORY, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT + ), + dataSeg, webpData.length + ), "InitializeFromMemory"); + + decoder = comCreateObj( + arena, factory, FACTORY_CREATE_DECODER_FROM_STREAM, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, + ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ), + wicStream, MemorySegment.NULL, 0 + ); + + frame = comCreateObj( + arena, decoder, DECODER_GET_FRAME, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ), + 0 + ); + + converter = comCreateObj( + arena, factory, FACTORY_CREATE_FORMAT_CONVERTER, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + + checkHr(comCallInt( + converter, FORMAT_CONVERTER_INITIALIZE, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, + ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_DOUBLE, + ValueLayout.JAVA_INT + ), + frame, guidPixelFormat32bppBGRA(arena), 0, MemorySegment.NULL, 0.0, 0 + ), "FormatConverter.Initialize"); + + MemorySegment wPtr = arena.allocate(ValueLayout.JAVA_INT); + MemorySegment hPtr = arena.allocate(ValueLayout.JAVA_INT); + checkHr(comCallInt( + converter, BITMAP_SOURCE_GET_SIZE, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ), + wPtr, hPtr + ), "GetSize"); + + int w = wPtr.get(ValueLayout.JAVA_INT, 0); + int h = hPtr.get(ValueLayout.JAVA_INT, 0); + int stride = w * 4; + int bufSize = stride * h; + + MemorySegment buffer = arena.allocate(bufSize); + checkHr(comCallInt( + converter, BITMAP_SOURCE_COPY_PIXELS, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, + ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ), + MemorySegment.NULL, stride, bufSize, buffer + ), "CopyPixels"); + + return new DecodedImage(buffer.toArray(ValueLayout.JAVA_INT), w, h); + } finally { + if (converter != null) comRelease(converter); + if (frame != null) comRelease(frame); + if (decoder != null) comRelease(decoder); + if (wicStream != null) comRelease(wicStream); + comRelease(factory); + } + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("WIC decode failed", t); + } + } + + @Override + public int[] getInfo(byte[] webpData) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment factory = this.com.createFactory(arena); + if (factory == null) throw new IllegalStateException("Failed to create WIC factory"); + + MemorySegment wicStream = null, decoder = null, frame = null; + try { + wicStream = comCreateObj( + arena, factory, FACTORY_CREATE_STREAM, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + + // InitializeFromMemory stores the pointer — must use arena-allocated memory + MemorySegment dataSeg = arena.allocate(webpData.length); + dataSeg.copyFrom(MemorySegment.ofArray(webpData)); + + checkHr(comCallInt( + wicStream, WIC_STREAM_INITIALIZE_FROM_MEMORY, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT + ), + dataSeg, webpData.length + ), "InitializeFromMemory"); + + decoder = comCreateObj( + arena, factory, FACTORY_CREATE_DECODER_FROM_STREAM, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, + ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ), + wicStream, MemorySegment.NULL, 0 + ); + + frame = comCreateObj( + arena, decoder, DECODER_GET_FRAME, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ), + 0 + ); + + MemorySegment wPtr = arena.allocate(ValueLayout.JAVA_INT); + MemorySegment hPtr = arena.allocate(ValueLayout.JAVA_INT); + checkHr(comCallInt( + frame, BITMAP_SOURCE_GET_SIZE, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ), + wPtr, hPtr + ), "GetSize"); + + return new int[] { + wPtr.get(ValueLayout.JAVA_INT, 0), + hPtr.get(ValueLayout.JAVA_INT, 0) + }; + } finally { + if (frame != null) comRelease(frame); + if (decoder != null) comRelease(decoder); + if (wicStream != null) comRelease(wicStream); + comRelease(factory); + } + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("WIC getInfo failed", t); + } + } + + @Override + public boolean isAvailable() { + return true; + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsCodecsEncoder.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsCodecsEncoder.java new file mode 100644 index 0000000..a4bfaf9 --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsCodecsEncoder.java @@ -0,0 +1,232 @@ +/*package org.redlance.platformtools.webp.impl.windows; + +import org.jetbrains.annotations.Nullable; +import org.redlance.platformtools.webp.encoder.PlatformWebPEncoder; + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; + +import static org.redlance.platformtools.webp.impl.windows.WindowsComHelper.*; + +public final class WindowsCodecsEncoder implements PlatformWebPEncoder { + private final WindowsComHelper com; + + // Encoder-only handle + private final MethodHandle createStreamOnHGlobal; + + private WindowsCodecsEncoder(WindowsComHelper com) { + this.com = com; + + this.createStreamOnHGlobal = LINKER.downcallHandle( + com.ole32.find("CreateStreamOnHGlobal").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ) + ); + } + + public static @Nullable WindowsCodecsEncoder tryCreate() { + WindowsComHelper com = WindowsComHelper.getInstance(); + if (com == null) return null; + + // Verify WebP encode support + try (Arena arena = Arena.ofConfined()) { + MemorySegment factory = com.createFactory(arena); + if (factory == null) return null; + + try { + MemorySegment encoder = comCreateObj( + arena, factory, FACTORY_CREATE_ENCODER, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ), + guidWebpContainer(arena), MemorySegment.NULL + ); + comRelease(encoder); + } finally { + comRelease(factory); + } + } catch (Throwable t) { + return null; + } + + return new WindowsCodecsEncoder(com); + } + + @Override + public String backendName() { + return "Windows WIC"; + } + + @Override + public byte[] encodeLossless(int[] argb, int width, int height) { + return encodeLossy(argb, width, height, 1.0f); + } + + @Override + public byte[] encodeLossy(int[] argb, int width, int height, float quality) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment factory = this.com.createFactory(arena); + if (factory == null) throw new IllegalStateException("Failed to create WIC factory"); + + MemorySegment stream = null, encoder = null, frame = null, props = null; + try { + MemorySegment streamPtr = arena.allocate(ValueLayout.ADDRESS); + checkHr((int) this.createStreamOnHGlobal.invokeExact( + MemorySegment.NULL, 1, streamPtr + ), "CreateStreamOnHGlobal"); + stream = streamPtr.get(ValueLayout.ADDRESS, 0); + + encoder = comCreateObj( + arena, factory, FACTORY_CREATE_ENCODER, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ), + guidWebpContainer(arena), MemorySegment.NULL + ); + + checkHr(comCallInt( + encoder, ENCODER_INITIALIZE, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT + ), + stream, 2 + ), "Encoder.Initialize"); + + MemorySegment framePtr = arena.allocate(ValueLayout.ADDRESS); + MemorySegment propsPtr = arena.allocate(ValueLayout.ADDRESS); + checkHr(comCallInt( + encoder, ENCODER_CREATE_NEW_FRAME, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS + ), + framePtr, propsPtr + ), "CreateNewFrame"); + frame = framePtr.get(ValueLayout.ADDRESS, 0); + props = propsPtr.get(ValueLayout.ADDRESS, 0); + + checkHr(comCallInt( + frame, FRAME_ENCODE_INITIALIZE, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS + ), + props + ), "FrameEncode.Initialize"); + + checkHr(comCallInt( + frame, FRAME_ENCODE_SET_SIZE, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT + ), + width, height + ), "SetSize"); + + checkHr(comCallInt( + frame, FRAME_ENCODE_SET_PIXEL_FORMAT, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS + ), + guidPixelFormat32bppBGRA(arena) + ), "SetPixelFormat"); + + int stride = width * 4; + + int bufSize = argb.length * 4; + MemorySegment argbSeg = arena.allocate(bufSize); + argbSeg.copyFrom(MemorySegment.ofArray(argb)); + + checkHr(comCallInt( + frame, FRAME_ENCODE_WRITE_PIXELS, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ), + height, stride, bufSize, argbSeg + ), "WritePixels"); + + checkHr(comCallInt( + frame, FRAME_ENCODE_COMMIT, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ) + ), "FrameEncode.Commit"); + + checkHr(comCallInt( + encoder, ENCODER_COMMIT, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ) + ), "Encoder.Commit"); + + return readStream(arena, stream); + } finally { + if (props != null && props.address() != 0) comRelease(props); + if (frame != null) comRelease(frame); + if (encoder != null) comRelease(encoder); + if (stream != null) comRelease(stream); + comRelease(factory); + } + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("WIC encode failed", t); + } + } + + private byte[] readStream(Arena arena, MemorySegment stream) throws Throwable { + // Seek to 0 + comCallInt( + stream, ISTREAM_SEEK, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ), + 0L, 0, MemorySegment.NULL // STREAM_SEEK_SET = 0 + ); + + // Stat to get size + MemorySegment statstg = arena.allocate(72); // sizeof(STATSTG) + checkHr(comCallInt( + stream, ISTREAM_STAT, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT + ), + statstg, 1 // STATFLAG_NONAME = 1 + ), "IStream.Stat"); + + long size = statstg.get(ValueLayout.JAVA_LONG, 8); // cbSize at offset 8 + if (size <= 0 || size > Integer.MAX_VALUE) { + throw new IllegalStateException("Invalid stream size: " + size); + } + + // Read + MemorySegment buffer = arena.allocate(size); + MemorySegment bytesRead = arena.allocate(ValueLayout.JAVA_INT); + checkHr(comCallInt( + stream, ISTREAM_READ, + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.ADDRESS + ), + buffer, (int) size, bytesRead + ), "IStream.Read"); + + int read = bytesRead.get(ValueLayout.JAVA_INT, 0); + return buffer.asSlice(0, read).toArray(ValueLayout.JAVA_BYTE); + } + + @Override + public boolean isAvailable() { + return true; + } +}*/ diff --git a/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsComHelper.java b/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsComHelper.java new file mode 100644 index 0000000..0dfd43b --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/impl/windows/WindowsComHelper.java @@ -0,0 +1,157 @@ +package org.redlance.platformtools.webp.impl.windows; + +import org.jetbrains.annotations.Nullable; + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; + +public final class WindowsComHelper { + static final Linker LINKER = Linker.nativeLinker(); + static final long PTR_SIZE = ValueLayout.ADDRESS.byteSize(); + + // COM vtable indices — shared + static final int IUNKNOWN_RELEASE = 2; + + // Decoder vtable indices + static final int FACTORY_CREATE_DECODER = 7; + static final int FACTORY_CREATE_DECODER_FROM_STREAM = 4; + static final int FACTORY_CREATE_FORMAT_CONVERTER = 10; + static final int FACTORY_CREATE_STREAM = 14; + static final int DECODER_GET_FRAME = 13; + static final int BITMAP_SOURCE_GET_SIZE = 3; + static final int BITMAP_SOURCE_COPY_PIXELS = 7; + static final int FORMAT_CONVERTER_INITIALIZE = 8; + static final int WIC_STREAM_INITIALIZE_FROM_MEMORY = 16; + + // Encoder vtable indices + static final int FACTORY_CREATE_ENCODER = 8; + static final int ENCODER_INITIALIZE = 3; + static final int ENCODER_CREATE_NEW_FRAME = 10; + static final int ENCODER_COMMIT = 11; + static final int FRAME_ENCODE_INITIALIZE = 3; + static final int FRAME_ENCODE_SET_SIZE = 4; + static final int FRAME_ENCODE_SET_PIXEL_FORMAT = 6; + static final int FRAME_ENCODE_WRITE_PIXELS = 10; + static final int FRAME_ENCODE_COMMIT = 12; + static final int ISTREAM_READ = 3; + static final int ISTREAM_SEEK = 5; + static final int ISTREAM_STAT = 12; + + private final MethodHandle coCreateInstance; + final SymbolLookup ole32; + + private WindowsComHelper(SymbolLookup ole32) { + this.ole32 = ole32; + + this.coCreateInstance = LINKER.downcallHandle(ole32.find("CoCreateInstance").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, ValueLayout.ADDRESS + ) + ); + } + + private static class Holder { + static final @Nullable WindowsComHelper INSTANCE = tryInit(); + } + + public static @Nullable WindowsComHelper getInstance() { + return Holder.INSTANCE; + } + + private static @Nullable WindowsComHelper tryInit() { + try { + SymbolLookup ole32 = SymbolLookup.libraryLookup("ole32", Arena.global()); + + MethodHandle coInitEx = LINKER.downcallHandle( + ole32.find("CoInitializeEx").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT + ) + ); + //noinspection unused — invokeExact requires matching return type + int hr = (int) coInitEx.invokeExact(MemorySegment.NULL, 0); + + return new WindowsComHelper(ole32); + } catch (Throwable t) { + return null; + } + } + + @Nullable MemorySegment createFactory(Arena arena) throws Throwable { + MemorySegment factoryPtr = arena.allocate(ValueLayout.ADDRESS); + int hr = (int) coCreateInstance.invokeExact( + guidWICImagingFactory(arena), MemorySegment.NULL, + 1 | 4, // CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER + guidIWICImagingFactory(arena), factoryPtr + ); + if (hr < 0) return null; + return factoryPtr.get(ValueLayout.ADDRESS, 0); + } + + static int comCallInt(MemorySegment obj, int vtableIndex, FunctionDescriptor fd, Object... extraArgs) throws Throwable { + MemorySegment vtable = obj.reinterpret(PTR_SIZE).get(ValueLayout.ADDRESS, 0).reinterpret(PTR_SIZE * (vtableIndex + 1)); + MemorySegment funcPtr = vtable.getAtIndex(ValueLayout.ADDRESS, vtableIndex); + MethodHandle mh = LINKER.downcallHandle(funcPtr, fd); + Object[] allArgs = new Object[extraArgs.length + 1]; + allArgs[0] = obj; + System.arraycopy(extraArgs, 0, allArgs, 1, extraArgs.length); + return (int) mh.invokeWithArguments(allArgs); + } + + static MemorySegment comCreateObj(Arena arena, MemorySegment obj, int vtableIndex, FunctionDescriptor fd, Object... extraArgs) throws Throwable { + MemorySegment ptr = arena.allocate(ValueLayout.ADDRESS); + Object[] args = new Object[extraArgs.length + 1]; + System.arraycopy(extraArgs, 0, args, 0, extraArgs.length); + args[extraArgs.length] = ptr; + int hr = comCallInt(obj, vtableIndex, fd, args); + checkHr(hr, "COM call"); + return ptr.get(ValueLayout.ADDRESS, 0); + } + + static void checkHr(int hr, String operation) { + if (hr < 0) throw new IllegalStateException(operation + " failed: HRESULT 0x" + Integer.toHexString(hr)); + } + + static void comRelease(MemorySegment obj) { + try { + comCallInt(obj, IUNKNOWN_RELEASE, FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS)); + } catch (Throwable ignored) {} + } + + // --- GUID helpers --- + + static MemorySegment writeGuid(Arena arena, int d1, short d2, short d3, byte... d4) { + MemorySegment seg = arena.allocate(16); + seg.set(ValueLayout.JAVA_INT_UNALIGNED, 0, d1); + seg.set(ValueLayout.JAVA_SHORT_UNALIGNED, 4, d2); + seg.set(ValueLayout.JAVA_SHORT_UNALIGNED, 6, d3); + MemorySegment.copy(MemorySegment.ofArray(d4), 0, seg, 8, 8); + return seg; + } + + static MemorySegment guidWICImagingFactory(Arena arena) { + return writeGuid(arena, 0xcacaf262, (short) 0x9370, (short) 0x4615, + (byte) 0xa1, (byte) 0x3b, (byte) 0x9f, (byte) 0x55, + (byte) 0x39, (byte) 0xda, (byte) 0x4c, (byte) 0x0a); + } + + static MemorySegment guidIWICImagingFactory(Arena arena) { + return writeGuid(arena, 0xec5ec8a9, (short) 0xc395, (short) 0x4314, + (byte) 0x9c, (byte) 0x77, (byte) 0x54, (byte) 0xd7, + (byte) 0xa9, (byte) 0x35, (byte) 0xff, (byte) 0x70); + } + + static MemorySegment guidWebpContainer(Arena arena) { + return writeGuid(arena, 0xe094b0e2, (short) 0x67f2, (short) 0x45b3, + (byte) 0xb0, (byte) 0xea, (byte) 0x11, (byte) 0x53, + (byte) 0x37, (byte) 0xca, (byte) 0x7c, (byte) 0xf3); + } + + static MemorySegment guidPixelFormat32bppBGRA(Arena arena) { + return writeGuid(arena, 0x6fddc324, (short) 0x4e03, (short) 0x4bfe, + (byte) 0xb1, (byte) 0x85, (byte) 0x3d, (byte) 0x77, + (byte) 0x76, (byte) 0x8d, (byte) 0xc9, (byte) 0x0e); + } +} diff --git a/webp/src/test/java/org/redlance/platformtools/webp/AutoBackendTest.java b/webp/src/test/java/org/redlance/platformtools/webp/AutoBackendTest.java new file mode 100644 index 0000000..78f854f --- /dev/null +++ b/webp/src/test/java/org/redlance/platformtools/webp/AutoBackendTest.java @@ -0,0 +1,26 @@ +package org.redlance.platformtools.webp; + +import org.junit.jupiter.api.Test; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; +import org.redlance.platformtools.webp.encoder.PlatformWebPEncoder; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +class AutoBackendTest { + @Test + void autoDecoderBackend() { + String expected = System.getProperty("expected.decode", ""); + assumeFalse(expected.isEmpty(), "No expected decoder specified"); + assertTrue(PlatformWebPDecoder.INSTANCE.isAvailable(), "Decoder should be available"); + assertEquals(expected, PlatformWebPDecoder.INSTANCE.backendName()); + } + + @Test + void autoEncoderBackend() { + String expected = System.getProperty("expected.encode", ""); + assumeFalse(expected.isEmpty(), "No expected encoder specified"); + assertTrue(PlatformWebPEncoder.INSTANCE.isAvailable(), "Encoder should be available"); + assertEquals(expected, PlatformWebPEncoder.INSTANCE.backendName()); + } +} diff --git a/webp/src/test/java/org/redlance/platformtools/webp/CrossBackendTest.java b/webp/src/test/java/org/redlance/platformtools/webp/CrossBackendTest.java new file mode 100644 index 0000000..5288818 --- /dev/null +++ b/webp/src/test/java/org/redlance/platformtools/webp/CrossBackendTest.java @@ -0,0 +1,79 @@ +package org.redlance.platformtools.webp; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; +import org.redlance.platformtools.webp.impl.libwebp.LibWebPDecoder; +import org.redlance.platformtools.webp.impl.macos.MacOSImageIODecoder; +import org.redlance.platformtools.webp.impl.ngengine.NgEngineDecoder; +import org.redlance.platformtools.webp.impl.windows.WindowsCodecsDecoder; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class CrossBackendTest { + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "test", // original test image (with alpha) + "gradient_rgb", // horizontal RGB gradient, opaque + "gradient_alpha", // alpha gradient + "checkerboard", // high-frequency pattern + "solid", // solid color + "circle_alpha", // circular alpha gradient 128x128 + "tall_gradient", // 32x256, tests vertical accumulation + "wide_gradient", // 256x32 + "noise", // pseudo-random noise pattern + }) + void allBackendsDecodeSamePixels(String name) throws IOException { + byte[] webp = TestUtils.loadWebP(name); + + PlatformWebPDecoder[] decoders = { + LibWebPDecoder.tryCreate(), + MacOSImageIODecoder.tryCreate(), + WindowsCodecsDecoder.tryCreate(), + NgEngineDecoder.tryCreate(), + }; + + DecodedImage reference = null; + String referenceName = null; + int tested = 0; + + for (PlatformWebPDecoder dec : decoders) { + if (dec == null) continue; + + DecodedImage decoded = dec.decode(webp); + assertTrue(decoded.width() > 0, name + ": invalid width from " + dec.backendName()); + assertTrue(decoded.height() > 0, name + ": invalid height from " + dec.backendName()); + assertEquals(decoded.width() * decoded.height(), decoded.argb().length, + name + ": pixel count mismatch from " + dec.backendName()); + + if (reference == null) { + reference = decoded; + referenceName = dec.backendName(); + } else { + String label = name + ": " + referenceName + " vs " + dec.backendName(); + assertEquals(reference.width(), decoded.width(), label + " width"); + assertEquals(reference.height(), decoded.height(), label + " height"); + assertPixelsEqual(reference.argb(), decoded.argb(), label); + } + tested++; + } + + assumeTrue(tested >= 2, "Need at least 2 backends to cross-check"); + } + + // RGB is undefined when alpha=0 — skip those pixels + private static void assertPixelsEqual(int[] expected, int[] actual, String label) { + assertEquals(expected.length, actual.length, "Pixel count mismatch: " + label); + for (int i = 0; i < expected.length; i++) { + int e = expected[i], a = actual[i]; + if (e == a) continue; + if ((e >>> 24) == 0 && (a >>> 24) == 0) continue; + fail("Pixel [" + i + "]: expected 0x" + Integer.toHexString(e) + + " but was 0x" + Integer.toHexString(a) + " (" + label + ")"); + } + } +} diff --git a/webp/src/test/java/org/redlance/platformtools/webp/DecodedImageTest.java b/webp/src/test/java/org/redlance/platformtools/webp/DecodedImageTest.java new file mode 100644 index 0000000..71e15e6 --- /dev/null +++ b/webp/src/test/java/org/redlance/platformtools/webp/DecodedImageTest.java @@ -0,0 +1,42 @@ +package org.redlance.platformtools.webp; + +import org.junit.jupiter.api.Test; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +class DecodedImageTest { + @Test + void pngCaching() throws IOException { + DecodedImage decoded = PlatformWebPDecoder.INSTANCE.decode(TestUtils.loadTestWebP()); + assertFalse(decoded.isPngCached()); + + byte[] png = decoded.toPng(); + assertTrue(png.length > 0); + assertTrue(decoded.isPngCached()); + assertSame(png, decoded.toPng(), "toPng() should return cached instance"); + } + + @Test + void fromPngPreservesPixels() throws IOException { + DecodedImage original = PlatformWebPDecoder.INSTANCE.decode(TestUtils.loadTestWebP()); + byte[] png = original.toPng(); + + DecodedImage fromPng = DecodedImage.fromPng(png); + assertEquals(original.width(), fromPng.width()); + assertEquals(original.height(), fromPng.height()); + assertTrue(fromPng.isPngCached()); + assertArrayEquals(original.argb(), fromPng.argb()); + } + + @Test + void isWebPDetection() throws IOException { + assertTrue(PlatformWebPDecoder.INSTANCE.isWebP(TestUtils.loadTestWebP())); + assertFalse(PlatformWebPDecoder.INSTANCE.isWebP(new byte[]{1, 2, 3})); + assertFalse(PlatformWebPDecoder.INSTANCE.isWebP(new byte[0])); + assertFalse(PlatformWebPDecoder.INSTANCE.isWebP(null)); + } +} diff --git a/webp/src/test/java/org/redlance/platformtools/webp/LibWebPTest.java b/webp/src/test/java/org/redlance/platformtools/webp/LibWebPTest.java new file mode 100644 index 0000000..565a120 --- /dev/null +++ b/webp/src/test/java/org/redlance/platformtools/webp/LibWebPTest.java @@ -0,0 +1,59 @@ +package org.redlance.platformtools.webp; + +import org.junit.jupiter.api.Test; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; +import org.redlance.platformtools.webp.encoder.PlatformWebPEncoder; +import org.redlance.platformtools.webp.impl.libwebp.LibWebPDecoder; +import org.redlance.platformtools.webp.impl.libwebp.LibWebPEncoder; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class LibWebPTest { + @Test + void encoderAvailableWithDecoder() { + PlatformWebPDecoder dec = LibWebPDecoder.tryCreate(); + assumeTrue(dec != null, "libwebp not available"); + assertNotNull(LibWebPEncoder.tryCreate(), "libwebp encoder should be available when decoder is"); + } + + @Test + void losslessRoundtripExact() { + PlatformWebPDecoder dec = LibWebPDecoder.tryCreate(); + PlatformWebPEncoder enc = LibWebPEncoder.tryCreate(); + assumeTrue(dec != null, "libwebp not available"); + + int[] original = TestUtils.generateTestImage(); + byte[] encoded = enc.encodeLossless(original, TestUtils.W, TestUtils.H); + DecodedImage decoded = dec.decode(encoded); + assertArrayEquals(original, decoded.argb(), "Lossless roundtrip pixels must match exactly"); + } + + @Test + void lossyRoundtripDimensions() { + PlatformWebPDecoder dec = LibWebPDecoder.tryCreate(); + PlatformWebPEncoder enc = LibWebPEncoder.tryCreate(); + assumeTrue(dec != null, "libwebp not available"); + + int[] original = TestUtils.generateTestImage(); + byte[] encoded = enc.encodeLossy(original, TestUtils.W, TestUtils.H, 0.75f); + DecodedImage decoded = dec.decode(encoded); + assertEquals(TestUtils.W, decoded.width()); + assertEquals(TestUtils.H, decoded.height()); + assertEquals(original.length, decoded.argb().length); + } + + @Test + void decodeTestFile() throws IOException { + PlatformWebPDecoder dec = LibWebPDecoder.tryCreate(); + assumeTrue(dec != null, "libwebp not available"); + + DecodedImage decoded = dec.decode(TestUtils.loadTestWebP()); + assertTrue(decoded.width() > 0); + assertTrue(decoded.height() > 0); + assertEquals(decoded.width() * decoded.height(), decoded.argb().length); + } +} diff --git a/webp/src/test/java/org/redlance/platformtools/webp/MacOSImageIOTest.java b/webp/src/test/java/org/redlance/platformtools/webp/MacOSImageIOTest.java new file mode 100644 index 0000000..0f8bca0 --- /dev/null +++ b/webp/src/test/java/org/redlance/platformtools/webp/MacOSImageIOTest.java @@ -0,0 +1,58 @@ +package org.redlance.platformtools.webp; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; +import org.redlance.platformtools.webp.impl.macos.MacOSImageIODecoder; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +@EnabledOnOs(OS.MAC) +class MacOSImageIOTest { + @Test + void decoderAvailable() { + PlatformWebPDecoder dec = MacOSImageIODecoder.tryCreate(); + assertNotNull(dec, "macOS ImageIO decoder should be available"); + assertEquals("macOS ImageIO", dec.backendName()); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "test", // original test image (with alpha) + "gradient_rgb", // horizontal RGB gradient, opaque + "gradient_alpha", // alpha gradient + "checkerboard", // high-frequency pattern + "solid", // solid color + "circle_alpha", // circular alpha gradient 128x128 + "tall_gradient", // 32x256, tests vertical accumulation + "wide_gradient", // 256x32 + "noise", // pseudo-random noise pattern + }) + void pixelExactDecode(String name) throws IOException { + PlatformWebPDecoder dec = MacOSImageIODecoder.tryCreate(); + assertNotNull(dec); + + DecodedImage decoded = dec.decode(TestUtils.loadWebP(name)); + assertTrue(decoded.width() > 0); + assertTrue(decoded.height() > 0); + assertEquals(decoded.width() * decoded.height(), decoded.argb().length); + + TestUtils.assertMatchesReference(decoded, name); + } + + /*@Test + void encoderMayBeUnavailable() { + // macOS currently does not support WebP encoding via ImageIO + MacOSImageIOEncoder enc = MacOSImageIOEncoder.tryCreate(); + if (enc != null) { + byte[] encoded = enc.encodeLossy(TestUtils.generateTestImage(), TestUtils.W, TestUtils.H, 0.75f); + assertTrue(encoded.length > 0); + } + }*/ +} diff --git a/webp/src/test/java/org/redlance/platformtools/webp/NgEngineDecoderTest.java b/webp/src/test/java/org/redlance/platformtools/webp/NgEngineDecoderTest.java new file mode 100644 index 0000000..d55c1cc --- /dev/null +++ b/webp/src/test/java/org/redlance/platformtools/webp/NgEngineDecoderTest.java @@ -0,0 +1,45 @@ +package org.redlance.platformtools.webp; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.impl.ngengine.NgEngineDecoder; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class NgEngineDecoderTest { + @Test + void decoderAvailable() { + NgEngineDecoder dec = NgEngineDecoder.tryCreate(); + assumeTrue(dec != null, "No Java WebP decoder on classpath"); + assertEquals("Java (ngengine)", dec.backendName()); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "test", // original test image (with alpha) + "gradient_rgb", // horizontal RGB gradient, opaque + "gradient_alpha", // alpha gradient + "checkerboard", // high-frequency pattern + "solid", // solid color + "circle_alpha", // circular alpha gradient 128x128 + "tall_gradient", // 32x256, tests vertical accumulation + "wide_gradient", // 256x32 + "noise", // pseudo-random noise pattern + }) + void pixelExactDecode(String name) throws IOException { + NgEngineDecoder dec = NgEngineDecoder.tryCreate(); + assumeTrue(dec != null, "No Java WebP decoder on classpath"); + + DecodedImage decoded = dec.decode(TestUtils.loadWebP(name)); + assertTrue(decoded.width() > 0); + assertTrue(decoded.height() > 0); + assertEquals(decoded.width() * decoded.height(), decoded.argb().length); + + TestUtils.assertMatchesReference(decoded, name); + } +} diff --git a/webp/src/test/java/org/redlance/platformtools/webp/TestUtils.java b/webp/src/test/java/org/redlance/platformtools/webp/TestUtils.java new file mode 100644 index 0000000..8070874 --- /dev/null +++ b/webp/src/test/java/org/redlance/platformtools/webp/TestUtils.java @@ -0,0 +1,61 @@ +package org.redlance.platformtools.webp; + +import org.redlance.platformtools.webp.decoder.DecodedImage; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +final class TestUtils { + static final int W = 64, H = 64; + + private TestUtils() { + } + + static int[] generateTestImage() { + int[] argb = new int[W * H]; + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + argb[y * W + x] = 0xFF000000 | ((x * 4) << 16) | ((y * 4) << 8) | 128; + return argb; + } + + static byte[] loadWebP(String name) throws IOException { + try (var is = TestUtils.class.getResourceAsStream("/" + name + ".webp")) { + assertNotNull(is, name + ".webp resource not found"); + return is.readAllBytes(); + } + } + + static byte[] loadTestWebP() throws IOException { + return loadWebP("test"); + } + + static DecodedImage loadReference(String name) throws IOException { + try (var is = TestUtils.class.getResourceAsStream("/" + name + "_ref.png")) { + assertNotNull(is, name + "_ref.png resource not found"); + return DecodedImage.fromPng(is.readAllBytes()); + } + } + + /** + * Compares decoded image against libwebp reference PNG (pixel-exact). + * Skips pixels where both have alpha=0 (RGB undefined for transparent pixels). + */ + static void assertMatchesReference(DecodedImage decoded, String name) throws IOException { + DecodedImage ref = loadReference(name); + assertEquals(ref.width(), decoded.width(), name + ": width mismatch vs reference"); + assertEquals(ref.height(), decoded.height(), name + ": height mismatch vs reference"); + + int[] ep = ref.argb(), ap = decoded.argb(); + assertEquals(ep.length, ap.length, name + ": pixel count mismatch vs reference"); + for (int i = 0; i < ep.length; i++) { + int e = ep[i], a = ap[i]; + if (e == a) continue; + if ((e >>> 24) == 0 && (a >>> 24) == 0) continue; + fail(name + " pixel [" + i + "] (" + (i % ref.width()) + "," + (i / ref.width()) + + "): expected 0x" + Integer.toHexString(e) + + " but was 0x" + Integer.toHexString(a)); + } + } +} diff --git a/webp/src/test/java/org/redlance/platformtools/webp/WindowsWICTest.java b/webp/src/test/java/org/redlance/platformtools/webp/WindowsWICTest.java new file mode 100644 index 0000000..6c8b44e --- /dev/null +++ b/webp/src/test/java/org/redlance/platformtools/webp/WindowsWICTest.java @@ -0,0 +1,53 @@ +package org.redlance.platformtools.webp; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; +import org.redlance.platformtools.webp.impl.windows.WindowsCodecsDecoder; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +@EnabledOnOs(OS.WINDOWS) +class WindowsWICTest { + @Test + void decoderAvailable() { + PlatformWebPDecoder dec = WindowsCodecsDecoder.tryCreate(); + assertNotNull(dec, "WIC decoder should be available on Windows"); + assertEquals("Windows WIC", dec.backendName()); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "test", // original test image (with alpha) + "gradient_rgb", // horizontal RGB gradient, opaque + "gradient_alpha", // alpha gradient + "checkerboard", // high-frequency pattern + "solid", // solid color + "circle_alpha", // circular alpha gradient 128x128 + "tall_gradient", // 32x256, tests vertical accumulation + "wide_gradient", // 256x32 + "noise", // pseudo-random noise pattern + }) + void pixelExactDecode(String name) throws IOException { + PlatformWebPDecoder dec = WindowsCodecsDecoder.tryCreate(); + assertNotNull(dec); + + DecodedImage decoded = dec.decode(TestUtils.loadWebP(name)); + assertTrue(decoded.width() > 0); + assertTrue(decoded.height() > 0); + assertEquals(decoded.width() * decoded.height(), decoded.argb().length); + + TestUtils.assertMatchesReference(decoded, name); + } + + /*@Test + void noEncoder() { + assertNull(WindowsCodecsEncoder.tryCreate(), "WIC should not have WebP encoder"); + }*/ +} diff --git a/webp/src/test/resources/checkerboard.webp b/webp/src/test/resources/checkerboard.webp new file mode 100644 index 0000000..952c2ad Binary files /dev/null and b/webp/src/test/resources/checkerboard.webp differ diff --git a/webp/src/test/resources/checkerboard_ref.png b/webp/src/test/resources/checkerboard_ref.png new file mode 100644 index 0000000..5794134 Binary files /dev/null and b/webp/src/test/resources/checkerboard_ref.png differ diff --git a/webp/src/test/resources/circle_alpha.webp b/webp/src/test/resources/circle_alpha.webp new file mode 100644 index 0000000..538f47b Binary files /dev/null and b/webp/src/test/resources/circle_alpha.webp differ diff --git a/webp/src/test/resources/circle_alpha_ref.png b/webp/src/test/resources/circle_alpha_ref.png new file mode 100644 index 0000000..f9e76b6 Binary files /dev/null and b/webp/src/test/resources/circle_alpha_ref.png differ diff --git a/webp/src/test/resources/gradient_alpha.webp b/webp/src/test/resources/gradient_alpha.webp new file mode 100644 index 0000000..4eeae25 Binary files /dev/null and b/webp/src/test/resources/gradient_alpha.webp differ diff --git a/webp/src/test/resources/gradient_alpha_ref.png b/webp/src/test/resources/gradient_alpha_ref.png new file mode 100644 index 0000000..90787d3 Binary files /dev/null and b/webp/src/test/resources/gradient_alpha_ref.png differ diff --git a/webp/src/test/resources/gradient_rgb.webp b/webp/src/test/resources/gradient_rgb.webp new file mode 100644 index 0000000..ccffb68 Binary files /dev/null and b/webp/src/test/resources/gradient_rgb.webp differ diff --git a/webp/src/test/resources/gradient_rgb_ref.png b/webp/src/test/resources/gradient_rgb_ref.png new file mode 100644 index 0000000..3d8b5f0 Binary files /dev/null and b/webp/src/test/resources/gradient_rgb_ref.png differ diff --git a/webp/src/test/resources/noise.webp b/webp/src/test/resources/noise.webp new file mode 100644 index 0000000..ace1c4e Binary files /dev/null and b/webp/src/test/resources/noise.webp differ diff --git a/webp/src/test/resources/noise_ref.png b/webp/src/test/resources/noise_ref.png new file mode 100644 index 0000000..38cc288 Binary files /dev/null and b/webp/src/test/resources/noise_ref.png differ diff --git a/webp/src/test/resources/solid.webp b/webp/src/test/resources/solid.webp new file mode 100644 index 0000000..4fdace6 Binary files /dev/null and b/webp/src/test/resources/solid.webp differ diff --git a/webp/src/test/resources/solid_ref.png b/webp/src/test/resources/solid_ref.png new file mode 100644 index 0000000..b0d5353 Binary files /dev/null and b/webp/src/test/resources/solid_ref.png differ diff --git a/webp/src/test/resources/tall_gradient.webp b/webp/src/test/resources/tall_gradient.webp new file mode 100644 index 0000000..14926d6 Binary files /dev/null and b/webp/src/test/resources/tall_gradient.webp differ diff --git a/webp/src/test/resources/tall_gradient_ref.png b/webp/src/test/resources/tall_gradient_ref.png new file mode 100644 index 0000000..172c748 Binary files /dev/null and b/webp/src/test/resources/tall_gradient_ref.png differ diff --git a/webp/src/test/resources/test.webp b/webp/src/test/resources/test.webp new file mode 100644 index 0000000..c2ffe17 Binary files /dev/null and b/webp/src/test/resources/test.webp differ diff --git a/webp/src/test/resources/test_ref.png b/webp/src/test/resources/test_ref.png new file mode 100644 index 0000000..7b0332e Binary files /dev/null and b/webp/src/test/resources/test_ref.png differ diff --git a/webp/src/test/resources/wide_gradient.webp b/webp/src/test/resources/wide_gradient.webp new file mode 100644 index 0000000..5878606 Binary files /dev/null and b/webp/src/test/resources/wide_gradient.webp differ diff --git a/webp/src/test/resources/wide_gradient_ref.png b/webp/src/test/resources/wide_gradient_ref.png new file mode 100644 index 0000000..41d9e39 Binary files /dev/null and b/webp/src/test/resources/wide_gradient_ref.png differ