LibGfx+Utilities: Add animation utility, make it write animated webps

The high-level design is that we have a static method on WebPWriter that
returns an AnimationWriter object. AnimationWriter has a virtual method
for writing individual frames. This allows streaming animations to disk,
without having to buffer up the entire animation in memory first.
The semantics of this function, add_frame(), are that data is flushed
to disk every time the function is called, so that no explicit `close()`
method is needed.

For some formats that store animation length at the start of the file,
including WebP, this means that this needs to write to a SeekableStream,
so that add_frame() can seek to the start and update the size when a
frame is written.

This design should work for GIF and APNG writing as well. We can move
AnimationWriter to a new header if we add writers for these.

Currently, `animation` can read any animated image format we can read
(apng, gif, webp) and convert it to an animated webp file.

The written animated webp file is not compressed whatsoever, so this
creates large output files at the moment.
This commit is contained in:
Nico Weber 2024-05-06 17:44:57 -04:00 committed by Tim Flynn
parent 27cc9e7386
commit 3a4e0c2804
Notes: sideshowbarker 2024-07-16 21:42:29 +09:00
5 changed files with 260 additions and 0 deletions

View File

@ -537,6 +537,7 @@ if (BUILD_LAGOM)
lagom_utility(adjtime SOURCES ../../Userland/Utilities/adjtime.cpp LIBS LibMain)
endif()
lagom_utility(animation SOURCES ../../Userland/Utilities/animation.cpp LIBS LibGfx LibMain)
lagom_utility(base64 SOURCES ../../Userland/Utilities/base64.cpp LIBS LibMain)
if (NOT EMSCRIPTEN)

View File

@ -327,4 +327,192 @@ ErrorOr<void> WebPWriter::encode(Stream& stream, Bitmap const& bitmap, Options c
return {};
}
class WebPAnimationWriter : public AnimationWriter {
public:
WebPAnimationWriter(SeekableStream& stream, IntSize dimensions)
: m_stream(stream)
, m_dimensions(dimensions)
{
}
virtual ErrorOr<void> add_frame(Bitmap&, int, IntPoint) override;
ErrorOr<void> update_size_in_header();
private:
SeekableStream& m_stream;
IntSize m_dimensions;
};
static ErrorOr<void> align_to_two(SeekableStream& stream)
{
// https://developers.google.com/speed/webp/docs/riff_container
// "If Chunk Size is odd, a single padding byte -- which MUST be 0 to conform with RIFF -- is added."
if (TRY(stream.tell()) % 2 != 0)
TRY(stream.write_value<u8>(0));
return {};
}
struct ANMFChunk {
u32 frame_x { 0 };
u32 frame_y { 0 };
u32 frame_width { 0 };
u32 frame_height { 0 };
u32 frame_duration_in_milliseconds { 0 };
enum class BlendingMethod {
UseAlphaBlending = 0,
DoNotBlend = 1,
};
BlendingMethod blending_method { BlendingMethod::UseAlphaBlending };
enum class DisposalMethod {
DoNotDispose = 0,
DisposeToBackgroundColor = 1,
};
DisposalMethod disposal_method { DisposalMethod::DoNotDispose };
ReadonlyBytes frame_data;
};
static ErrorOr<void> write_ANMF_chunk(Stream& stream, ANMFChunk const& chunk)
{
TRY(write_chunk_header(stream, "ANMF"sv, 16 + chunk.frame_data.size()));
LittleEndianOutputBitStream bit_stream { MaybeOwned<Stream>(stream) };
// "Frame X: 24 bits (uint24)
// The X coordinate of the upper left corner of the frame is Frame X * 2."
TRY(bit_stream.write_bits(chunk.frame_x / 2, 24u));
// "Frame Y: 24 bits (uint24)
// The Y coordinate of the upper left corner of the frame is Frame Y * 2."
TRY(bit_stream.write_bits(chunk.frame_y / 2, 24u));
// "Frame Width: 24 bits (uint24)
// The 1-based width of the frame. The frame width is 1 + Frame Width Minus One."
TRY(bit_stream.write_bits(chunk.frame_width - 1, 24u));
// "Frame Height: 24 bits (uint24)
// The 1-based height of the frame. The frame height is 1 + Frame Height Minus One."
TRY(bit_stream.write_bits(chunk.frame_height - 1, 24u));
// "Frame Duration: 24 bits (uint24)"
TRY(bit_stream.write_bits(chunk.frame_duration_in_milliseconds, 24u));
// Don't use bit_stream.write_bits() to write individual flags here:
// The spec describes bit flags in MSB to LSB order, but write_bits() writes LSB to MSB.
u8 flags = 0;
// "Reserved: 6 bits
// MUST be 0. Readers MUST ignore this field."
// "Blending method (B): 1 bit"
if (chunk.blending_method == ANMFChunk::BlendingMethod::DoNotBlend)
flags |= 0x2;
// "Disposal method (D): 1 bit"
if (chunk.disposal_method == ANMFChunk::DisposalMethod::DisposeToBackgroundColor)
flags |= 0x1;
TRY(bit_stream.write_bits(flags, 8u));
// FIXME: Make ~LittleEndianOutputBitStream do this, or make it VERIFY() that it has happened at least.
TRY(bit_stream.flush_buffer_to_stream());
TRY(stream.write_until_depleted(chunk.frame_data));
if (chunk.frame_data.size() % 2 != 0)
TRY(stream.write_value<u8>(0));
return {};
}
ErrorOr<void> WebPAnimationWriter::add_frame(Bitmap& bitmap, int duration_ms, IntPoint at)
{
if (at.x() < 0 || at.y() < 0 || at.x() + bitmap.width() > m_dimensions.width() || at.y() + bitmap.height() > m_dimensions.height())
return Error::from_string_literal("Frame does not fit in animation dimensions");
// FIXME: The whole writing-and-reading-into-buffer over-and-over is awkward and inefficient.
AllocatingMemoryStream vp8l_header_stream;
TRY(write_VP8L_header(vp8l_header_stream, bitmap.width(), bitmap.height(), true));
auto vp8l_header_bytes = TRY(vp8l_header_stream.read_until_eof());
AllocatingMemoryStream vp8l_data_stream;
TRY(write_VP8L_image_data(vp8l_data_stream, bitmap));
auto vp8l_data_bytes = TRY(vp8l_data_stream.read_until_eof());
AllocatingMemoryStream vp8l_chunk_stream;
TRY(write_chunk_header(vp8l_chunk_stream, "VP8L"sv, vp8l_header_bytes.size() + vp8l_data_bytes.size()));
TRY(vp8l_chunk_stream.write_until_depleted(vp8l_header_bytes));
TRY(vp8l_chunk_stream.write_until_depleted(vp8l_data_bytes));
TRY(align_to_two(vp8l_chunk_stream));
auto vp8l_chunk_bytes = TRY(vp8l_chunk_stream.read_until_eof());
ANMFChunk chunk;
chunk.frame_x = static_cast<u32>(at.x());
chunk.frame_y = static_cast<u32>(at.y());
chunk.frame_width = static_cast<u32>(bitmap.width());
chunk.frame_height = static_cast<u32>(bitmap.height());
chunk.frame_duration_in_milliseconds = static_cast<u32>(duration_ms);
chunk.blending_method = ANMFChunk::BlendingMethod::DoNotBlend;
chunk.disposal_method = ANMFChunk::DisposalMethod::DoNotDispose;
chunk.frame_data = vp8l_chunk_bytes;
TRY(write_ANMF_chunk(m_stream, chunk));
TRY(update_size_in_header());
return {};
}
ErrorOr<void> WebPAnimationWriter::update_size_in_header()
{
auto current_offset = TRY(m_stream.tell());
TRY(m_stream.seek(4, SeekMode::SetPosition));
VERIFY(current_offset > 8);
TRY(m_stream.write_value<LittleEndian<u32>>(current_offset - 8));
TRY(m_stream.seek(current_offset, SeekMode::SetPosition));
return {};
}
struct ANIMChunk {
u32 background_color { 0 };
u16 loop_count { 0 };
};
static ErrorOr<void> write_ANIM_chunk(Stream& stream, ANIMChunk const& chunk)
{
TRY(write_chunk_header(stream, "ANIM"sv, 6)); // Size of the ANIM chunk.
TRY(stream.write_value<LittleEndian<u32>>(chunk.background_color));
TRY(stream.write_value<LittleEndian<u16>>(chunk.loop_count));
return {};
}
ErrorOr<NonnullOwnPtr<AnimationWriter>> WebPWriter::start_encoding_animation(SeekableStream& stream, IntSize dimensions, int loop_count, Color background_color, Options const& options)
{
// We'll update the stream with the actual size later.
TRY(write_webp_header(stream, 0));
VP8XHeader vp8x_header;
vp8x_header.has_icc = options.icc_data.has_value();
vp8x_header.width = dimensions.width();
vp8x_header.height = dimensions.height();
vp8x_header.has_animation = true;
TRY(write_VP8X_chunk(stream, vp8x_header));
VERIFY(TRY(stream.tell()) % 2 == 0);
ByteBuffer iccp_chunk_bytes;
if (options.icc_data.has_value()) {
TRY(write_chunk_header(stream, "ICCP"sv, options.icc_data.value().size()));
TRY(stream.write_until_depleted(options.icc_data.value()));
TRY(align_to_two(stream));
}
TRY(write_ANIM_chunk(stream, { .background_color = background_color.value(), .loop_count = static_cast<u16>(loop_count) }));
auto writer = make<WebPAnimationWriter>(stream, dimensions);
TRY(writer->update_size_in_header());
return writer;
}
}

View File

@ -7,7 +7,9 @@
#pragma once
#include <AK/Error.h>
#include <LibGfx/Color.h>
#include <LibGfx/Forward.h>
#include <LibGfx/Point.h>
namespace Gfx {
@ -15,6 +17,17 @@ struct WebPEncoderOptions {
Optional<ReadonlyBytes> icc_data;
};
class AnimationWriter {
public:
virtual ~AnimationWriter() = default;
// Flushes the frame to disk.
// IntRect { at, at + bitmap.size() } must fit in the dimensions
// passed to `start_writing_animation()`.
// FIXME: Consider passing in disposal method and blend mode.
virtual ErrorOr<void> add_frame(Bitmap&, int duration_ms, IntPoint at = {}) = 0;
};
class WebPWriter {
public:
using Options = WebPEncoderOptions;
@ -22,6 +35,9 @@ public:
// Always lossless at the moment.
static ErrorOr<void> encode(Stream&, Bitmap const&, Options const& = {});
// Always lossless at the moment.
static ErrorOr<NonnullOwnPtr<AnimationWriter>> start_encoding_animation(SeekableStream&, IntSize dimensions, int loop_count = 0, Color background_color = Color::Black, Options const& = {});
private:
WebPWriter() = delete;
};

View File

@ -74,6 +74,7 @@ install(CODE "file(CREATE_LINK gunzip ${CMAKE_INSTALL_PREFIX}/bin/zcat SYMBOLIC)
target_link_libraries(abench PRIVATE LibAudio LibFileSystem)
target_link_libraries(aconv PRIVATE LibAudio LibFileSystem)
target_link_libraries(animation PRIVATE LibGfx)
target_link_libraries(aplay PRIVATE LibAudio LibFileSystem LibIPC)
target_link_libraries(asctl PRIVATE LibAudio LibIPC)
target_link_libraries(bt PRIVATE LibSymbolication LibURL)

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2024, Nico Weber <thakis@chromium.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibCore/ArgsParser.h>
#include <LibCore/File.h>
#include <LibCore/MappedFile.h>
#include <LibGfx/ImageFormats/ImageDecoder.h>
#include <LibGfx/ImageFormats/WebPWriter.h>
struct Options {
StringView in_path;
StringView out_path;
};
static ErrorOr<Options> parse_options(Main::Arguments arguments)
{
Options options;
Core::ArgsParser args_parser;
args_parser.add_positional_argument(options.in_path, "Path to input image file", "FILE");
args_parser.add_option(options.out_path, "Path to output image file", "output", 'o', "FILE");
args_parser.parse(arguments);
if (options.out_path.is_empty())
return Error::from_string_view("-o is required "sv);
return options;
}
ErrorOr<int> serenity_main(Main::Arguments arguments)
{
Options options = TRY(parse_options(arguments));
// FIXME: Allow multiple single frames as input too, and allow manually setting their duration.
auto file = TRY(Core::MappedFile::map(options.in_path));
auto decoder = TRY(Gfx::ImageDecoder::try_create_for_raw_bytes(file->bytes()));
if (!decoder)
return Error::from_string_view("Could not find decoder for input file"sv);
auto output_file = TRY(Core::File::open(options.out_path, Core::File::OpenMode::Write));
auto output_stream = TRY(Core::OutputBufferedFile::create(move(output_file)));
auto animation_writer = TRY(Gfx::WebPWriter::start_encoding_animation(*output_stream, decoder->size(), decoder->loop_count()));
for (size_t i = 0; i < decoder->frame_count(); ++i) {
auto frame = TRY(decoder->frame(i));
TRY(animation_writer->add_frame(*frame.image, frame.duration));
}
return 0;
}