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 @@ + + + + \ No newline at end of file diff --git a/README.MD b/README.MD index 2d76fe0..2de7926 100644 --- a/README.MD +++ b/README.MD @@ -13,6 +13,7 @@ The library is split into independent modules so you can include only what you n | **referer** | `referer` | File origin/referrer metadata (Windows & macOS) | | **favorites** | `favorites` | Finder Sidebar favorites (macOS) | | **progress** | `progress` | Taskbar/dock progress bars (macOS) | +| **webp** | `webp` | Compact WebP codec (libwebp, macOS ImageIO, Windows WIC, pure-Java ngengine fallback) | ## ✨ Features @@ -25,6 +26,11 @@ The library is split into independent modules so you can include only what you n * **[Taskbar Progress Bars](#4-taskbar-progress-bars)** (`progress`): Display progress indicators on the taskbar/dock icon. * Supports multiple stacked progress bars (up to 8). * *Note: Windows taskbar support is planned for future releases.* +* **[WebP Codec](#5-webp-codec)** (`webp`): Compact, pixel-exact WebP decoding/encoding (Java 22+, FFM API). + * **macOS:** ImageIO (CoreGraphics) + * **Windows:** WIC (Windows Imaging Component) + * **Cross-platform:** libwebp (if found on library path) + * **Pure-Java fallback:** [ngengine image-webp-java](https://github.com/NostrGameEngine/image-webp-java) (~92 KB bundled) * **Java 8 Compatible:** Built on Java 17 but distributed with a Java 8 compatible version (Multi-Release JAR). ## 📦 Installation @@ -39,13 +45,14 @@ repositories { dependencies { // Include only the modules you need: - implementation "org.redlance.platformtools:accent:3.3.0" - implementation "org.redlance.platformtools:referer:3.3.0" - implementation "org.redlance.platformtools:favorites:3.3.0" - implementation "org.redlance.platformtools:progress:3.3.0" + implementation "org.redlance.platformtools:accent:4.0.0" + implementation "org.redlance.platformtools:referer:4.0.0" + implementation "org.redlance.platformtools:favorites:4.0.0" + implementation "org.redlance.platformtools:progress:4.0.0" + implementation "org.redlance.platformtools:webp:4.0.0" // For Java 8 (Downgraded version), add classifier: - // implementation("org.redlance.platformtools:accent:3.3.0:java8") + // implementation("org.redlance.platformtools:accent:4.0.0:java8") } ``` @@ -62,7 +69,7 @@ dependencies { org.redlance.platformtools accent - 3.3.0 + 4.0.0 ``` @@ -187,6 +194,55 @@ public class ProgressExample { } ``` +### 5. WebP Codec +Compact, pixel-exact WebP decoding and encoding module. The goal is **minimal footprint** and **zero native dependencies** out of the box — the bundled pure-Java decoder adds only ~92 KB (after ProGuard) while producing bit-identical output to libwebp. + +> **Note:** Requires Java 22+ (uses the FFM API). Other modules remain Java 8 compatible. + +Decoder priority (first available wins): +1. **libwebp** — system-installed native library (fastest, encode + decode) +2. **macOS ImageIO** — CoreGraphics via FFM (decode only, no extra dependencies) +3. **Windows WIC** — Windows Imaging Component via FFM (decode only, requires WebP codec from MS Store) +4. **ngengine** — pure-Java fallback ([image-webp-java](https://github.com/NostrGameEngine/image-webp-java), decode only, ~92 KB bundled) + +The `webp` module has two variants: +- **Standard** (`webp`) — uses platform-native APIs or system-installed libwebp. Zero bundled dependencies. +- **Bundled** (`webp` with classifier `bundled`) — includes a shaded and ProGuard-optimized [ngengine image-webp-java](https://github.com/NostrGameEngine/image-webp-java) decoder (~92 KB). Pixel-exact on all platforms without native libraries. + +```groovy +// Standard — requires platform support or libwebp on library path +implementation "org.redlance.platformtools:webp:4.0.0" + +// Bundled — self-contained, works everywhere (decode only) +implementation "org.redlance.platformtools:webp:4.0.0:bundled" +``` + +```java +import org.redlance.platformtools.webp.decoder.PlatformWebPDecoder; +import org.redlance.platformtools.webp.decoder.DecodedImage; +import org.redlance.platformtools.webp.encoder.PlatformWebPEncoder; + +public class WebPExample { + public void decodeWebP(byte[] webpData) { + // Get image dimensions without full decode + int[] info = PlatformWebPDecoder.INSTANCE.getInfo(webpData); + System.out.println("Size: " + info[0] + "x" + info[1]); + + // Full decode to ARGB pixels + DecodedImage image = PlatformWebPDecoder.INSTANCE.decode(webpData); + int[] argb = image.argb(); // width * height pixels + } + + public void encodeWebP(int[] argb, int width, int height) { + // Lossless encoding (requires libwebp or platform encoder) + byte[] lossless = PlatformWebPEncoder.INSTANCE.encodeLossless(argb, width, height); + + // Lossy encoding (0.0 = smallest, 1.0 = best quality) + byte[] lossy = PlatformWebPEncoder.INSTANCE.encodeLossy(argb, width, height, 0.75f); + } +} +``` + ## 🛠 System Requirements * **Java:** 8 or higher. * **Operating Systems:** diff --git a/build.gradle b/build.gradle index a89c370..ecf4512 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,20 @@ +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath "com.guardsquare:proguard-gradle:7.8.2" + } +} + plugins { id "xyz.wagyourtail.jvmdowngrader" version "1.3.6" apply false + id "com.gradleup.shadow" version "9.3.2" apply false } allprojects { group = "org.redlance" - version = "3.3.1" + version = "4.0.0" repositories { mavenCentral() @@ -58,6 +68,8 @@ configure(publishedModules) { } shadeDowngradedApi { + onlyIf { !downgradeJar.state.skipped } + downgradeTo = JavaVersion.VERSION_1_8 shadePath { return "org/redlance/platformtools/" @@ -72,10 +84,6 @@ configure(publishedModules) { publishing { publications { mavenJava(MavenPublication) { - artifact(tasks.shadeDowngradedApi) { - classifier = "java8" - extension = "jar" - } groupId = "org.redlance.platformtools" artifactId = project.name @@ -111,6 +119,17 @@ configure(publishedModules) { } } + afterEvaluate { + publications.mavenJava { + if (tasks.downgradeJar.enabled) { + artifact(tasks.shadeDowngradedApi) { + classifier = "java8" + extension = "jar" + } + } + } + } + repositories { maven { name = "RedlanceMinecraft" diff --git a/settings.gradle b/settings.gradle index 0e05e5b..f7e769c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,3 +20,4 @@ include "referer" include "favorites" include "progress" include "testing" +include "webp" diff --git a/testing/build.gradle b/testing/build.gradle index 803b902..d2cb2fe 100644 --- a/testing/build.gradle +++ b/testing/build.gradle @@ -1,6 +1,30 @@ +plugins { + id "com.gradleup.shadow" +} + dependencies { implementation(project(":accent")) implementation(project(":referer")) implementation(project(":favorites")) implementation(project(":progress")) + implementation(project(":webp")) +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = "UTF-8" + options.release.set(25) +} + +jar { + manifest.attributes("Main-Class": "org.redlance.platformtools.testing.TestingApp") +} + +shadowJar { + dependencies { + exclude(project(":webp")) + } + + dependsOn(":webp:proguardJar") + from(zipTree(project(":webp").file("build/libs/PlatformTools-webp-${version}-bundled.jar"))) + mergeServiceFiles() } diff --git a/testing/src/main/java/module-info.java b/testing/src/main/java/module-info.java index 39b3d62..85cfe7f 100644 --- a/testing/src/main/java/module-info.java +++ b/testing/src/main/java/module-info.java @@ -3,5 +3,6 @@ requires platformtools.referer; requires platformtools.favorites; requires platformtools.progress; + requires platformtools.webp; requires java.desktop; } diff --git a/testing/src/main/java/org/redlance/platformtools/testing/TestingApp.java b/testing/src/main/java/org/redlance/platformtools/testing/TestingApp.java index 430286a..5913426 100644 --- a/testing/src/main/java/org/redlance/platformtools/testing/TestingApp.java +++ b/testing/src/main/java/org/redlance/platformtools/testing/TestingApp.java @@ -4,12 +4,22 @@ import org.redlance.platformtools.favorites.PlatformFinderFavorites; import org.redlance.platformtools.progress.PlatformProgressBars; import org.redlance.platformtools.referer.PlatformFileReferer; +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.ngengine.NgEngineDecoder; +import org.redlance.platformtools.webp.impl.libwebp.LibWebPDecoder; +import org.redlance.platformtools.webp.impl.libwebp.LibWebPEncoder; +import org.redlance.platformtools.webp.impl.macos.MacOSImageIODecoder; +import org.redlance.platformtools.webp.impl.windows.WindowsCodecsDecoder; import javax.swing.*; import java.awt.*; import java.awt.event.*; +import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.util.Collection; public class TestingApp extends JFrame { @@ -41,6 +51,10 @@ public TestingApp(Color initialColor) { recreateButton.addActionListener(this::onRecreate); controlsPanel.add(recreateButton); + JButton webpButton = new JButton("WebP"); + webpButton.addActionListener(this::onWebP); + controlsPanel.add(webpButton); + JButton chooseFileButton = new JButton("Select file"); chooseFileButton.addActionListener(this::onChooseFile); controlsPanel.add(chooseFileButton); @@ -167,6 +181,10 @@ private void onRecreate(ActionEvent e) { PlatformAccent.INSTANCE.resubscribe(); } + private void onWebP(ActionEvent e) { + showWebPDialog(); + } + private void onProgressBar(ActionEvent e) { showProgressDialog(); } @@ -225,4 +243,175 @@ private void showProgressDialog() { dialog.add(addButton, BorderLayout.SOUTH); dialog.setVisible(true); } + + private void showWebPDialog() { + JDialog dialog = new JDialog(this, "WebP Test", false); + dialog.setSize(500, 400); + dialog.setLocationRelativeTo(this); + dialog.setLayout(new BorderLayout()); + + JTextArea log = new JTextArea(); + log.setEditable(false); + log.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + dialog.add(new JScrollPane(log), BorderLayout.CENTER); + + // Status + boolean decodeAvailable = PlatformWebPDecoder.INSTANCE.isAvailable(); + boolean encodeAvailable = PlatformWebPEncoder.INSTANCE.isAvailable(); + log.append("Decoder: " + (decodeAvailable + ? "available (" + PlatformWebPDecoder.INSTANCE.backendName() + ")" + : "unavailable") + "\n"); + log.append("Encoder: " + (encodeAvailable + ? "available (" + PlatformWebPEncoder.INSTANCE.backendName() + ")" + : "unavailable") + "\n\n"); + + JPanel buttons = new JPanel(new FlowLayout()); + + // Probe each backend individually + JButton probeBtn = new JButton("Probe Backends"); + probeBtn.addActionListener(event -> { + log.append("--- Probing backends ---\n"); + + try { + PlatformWebPDecoder libDec = LibWebPDecoder.tryCreate(); + PlatformWebPEncoder libEnc = LibWebPEncoder.tryCreate(); + log.append(" libwebp: decode " + (libDec != null ? "OK" : "not found") + + ", encode " + (libEnc != null ? "OK" : "not found") + "\n"); + } catch (Throwable t) { + log.append(" libwebp: ERROR " + t + "\n"); + } + + try { + MacOSImageIODecoder macosDec = MacOSImageIODecoder.create(); + log.append(" macOS ImageIO: decode OK\n"); + } catch (Throwable t) { + log.append(" macOS ImageIO decode: " + t + "\n"); + } + try { + // MacOSImageIOEncoder macosEnc = MacOSImageIOEncoder.tryCreate(); + log.append(" macOS ImageIO: encode " + (/*macosEnc != null ? "OK" :*/ "not available") + "\n"); + } catch (Throwable t) { + log.append(" macOS ImageIO encode: ERROR " + t + "\n"); + } + + try { + WindowsCodecsDecoder wicDec = WindowsCodecsDecoder.tryCreate(); + // WindowsCodecsEncoder wicEnc = WindowsCodecsEncoder.tryCreate(); + log.append(" Windows WIC: decode " + (wicDec != null ? "OK" : "not available") + + ", encode " + (/*wicEnc != null ? "OK" :*/ "not available") + "\n"); + } catch (Throwable t) { + log.append(" Windows WIC: ERROR " + t + "\n"); + } + + try { + NgEngineDecoder ngDec = NgEngineDecoder.tryCreate(); + log.append(" ngengine: decode " + (ngDec != null ? "OK" : "not found") + "\n"); + } catch (Throwable t) { + log.append(" ngengine: ERROR " + t + "\n"); + } + + log.append("\n"); + }); + buttons.add(probeBtn); + + // Encode test + JButton encodeBtn = new JButton("Encode Test"); + encodeBtn.setEnabled(encodeAvailable); + encodeBtn.addActionListener(event -> { + int w = 256, h = 256; + int[] argb = new int[w * h]; + java.util.Random rng = new java.util.Random(42); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int r = Math.min(255, (x + rng.nextInt(32)) & 0xFF); + int g = Math.min(255, (y + rng.nextInt(32)) & 0xFF); + int b = rng.nextInt(256); + argb[y * w + x] = (0xFF << 24) | (r << 16) | (g << 8) | b; + } + } + + log.append("Encode 256x256 noisy gradient:\n"); + log.append(" Raw ARGB: " + argb.length + " pixels\n"); + + byte[] lossless = null; + try { + lossless = PlatformWebPEncoder.INSTANCE.encodeLossless(argb, w, h); + log.append(" Lossless: " + lossless.length + " bytes\n"); + } catch (Exception ex) { + log.append(" Lossless: FAILED: " + ex.getMessage() + "\n"); + } + + try { + byte[] lossy75 = PlatformWebPEncoder.INSTANCE.encodeLossy(argb, w, h, 0.75f); + log.append(" Lossy 75%%: " + lossy75.length + " bytes\n"); + } catch (Exception ex) { + log.append(" Lossy 75%%: FAILED: " + ex.getMessage() + "\n"); + } + + try { + byte[] lossy50 = PlatformWebPEncoder.INSTANCE.encodeLossy(argb, w, h, 0.50f); + log.append(" Lossy 50%%: " + lossy50.length + " bytes\n"); + } catch (Exception ex) { + log.append(" Lossy 50%%: FAILED: " + ex.getMessage() + "\n"); + } + + // Roundtrip test + if (lossless != null && decodeAvailable) { + try { + DecodedImage decoded = PlatformWebPDecoder.INSTANCE.decode(lossless); + log.append(" Roundtrip: " + decoded.width() + "x" + decoded.height() + + " (" + decoded.argb().length + " pixels)\n"); + } catch (Exception ex) { + log.append(" Roundtrip: FAILED: " + ex.getMessage() + "\n"); + } + } + log.append("\n"); + }); + buttons.add(encodeBtn); + + // Decode file + JButton decodeBtn = new JButton("Decode File"); + decodeBtn.setEnabled(decodeAvailable); + decodeBtn.addActionListener(event -> { + JFileChooser fc = new JFileChooser(); + fc.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("WebP files", "webp")); + if (fc.showOpenDialog(dialog) != JFileChooser.APPROVE_OPTION) return; + + try { + byte[] webpData = Files.readAllBytes(fc.getSelectedFile().toPath()); + log.append("File: " + fc.getSelectedFile().getName() + " (" + webpData.length + " bytes)\n"); + + try { + int[] info = PlatformWebPDecoder.INSTANCE.getInfo(webpData); + log.append(" Info: " + info[0] + "x" + info[1] + "\n"); + } catch (Exception ex) { + log.append(" Info: FAILED: " + ex.getMessage() + "\n"); + } + + try { + DecodedImage decoded = PlatformWebPDecoder.INSTANCE.decode(webpData); + log.append(" Decoded: " + decoded.width() + "x" + decoded.height() + "\n"); + + // Show decoded image + BufferedImage img = new BufferedImage(decoded.width(), decoded.height(), BufferedImage.TYPE_INT_ARGB); + img.setRGB(0, 0, decoded.width(), decoded.height(), decoded.argb(), 0, decoded.width()); + + JDialog preview = new JDialog(dialog, "Preview: " + fc.getSelectedFile().getName(), false); + preview.add(new JLabel(new ImageIcon(img))); + preview.pack(); + preview.setLocationRelativeTo(dialog); + preview.setVisible(true); + } catch (Exception ex) { + log.append(" Decode: FAILED: " + ex.getMessage() + "\n"); + } + } catch (IOException ex) { + log.append(" Error: " + ex.getMessage() + "\n"); + } + log.append("\n"); + }); + buttons.add(decodeBtn); + + dialog.add(buttons, BorderLayout.SOUTH); + dialog.setVisible(true); + } } diff --git a/webp/build.gradle b/webp/build.gradle new file mode 100644 index 0000000..0067d7c --- /dev/null +++ b/webp/build.gradle @@ -0,0 +1,84 @@ +import proguard.gradle.ProGuardTask + +plugins { + id "com.gradleup.shadow" +} + +configurations { + bundled + implementation.extendsFrom(bundled) +} + +dependencies { + // api(project(":common")) + + bundled("org.ngengine:image-webp-java:0.1.1") + + testImplementation(platform("org.junit:junit-bom:6.0.3")) + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-params") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +test { + useJUnitPlatform() + jvmArgs("--enable-native-access=ALL-UNNAMED") + modularity.inferModulePath.set(false) + systemProperty("expected.decode", System.getProperty("expected.decode", "")) + systemProperty("expected.encode", System.getProperty("expected.encode", "")) + + testLogging { + events("passed", "skipped", "failed") + + showExceptions = true + showCauses = true + showStackTraces = true + exceptionFormat = "full" + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = "UTF-8" + options.release.set(22) + options.compilerArgs.addAll(["--add-reads", "platformtools.webp=ALL-UNNAMED"]) +} + +shadowJar { + archiveClassifier.set("shadow") + configurations = [project.configurations.bundled] + + relocate("org.ngengine", "org.redlance.platformtools.webp.shaded.ngengine") + exclude("META-INF/maven/**") +} + +tasks.register("proguardJar", ProGuardTask) { + dependsOn shadowJar + + injars(shadowJar.archiveFile) + outjars(layout.buildDirectory.file("libs/${base.archivesName.get()}-${version}-bundled.jar")) + libraryjars("${System.getProperty('java.home')}/jmods") + + // Keep all our classes, only shrink shaded dependencies + keep("class !org.redlance.platformtools.webp.shaded.**, org.redlance.** { *; }") + + dontnote() + ignorewarnings() + dontobfuscate() + optimizationpasses(10) + configuration(file("proguard.pro")) +} + +assemble.dependsOn proguardJar +downgradeJar.enabled = false + +shadow { + addShadowVariantIntoJavaComponent = false +} + +publishing.publications.named("mavenJava") { + artifact(proguardJar.getOutJarFileCollection().singleFile) { + classifier = "bundled" + builtBy(tasks.named("proguardJar")) + } +} diff --git a/webp/proguard.pro b/webp/proguard.pro new file mode 100644 index 0000000..bafe5a8 --- /dev/null +++ b/webp/proguard.pro @@ -0,0 +1,2 @@ +-optimizeaggressively +-allowaccessmodification \ No newline at end of file diff --git a/webp/src/main/java/module-info.java b/webp/src/main/java/module-info.java new file mode 100644 index 0000000..8a42be3 --- /dev/null +++ b/webp/src/main/java/module-info.java @@ -0,0 +1,12 @@ +module platformtools.webp { + exports org.redlance.platformtools.webp.decoder; + exports org.redlance.platformtools.webp.encoder; + + exports org.redlance.platformtools.webp.impl.libwebp to platformtools.testing; + exports org.redlance.platformtools.webp.impl.macos to platformtools.testing; + exports org.redlance.platformtools.webp.impl.windows to platformtools.testing; + exports org.redlance.platformtools.webp.impl.ngengine to platformtools.testing; + + requires java.desktop; + requires static org.jetbrains.annotations; +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/decoder/DecodedImage.java b/webp/src/main/java/org/redlance/platformtools/webp/decoder/DecodedImage.java new file mode 100644 index 0000000..c263886 --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/decoder/DecodedImage.java @@ -0,0 +1,91 @@ +package org.redlance.platformtools.webp.decoder; + +import org.jetbrains.annotations.Nullable; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * Decoded image with raw pixel data. + */ +@SuppressWarnings("unused") // API +public final class DecodedImage { + private final int[] argb; + private final int width; + private final int height; + private byte @Nullable [] png; + + /** + * Creates a decoded image from packed ARGB pixels. + * + * @param argb pixel data as packed ARGB integers ({@code (a << 24) | (r << 16) | (g << 8) | b}), + * length must be {@code width * height} + * @param width image width in pixels + * @param height image height in pixels + */ + public DecodedImage(int[] argb, int width, int height) { + this.argb = argb; + this.width = width; + this.height = height; + } + + /** + * Parses a PNG image into a {@link DecodedImage}. + * + * @param pngData raw PNG file bytes + * @return decoded image with cached PNG bytes + * @throws IOException if the data is not a valid PNG or decoding fails + */ + public static DecodedImage fromPng(byte[] pngData) throws IOException { + try (ByteArrayInputStream is = new ByteArrayInputStream(pngData)) { + BufferedImage img = ImageIO.read(is); + if (img == null) throw new IOException("Failed to decode PNG"); + + int w = img.getWidth(); + int h = img.getHeight(); + DecodedImage decoded = new DecodedImage(img.getRGB(0, 0, w, h, null, 0, w), w, h); + decoded.png = pngData; + return decoded; + } + } + + public int[] argb() { + return this.argb; + } + + public int width() { + return this.width; + } + + public int height() { + return this.height; + } + + /** + * Returns {@code true} if PNG bytes are already cached. + */ + public boolean isPngCached() { + return this.png != null; + } + + /** + * Converts this image to PNG format. Cached after the first call. + * + * @return PNG file bytes + * @throws IOException if encoding fails + */ + public byte[] toPng() throws IOException { + if (this.png != null) return this.png; + + BufferedImage img = new BufferedImage(this.width, this.height, BufferedImage.TYPE_INT_ARGB); + img.setRGB(0, 0, this.width, this.height, this.argb, 0, this.width); + + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(img, "png", out); + return this.png = out.toByteArray(); + } + } +} diff --git a/webp/src/main/java/org/redlance/platformtools/webp/decoder/PlatformWebPDecoder.java b/webp/src/main/java/org/redlance/platformtools/webp/decoder/PlatformWebPDecoder.java new file mode 100644 index 0000000..ed2ffab --- /dev/null +++ b/webp/src/main/java/org/redlance/platformtools/webp/decoder/PlatformWebPDecoder.java @@ -0,0 +1,66 @@ +package org.redlance.platformtools.webp.decoder; + +import org.redlance.platformtools.webp.impl.PlatformWebPDecoderImpl; + +/** + * Platform-native WebP image decoder. + * + *

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