diff --git a/Tests/LibGfx/TestImageWriter.cpp b/Tests/LibGfx/TestImageWriter.cpp index 6fd60df0179..dc2196345a4 100644 --- a/Tests/LibGfx/TestImageWriter.cpp +++ b/Tests/LibGfx/TestImageWriter.cpp @@ -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); +} diff --git a/Userland/Libraries/LibGfx/ImageFormats/AnimationWriter.cpp b/Userland/Libraries/LibGfx/ImageFormats/AnimationWriter.cpp index bfd7f8ccc61..1f9bf959ddd 100644 --- a/Userland/Libraries/LibGfx/ImageFormats/AnimationWriter.cpp +++ b/Userland/Libraries/LibGfx/ImageFormats/AnimationWriter.cpp @@ -4,10 +4,92 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include +#include 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 AnimationWriter::add_frame_relative_to_last_frame(Bitmap& frame, int duration_ms, RefPtr 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()); +} + } diff --git a/Userland/Libraries/LibGfx/ImageFormats/AnimationWriter.h b/Userland/Libraries/LibGfx/ImageFormats/AnimationWriter.h index 9b08134f291..9048f088b9f 100644 --- a/Userland/Libraries/LibGfx/ImageFormats/AnimationWriter.h +++ b/Userland/Libraries/LibGfx/ImageFormats/AnimationWriter.h @@ -21,6 +21,8 @@ public: // passed to `start_writing_animation()`. // FIXME: Consider passing in disposal method and blend mode. virtual ErrorOr add_frame(Bitmap&, int duration_ms, IntPoint at = {}) = 0; + + ErrorOr add_frame_relative_to_last_frame(Bitmap&, int duration_ms, RefPtr last_frame); }; } diff --git a/Userland/Utilities/animation.cpp b/Userland/Utilities/animation.cpp index af675675a29..b63ddbbeafa 100644 --- a/Userland/Utilities/animation.cpp +++ b/Userland/Utilities/animation.cpp @@ -14,6 +14,7 @@ struct Options { StringView in_path; StringView out_path; + bool write_full_frames { false }; }; static ErrorOr parse_options(Main::Arguments arguments) @@ -22,6 +23,7 @@ static ErrorOr 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 serenity_main(Main::Arguments arguments) auto animation_writer = TRY(Gfx::WebPWriter::start_encoding_animation(*output_stream, decoder->size(), decoder->loop_count())); + RefPtr 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;