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 шт):
Как я уже предложил в комментариях:
Я бы написал свой парсер 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();
}
}
}
Насколько я понимаю, в своём вопросе вы использовали вот это изображение:
(первый результат в 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.jarcommon-lang-3.12.0.jarimageio-core-3.12.0.jarimageio-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-изображений:
Похоже, что у нас всё получилось так, как и было задумано - артефактов на изображениях не наблюдается.




