Java: Как получить PNG-кадры анимированного Webp-изображения?

Мне нужно получить отдельные кадры анимированного Webp-изображения

Пример изображения: https://mathiasbynens.be/demo/animated-webp-supported.webp

Подключив библиотеку,

implementation("com.twelvemonkeys.imageio:imageio-webp:3.12.0")

Изначально я использовал самый простой метод для сохранения отдельных кадров:

public static void extractFrames(String webpUrl, String outputDir) throws Exception {
    File dir = new File(outputDir);
    try (ImageInputStream input = ImageIO.createImageInputStream(new URI(webpUrl).toURL().openStream())) {
        
        Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
        ImageReader reader = readers.next();
        reader.setInput(input);
        int frameCount = reader.getNumImages(true);
        
        System.out.println("Найдено кадров: " + frameCount);
        
        for (int i = 0; i < frameCount; i++) {
            BufferedImage frame = reader.read(i);
            File outputFile = new File(dir, i + ".png");
            ImageIO.write(frame, "png", outputFile);
            System.out.println("Сохранён кадр: " + outputFile.getAbsolutePath());
        }
        reader.dispose();
    }
}

Но из-за того, что Webp в целях оптимизации хранит не цельные кадры, а только разницу между ними, в результате получается корректно сохранить только первый кадр, а остальные имеют отсутствующие фрагменты:

введите сюда описание изображения


Вторая реализация метода накладывает каждый новый кадр на предыдущий:

public static void extractFrames(String webpUrl, String outputDir) throws Exception {
    File dir = new File(outputDir);

    try (ImageInputStream input = ImageIO.createImageInputStream(new URI(webpUrl).toURL().openStream())) {
        Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
        ImageReader reader = readers.next();
        reader.setInput(input);

        int frameCount = reader.getNumImages(true);
        System.out.println("Найдено кадров: " + frameCount);

        BufferedImage canvas = null;

        for (int i = 0; i < frameCount; i++) {
            BufferedImage rawFrame = reader.read(i);

            if (canvas == null) {
                canvas = new BufferedImage(
                        reader.getWidth(i),
                        reader.getHeight(i),
                        BufferedImage.TYPE_INT_ARGB
                );
            }

            Graphics2D g = canvas.createGraphics();
            g.drawImage(rawFrame, 0, 0, null);
            g.dispose();

            File outputFile = new File(dir, i + ".png");
            ImageIO.write(canvas, "png", outputFile);
        }

        reader.dispose();
    }
}

Но из-за того, что последующие кадры имеют отличные от первого кадра размеры, а их установка происходит в координаты x = 0, y = 0 (то есть выравнивание по левому верхнему углу), они накладываются друг на друга со смещением и тоже получается кривой результат:

введите сюда описание изображения

введите сюда описание изображения


Я так и не нашел способ получать из изображения информацию о расположении кадров, также как не смог найти какую-либо библиотеку с реализованным функционалом. А реализовывать свой декодер с нуля не очень хочется :)

Надо уточнить, что мне не подойдут решения, реализованные через обращения к сторонним сервисам и программам для работы с Webp.

Возможно существует какая-то библиотека, которую я пропустил?


Ответы (1 шт):

Автор решения: Ivan Shatsky

Как я уже предложил в комментариях:

Я бы написал свой парсер webp, который бы проходил все ANMF-чанки (каждому сответствует один кадр), и из каждого чанка доставал X и Y смещения для этого кадра вручную. Все эти смещения надо сохранить, а дальше, когда вы получили очередной кадр с помощью библиотеки twelvemonkeys, накладывайте его на предыдущий с учётом этого смещения. Свой собственный декодер самого изображения писать для этого не надо.

Оказалось, это совсем не сложно. Вот вам уже готовый парсер, занял у меня максимум час времени:

import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;

public class WebPFrameParser {

    // Класс для хранения метаданных
    public static class FrameMetadata {
        final int xOffset; // Смещение по X (относительно холста)
        final int yOffset; // Смещение по Y (относительно холста)
        final int width;   // Ширина кадра
        final int height;  // Высота кадра

        public FrameMetadata(int xOffset, int yOffset, int width, int height) {
            this.xOffset = xOffset;
            this.yOffset = yOffset;
            this.width = width;
            this.height = height;
        }

        @Override
        public String toString() {
            return String.format("Смещение: (%d, %d), Размер: %dx%d", xOffset, yOffset, width, height);
        }
    }

    // Утилитарная функция для чтения 4-байтового Little-Endian числа
    private static int readLittleEndian4Bytes(RandomAccessFile raf) throws IOException {
        byte[] bytes = new byte[4];
        raf.readFully(bytes);
        // Используем ByteBuffer для корректной интерпретации байтов в Little-Endian
        return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).getInt();
    }

    // Утилитарная функция для чтения 3-байтового Little-Endian числа из буфера по заданному смещению
    private static int readLittleEndian3Bytes(byte[] data, int offset) throws IOException {
        return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8) | ((data[offset + 2] & 0xFF) << 16);
    }

    // Точка входа для парсинга
    public static List<FrameMetadata> parseAnimatedWebP(String filePath) throws IOException {
        List<FrameMetadata> frames = new ArrayList<>();
        
        try (RandomAccessFile raf = new RandomAccessFile(filePath, "r")) {
            
            // Проверяем заголовок RIFF (12 байт)
            raf.seek(0);
            byte[] riffHeader = new byte[12];
            raf.readFully(riffHeader);

            // Проверка сигнатур
            if (!new String(riffHeader, 0, 4).equals("RIFF") || 
                !new String(riffHeader, 8, 4).equals("WEBP")) {
                throw new IOException("Файл не является корректным WebP (RIFF/WEBP)");
            }

            // Начинаем парсинг чанков сразу после заголовка WEBP
            long currentOffset = 12;
            long fileSize = raf.length();

            // Итерируемся по всем чанкам
            while (currentOffset < fileSize - 8) { 
                raf.seek(currentOffset);

                // Каждый чанк имеет структуру: [4-байта ID] [4-байта Размер данных] [Данные]
                byte[] chunkId = new byte[4];
                raf.readFully(chunkId);
                String id = new String(chunkId);

                // Читаем размер данных чанка (Little-Endian)
                int chunkSize = readLittleEndian4Bytes(raf); 
                
                // Проверяем, является ли текущий чанк чанком кадра
                if (id.equals("ANMF")) {
                    // Парсим чанк и добавляем полученные данные в список
                    frames.add(parseANMFChunk(raf, currentOffset + 8)); 
                    //System.out.println("Найден ANMF-чанк. " + frames.get(frames.size() - 1));
                }

                // Переходим к следующему чанку: + 8 байт (ID + Размер) + chunkSize
                // Чанки выравниваются по границе 2 байт, поэтому в конце добавляем chunkSize % 2
                currentOffset += 8 + chunkSize + chunkSize % 2; 
            }

        }
        return frames;
    }

    // Парсинг данных внутри ANMF-чанка
    private static FrameMetadata parseANMFChunk(RandomAccessFile raf, long dataStartOffset) throws IOException {
        raf.seek(dataStartOffset);
        
        // ANMF имеет фиксированный 16-байтовый заголовок:
        // 3 байта: X-смещение
        // 3 байта: Y-смещение
        // 3 байта: Ширина кадра
        // 3 байта: Высота кадра
        // 2 байта: Время отображения (duration)
        // 1 байт: Флаги
        // 1 байт: Резерв
        
        // Читаем 12 байт, содержащих смещения и размеры
        byte[] frameData = new byte[12];
        raf.readFully(frameData);

        // The X coordinate of the upper left corner of the frame is Frame X * 2.
        int xOffset = readLittleEndian3Bytes(frameData, 0) * 2;
        // The Y coordinate of the upper left corner of the frame is Frame Y * 2.
        int yOffset = readLittleEndian3Bytes(frameData, 3) * 2;
        // The 1-based width of the frame. The frame width is 1 + Frame Width Minus One.
        int width = readLittleEndian3Bytes(frameData, 6) + 1;
        // The 1-based height of the frame. The frame height is 1 + Frame Height Minus One.
        int height = readLittleEndian3Bytes(frameData, 9) + 1;

        return new FrameMetadata(xOffset, yOffset, width, height);
    }
    
    public static void main(String[] args) {
        if (args.length != 1) {
            System.out.println("Использование: java WebPFrameParser <путь_к_WebP_файлу>");
            return;
        }

        try {
            List<FrameMetadata> metadata = parseAnimatedWebP(args[0]);
            
            if (metadata.isEmpty()) {
                System.out.println("WebP файл не содержит анимированных кадров (ANMF).");
            } else {
                System.out.println("\n--- Результат парсинга метаданных ---");
                for (int i = 0; i < metadata.size(); i++) {
                    System.out.printf("Кадр %d: %s\n", i, metadata.get(i));
                }
            }

        } catch (IOException e) {
            System.err.println("Ошибка при парсинге WebP файла: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

Насколько я понимаю, в своём вопросе вы использовали вот это изображение:

Animated WebP sample

(первый результат в google по запросу "animated webp samples", хе-хе)

Вот результат работы моего парсера с этим изображением:

--- Результат парсинга метаданных ---
Кадр 0: Смещение: (0, 0), Размер: 400x400
Кадр 1: Смещение: (0, 0), Размер: 400x400
Кадр 2: Смещение: (0, 0), Размер: 400x400
Кадр 3: Смещение: (0, 6), Размер: 400x394
Кадр 4: Смещение: (0, 6), Размер: 371x394
Кадр 5: Смещение: (6, 6), Размер: 394x382
Кадр 6: Смещение: (0, 0), Размер: 400x388
Кадр 7: Смещение: (0, 0), Размер: 394x383
Кадр 8: Смещение: (0, 6), Размер: 394x394
Кадр 9: Смещение: (22, 6), Размер: 372x394
Кадр 10: Смещение: (0, 0), Размер: 400x400
Кадр 11: Смещение: (0, 6), Размер: 320x382

Дальше интегрировать класс WebPFrameParser в свою программу я думаю вы уже сможете и сами (ширина и высота кадра вам в общем-то и не нужны, у меня они здесь только для демонстрации).

Удачи!


Продолжение

В парсере, который я написал и показал выше, меня немного смущала одна деталь. Мой парсер работал с локальными файлами, а топикстартер использовал в качестве источника данных URL, поэтому для интеграции в его основную программу класс WebPFrameParser всё равно потребовал бы серьёзного рефакторинга. К тому же на исходный вопрос - как получить отдельные кадры анимированного webp-изображения - окончательного ответа по сути дела дано не было. Поэтому я решил довести дело до конца и скрестить ужа и ежа код топикстартера, извлекающий отдельные кадры с помощью библиотеки TwelveMonkeys, и свой парсер, извлекающий метаданные этих кадров. Вот что у меня получилось (признаюсь честно, с небольшим ассистированием со стороны ChatGPT, но не подумайте ничего плохого - от того кода, который он предложил, здесь осталось не так уж много):

import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Iterator;
import java.util.ArrayList;
import java.util.List;

public class WebPExtractor {

    public static class FrameMetadata {
        public final int xOffset, yOffset;

        public FrameMetadata(int xOffset, int yOffset) {
            this.xOffset = xOffset;
            this.yOffset = yOffset;
        }
    }

    // Read 3-byte little-endian integer
    private static int readLE3(ByteBuffer buffer) {
        return (buffer.get() & 0xFF) | ((buffer.get() & 0xFF) << 8) | ((buffer.get() & 0xFF) << 16);
    }

    // Open a WebP file or URL and return an InputStream
    public static InputStream openStream(String pathOrUrl) throws IOException, InterruptedException {
        URI uri;
        try {
            uri = URI.create(pathOrUrl);
        } catch (IllegalArgumentException e) {
            // Not a URI, treat as local file
            return Files.newInputStream(Path.of(pathOrUrl));
        }

        String scheme = uri.getScheme();
        if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
            HttpClient client = HttpClient.newHttpClient();
            HttpRequest request = HttpRequest.newBuilder(uri).build();
            HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
            return response.body();
        } else {
            // Unknown scheme or null, try as local file
            return Files.newInputStream(Path.of(pathOrUrl));
        }
    }

    // Parse ANMF chunks from a byte array
    public static List<FrameMetadata> parseAnimatedWebP(byte[] data) throws Exception {
        List<FrameMetadata> frames = new ArrayList<>();
        ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);

        // Check RIFF header
        if (buf.remaining() < 12
            || buf.getInt(0) != 0x46464952  // little-endian 'RIFF'
            || buf.getInt(8) != 0x50424557) // little-endian 'WEBP'
        {
            throw new Exception("Not a valid WebP file (RIFF/WEBP)");
        }

        int offset = 12;
        while (offset + 8 <= buf.capacity()) {
            buf.position(offset);
            int chunkId = buf.getInt();
            int chunkSize = buf.getInt();

            if (chunkId == 0x464D4E41) { // little-endian 'ANMF'
                buf.position(offset + 8);
                int x = readLE3(buf) * 2;
                int y = readLE3(buf) * 2;
                frames.add(new FrameMetadata(x, y));
            }

            offset += 8 + chunkSize + (chunkSize % 2); // align to even boundary
        }

        return frames;
    }

    // Extract and save frames from a WebP file (local or remote)
    public static void extractFrames(String pathOrUrl, String outputDir) throws Exception {
        File dir = new File(outputDir);
        if (!dir.exists()) dir.mkdirs();

        // Step 1: Read the bytes to use both for parser and ImageIO
        byte[] webpData;
        try (InputStream in = openStream(pathOrUrl)) {
            webpData = in.readAllBytes();
        }

        // Step 2: Parse offsets from ANMF chunks
        List<FrameMetadata> frames = parseAnimatedWebP(webpData);
        System.out.println("Parsed " + frames.size() + " ANMF chunks");

        // Step 3: Decode frames using TwelveMonkeys
        try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(webpData))) {
            Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
            ImageReader reader = readers.next();
            reader.setInput(input);
            int frameCount = reader.getNumImages(true);
            System.out.println("Got " + frameCount + " frames from ImageReader");

            if (frameCount != frames.size()) {
                System.out.println("Warning: frame count mismatch (ANMF vs ImageReader)");
                // Тут надо что-то делать... или нет...
            }

            int canvasWidth = reader.getWidth(0);
            int canvasHeight = reader.getHeight(0);
            BufferedImage canvas = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_ARGB);

            for (int i = 0; i < frameCount; i++) {
                BufferedImage rawFrame = reader.read(i);

                // Это ChatGPT так перестраховался, но пока что оставлю как есть
                // Теоретически здесь достаточно просто FrameMetadata meta = frames.get(i);
                // Вообще поведение здесь должно зависеть от того, что мы собираемся делать,
                // если количество фреймов, найденных парсером, не совпало с тем, которое
                // вернула библиотека TwelveMonkeys
                FrameMetadata meta = (i < frames.size()) ? frames.get(i) : new FrameMetadata(0, 0);

                Graphics2D g = canvas.createGraphics();
                g.drawImage(rawFrame, meta.xOffset, meta.yOffset, null);
                g.dispose();

                File outputFile = new File(dir, String.format("frame%03d.png", i));
                ImageIO.write(canvas, "png", outputFile);
            }

            System.out.println("Extraction complete");

            reader.dispose();
        }
    }

    // Entry point
    public static void main(String[] args) throws Exception {
        if (args.length != 2) {
            System.out.println("Usage: java WebPExtractor <file_or_url> <output_dir>");
            return;
        }

        extractFrames(args[0], args[1]);
    }
}

Это уже не парсер чанков, а полноценный экстрактор отдельных кадров, причём в качестве источника изображения он может принимать как имя локального файла, так и URL. RandomAccessFile мы по понятным причинам здесь больше не используем, все данные сразу читаем в память (не скачивать же нам дважды один и тот же файл, верно?)

Для работы нашей программе нужно четыре jar-файла:

  • common-image-3.12.0.jar
  • common-lang-3.12.0.jar
  • imageio-core-3.12.0.jar
  • imageio-webp-3.12.0.jar

(у меня они лежат в директории libs)

Компилируем, запускаем:

>javac -cp "libs/*" WebPExtractor.java
>java -cp ".;libs/*" WebPExtractor https://mathiasbynens.be/demo/animated-webp-supported.webp out
Parsed 12 ANMF chunks
Got 12 frames from ImageReader
Extraction complete

И получаем в директории out вот такие 12 png-изображений:

12 кадров webp-файла

Похоже, что у нас всё получилось так, как и было задумано - артефактов на изображениях не наблюдается.

→ Ссылка