mirror of
https://github.com/LadybirdBrowser/ladybird.git
synced 2024-11-21 23:20:20 +00:00
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:
parent
6c79efcae4
commit
e2336e2099
Notes:
sideshowbarker
2024-07-16 23:55:09 +09:00
Author: https://github.com/nico Commit: https://github.com/SerenityOS/serenity/commit/e2336e2099 Pull-request: https://github.com/SerenityOS/serenity/pull/24288
4 changed files with 128 additions and 1 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
TRY(animation_writer->add_frame(*frame.image, frame.duration));
|
||||
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;
|
||||
|
|
Loading…
Reference in a new issue