LibGfx+animation: Only store changed pixels in animation frames

For example, for 7z7c.gif, we now store one 500x500 frame and then
a 94x78 frame at (196, 208) and a 91x78 frame at (198, 208).

This reduces how much data we have to store.

We currently store all pixels in the rect with changed pixels.
We could in the future store pixels that are equal in that rect
as transparent pixels. When inputs are gif files, this would
guaranteee that new frames only have at most 256 distinct colors
(since GIFs require that), which would help a future color indexing
transform. For now, we don't do that though.

The API I'm adding here is a bit ugly:

* WebPs can only store x/y offsets that are a multiple of 2. This
  currently leaks into the AnimationWriter base class.
  (Since we potentially have to make a webp frame 1 pixel wider
  and higher due to this, it's possible to have a frame that has
  <= 256 colors in a gif input but > 256 colors in the webp,
  if we do the technique above.)

* Every client writing animations has to have logic to track
  previous frames, decide which of the two functions to call, etc.

This also adds an opt-out flag to `animation`, because:

1. Some clients apparently assume the size of the last VP8L
   chunk is the size of the image
   (see https://github.com/discord/lilliput/issues/159).

2. Having incremental frames is good for filesize and for
   playing the animation start-to-end, but it makes it hard
   to extract arbitrary frames (have to extract all frames
   from start to target frame) -- but this is mean tto be a
   delivery codec, not an editing codec. It's also more vulnerable to
   corrupted bytes in the middle of the file -- but transport
   protocols are good these days.
   (It'd also be an idea to write a full frame every N frames.)

For https://giphy.com/gifs/XT9HMdwmpHqqOu1f1a (an 184K gif),
output webp size goes from 21M to 11M.

For 7z7c.gif (an 11K gif), output webp size goes from 2.1M to 775K.

(The webp image data still isn't compressed at all.)
This commit is contained in:
Nico Weber 2024-05-11 20:23:41 -04:00 committed by Tim Flynn
parent 6c79efcae4
commit e2336e2099
Notes: sideshowbarker 2024-07-16 23:55:09 +09:00
4 changed files with 128 additions and 1 deletions

View file

@ -177,3 +177,38 @@ TEST_CASE(test_webp_animation)
EXPECT_EQ(frame1.duration, 200);
expect_bitmaps_equal(*frame1.image, *rgba_bitmap);
}
TEST_CASE(test_webp_incremental_animation)
{
auto rgb_bitmap_1 = TRY_OR_FAIL(create_test_rgb_bitmap());
auto rgb_bitmap_2 = TRY_OR_FAIL(create_test_rgb_bitmap());
// WebP frames can't be at odd coordinates. Make a pixel at an odd coordinate different to make sure we handle this.
rgb_bitmap_2->scanline(3)[3] = Color::Red;
// 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::WebPWriter::start_encoding_animation(stream, rgb_bitmap_1->size()));
TRY_OR_FAIL(animation_writer->add_frame(*rgb_bitmap_1, 100));
TRY_OR_FAIL(animation_writer->add_frame_relative_to_last_frame(*rgb_bitmap_2, 200, *rgb_bitmap_1));
auto encoded_animation = ReadonlyBytes { stream_buffer.data(), stream.offset() };
auto decoded_animation_plugin = TRY_OR_FAIL(Gfx::WebPImageDecoderPlugin::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_1->size());
auto frame0 = TRY_OR_FAIL(decoded_animation_plugin->frame(0));
EXPECT_EQ(frame0.duration, 100);
expect_bitmaps_equal(*frame0.image, *rgb_bitmap_1);
auto frame1 = TRY_OR_FAIL(decoded_animation_plugin->frame(1));
EXPECT_EQ(frame1.duration, 200);
expect_bitmaps_equal(*frame1.image, *rgb_bitmap_2);
}

View file

@ -4,10 +4,92 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibGfx/Bitmap.h>
#include <LibGfx/ImageFormats/AnimationWriter.h>
#include <LibGfx/Rect.h>
namespace Gfx {
AnimationWriter::~AnimationWriter() = default;
static bool are_scanlines_equal(Bitmap const& a, Bitmap const& b, int y)
{
for (int x = 0; x < a.width(); ++x) {
if (a.get_pixel(x, y) != b.get_pixel(x, y))
return false;
}
return true;
}
static bool are_columns_equal(Bitmap const& a, Bitmap const& b, int x, int y1, int y2)
{
for (int y = y1; y < y2; ++y) {
if (a.get_pixel(x, y) != b.get_pixel(x, y))
return false;
}
return true;
}
static Gfx::IntRect rect_where_pixels_are_different(Bitmap const& a, Bitmap const& b)
{
VERIFY(a.size() == b.size());
// FIXME: This works on physical pixels.
VERIFY(a.scale() == 1);
VERIFY(b.scale() == 1);
int number_of_equal_pixels_at_top = 0;
while (number_of_equal_pixels_at_top < a.height() && are_scanlines_equal(a, b, number_of_equal_pixels_at_top))
++number_of_equal_pixels_at_top;
int number_of_equal_pixels_at_bottom = 0;
while (number_of_equal_pixels_at_bottom < a.height() - number_of_equal_pixels_at_top && are_scanlines_equal(a, b, a.height() - number_of_equal_pixels_at_bottom - 1))
++number_of_equal_pixels_at_bottom;
int const y1 = number_of_equal_pixels_at_top;
int const y2 = a.height() - number_of_equal_pixels_at_bottom;
int number_of_equal_pixels_at_left = 0;
while (number_of_equal_pixels_at_left < a.width() && are_columns_equal(a, b, number_of_equal_pixels_at_left, y1, y2))
++number_of_equal_pixels_at_left;
int number_of_equal_pixels_at_right = 0;
while (number_of_equal_pixels_at_right < a.width() - number_of_equal_pixels_at_left && are_columns_equal(a, b, a.width() - number_of_equal_pixels_at_right - 1, y1, y2))
++number_of_equal_pixels_at_right;
// WebP can only encode even-sized animation frame positions.
// FIXME: Change API shape in some way so that the AnimationWriter base class doesn't have to know about this detail of a subclass.
if (number_of_equal_pixels_at_left % 2 != 0)
--number_of_equal_pixels_at_left;
if (number_of_equal_pixels_at_top % 2 != 0)
--number_of_equal_pixels_at_top;
Gfx::IntRect rect;
rect.set_x(number_of_equal_pixels_at_left);
rect.set_y(number_of_equal_pixels_at_top);
rect.set_width(a.width() - number_of_equal_pixels_at_left - number_of_equal_pixels_at_right);
rect.set_height(a.height() - number_of_equal_pixels_at_top - number_of_equal_pixels_at_bottom);
return rect;
}
ErrorOr<void> AnimationWriter::add_frame_relative_to_last_frame(Bitmap& frame, int duration_ms, RefPtr<Bitmap> last_frame)
{
if (!last_frame)
return add_frame(frame, duration_ms);
auto rect = rect_where_pixels_are_different(*last_frame, frame);
// FIXME: It would be nice to have a way to crop a bitmap without copying the data.
auto differences = TRY(frame.cropped(rect));
// FIXME: Another idea: If all frames of the animation have no alpha,
// this could set color values of pixels that are in the changed rect that are
// equal to the last frame to transparent black and set the frame to be blended.
// That might take less space after compression.
// This assumes a replacement disposal method.
return add_frame(differences, duration_ms, rect.location());
}
}

View file

@ -21,6 +21,8 @@ public:
// 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;
ErrorOr<void> add_frame_relative_to_last_frame(Bitmap&, int duration_ms, RefPtr<Bitmap> last_frame);
};
}

View file

@ -14,6 +14,7 @@
struct Options {
StringView in_path;
StringView out_path;
bool write_full_frames { false };
};
static ErrorOr<Options> parse_options(Main::Arguments arguments)
@ -22,6 +23,7 @@ static ErrorOr<Options> parse_options(Main::Arguments arguments)
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.add_option(options.write_full_frames, "Do not store incremental frames. Produces larger files.", "write-full-frames");
args_parser.parse(arguments);
if (options.out_path.is_empty())
@ -46,9 +48,15 @@ ErrorOr<int> serenity_main(Main::Arguments arguments)
auto animation_writer = TRY(Gfx::WebPWriter::start_encoding_animation(*output_stream, decoder->size(), decoder->loop_count()));
RefPtr<Gfx::Bitmap> last_frame;
for (size_t i = 0; i < decoder->frame_count(); ++i) {
auto frame = TRY(decoder->frame(i));
if (options.write_full_frames) {
TRY(animation_writer->add_frame(*frame.image, frame.duration));
} else {
TRY(animation_writer->add_frame_relative_to_last_frame(*frame.image, frame.duration, last_frame));
last_frame = frame.image;
}
}
return 0;