LibGfx/PNGWriter: Implement support for writing animated PNGs

Based on #24021.

Co-Authored-By: Pixel Brush <letsplaytvirtmann@gmail.com>
This commit is contained in:
Nico Weber 2024-08-12 21:56:37 -04:00
parent 4484196b3b
commit a2b59b9c98
3 changed files with 209 additions and 7 deletions

View file

@ -196,6 +196,37 @@ TEST_CASE(test_png_paeth_simd)
}
}
TEST_CASE(test_png_animation)
{
auto rgb_bitmap = TRY_OR_FAIL(create_test_rgb_bitmap());
auto rgba_bitmap = TRY_OR_FAIL(create_test_rgba_bitmap());
// 20 kiB is enough for two 47x33 frames.
auto stream_buffer = TRY_OR_FAIL(ByteBuffer::create_uninitialized(20 * 1024));
FixedMemoryStream stream { Bytes { stream_buffer } };
auto animation_writer = TRY_OR_FAIL(Gfx::PNGWriter::start_encoding_animation(stream, rgb_bitmap->size()));
TRY_OR_FAIL(animation_writer->add_frame(*rgb_bitmap, 100));
TRY_OR_FAIL(animation_writer->add_frame(*rgba_bitmap, 200));
auto encoded_animation = ReadonlyBytes { stream_buffer.data(), stream.offset() };
auto decoded_animation_plugin = TRY_OR_FAIL(Gfx::PNGImageDecoderPlugin::create(encoded_animation));
EXPECT(decoded_animation_plugin->is_animated());
EXPECT_EQ(decoded_animation_plugin->frame_count(), 2u);
EXPECT_EQ(decoded_animation_plugin->loop_count(), 0u);
EXPECT_EQ(decoded_animation_plugin->size(), rgb_bitmap->size());
auto frame0 = TRY_OR_FAIL(decoded_animation_plugin->frame(0));
EXPECT_EQ(frame0.duration, 100);
expect_bitmaps_equal(*frame0.image, *rgb_bitmap);
auto frame1 = TRY_OR_FAIL(decoded_animation_plugin->frame(1));
EXPECT_EQ(frame1.duration, 200);
expect_bitmaps_equal(*frame1.image, *rgba_bitmap);
}
TEST_CASE(test_qoi)
{
TRY_OR_FAIL((test_roundtrip<Gfx::QOIWriter, Gfx::QOIImageDecoderPlugin>(TRY_OR_FAIL(create_test_rgb_bitmap()))));

View file

@ -2,6 +2,7 @@
* Copyright (c) 2021, Pierre Hoffmeister
* Copyright (c) 2021, Andreas Kling <kling@serenityos.org>
* Copyright (c) 2021, Aziz Berkay Yesilyurt <abyesilyurt@gmail.com>
* Copyright (c) 2024, Torben Jonas Virtmann
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -117,6 +118,55 @@ ErrorOr<void> PNGWriter::add_png_header()
return {};
}
ErrorOr<void> PNGWriter::add_acTL_chunk(u32 num_frames, u32 loop_count)
{
// https://www.w3.org/TR/png/#acTL-chunk
PNGChunk png_chunk { "acTL"_string };
TRY(png_chunk.add_as_big_endian(num_frames));
TRY(png_chunk.add_as_big_endian(loop_count));
TRY(add_chunk(png_chunk));
return {};
}
struct fcTLData {
u32 sequence_number { 0 };
u32 width { 0 };
u32 height { 0 };
u32 x_offset { 0 };
u32 y_offset { 0 };
u16 delay_numerator { 0 };
u16 delay_denominator { 1 };
// dispose_op values
// 0 APNG_DISPOSE_OP_NONE
// 1 APNG_DISPOSE_OP_BACKGROUND
// 2 APNG_DISPOSE_OP_PREVIOUS
u8 dispose_operation { 0 };
// blend_op values
// value
// 0 APNG_BLEND_OP_SOURCE
// 1 APNG_BLEND_OP_OVER
u8 blend_operation { 0 };
};
ErrorOr<void> PNGWriter::add_fcTL_chunk(fcTLData const& data)
{
// https://www.w3.org/TR/png/#fcTL-chunk
// TODO: Constraints on frame regions
PNGChunk png_chunk { "fcTL"_string };
TRY(png_chunk.add_as_big_endian(data.sequence_number));
TRY(png_chunk.add_as_big_endian(data.width));
TRY(png_chunk.add_as_big_endian(data.height));
TRY(png_chunk.add_as_big_endian(data.x_offset));
TRY(png_chunk.add_as_big_endian(data.y_offset));
TRY(png_chunk.add_as_big_endian(data.delay_numerator));
TRY(png_chunk.add_as_big_endian(data.delay_denominator));
TRY(png_chunk.add_u8(data.dispose_operation));
TRY(png_chunk.add_u8(data.blend_operation));
TRY(add_chunk(png_chunk));
return {};
}
ErrorOr<void> PNGWriter::add_IHDR_chunk(u32 width, u32 height, u8 bit_depth, PNG::ColorType color_type, u8 compression_method, u8 filter_method, u8 interlace_method)
{
PNGChunk png_chunk { "IHDR"_string };
@ -172,11 +222,8 @@ union [[gnu::packed]] Pixel {
static_assert(AssertSize<Pixel, 4>());
template<bool include_alpha>
ErrorOr<void> PNGWriter::add_IDAT_chunk(Gfx::Bitmap const& bitmap, Compress::ZlibCompressionLevel compression_level)
static ErrorOr<void> add_image_data_to_chunk(Gfx::Bitmap const& bitmap, PNGChunk& png_chunk, Compress::ZlibCompressionLevel compression_level)
{
PNGChunk png_chunk { "IDAT"_string };
TRY(png_chunk.reserve(bitmap.size_in_bytes()));
ByteBuffer uncompressed_block_data;
TRY(uncompressed_block_data.try_ensure_capacity(bitmap.size_in_bytes() + bitmap.height()));
@ -287,9 +334,27 @@ ErrorOr<void> PNGWriter::add_IDAT_chunk(Gfx::Bitmap const& bitmap, Compress::Zli
scanline_minus_1 = scanline;
}
TRY(png_chunk.compress_and_add(uncompressed_block_data, compression_level));
TRY(add_chunk(png_chunk));
return {};
return png_chunk.compress_and_add(uncompressed_block_data, compression_level);
}
template<bool include_alpha>
ErrorOr<void> PNGWriter::add_fdAT_chunk(Gfx::Bitmap const& bitmap, u32 sequence_number, Compress::ZlibCompressionLevel compression_level)
{
// https://www.w3.org/TR/png/#fdAT-chunk
PNGChunk png_chunk { "fdAT"_string };
TRY(png_chunk.reserve(bitmap.size_in_bytes() + 4));
TRY(png_chunk.add_as_big_endian(sequence_number));
TRY(add_image_data_to_chunk<include_alpha>(bitmap, png_chunk, compression_level));
return add_chunk(png_chunk);
}
template<bool include_alpha>
ErrorOr<void> PNGWriter::add_IDAT_chunk(Gfx::Bitmap const& bitmap, Compress::ZlibCompressionLevel compression_level)
{
PNGChunk png_chunk { "IDAT"_string };
TRY(png_chunk.reserve(bitmap.size_in_bytes()));
TRY(add_image_data_to_chunk<include_alpha>(bitmap, png_chunk, compression_level));
return add_chunk(png_chunk);
}
static bool bitmap_has_transparency(Bitmap const& bitmap)
@ -326,4 +391,99 @@ ErrorOr<ByteBuffer> PNGWriter::encode(Gfx::Bitmap const& bitmap, Options options
return stream.read_until_eof();
}
class PNGAnimationWriter : public AnimationWriter {
public:
PNGAnimationWriter(SeekableStream& stream, IntSize dimensions, int loop_count, PNGWriter::Options const& options)
: m_writer(stream)
, m_stream(stream)
, m_dimensions(dimensions)
, m_loop_count(loop_count)
, m_options(options)
{
}
virtual ErrorOr<void> add_frame(Bitmap&, int, IntPoint, BlendMode) override;
private:
PNGWriter m_writer;
SeekableStream& m_stream;
IntSize const m_dimensions;
int const m_loop_count { 0 };
bool m_is_first_frame { true };
u32 m_sequence_number { 0 };
u32 m_number_of_frames { 0 };
size_t m_acTL_offset { 0 };
PNGWriter::Options const m_options;
};
ErrorOr<void> PNGAnimationWriter::add_frame(Bitmap& bitmap, int duration_ms, IntPoint at, BlendMode)
{
++m_number_of_frames;
bool const is_first_frame = m_number_of_frames == 1;
if (is_first_frame) {
// "The fcTL chunk corresponding to the default image, if it exists, has these restrictions:
// * The x_offset and y_offset fields must be 0.
// * The width and height fields must equal the corresponding fields from the IHDR chunk."
// FIXME: If this ends up happening in practice, we should composite `bitmap` to a temporary bitmap and store that as first frame.
if (at != IntPoint {})
return Error::from_string_literal("First APNG frame must have x_offset and y_offset set to 0");
if (bitmap.size() != m_dimensions)
return Error::from_string_literal("First APNG frame must have the same dimensions as the APNG itself");
// All frames in an APNG use the same IHDR chunk, which means they all have the same color type.
// To decide if we should write RGB or RGBA, we'd really have to check all frames, but that needs a
// lot of memory and makes streaming impossible.
// Instead, we always include an alpha channel. In practice, inter-frame compression means that
// even for animations without transparency, all but the first frame will have transparent pixels.
// The APNG format doesn't give us super great options here.
TRY(m_writer.add_png_header());
TRY(m_writer.add_IHDR_chunk(m_dimensions.width(), m_dimensions.height(), 8, PNG::ColorType::TruecolorWithAlpha, 0, 0, 0));
if (m_options.icc_data.has_value())
TRY(m_writer.add_iCCP_chunk(m_options.icc_data.value(), m_options.compression_level));
m_acTL_offset = TRY(m_stream.tell());
TRY(m_writer.add_acTL_chunk(m_number_of_frames, m_loop_count));
} else {
// Overwrite previous acTL chunk to update its num_frames. Use add_acTL_chunk to make sure the chunk's crc is updated too.
auto current_offset = TRY(m_stream.tell());
TRY(m_stream.seek(m_acTL_offset, SeekMode::SetPosition));
TRY(m_writer.add_acTL_chunk(m_number_of_frames, m_loop_count));
TRY(m_stream.seek(current_offset, SeekMode::SetPosition));
// Overwrite previous IEND marker.
TRY(m_stream.seek(-12, SeekMode::FromCurrentPosition));
}
fcTLData fcTL_data;
fcTL_data.sequence_number = m_sequence_number;
fcTL_data.width = bitmap.width();
fcTL_data.height = bitmap.height();
fcTL_data.delay_numerator = duration_ms;
fcTL_data.delay_denominator = 1000;
fcTL_data.x_offset = at.x();
fcTL_data.y_offset = at.y();
TRY(m_writer.add_fcTL_chunk(fcTL_data));
m_sequence_number++;
if (is_first_frame) {
TRY(m_writer.add_IDAT_chunk<true>(bitmap, m_options.compression_level));
} else {
TRY(m_writer.add_fdAT_chunk<true>(bitmap, m_sequence_number, m_options.compression_level));
m_sequence_number++;
}
TRY(m_writer.add_IEND_chunk());
return {};
}
ErrorOr<NonnullOwnPtr<AnimationWriter>> PNGWriter::start_encoding_animation(SeekableStream& stream, IntSize dimensions, int loop_count, Options const& options)
{
auto writer = make<PNGAnimationWriter>(stream, dimensions, loop_count, options);
return writer;
}
}

View file

@ -1,6 +1,7 @@
/*
* Copyright (c) 2021, Pierre Hoffmeister
* Copyright (c) 2021, Andreas Kling <kling@serenityos.org>
* Copyright (c) 2024, Torben Jonas Virtmann
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -11,6 +12,7 @@
#include <AK/Vector.h>
#include <LibCompress/Zlib.h>
#include <LibGfx/Forward.h>
#include <LibGfx/ImageFormats/AnimationWriter.h>
#include <LibGfx/ImageFormats/PNGShared.h>
namespace Gfx {
@ -26,6 +28,8 @@ struct PNGWriterOptions {
Optional<ReadonlyBytes> icc_data;
};
struct fcTLData;
class PNGWriter {
public:
using Options = PNGWriterOptions;
@ -33,13 +37,20 @@ public:
static ErrorOr<void> encode(Stream&, Bitmap const&, Options const& = {});
static ErrorOr<ByteBuffer> encode(Gfx::Bitmap const&, Options options = Options {});
static ErrorOr<NonnullOwnPtr<AnimationWriter>> start_encoding_animation(SeekableStream&, IntSize dimensions, int loop_count = 0, Options const& = {});
private:
friend class PNGAnimationWriter;
PNGWriter(Stream&);
Stream& m_stream;
ErrorOr<void> add_chunk(PNGChunk&);
ErrorOr<void> add_png_header();
ErrorOr<void> add_acTL_chunk(u32 num_frames, u32 loop_count);
ErrorOr<void> add_fcTL_chunk(fcTLData const& data);
template<bool include_alpha>
ErrorOr<void> add_fdAT_chunk(Gfx::Bitmap const&, u32 sequence_number, Compress::ZlibCompressionLevel);
ErrorOr<void> add_IHDR_chunk(u32 width, u32 height, u8 bit_depth, PNG::ColorType color_type, u8 compression_method, u8 filter_method, u8 interlace_method);
ErrorOr<void> add_iCCP_chunk(ReadonlyBytes icc_data, Compress::ZlibCompressionLevel);
template<bool include_alpha>