diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index fd0d2ee3723..4b6ad27c6f1 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -26,7 +26,7 @@ runs: sudo apt-get update sudo apt-get install autoconf autoconf-archive automake build-essential cmake libavcodec-dev fonts-liberation2 zip curl tar ccache clang-18 clang++-18 lld-18 gcc-13 g++-13 libstdc++-13-dev \ - ninja-build unzip qt6-base-dev qt6-tools-dev-tools libqt6svg6-dev qt6-multimedia-dev libgl1-mesa-dev libpulse-dev libssl-dev libegl1-mesa-dev + ninja-build unzip qt6-base-dev qt6-tools-dev-tools libqt6svg6-dev qt6-multimedia-dev libgl1-mesa-dev libpulse-dev libssl-dev libegl1-mesa-dev nasm sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-18 100 sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-18 100 @@ -52,7 +52,7 @@ runs: set -e sudo xcode-select --switch /Applications/Xcode_15.4.app brew update - brew install autoconf autoconf-archive automake coreutils bash ffmpeg ninja wabt ccache unzip qt llvm@18 + brew install autoconf autoconf-archive automake coreutils bash ffmpeg ninja wabt ccache unzip qt llvm@18 nasm - name: 'Install vcpkg' shell: bash diff --git a/Tests/LibGfx/TestImageDecoder.cpp b/Tests/LibGfx/TestImageDecoder.cpp index 6d2bcb44332..d5c68c4a669 100644 --- a/Tests/LibGfx/TestImageDecoder.cpp +++ b/Tests/LibGfx/TestImageDecoder.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -1007,3 +1008,64 @@ TEST_CASE(test_jxl_modular_property_8) } } } + +TEST_CASE(test_avif_simple_lossy) +{ + auto file = TRY_OR_FAIL(Core::MappedFile::map(TEST_INPUT("avif/simple-lossy.avif"sv))); + EXPECT(Gfx::AVIFImageDecoderPlugin::sniff(file->bytes())); + auto plugin_decoder = TRY_OR_FAIL(Gfx::AVIFImageDecoderPlugin::create(file->bytes())); + + auto frame = TRY_OR_FAIL(expect_single_frame_of_size(*plugin_decoder, { 240, 240 })); + + // While AVIF YUV contents are defined bit-exact, the YUV->RGB conversion isn't. + // So pixels changing by 1 or so below is fine if you change code. + EXPECT_EQ(frame.image->get_pixel(120, 232), Gfx::Color(0xf1, 0xef, 0xf0, 255)); + EXPECT_EQ(frame.image->get_pixel(198, 202), Gfx::Color(0x7b, 0xaa, 0xd6, 255)); +} + +TEST_CASE(test_avif_simple_lossless) +{ + auto file = TRY_OR_FAIL(Core::MappedFile::map(TEST_INPUT("avif/simple-lossless.avif"sv))); + EXPECT(Gfx::AVIFImageDecoderPlugin::sniff(file->bytes())); + auto plugin_decoder = TRY_OR_FAIL(Gfx::AVIFImageDecoderPlugin::create(file->bytes())); + + auto frame = TRY_OR_FAIL(expect_single_frame_of_size(*plugin_decoder, { 386, 395 })); + EXPECT_EQ(frame.image->get_pixel(0, 0), Gfx::Color(0, 0, 0, 0)); + EXPECT_EQ(frame.image->get_pixel(289, 332), Gfx::Color(0xf2, 0xee, 0xd3, 255)); +} + +TEST_CASE(test_avif_simple_lossy_bitdepth10) +{ + auto file = TRY_OR_FAIL(Core::MappedFile::map(TEST_INPUT("avif/simple-bitdepth10.avif"sv))); + EXPECT(!Gfx::AVIFImageDecoderPlugin::sniff(file->bytes())); +} + +TEST_CASE(test_avif_icc_profile) +{ + auto file = TRY_OR_FAIL(Core::MappedFile::map(TEST_INPUT("avif/icc_profile.avif"sv))); + EXPECT(Gfx::AVIFImageDecoderPlugin::sniff(file->bytes())); + auto plugin_decoder = TRY_OR_FAIL(Gfx::AVIFImageDecoderPlugin::create(file->bytes())); + + auto frame = TRY_OR_FAIL(expect_single_frame_of_size(*plugin_decoder, { 240, 240 })); + EXPECT(TRY_OR_FAIL(plugin_decoder->icc_data()).has_value()); +} + +TEST_CASE(test_avif_no_icc_profile) +{ + auto file = TRY_OR_FAIL(Core::MappedFile::map(TEST_INPUT("avif/simple-lossy.avif"sv))); + EXPECT(Gfx::AVIFImageDecoderPlugin::sniff(file->bytes())); + auto plugin_decoder = TRY_OR_FAIL(Gfx::AVIFImageDecoderPlugin::create(file->bytes())); + + auto frame = TRY_OR_FAIL(expect_single_frame_of_size(*plugin_decoder, { 240, 240 })); + EXPECT(!TRY_OR_FAIL(plugin_decoder->icc_data()).has_value()); +} + +TEST_CASE(test_avif_frame_out_of_bounds) +{ + auto file = TRY_OR_FAIL(Core::MappedFile::map(TEST_INPUT("avif/simple-lossy.avif"sv))); + EXPECT(Gfx::AVIFImageDecoderPlugin::sniff(file->bytes())); + auto plugin_decoder = TRY_OR_FAIL(Gfx::AVIFImageDecoderPlugin::create(file->bytes())); + + auto frame1 = TRY_OR_FAIL(plugin_decoder->frame(0)); + EXPECT(plugin_decoder->frame(1).is_error()); +} diff --git a/Tests/LibGfx/test-inputs/avif/icc_profile.avif b/Tests/LibGfx/test-inputs/avif/icc_profile.avif new file mode 100755 index 00000000000..d79c6a2f1dc Binary files /dev/null and b/Tests/LibGfx/test-inputs/avif/icc_profile.avif differ diff --git a/Tests/LibGfx/test-inputs/avif/simple-bitdepth10.avif b/Tests/LibGfx/test-inputs/avif/simple-bitdepth10.avif new file mode 100644 index 00000000000..ebd56a8f835 Binary files /dev/null and b/Tests/LibGfx/test-inputs/avif/simple-bitdepth10.avif differ diff --git a/Tests/LibGfx/test-inputs/avif/simple-lossless.avif b/Tests/LibGfx/test-inputs/avif/simple-lossless.avif new file mode 100644 index 00000000000..66a3d864daa Binary files /dev/null and b/Tests/LibGfx/test-inputs/avif/simple-lossless.avif differ diff --git a/Tests/LibGfx/test-inputs/avif/simple-lossy.avif b/Tests/LibGfx/test-inputs/avif/simple-lossy.avif new file mode 100644 index 00000000000..1aaffad649e Binary files /dev/null and b/Tests/LibGfx/test-inputs/avif/simple-lossy.avif differ diff --git a/Userland/Libraries/LibGfx/CMakeLists.txt b/Userland/Libraries/LibGfx/CMakeLists.txt index 77c920428d6..ee1d354bab5 100644 --- a/Userland/Libraries/LibGfx/CMakeLists.txt +++ b/Userland/Libraries/LibGfx/CMakeLists.txt @@ -52,6 +52,7 @@ set(SOURCES ImageFormats/WebPSharedLossless.cpp ImageFormats/WebPWriter.cpp ImageFormats/WebPWriterLossless.cpp + ImageFormats/AVIFLoader.cpp ImmutableBitmap.cpp MedianCut.cpp Painter.cpp @@ -96,5 +97,6 @@ find_package(PkgConfig) pkg_check_modules(WOFF2 REQUIRED IMPORTED_TARGET libwoff2dec) find_package(JPEG REQUIRED) find_package(PNG REQUIRED) +find_package(LIBAVIF REQUIRED) -target_link_libraries(LibGfx PRIVATE PkgConfig::WOFF2 JPEG::JPEG PNG::PNG) +target_link_libraries(LibGfx PRIVATE PkgConfig::WOFF2 JPEG::JPEG PNG::PNG avif) diff --git a/Userland/Libraries/LibGfx/ImageFormats/AVIFLoader.cpp b/Userland/Libraries/LibGfx/ImageFormats/AVIFLoader.cpp new file mode 100644 index 00000000000..f10030bbccc --- /dev/null +++ b/Userland/Libraries/LibGfx/ImageFormats/AVIFLoader.cpp @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2023, Nico Weber + * Copyright (c) 2024, doctortheemh + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include + +#include + +namespace Gfx { + +class AVIFLoadingContext { + AK_MAKE_NONMOVABLE(AVIFLoadingContext); + AK_MAKE_NONCOPYABLE(AVIFLoadingContext); + +public: + enum State { + NotDecoded = 0, + Error, + HeaderDecoded, + BitmapDecoded, + }; + + State state { State::NotDecoded }; + ReadonlyBytes data; + + avifDecoder* decoder { nullptr }; + + // image properties + Optional size; + bool has_alpha { false }; + size_t image_count { 0 }; + size_t repetition_count { 0 }; + ByteBuffer icc_data; + + Vector frame_descriptors; + + AVIFLoadingContext() = default; + ~AVIFLoadingContext() + { + avifDecoderDestroy(decoder); + decoder = nullptr; + } +}; + +AVIFImageDecoderPlugin::AVIFImageDecoderPlugin(ReadonlyBytes data, OwnPtr context) + : m_context(move(context)) +{ + m_context->data = data; +} + +AVIFImageDecoderPlugin::~AVIFImageDecoderPlugin() +{ +} + +static ErrorOr decode_avif_header(AVIFLoadingContext& context) +{ + if (context.state >= AVIFLoadingContext::HeaderDecoded) + return {}; + + if (context.decoder == nullptr) { + context.decoder = avifDecoderCreate(); + + if (context.decoder == nullptr) { + return Error::from_string_literal("failed to allocate AVIF decoder"); + } + } + + avifResult result = avifDecoderSetIOMemory(context.decoder, context.data.data(), context.data.size()); + if (result != AVIF_RESULT_OK) + return Error::from_string_literal("Cannot set IO on avifDecoder"); + + result = avifDecoderParse(context.decoder); + if (result != AVIF_RESULT_OK) + return Error::from_string_literal("Failed to decode AVIF"); + + if (context.decoder->image->depth != 8) + return Error::from_string_literal("Unsupported bitdepth"); + + // Image header now decoded, save some results for fast access in other parts of the plugin. + context.size = IntSize { context.decoder->image->width, context.decoder->image->height }; + context.has_alpha = context.decoder->alphaPresent == 1; + context.image_count = context.decoder->imageCount; + context.repetition_count = context.decoder->repetitionCount <= 0 ? 0 : context.decoder->repetitionCount; + context.state = AVIFLoadingContext::State::HeaderDecoded; + + if (context.decoder->image->icc.size > 0) { + context.icc_data.resize(context.decoder->image->icc.size); + memcpy(context.icc_data.data(), context.decoder->image->icc.data, context.decoder->image->icc.size); + } + + return {}; +} + +static ErrorOr decode_avif_image(AVIFLoadingContext& context) +{ + VERIFY(context.state >= AVIFLoadingContext::State::HeaderDecoded); + + avifRGBImage rgb; + while (avifDecoderNextImage(context.decoder) == AVIF_RESULT_OK) { + auto bitmap_format = context.has_alpha ? BitmapFormat::BGRA8888 : BitmapFormat::BGRx8888; + auto bitmap = TRY(Bitmap::create(bitmap_format, context.size.value())); + + avifRGBImageSetDefaults(&rgb, context.decoder->image); + rgb.pixels = bitmap->scanline_u8(0); + rgb.rowBytes = bitmap->pitch(); + rgb.format = avifRGBFormat::AVIF_RGB_FORMAT_BGRA; + + avifResult result = avifImageYUVToRGB(context.decoder->image, &rgb); + if (result != AVIF_RESULT_OK) + return Error::from_string_literal("Conversion from YUV to RGB failed"); + + auto duration = context.decoder->imageCount == 1 ? 0 : static_cast(context.decoder->imageTiming.duration * 1000); + context.frame_descriptors.append(ImageFrameDescriptor { bitmap, duration }); + + context.state = AVIFLoadingContext::BitmapDecoded; + } + + return {}; +} + +IntSize AVIFImageDecoderPlugin::size() +{ + return m_context->size.value(); +} + +bool AVIFImageDecoderPlugin::sniff(ReadonlyBytes data) +{ + AVIFLoadingContext context; + context.data = data; + return !decode_avif_header(context).is_error(); +} + +ErrorOr> AVIFImageDecoderPlugin::create(ReadonlyBytes data) +{ + auto context = TRY(try_make()); + auto plugin = TRY(adopt_nonnull_own_or_enomem(new (nothrow) AVIFImageDecoderPlugin(data, move(context)))); + TRY(decode_avif_header(*plugin->m_context)); + return plugin; +} + +bool AVIFImageDecoderPlugin::is_animated() +{ + return m_context->image_count > 1; +} + +size_t AVIFImageDecoderPlugin::loop_count() +{ + return is_animated() ? m_context->repetition_count : 0; +} + +size_t AVIFImageDecoderPlugin::frame_count() +{ + if (!is_animated()) + return 1; + return m_context->image_count; +} + +size_t AVIFImageDecoderPlugin::first_animated_frame_index() +{ + return 0; +} + +ErrorOr AVIFImageDecoderPlugin::frame(size_t index, Optional) +{ + if (index >= frame_count()) + return Error::from_string_literal("AVIFImageDecoderPlugin: Invalid frame index"); + + if (m_context->state == AVIFLoadingContext::State::Error) + return Error::from_string_literal("AVIFImageDecoderPlugin: Decoding failed"); + + if (m_context->state < AVIFLoadingContext::State::BitmapDecoded) { + TRY(decode_avif_image(*m_context)); + m_context->state = AVIFLoadingContext::State::BitmapDecoded; + } + + if (index >= m_context->frame_descriptors.size()) + return Error::from_string_literal("AVIFImageDecoderPlugin: Invalid frame index"); + return m_context->frame_descriptors[index]; +} + +ErrorOr> AVIFImageDecoderPlugin::icc_data() +{ + if (m_context->state < AVIFLoadingContext::State::HeaderDecoded) + (void)frame(0); + + if (!m_context->icc_data.is_empty()) + return m_context->icc_data; + return OptionalNone {}; +} + +} diff --git a/Userland/Libraries/LibGfx/ImageFormats/AVIFLoader.h b/Userland/Libraries/LibGfx/ImageFormats/AVIFLoader.h new file mode 100644 index 00000000000..5b03f449abb --- /dev/null +++ b/Userland/Libraries/LibGfx/ImageFormats/AVIFLoader.h @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023, Nico Weber + * Copyright (c) 2024, doctortheemh + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +namespace Gfx { + +class AVIFLoadingContext; + +class AVIFImageDecoderPlugin final : public ImageDecoderPlugin { +public: + static bool sniff(ReadonlyBytes); + static ErrorOr> create(ReadonlyBytes); + + virtual ~AVIFImageDecoderPlugin() override; + + virtual IntSize size() override; + + virtual bool is_animated() override; + virtual size_t loop_count() override; + virtual size_t frame_count() override; + virtual size_t first_animated_frame_index() override; + virtual ErrorOr frame(size_t index, Optional ideal_size = {}) override; + virtual ErrorOr> icc_data() override; + +private: + AVIFImageDecoderPlugin(ReadonlyBytes, OwnPtr); + + OwnPtr m_context; +}; + +} diff --git a/Userland/Libraries/LibGfx/ImageFormats/ImageDecoder.cpp b/Userland/Libraries/LibGfx/ImageFormats/ImageDecoder.cpp index d5656872926..7e62da9e1a6 100644 --- a/Userland/Libraries/LibGfx/ImageFormats/ImageDecoder.cpp +++ b/Userland/Libraries/LibGfx/ImageFormats/ImageDecoder.cpp @@ -5,6 +5,7 @@ */ #include +#include #include #include #include @@ -35,6 +36,7 @@ static ErrorOr> probe_and_sniff_for_appropriate_plugi { TIFFImageDecoderPlugin::sniff, TIFFImageDecoderPlugin::create }, { TinyVGImageDecoderPlugin::sniff, TinyVGImageDecoderPlugin::create }, { WebPImageDecoderPlugin::sniff, WebPImageDecoderPlugin::create }, + { AVIFImageDecoderPlugin::sniff, AVIFImageDecoderPlugin::create } }; for (auto& plugin : s_initializers) { diff --git a/vcpkg.json b/vcpkg.json index b6b76ea3f38..853a93410fe 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -13,6 +13,12 @@ "apng" ] }, + { + "name": "libavif", + "features": [ + "dav1d" + ] + }, { "name": "skia", "platform": "osx", @@ -59,6 +65,10 @@ "name": "libpng", "version": "1.6.43#1" }, + { + "name": "libavif", + "version": "1.0.4#1" + }, { "name": "skia", "version": "124#0"