diff --git a/Meta/Lagom/CMakeLists.txt b/Meta/Lagom/CMakeLists.txt index dc89743d2a8..f2f16b91351 100644 --- a/Meta/Lagom/CMakeLists.txt +++ b/Meta/Lagom/CMakeLists.txt @@ -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) diff --git a/Userland/Libraries/LibGfx/ImageFormats/WebPWriter.cpp b/Userland/Libraries/LibGfx/ImageFormats/WebPWriter.cpp index 082a016ed42..62c30f72101 100644 --- a/Userland/Libraries/LibGfx/ImageFormats/WebPWriter.cpp +++ b/Userland/Libraries/LibGfx/ImageFormats/WebPWriter.cpp @@ -327,4 +327,192 @@ ErrorOr 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 add_frame(Bitmap&, int, IntPoint) override; + + ErrorOr update_size_in_header(); + +private: + SeekableStream& m_stream; + IntSize m_dimensions; +}; + +static ErrorOr 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(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 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) }; + + // "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(0)); + + return {}; +} + +ErrorOr 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(at.x()); + chunk.frame_y = static_cast(at.y()); + chunk.frame_width = static_cast(bitmap.width()); + chunk.frame_height = static_cast(bitmap.height()); + chunk.frame_duration_in_milliseconds = static_cast(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 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>(current_offset - 8)); + TRY(m_stream.seek(current_offset, SeekMode::SetPosition)); + return {}; +} + +struct ANIMChunk { + u32 background_color { 0 }; + u16 loop_count { 0 }; +}; + +static ErrorOr 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>(chunk.background_color)); + TRY(stream.write_value>(chunk.loop_count)); + return {}; +} + +ErrorOr> 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(loop_count) })); + + auto writer = make(stream, dimensions); + TRY(writer->update_size_in_header()); + return writer; +} + } diff --git a/Userland/Libraries/LibGfx/ImageFormats/WebPWriter.h b/Userland/Libraries/LibGfx/ImageFormats/WebPWriter.h index 81d166d1d8c..8c4488bc235 100644 --- a/Userland/Libraries/LibGfx/ImageFormats/WebPWriter.h +++ b/Userland/Libraries/LibGfx/ImageFormats/WebPWriter.h @@ -7,7 +7,9 @@ #pragma once #include +#include #include +#include namespace Gfx { @@ -15,6 +17,17 @@ struct WebPEncoderOptions { Optional 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 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 encode(Stream&, Bitmap const&, Options const& = {}); + // Always lossless at the moment. + static ErrorOr> start_encoding_animation(SeekableStream&, IntSize dimensions, int loop_count = 0, Color background_color = Color::Black, Options const& = {}); + private: WebPWriter() = delete; }; diff --git a/Userland/Utilities/CMakeLists.txt b/Userland/Utilities/CMakeLists.txt index 12b8ef0ec9e..f6374bd90c9 100644 --- a/Userland/Utilities/CMakeLists.txt +++ b/Userland/Utilities/CMakeLists.txt @@ -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) diff --git a/Userland/Utilities/animation.cpp b/Userland/Utilities/animation.cpp new file mode 100644 index 00000000000..0cff245f25c --- /dev/null +++ b/Userland/Utilities/animation.cpp @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024, Nico Weber + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include + +struct Options { + StringView in_path; + StringView out_path; +}; + +static ErrorOr 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 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; +}