From d1b747192f16c9bf1155315b455ac1448b66f56c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=80=9A=E6=B4=B2?= Date: Wed, 16 Sep 2026 20:48:02 +0800 Subject: [PATCH] Logo (Image): adds animation (gif / apng) support --- CHANGELOG.md | 51 +++ CMakeLists.txt | 8 + doc/help.json | 16 + src/common/impl/base64.c | 10 +- src/logo/image/im7.c | 437 ++++++++++++++++++++++++ src/logo/image/image.c | 706 +++++++++++++++++++++++++++++++++++++-- src/logo/image/image.h | 55 +++ src/logo/image/imageio.c | 541 +++++++++++++++++++++++------- src/logo/image/wic.cpp | 580 ++++++++++++++++++++++++++++---- src/options/logo.c | 11 + src/options/logo.h | 10 + tests/base64.c | 153 +++++++++ 12 files changed, 2357 insertions(+), 221 deletions(-) create mode 100644 tests/base64.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 786fbef96..f4816265d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,58 @@ # Unreleased +Changes: +* ImageMagick is no longer used for image logos on Windows and macOS, and has been replaced by the platform image frameworks (WIC on Windows, ImageIO on macOS). (Logo, Windows / macOS) + * Image logos on Windows and macOS no longer depend on ImageMagick being installed. + * Image sources that only ImageMagick could decode, such as SVG, PDF and PostScript, are no longer supported on Windows: neither WIC nor the embedded sixel encoder can decode them. +* The `--logo-recache` option has been replaced by `--logo-cache `, and the `logo.recache` JSON property has been renamed to `logo.cache`. (Logo) + * `--logo-cache true` (the default) reuses a cached rendering when it is valid, and writes it back on a cache miss. + * `--logo-cache false` ignores the image logo cache completely: nothing is read from it and nothing is written to it. + * `--logo-cache regen` does what `--logo-recache true` used to do. + * `logo.cache` accepts a boolean, or the string `"regen"`. + Features: +* Improved image logo support + * Backend rewritten + * Added a native image decoding backend on Windows (WIC) and macOS (ImageIO). + * Added an embedded libsixel encoder, used to produce sixel output on Windows and macOS. It is reported by `fastfetch --list-features` as "Embedded sixel". + * Enabled chafa image output on Windows and macOS independently of ImageMagick. + * As a result, `fastfetch --sixel X:\path\to\image` now works out of the box on Windows Terminal. + * Image logo cache entries are now validated against the modification time of the source image. (Logo) + * Editing an image logo in place now invalidates its cached rendering. + * Cache entries written by older versions are not reused, as they carry no modification time. + * Image logos can now be animated, when the terminal and the image protocol support it. (Logo) + * `--logo-animation-frame <0>` (`logo.animationFrame: 0` in the JSON config) plays a GIF or APNG. Only the `kitty` image protocol can play an animation; the frames are decoded and composed by fastfetch, so no external program is involved. + * `--logo-animation-frame ` renders the Nth frame as a still image, and negative values count back from the end, so `-1` is the last frame. This works for the `sixel`, `kitty` and `chafa` logo types. Note that negative values can only be given in the JSON config, as the command line parser reads a leading `-` as another option. + * The default is `1`, which renders a still image, so nothing changes for anyone who does not opt in. + * The frames come from the platform image framework (WIC on Windows, ImageIO on macOS, ImageMagick 7 on Linux). A single frame GIF falls back to a still image. A build with none of those, or with ImageMagick 6, reports an error instead of quietly showing a still image. + * A terminal that supports the kitty graphics protocol but not its animation frames, as Konsole does not, shows the first frame. * Added CPU name and frequency detection support on SPARC. (CPU, Linux) +* Added package detection support for CRUX. (Packages, Linux) + * Exposed in custom format as `{crux}`. +* Improved COSMIC detection (DE / WM, Linux) + * The version is now read from the `COSMIC_VERSION` environment variable when it is set. +* Improved accuracy and performance of process name detection in the Top module. (Top, macOS) +* Improved Packages detection on Windows (Packages, Windows) + * `winget list` is now invoked with `--source winget`, so only packages installed by winget itself are counted, and the slow msstore HTTP round trips are skipped. +* Improved Wallpaper detection on macOS Sonoma and later (#2559, Wallpaper, macOS) + * The image path is now also extracted from the `Configuration` field of the wallpaper plist, and the `NSWorkspace` fallback is tried last. +* Removed the `kvm` dependency on OpenBSD by using `sysctl` directly. (General, OpenBSD) +* Modules that were selected on the command line via `--structure` / `-s` now honors module options configured in the JSON config. (CommandOption) + +Bugfixes: +* Fixed Base64 encoding producing wrong output for some inputs. (General) +* Fixed image logos not working when ImageMagick is built without a quantum depth suffix in its library name, as on FreeBSD. (Logo, FreeBSD) +* Fixed TerminalFont detection on Windows ignoring Windows Terminal JSON fragment files. (#2573, TerminalFont, Windows) +* Fixed 64-bit values being truncated by `strtoul` on platforms where `unsigned long` is 32-bit. (Swap / PhysicalDisk / PhysicalMemory / GPU) +* Fixed read-only SQLite databases failing with `SQLITE_READONLY` when the database directory is not writable. (Packages) + * This fixes PKG package count detection on FreeBSD +* Fixed `{#keys}` and `{#title}` in module format strings not honoring the `brightColor` display option. (Format) +* Fixed `paddingTop` and `paddingLeft` being ignored by the `kitty-icat` image logo type. (Logo) +* Some internal cleanups and optimizations. + +Logos: +* Added ALT Atomic +* Removed Zerene # 2.68.1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f09152a8..54fa295c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2211,6 +2211,13 @@ if (BUILD_TESTS) PRIVATE libfastfetch ) + add_executable(fastfetch-test-base64 + tests/base64.c + ) + target_link_libraries(fastfetch-test-base64 + PRIVATE libfastfetch + ) + enable_testing() add_test(NAME test-strbuf COMMAND fastfetch-test-strbuf) add_test(NAME test-list COMMAND fastfetch-test-list) @@ -2219,6 +2226,7 @@ if (BUILD_TESTS) add_test(NAME test-duration COMMAND fastfetch-test-duration) add_test(NAME test-strutil COMMAND fastfetch-test-strutil) add_test(NAME test-networking COMMAND fastfetch-test-networking) + add_test(NAME test-base64 COMMAND fastfetch-test-base64) endif() ################## diff --git a/doc/help.json b/doc/help.json index d320a3674..14ef40fdb 100644 --- a/doc/help.json +++ b/doc/help.json @@ -293,6 +293,7 @@ { "long": "logo-cache", "desc": "Specify how the image logo cache is used", + "remark": "See \"--help logo-animation-frame\" for the one case where a cached rendering can outlive the image type that produced it", "arg": { "type": "enum", "optional": true, @@ -304,6 +305,21 @@ } } }, + { + "long": "logo-animation-frame", + "desc": "Select which frame of an animated image logo to render", + "remark": [ + "Animated kitty graphics only, and only if the image source is animated; 1 or higher renders that frame as a still image, 0 plays the animation", + "A selected frame is cached exactly like the first frame is, sharing its entries; the frame number is recorded next to them, so changing this option re-renders", + "That record is per cache directory, not per image type: switching --logo-type between two types that both have a cached rendering here, and asking for a different frame, can pair one type's rendering with the other's frame number", + "Run \"--logo-cache regen\" after such a switch" + ], + "arg": { + "type": "num", + "optional": true, + "default": 1 + } + }, { "long": "file", "desc": "Short for --logo-type file --logo ", diff --git a/src/common/impl/base64.c b/src/common/impl/base64.c index 3d743691b..50d7d4718 100644 --- a/src/common/impl/base64.c +++ b/src/common/impl/base64.c @@ -17,15 +17,19 @@ void ffBase64EncodeRaw(uint32_t size, const char* str, uint32_t* out_size, char* str += 3; } + // The bytes have to be widened through `uint8_t`: `char` is signed here, so widening a byte + // with the high bit set would sign extend it and the `& 63` below would then read the extension + // instead of the byte's own bits. The whole groups above are immune because they read through a + // `uint32_t` instead. if (size % 3 == 1) { - uint64_t n = (uint64_t) *str << 16; + uint64_t n = (uint64_t) (uint8_t) *str << 16; *out++ = chars[(n >> 18) & 63]; *out++ = chars[(n >> 12) & 63]; *out++ = '='; *out++ = '='; } else if (size % 3 == 2) { - uint64_t n = (uint64_t) *str++ << 16; - n |= (uint64_t) *str << 8; + uint64_t n = (uint64_t) (uint8_t) *str++ << 16; + n |= (uint64_t) (uint8_t) *str << 8; *out++ = chars[(n >> 18) & 63]; *out++ = chars[(n >> 12) & 63]; *out++ = chars[(n >> 6) & 63]; diff --git a/src/logo/image/im7.c b/src/logo/image/im7.c index 8f311f4cc..0768b4a37 100644 --- a/src/logo/image/im7.c +++ b/src/logo/image/im7.c @@ -3,6 +3,7 @@ #include "image.h" #include "common/library.h" #include "common/mallocHelper.h" + #include "common/strutil.h" #include #include @@ -104,6 +105,15 @@ static FFLogoImageResult im7EncodeImage(FFLogoRequestData* requestData, const ch ffCopyMagickString(imageInfoOut->magick, magick, magickLength); + // The raw pixel coders write image->depth bits per sample, not 8: a 1-bit grayscale source comes + // back as columns*4/8 bytes per row and a 16-bit one as columns*8, while the RGBA caller hands + // the blob on as RGBA8 and derives its length from width*height*4. Pin the depth for the raw + // formats only -- the SIXEL coder quantises on its own, so it keeps the source depth and its + // output stays byte identical. + if (ffStrEquals(magick, "RGBA")) { + image->depth = 8; + } + blob = ffImageToBlob(imageInfoOut, image, &length, exceptionInfo); if (blob == nullptr || length == 0) { goto cleanup; @@ -148,6 +158,17 @@ bool ffImageCreateIM7(FFLogoRequestData* requestData, FFImageBuffer* out, const return false; } + // FFImageBuffer carries no length, so every consumer derives it from width*height*4. Refuse any + // other size rather than let them read past the blob -- the raw coder's depth scaling used to + // produce one (see the depth pin in im7EncodeImage). + if (length != (size_t) requestData->logoPixelWidth * requestData->logoPixelHeight * 4) { + if (error) { + *error = "Image Magick did not return an RGBA8 buffer"; + } + free(blob); + return false; + } + out->data = blob; out->width = requestData->logoPixelWidth; out->height = requestData->logoPixelHeight; @@ -167,4 +188,420 @@ bool ffImageSixelEncodeIM7(FFLogoRequestData* requestData, FFstrbuf* out, const return true; } +// Encodes pixels the caller already has. The selected-frame path composes its frame itself, so it +// can not go through ffImageSixelEncodeIM7 -- that one re-reads the source and would encode +// whatever the still path would have shown instead of the frame that was asked for. +bool ffImageSixelEncodeBufferIM7(const FFImageBuffer* buffer, FFstrbuf* out, const char** error) { + // clang-format off + #if _WIN32 + FF_LIBRARY_LOAD(imageMagick, false, + "libMagickCore-7.Q16HDRI-10" FF_LIBRARY_EXTENSION, 0 + ) + #else + FF_LIBRARY_LOAD(imageMagick, false, + "libMagickCore-7.Q16HDRI" FF_LIBRARY_EXTENSION, 11, + "libMagickCore-7.Q16" FF_LIBRARY_EXTENSION, 11, + "libMagickCore-7" FF_LIBRARY_EXTENSION, 11 + ) + #endif + // clang-format on + + FF_LIBRARY_LOAD_SYMBOL(imageMagick, MagickCoreGenesis, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, MagickCoreTerminus, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, AcquireExceptionInfo, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, DestroyExceptionInfo, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, AcquireImageInfo, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, DestroyImageInfo, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, DestroyImage, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, CopyMagickString, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, ConstituteImage, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, ImageToBlob, false) + + ffMagickCoreGenesis(nullptr, MagickFalse); + + ExceptionInfo* exceptionInfo = ffAcquireExceptionInfo(); + if (exceptionInfo == nullptr) { + if (error) *error = "failed to acquire an ImageMagick exception"; + ffMagickCoreTerminus(); + return false; + } + + Image* image = ffConstituteImage(buffer->width, buffer->height, "RGBA", CharPixel, + buffer->data, exceptionInfo); + if (image == nullptr) { + if (error) *error = "failed to wrap the animation frame"; + ffDestroyExceptionInfo(exceptionInfo); + ffMagickCoreTerminus(); + return false; + } + + ImageInfo* imageInfoOut = ffAcquireImageInfo(); + if (imageInfoOut == nullptr) { + if (error) *error = "failed to acquire an ImageMagick image info"; + ffDestroyImage(image); + ffDestroyExceptionInfo(exceptionInfo); + ffMagickCoreTerminus(); + return false; + } + ffCopyMagickString(imageInfoOut->magick, "SIXEL", 6); + + size_t length = 0; + FF_AUTO_FREE void* blob = ffImageToBlob(imageInfoOut, image, &length, exceptionInfo); + + ffDestroyImageInfo(imageInfoOut); + ffDestroyImage(image); + ffDestroyExceptionInfo(exceptionInfo); + ffMagickCoreTerminus(); + + if (blob == nullptr || length == 0) { + if (error) *error = "failed to encode the animation frame as sixel"; + return false; + } + + ffStrbufSetNS(out, (uint32_t) length, (const char*) blob); + + // leak imageMagick to prevent fastfetch from crashing #552, as the static path does + imageMagick = nullptr; + return true; +} + +// --------------------------------------------------------------------------------------------- +// Animation +// +// ImageMagick reads an animated GIF as a list of *sub-frames*: every image holds only the frame's +// own rectangle in image->columns x image->rows, and where it belongs on the logical screen in +// image->page. CoalesceImages turns that into canvas-sized frames, which is the shape the session +// contract asks for. +// +// Its idea of composition is not a browser's, though, so three things are steered before it runs. +// All three were measured against an independent reference compositor rather than guessed, and so +// was the fourth difference, which cannot be steered and is normalised after the fact instead. The +// measurements and the memory cost that comes with reusing the composition are in +// doc/kitty-animation.md §11-E. +// --------------------------------------------------------------------------------------------- + +// The entry points the session calls have to outlive ffImageAnimationOpenIM7, so unlike the +// locals im7EncodeImage loads they belong to the session. +typedef struct FFIm7Animation { + FF_LIBRARY_SYMBOL(ResizeImage) + FF_LIBRARY_SYMBOL(ImageToBlob) + FF_LIBRARY_SYMBOL(DestroyImageList) + FF_LIBRARY_SYMBOL(DestroyImageInfo) + FF_LIBRARY_SYMBOL(DestroyExceptionInfo) + FF_LIBRARY_SYMBOL(MagickCoreTerminus) + FF_LIBRARY_SYMBOL(SetImageAlphaChannel) + + Image* images; // coalesced, so every frame is canvas sized + Image* cursor; // the next frame to hand out + ImageInfo* blobInfo; // magick "RGBA", reused for every frame + ExceptionInfo* exceptionInfo; + uint32_t nextIndex; + uint32_t outputWidth; + uint32_t outputHeight; + int32_t minGap; +} FFIm7Animation; + +// The aspect-ratio rule the static path applies inline, needed here for the canvas rather than +// for a decoded frame. +static bool im7ResolveTargetSize(FFLogoRequestData* requestData, uint32_t sourceWidth, uint32_t sourceHeight, const char** error) { + uint32_t width = requestData->logoPixelWidth; + uint32_t height = requestData->logoPixelHeight; + if (width == 0 && height == 0) { + width = sourceWidth; + height = sourceHeight; + } else if (width == 0) { + width = (uint32_t) ((double) sourceWidth / (double) sourceHeight * height); + } else if (height == 0) { + height = (uint32_t) ((double) sourceHeight / (double) sourceWidth * width); + } + + if (width == 0 || height == 0) { + if (error) *error = "invalid target dimensions"; + return false; + } + + requestData->logoPixelWidth = width; + requestData->logoPixelHeight = height; + return true; +} + +// Three measured differences between ImageMagick's composition and a browser's, all fixed on the +// input side because the composition itself is the part worth reusing: +// +// 1. ImageMagick fills the canvas with the logical screen's background colour, opaque. Browsers +// start transparent and never paint it. -> make every frame's background colour transparent. +// 2. A frame with no transparent index carries no alpha channel, and the canvas CoalesceImages +// clones from it inherits that, so the transparent fill above would be discarded. -> give +// those frames an opaque alpha channel, which is what having no transparent index means. +// 3. A frame that is never displayed (delay 0) must not erase the one before it, which is what +// browsers and kitty do. ImageMagick honours the disposal anyway. -> drop the disposal. +static void im7SteerComposition(FFIm7Animation* session, Image* images) { + for (Image* image = images; image != nullptr; image = image->next) { + image->background_color.red = 0; + image->background_color.green = 0; + image->background_color.blue = 0; + image->background_color.alpha = 0; + image->background_color.alpha_trait = BlendPixelTrait; + + if (image->alpha_trait == UndefinedPixelTrait) { + session->ffSetImageAlphaChannel(image, OpaqueAlphaChannel, session->exceptionInfo); + } + + if (image->delay == 0 && image->dispose == BackgroundDispose) { + image->dispose = NoneDispose; + } + } +} + +// Where a BackgroundDispose region is cleared, ImageMagick uses the background colour it read from +// the source and only forces the alpha to 0. The RGB under a zero alpha is unused by kitty and by +// chafa, but the sixel encoder does look at it, and the Windows backend clears to zero -- so the +// pixels are normalised here rather than left to differ between the two. +static void im7ClearTransparentPixels(uint8_t* pixels, size_t length) { + for (size_t i = 0; i + 3 < length; i += 4) { + if (pixels[i + 3] == 0) { + pixels[i] = 0; + pixels[i + 1] = 0; + pixels[i + 2] = 0; + } + } +} + +// The source's delay in centiseconds. GIFs are read with 100 ticks per second, so the conversion +// is a no-op there, but nothing guarantees that for every format the coder may hand back. +static uint32_t im7FrameDelayCs(const Image* image) { + if (image->ticks_per_second <= 0) { + return (uint32_t) image->delay; + } + + return (uint32_t) ((uint64_t) image->delay * 100u / (uint64_t) image->ticks_per_second); +} + +// ImageMagick counts how often the animation is *played*; the source counts how often it repeats +// after the first play. 0 means forever and 1 means the source declared no loop count at all, +// which is the -1 the session contract uses. Checked against the Windows backend on the same +// files: no NETSCAPE block -> 1 here and -1 there, NETSCAPE 0 -> 0 and 0, NETSCAPE 3 -> 4 and 3. +static int32_t im7LoopCount(const Image* image) { + if (image->iterations == 0) { + return 0; + } + if (image->iterations == 1) { + return -1; + } + + const size_t loops = image->iterations - 1; + return loops > (size_t) INT32_MAX ? INT32_MAX : (int32_t) loops; +} + +static bool im7GetFrame(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error) { + FFIm7Animation* session = (FFIm7Animation*) ffImageAnimationGetImpl(animation); + + // CoalesceImages has already composed every frame, so a frame is only ever walked past once, + // in order -- and taking one does not depend on having taken the previous one. + if (index < session->nextIndex) { + *error = "animation frames must be taken in order"; + return false; + } + + while (session->nextIndex < index && session->cursor != nullptr) { + session->cursor = session->cursor->next; + ++session->nextIndex; + } + + if (session->cursor == nullptr) { + *error = "the animation has no such frame"; + return false; + } + + const uint32_t rawDelay = im7FrameDelayCs(session->cursor); + + // kitty's mapping, which the design settled on: the raw delay is in centiseconds, the 100 ms + // floor exists only for sources whose frames are *all* zero, and whatever is left at <= 0 means + // "gapless" rather than 100 ms. + const int32_t gap = (int32_t) (rawDelay > (uint32_t) session->minGap ? rawDelay : (uint32_t) session->minGap) * 10; + out->delayMs = gap > 0 ? gap : -1; + + // Each coalesced frame is canvas sized already; the scaling to the output size is per frame, + // exactly as it is for a still image. + Image* resized = session->ffResizeImage(session->cursor, session->outputWidth, session->outputHeight, + UndefinedFilter, session->exceptionInfo); + if (resized == nullptr) { + *error = "failed to resize the animation frame"; + return false; + } + + size_t length = 0; + void* blob = session->ffImageToBlob(session->blobInfo, resized, &length, session->exceptionInfo); + session->ffDestroyImageList(resized); + if (blob == nullptr || length != (size_t) session->outputWidth * session->outputHeight * 4) { + if (blob != nullptr) free(blob); + *error = "failed to export the animation frame"; + return false; + } + + im7ClearTransparentPixels((uint8_t*) blob, length); + + out->data = (uint8_t*) blob; + session->cursor = session->cursor->next; + ++session->nextIndex; + return true; +} + +static void im7FreeSession(FFIm7Animation* session) { + if (session->images) session->ffDestroyImageList(session->images); + if (session->blobInfo) session->ffDestroyImageInfo(session->blobInfo); + if (session->exceptionInfo) session->ffDestroyExceptionInfo(session->exceptionInfo); + // Only ever reached once MagickCoreGenesis has run, and the symbol load in front of that has + // already been checked, so this pointer is non-null here. + session->ffMagickCoreTerminus(); + + // The library handle is leaked on purpose, exactly as the static path does it: unloading + // libMagickCore while the process still exits through it crashes (#552). + free(session); +} + +static void im7DestroyAnimation(FFImageAnimation* animation) { + FFIm7Animation* session = (FFIm7Animation*) ffImageAnimationGetImpl(animation); + if (session != nullptr) { + im7FreeSession(session); + } +} + +bool ffImageAnimationOpenIM7(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error) { + // clang-format off + #if _WIN32 + FF_LIBRARY_LOAD(imageMagick, false, + "libMagickCore-7.Q16HDRI-10" FF_LIBRARY_EXTENSION, 0 + ) + #else + FF_LIBRARY_LOAD(imageMagick, false, + "libMagickCore-7.Q16HDRI" FF_LIBRARY_EXTENSION, 11, + "libMagickCore-7.Q16" FF_LIBRARY_EXTENSION, 11, + "libMagickCore-7" FF_LIBRARY_EXTENSION, 11 + ) + #endif + // clang-format on + + FF_LIBRARY_LOAD_SYMBOL(imageMagick, MagickCoreGenesis, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, AcquireExceptionInfo, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, AcquireImageInfo, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, ReadImage, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, CoalesceImages, false) + FF_LIBRARY_LOAD_SYMBOL(imageMagick, CopyMagickString, false) + + FFIm7Animation* session = (FFIm7Animation*) calloc(1, sizeof(*session)); + if (session == nullptr) { + if (error) *error = "out of memory"; + return false; + } + + // The session owns these, so every failure below has to free the session by hand rather than + // go through im7DestroyAnimation, which expects the whole thing to be in place. + session->ffResizeImage = (typeof(&ResizeImage)) dlsym(imageMagick, "ResizeImage"); + session->ffImageToBlob = (typeof(&ImageToBlob)) dlsym(imageMagick, "ImageToBlob"); + session->ffDestroyImageList = (typeof(&DestroyImageList)) dlsym(imageMagick, "DestroyImageList"); + session->ffDestroyImageInfo = (typeof(&DestroyImageInfo)) dlsym(imageMagick, "DestroyImageInfo"); + session->ffDestroyExceptionInfo = (typeof(&DestroyExceptionInfo)) dlsym(imageMagick, "DestroyExceptionInfo"); + session->ffMagickCoreTerminus = (typeof(&MagickCoreTerminus)) dlsym(imageMagick, "MagickCoreTerminus"); + session->ffSetImageAlphaChannel = (typeof(&SetImageAlphaChannel)) dlsym(imageMagick, "SetImageAlphaChannel"); + if (session->ffResizeImage == nullptr || session->ffImageToBlob == nullptr || + session->ffDestroyImageList == nullptr || session->ffDestroyImageInfo == nullptr || + session->ffDestroyExceptionInfo == nullptr || session->ffMagickCoreTerminus == nullptr || + session->ffSetImageAlphaChannel == nullptr) { + if (error) *error = "the ImageMagick library is incomplete"; + free(session); + return false; + } + + // The core stays initialised for the whole session: the Image objects below belong to it, and + // terminating it while they are alive would leave them dangling. The static path can afford to + // initialise and terminate within one call because it never keeps an Image across calls. + ffMagickCoreGenesis(nullptr, MagickFalse); + + session->exceptionInfo = ffAcquireExceptionInfo(); + if (session->exceptionInfo == nullptr) { + if (error) *error = "failed to acquire an ImageMagick exception"; + im7FreeSession(session); + return false; + } + + ImageInfo* imageInfoIn = ffAcquireImageInfo(); + if (imageInfoIn == nullptr) { + if (error) *error = "failed to acquire an ImageMagick image info"; + im7FreeSession(session); + return false; + } + + //+1, because we need to copy the null byte too + ffCopyMagickString(imageInfoIn->filename, instance.config.logo.source.chars, instance.config.logo.source.length + 1); + + Image* raw = ffReadImage(imageInfoIn, session->exceptionInfo); + session->ffDestroyImageInfo(imageInfoIn); + if (raw == nullptr) { + if (error) *error = "failed to load the image source"; + im7FreeSession(session); + return false; + } + + im7SteerComposition(session, raw); + + session->images = ffCoalesceImages(raw, session->exceptionInfo); + // CoalesceImages built a new list; the one it was given is still ours to destroy. + session->ffDestroyImageList(raw); + if (session->images == nullptr) { + if (error) *error = "failed to compose the animation frames"; + im7FreeSession(session); + return false; + } + + // The coalesced frames are canvas sized, which is what the target size is derived from. + uint32_t frameCount = 0; + for (Image* image = session->images; image != nullptr; image = image->next) { + ++frameCount; + } + + if (frameCount == 0 || + !im7ResolveTargetSize(requestData, (uint32_t) session->images->columns, (uint32_t) session->images->rows, error)) { + if (frameCount == 0 && error) *error = "the image source has no frames"; + im7FreeSession(session); + return false; + } + + session->blobInfo = ffAcquireImageInfo(); + if (session->blobInfo == nullptr) { + if (error) *error = "failed to acquire an ImageMagick image info"; + im7FreeSession(session); + return false; + } + ffCopyMagickString(session->blobInfo->magick, "RGBA", 5); + + // kitty's rule: the 100 ms floor applies only when every frame's raw delay is <= 0. One frame + // with a delay settles it, so the normal case stops after the first read. + session->minGap = 10; + for (Image* image = session->images; image != nullptr; image = image->next) { + if (im7FrameDelayCs(image) > 0) { + session->minGap = 0; + break; + } + } + + session->cursor = session->images; + session->nextIndex = 0; + session->outputWidth = requestData->logoPixelWidth; + session->outputHeight = requestData->logoPixelHeight; + + FFImageAnimation* animation = ffImageAnimationCreate(frameCount, im7LoopCount(session->images), + session, im7GetFrame, im7DestroyAnimation); + if (animation == nullptr) { + if (error) *error = "out of memory"; + im7FreeSession(session); + return false; + } + + // leak imageMagick to prevent fastfetch from crashing #552, as the static path does + imageMagick = nullptr; + *out = animation; + return true; +} + #endif diff --git a/src/logo/image/image.c b/src/logo/image/image.c index 0ae1348f5..5bd440aa7 100644 --- a/src/logo/image/image.c +++ b/src/logo/image/image.c @@ -456,7 +456,21 @@ static bool printImageKittyDirect(bool printError) { #define FF_CACHE_FILE_SIXEL "sixel" #define FF_CACHE_FILE_KITTY_COMPRESSED "kittyc" #define FF_CACHE_FILE_KITTY_UNCOMPRESSED "kittyu" + // Animation payload. The envelope can not be cached because it carries the image id, and that + // id has to differ on every run; this entry holds only what is id-independent (see §7.2). + #define FF_CACHE_FILE_KITTY_ANIMATION "kittyanim" #define FF_CACHE_FILE_CHAFA "chafa" + // The frame the payloads in the same directory were rendered from. A selected frame is stored + // exactly like the first frame is -- same entries, same payload format -- so this sidecar is + // what tells the two apart, and what makes a change of --logo-animation-frame refresh the entry + // instead of being served the frame that was asked for last time. It can not be part of the + // entry name: the frame number ranges over the whole int32, and removeCachedFiles works from a + // fixed list that could never enumerate it. + // The entry sits next to every payload of the directory, so it describes whichever of them was + // written last. Switching --logo-protocol between two protocols that both have a payload here, + // and asking for different frames, can therefore pair one protocol's payload with the other's + // frame number; the cache has to be refreshed after such a switch (`--logo-cache regen`). + #define FF_CACHE_FILE_FRAME "frame" // Modification time of the image source the entry was produced from. Written last, so an // entry that was interrupted mid-write is never mistaken for a complete one. #define FF_CACHE_FILE_MTIME "mtime" @@ -466,6 +480,8 @@ static bool printImageKittyDirect(bool printError) { #include #include + #include "common/time.h" + #ifndef _WIN32 #include #else @@ -478,7 +494,14 @@ static bool printImageKittyDirect(bool printError) { #include static bool compressBlob(void** blob, size_t* length) { - FF_LIBRARY_LOAD(zlib, false, "libz" FF_LIBRARY_EXTENSION, 2) + FF_LIBRARY_LOAD(zlib, false, + #ifdef _WIN32 + "zlib1" + #else + "libz" + #endif + FF_LIBRARY_EXTENSION, + 2) FF_LIBRARY_LOAD_SYMBOL(zlib, compressBound, false) FF_LIBRARY_LOAD_SYMBOL(zlib, compress2, false) @@ -496,7 +519,15 @@ static bool compressBlob(void** blob, size_t* length) { return false; } - if (ffcompress2(compressed, &compressedLength, *blob, (uLong) *length, Z_BEST_COMPRESSION) != Z_OK) { + // Level 6, not 9. Measured on a 44 frame 400x400 GIF (2026-09-16): the level 9 encode is 573 ms + // of the 720 ms the whole kitty animation path takes, i.e. it dwarfs decoding and the per frame + // export combined. Level 6 does the same job in a fifth of the time for 4.9% more bytes, which is + // also the level kitten icat uses. Level 1 would halve it again but doubles the payload, and past + // that point the terminal is the slow one, not us. + // The gap is far wider on Windows, where the same frames cost 37.7 ms each at level 9 against + // 6.4 at level 6: raising this back to 9 now that the DLL name above actually loads is a 4.2x + // slowdown there (398 ms -> 1692 ms). Re-measure both platforms before touching it. + if (ffcompress2(compressed, &compressedLength, *blob, (uLong) *length, Z_DEFAULT_COMPRESSION) != Z_OK) { free(compressed); return false; } @@ -523,15 +554,10 @@ static void writeCacheData(FFLogoRequestData* requestData, const void* value, si ffStrbufSubstrBefore(&requestData->cacheDir, cacheDirLength); } -static void printImagePixels(FFLogoRequestData* requestData, const FFstrbuf* result, const char* cacheFileName) { +// The character dimensions describe the source, not one particular rendering of it, so they live +// in their own entries. The animation path reuses them without writing a payload of its own. +static void writeImageSizeCache(FFLogoRequestData* requestData) { const FFOptionsLogo* options = &instance.config.logo; - // Calculate character dimensions - instance.state.logoWidth = requestData->logoCharacterWidth + options->paddingLeft + options->paddingRight; - instance.state.logoHeight = requestData->logoCharacterHeight + options->paddingTop - 1; - - // Write cache files - writeCacheData(requestData, result->chars, result->length, cacheFileName); - if (options->width == 0) { writeCacheData(requestData, &requestData->logoCharacterWidth, sizeof(requestData->logoCharacterWidth), FF_CACHE_FILE_WIDTH); } @@ -539,7 +565,20 @@ static void printImagePixels(FFLogoRequestData* requestData, const FFstrbuf* res if (options->height == 0) { writeCacheData(requestData, &requestData->logoCharacterHeight, sizeof(requestData->logoCharacterHeight), FF_CACHE_FILE_HEIGHT); } +} +// Records which frame the payload written alongside it came from. Every payload write is followed +// by this one, so asking for another frame refreshes the entry instead of being served the frame +// that was asked for last time. What goes in is the *requested* selector, not the index it resolves +// to: on a single-frame source every selector resolves to frame 0, and storing that would make +// every selector but one miss on every run. +static void writeFrameCache(FFLogoRequestData* requestData) { + const int32_t frame = instance.config.logo.animationFrame; + writeCacheData(requestData, &frame, sizeof(frame), FF_CACHE_FILE_FRAME); +} + +static void printImageResult(FFLogoRequestData* requestData, const FFstrbuf* result) { + const FFOptionsLogo* options = &instance.config.logo; // Write result to stdout ffPrintCharTimes('\n', options->paddingTop); if (options->position == FF_LOGO_POSITION_RIGHT) { @@ -560,6 +599,34 @@ static void printImagePixels(FFLogoRequestData* requestData, const FFstrbuf* res } } +static void printImagePixels(FFLogoRequestData* requestData, const FFstrbuf* result, const char* cacheFileName) { + const FFOptionsLogo* options = &instance.config.logo; + // Calculate character dimensions + instance.state.logoWidth = requestData->logoCharacterWidth + options->paddingLeft + options->paddingRight; + instance.state.logoHeight = requestData->logoCharacterHeight + options->paddingTop - 1; + + // Write cache files + writeCacheData(requestData, result->chars, result->length, cacheFileName); + writeImageSizeCache(requestData); + + printImageResult(requestData, result); +} + +// Same as printImagePixels, but the payload is not written to the cache: the bytes handed to the +// terminal are not the cached ones. Used by the animation path, whose envelope carries the image +// id and therefore differs on every run, and which stores its frames in the `kittyanim` entry +// instead. +static void printImagePixelsNoCache(FFLogoRequestData* requestData, const FFstrbuf* result) { + const FFOptionsLogo* options = &instance.config.logo; + // Calculate character dimensions + instance.state.logoWidth = requestData->logoCharacterWidth + options->paddingLeft + options->paddingRight; + instance.state.logoHeight = requestData->logoCharacterHeight + options->paddingTop - 1; + + writeImageSizeCache(requestData); + + printImageResult(requestData, result); +} + // The backends report the real pixel dimensions; the character dimensions are derived here static void fillCharacterDimensions(FFLogoRequestData* requestData) { requestData->logoCharacterWidth = (uint32_t) ceil((double) requestData->logoPixelWidth / requestData->characterPixelWidth); @@ -572,10 +639,16 @@ static bool printImageSixel(FFLogoRequestData* requestData, const FFstrbuf* resu } printImagePixels(requestData, result, FF_CACHE_FILE_SIXEL); + writeFrameCache(requestData); return true; } -static void appendKittyChunk(FFstrbuf* result, const char** blob, size_t* length, bool printEscapeCode) { +// Appends one chunk of a chunked graphics command. `printEscapeCode` is false for the first chunk +// only, because the caller has already written the escape introducer and the control data. +// `controlPrefix` adds keys in front of `m`; animation frame data needs it on continuation chunks +// ("When sending animation frame data, subsequent chunks must also specify the a=f key"), the +// static path passes nullptr and is therefore unchanged. +static void appendKittyChunk(FFstrbuf* result, const char** blob, size_t* length, bool printEscapeCode, const char* controlPrefix) { uint32_t chunkSize = *length > FF_KITTY_MAX_CHUNK_SIZE ? FF_KITTY_MAX_CHUNK_SIZE : (uint32_t) *length; if (printEscapeCode) { @@ -584,6 +657,11 @@ static void appendKittyChunk(FFstrbuf* result, const char** blob, size_t* length ffStrbufAppendC(result, ','); } + if (controlPrefix) { + ffStrbufAppendS(result, controlPrefix); + ffStrbufAppendC(result, ','); + } + ffStrbufAppendS(result, chunkSize != *length ? "m=1" : "m=0"); ffStrbufAppendC(result, ';'); ffStrbufAppendNS(result, chunkSize, *blob); @@ -592,6 +670,7 @@ static void appendKittyChunk(FFstrbuf* result, const char** blob, size_t* length *blob += chunkSize; } +// The compressed and the uncompressed entry hold the same rendering and share one frame sidecar. static bool printImageKitty(FFLogoRequestData* requestData, const FFImageBuffer* buffer) { size_t length = (size_t) buffer->width * buffer->height * 4; FF_AUTO_FREE void* blob = malloc(length); @@ -619,12 +698,13 @@ static bool printImageKitty(FFLogoRequestData* requestData, const FFImageBuffer* if (isCompressed) { ffStrbufAppendS(&result, ",o=z"); } - appendKittyChunk(&result, ¤tPos, &remainingLength, false); + appendKittyChunk(&result, ¤tPos, &remainingLength, false, nullptr); while (remainingLength > 0) { - appendKittyChunk(&result, ¤tPos, &remainingLength, true); + appendKittyChunk(&result, ¤tPos, &remainingLength, true, nullptr); } printImagePixels(requestData, &result, isCompressed ? FF_CACHE_FILE_KITTY_COMPRESSED : FF_CACHE_FILE_KITTY_UNCOMPRESSED); + writeFrameCache(requestData); return true; } @@ -700,6 +780,7 @@ static bool printImageChafa(FFLogoRequestData* requestData, const FFImageBuffer* ffLogoPrintChars(result.chars, false); writeCacheData(requestData, result.chars, result.length, FF_CACHE_FILE_CHAFA); + writeFrameCache(requestData); // FIXME: These functions must be imported from `libglib` dlls on Windows FF_LIBRARY_LOAD_SYMBOL_LAZY(chafa, g_string_free); @@ -748,6 +829,91 @@ void ffImageDestroy(FFImageBuffer* buffer) { buffer->height = 0; } +void ffImageFrameDestroy(FFImageFrame* frame) { + free(frame->data); + frame->data = nullptr; + frame->delayMs = 0; +} + +// The session itself is platform independent; a backend only has to fill it in through +// ffImageAnimationCreate and implement the two callbacks. +struct FFImageAnimation { + uint32_t frameCount; + int32_t loopCount; + void* impl; + bool (*getFrame)(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error); + void (*destroy)(FFImageAnimation* animation); +}; + +FFImageAnimation* ffImageAnimationCreate(uint32_t frameCount, int32_t loopCount, void* impl, + bool (*getFrame)(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error), + void (*destroy)(FFImageAnimation* animation)) { + FFImageAnimation* animation = malloc(sizeof(*animation)); + if (animation == nullptr) { + return nullptr; + } + + *animation = (FFImageAnimation) { + .frameCount = frameCount, + .loopCount = loopCount, + .impl = impl, + .getFrame = getFrame, + .destroy = destroy, + }; + return animation; +} + +void* ffImageAnimationGetImpl(const FFImageAnimation* animation) { + return animation->impl; +} + +bool ffImageAnimationOpen(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error) { + // The sessions belong to the backends: ImageIO composes frames internally, WIC does not and + // has to keep a canvas, ImageMagick needs CoalesceImages. Windows, macOS and ImageMagick 7 are + // wired up; ImageMagick 6 is deliberately left out (see image.h), and a build with none of them + // keeps failing loudly rather than falling back to a still image. + // + // The order mirrors ffImageCreate: on macOS ImageIO is the decoder even when ImageMagick is + // available too, so the animation has to come from the same place the stills do. + #ifdef _WIN32 + return ffImageAnimationOpenWIC(requestData, out, error); + #elif defined(__APPLE__) + return ffImageAnimationOpenImageIO(requestData, out, error); + #elif defined(FF_HAVE_IMAGEMAGICK7) + return ffImageAnimationOpenIM7(requestData, out, error); + #else + FF_UNUSED(requestData, out); + *error = "the image source can not be animated by this build"; + return false; + #endif +} + +uint32_t ffImageAnimationFrameCount(const FFImageAnimation* animation) { + return animation->frameCount; +} + +int32_t ffImageAnimationLoopCount(const FFImageAnimation* animation) { + return animation->loopCount; +} + +bool ffImageAnimationGetFrame(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error) { + if (index >= animation->frameCount) { + *error = "the requested frame is out of range"; + return false; + } + + return animation->getFrame(animation, index, out, error); +} + +void ffImageAnimationClose(FFImageAnimation* animation) { + if (animation == nullptr) { + return; + } + + animation->destroy(animation); + free(animation); +} + bool ffImageSixelEncode(FFLogoRequestData* requestData, FFstrbuf* out, const char** error) { // Windows (WIC) and macOS (ImageIO) decode and resize to RGBA first, then the embedded // libsixel encoder takes over. Other platforms let ImageMagick encode straight from the @@ -783,6 +949,35 @@ bool ffImageSixelEncode(FFLogoRequestData* requestData, FFstrbuf* out, const cha #endif } +bool ffImageSixelEncodeBuffer(const FFImageBuffer* buffer, FFstrbuf* out, const char** error) { + // Windows and macOS hand the pixels to the embedded encoder. ImageMagick has no way to encode + // pixels it was not given an Image for, so its SIXEL coder is reached through a ConstituteImage + // round trip instead. + #if defined(_WIN32) || defined(__APPLE__) + #ifdef FF_HAVE_SIXEL + return ffSixelEncode(buffer, out, error); + #else + FF_UNUSED(buffer, out); + if (error) { + *error = "sixel support is not compiled in"; + } + return false; + #endif + #else + #ifdef FF_HAVE_IMAGEMAGICK7 + return ffImageSixelEncodeBufferIM7(buffer, out, error); + #else + // Only the animation path calls this, and only ImageMagick 7 has an animation backend, so a + // build without it never gets here with something to encode. + FF_UNUSED(buffer, out); + if (error) { + *error = "sixel support is not compiled in"; + } + return false; + #endif + #endif +} + static FFNativeFD getCacheFD(FFLogoRequestData* requestData, const char* fileName) { uint32_t cacheDirLength = requestData->cacheDir.length; ffStrbufAppendS(&requestData->cacheDir, fileName); @@ -833,6 +1028,15 @@ static uint64_t readCachedUint64(FFLogoRequestData* requestData, const char* cac return result; } +// The frame the payload in the same directory was rendered from. A payload with no sidecar predates +// the sidecar and holds the first frame, which is exactly what the default selector asks for, so +// treating it as such keeps the caches of older builds usable. +static int32_t readCachedFrame(FFLogoRequestData* requestData, const char* frameFileName) { + int32_t frame = FF_LOGO_ANIMATION_FRAME_FIRST; + readCachedData(requestData, &frame, sizeof(frame), frameFileName); + return frame; +} + // Drops everything a previous version of the source left in the entry directory. // The directory is keyed on the source path and the pixel size only, so it is reused across // edits; without this, a payload written for another logo type would be read back. @@ -844,9 +1048,13 @@ static void removeCachedFiles(FFLogoRequestData* requestData) { FF_CACHE_FILE_SIXEL, FF_CACHE_FILE_KITTY_COMPRESSED, FF_CACHE_FILE_KITTY_UNCOMPRESSED, + FF_CACHE_FILE_KITTY_ANIMATION, FF_CACHE_FILE_CHAFA, + FF_CACHE_FILE_FRAME, }; + // Every entry that is ever written is listed here; an entry left out would survive a source + // change and be read back for the new source. uint32_t cacheDirLength = requestData->cacheDir.length; for (uint32_t i = 0; i < ARRAY_SIZE(files); ++i) { ffStrbufAppendS(&requestData->cacheDir, files[i]); @@ -856,6 +1064,13 @@ static void removeCachedFiles(FFLogoRequestData* requestData) { } static bool printCachedChars(FFLogoRequestData* requestData, const char* cacheFileName) { + if (instance.config.logo.animationFrame != readCachedFrame(requestData, FF_CACHE_FILE_FRAME)) { + // The entry holds the rendering of another frame, or -- for the animation selector -- of + // no single frame at all. Reporting a miss here is what sends the slow path off to render + // the one that was asked for. + return false; + } + FF_STRBUF_AUTO_DESTROY content = ffStrbufCreate(); readCachedStrbuf(requestData, &content, cacheFileName); @@ -867,6 +1082,411 @@ static bool printCachedChars(FFLogoRequestData* requestData, const char* cacheFi return true; } +// --------------------------------------------------------------------------------------------- +// kitty animations +// +// A `kittyanim` entry holds only what is independent of the image id: the pixel size, the frame +// count, each frame's gap, the loop count, and the frames' base64 payloads. The envelope is +// rebuilt on every run, because it carries the id and that id has to differ between runs. +// --------------------------------------------------------------------------------------------- + +static const char FF_KITTY_ANIMATION_MAGIC[4] = { 'F', 'K', 'A', '1' }; + +typedef struct FFKittyAnimationFrame { + int32_t delayMs; + uint32_t payloadLength; // base64 bytes this frame occupies inside the payload block + bool compressed; // the frame's base64 decodes to a zlib stream, so it needs `o=z` +} FFKittyAnimationFrame; + +typedef struct FFKittyAnimation { + uint32_t width; + uint32_t height; + uint32_t frameCount; + int32_t loopCount; // 0 = infinite; -1 = the source declares none + FFKittyAnimationFrame* frames; + char* payload; // base64 of every frame, concatenated in frame order + uint32_t payloadLength; +} FFKittyAnimation; + +static void destroyKittyAnimation(FFKittyAnimation* animation) { + free(animation->frames); + free(animation->payload); + animation->frames = nullptr; + animation->payload = nullptr; +} + +// The entry is written and read by the same machine, so the host's own representation is used. +static void appendRaw(FFstrbuf* result, const void* value, uint32_t size) { + ffStrbufAppendNS(result, size, (const char*) value); +} + +static void appendUint32(FFstrbuf* result, uint32_t value) { + appendRaw(result, &value, sizeof(value)); +} + +static void appendInt32(FFstrbuf* result, int32_t value) { + appendRaw(result, &value, sizeof(value)); +} + +typedef struct FFKittyAnimationReader { + const char* data; + uint32_t length; + uint32_t offset; +} FFKittyAnimationReader; + +static bool readRaw(FFKittyAnimationReader* reader, void* out, uint32_t size) { + if (size > reader->length - reader->offset) { + return false; + } + + memcpy(out, reader->data + reader->offset, size); + reader->offset += size; + return true; +} + +static bool readUint32(FFKittyAnimationReader* reader, uint32_t* out) { + return readRaw(reader, out, sizeof(*out)); +} + +static bool readInt32(FFKittyAnimationReader* reader, int32_t* out) { + return readRaw(reader, out, sizeof(*out)); +} + +static void serializeKittyAnimation(FFstrbuf* result, const FFKittyAnimation* animation) { + appendRaw(result, FF_KITTY_ANIMATION_MAGIC, sizeof(FF_KITTY_ANIMATION_MAGIC)); + appendUint32(result, animation->width); + appendUint32(result, animation->height); + appendUint32(result, animation->frameCount); + appendInt32(result, animation->loopCount); + + for (uint32_t i = 0; i < animation->frameCount; ++i) { + const FFKittyAnimationFrame* frame = &animation->frames[i]; + uint8_t flags = frame->compressed ? 1 : 0; + appendInt32(result, frame->delayMs); + appendUint32(result, frame->payloadLength); + appendRaw(result, &flags, sizeof(flags)); + } + + appendRaw(result, animation->payload, animation->payloadLength); +} + +static bool parseKittyAnimation(const char* data, uint32_t length, FFKittyAnimation* out, const char** error) { + FFKittyAnimationReader reader = { .data = data, .length = length, .offset = 0 }; + + char magic[sizeof(FF_KITTY_ANIMATION_MAGIC)]; + if (!readRaw(&reader, magic, sizeof(magic)) || memcmp(magic, FF_KITTY_ANIMATION_MAGIC, sizeof(magic)) != 0) { + *error = "the cached animation has an unknown format"; + return false; + } + + if (!readUint32(&reader, &out->width) || !readUint32(&reader, &out->height) || + !readUint32(&reader, &out->frameCount) || !readInt32(&reader, &out->loopCount)) { + *error = "the cached animation is truncated"; + return false; + } + + if (out->frameCount == 0) { + *error = "the cached animation has no frames"; + return false; + } + + out->frames = calloc(out->frameCount, sizeof(*out->frames)); + if (out->frames == nullptr) { + *error = "out of memory"; + return false; + } + + uint32_t totalPayloadLength = 0; + for (uint32_t i = 0; i < out->frameCount; ++i) { + FFKittyAnimationFrame* frame = &out->frames[i]; + uint8_t flags = 0; + if (!readInt32(&reader, &frame->delayMs) || !readUint32(&reader, &frame->payloadLength) || + !readRaw(&reader, &flags, sizeof(flags))) { + *error = "the cached animation is truncated"; + return false; + } + + frame->compressed = flags != 0; + + if (totalPayloadLength > UINT32_MAX - frame->payloadLength) { + *error = "the cached animation is too large"; + return false; + } + totalPayloadLength += frame->payloadLength; + } + + if (totalPayloadLength > reader.length - reader.offset) { + *error = "the cached animation is truncated"; + return false; + } + + out->payloadLength = totalPayloadLength; + out->payload = malloc((size_t) totalPayloadLength + 1); + if (out->payload == nullptr) { + *error = "out of memory"; + return false; + } + + memcpy(out->payload, reader.data + reader.offset, totalPayloadLength); + out->payload[totalPayloadLength] = '\0'; + return true; +} + +// The id has to differ on every run: reusing one makes the terminal append the new frames to the +// image the previous run left in its storage, which doubles the frame count. The low 24 bits take +// part in the colour / decoration encoding, so they are kept non-zero (kitten icat does the same +// in next_random()). The entropy comes from the clock, the pid and a stack address, which is +// plenty for a process that runs once. +static uint32_t getKittyImageId(void) { + uint32_t id = (uint32_t) ffTimeGetNow(); + id ^= (uint32_t) getpid() * 2654435761u; + id ^= (uint32_t) ((uintptr_t) &id >> 4); + id &= 0xFFFFFF; + return id != 0 ? id : 1; +} + +// The envelope, per the kitty protocol: the root frame is transmitted with `a=T` and has no gap +// of its own, so its gap is set with a separate `a=a` command; every further frame is an `a=f` +// command that replaces the pixels (`X=1`) because the backend already composed a full canvas. +// Every command carries the image id and `q=2`, so nothing is ever written back to the tty. +static void emitKittyAnimation(FFstrbuf* result, const FFKittyAnimation* animation, uint32_t imageId) { + const char* currentPos = animation->payload; + + for (uint32_t i = 0; i < animation->frameCount; ++i) { + const FFKittyAnimationFrame* frame = &animation->frames[i]; + size_t frameLength = frame->payloadLength; + + if (i == 0) { + // Here s / v are the source rectangle, not the animation state + ffStrbufAppendF(result, "\033_Ga=T,f=32,s=%u,v=%u,i=%u,q=2", animation->width, animation->height, imageId); + } else { + // A frame that covers the whole image is transmitted exactly like image data, plus the + // frame keys. s / v are not optional here even though they always repeat the image size: + // the terminal sizes the frame's canvas from them and rejects the command outright when + // they are missing. + ffStrbufAppendF(result, "\033_Ga=f,f=32,s=%u,v=%u,i=%u,z=%d,X=1,q=2", + animation->width, animation->height, imageId, (int) frame->delayMs); + } + + if (frame->compressed) { + ffStrbufAppendS(result, ",o=z"); + } + + appendKittyChunk(result, ¤tPos, &frameLength, false, nullptr); + while (frameLength > 0) { + // "When sending animation frame data, subsequent chunks must also specify the a=f key" + appendKittyChunk(result, ¤tPos, &frameLength, true, i == 0 ? "q=2" : "a=f,q=2"); + } + + if (i == 0) { + // "the first frame or root frame is created with the base image data and has no gap, + // so its gap must be set using this control code" + ffStrbufAppendF(result, "\033_Ga=a,i=%u,r=1,z=%d,q=2\033\\", imageId, (int) frame->delayMs); + } + } + + // v is the loop count here: 1 loops forever, N loops N-1 times + int32_t loops = 1; + if (animation->loopCount > 0) { + loops = animation->loopCount < INT32_MAX ? animation->loopCount + 1 : INT32_MAX; + } + ffStrbufAppendF(result, "\033_Ga=a,i=%u,v=%d,q=2\033\\", imageId, (int) loops); + ffStrbufAppendF(result, "\033_Ga=a,i=%u,s=3,q=2\033\\", imageId); +} + +static bool printCachedKittyAnimation(FFLogoRequestData* requestData) { + FF_STRBUF_AUTO_DESTROY content = ffStrbufCreate(); + if (!readCachedStrbuf(requestData, &content, FF_CACHE_FILE_KITTY_ANIMATION) || content.length == 0) { + return false; + } + + FFKittyAnimation animation = {}; + const char* error = "the cached animation is corrupt"; + if (!parseKittyAnimation(content.chars, content.length, &animation, &error)) { + return false; + } + + FF_STRBUF_AUTO_DESTROY result = ffStrbufCreateA(animation.payloadLength + animation.frameCount * 128 + 512); + emitKittyAnimation(&result, &animation, getKittyImageId()); + + const FFOptionsLogo* options = &instance.config.logo; + instance.state.logoWidth = requestData->logoCharacterWidth + options->paddingLeft + options->paddingRight; + instance.state.logoHeight = requestData->logoCharacterHeight + options->paddingTop; + printImageResult(requestData, &result); + + destroyKittyAnimation(&animation); + return true; +} + +// Decodes the source frame by frame, encodes every frame, and stores the result as a `kittyanim` +// entry. Frames are taken, encoded and released one at a time: keeping them all would cost +// frames * W * H * 4 bytes, which a tool that prints a logo once can not afford. +static bool encodeKittyAnimation(FFLogoRequestData* requestData, const char** error) { + FFImageAnimation* animation = nullptr; + if (!ffImageAnimationOpen(requestData, &animation, error)) { + return false; + } + + fillCharacterDimensions(requestData); + + const uint32_t frameCount = ffImageAnimationFrameCount(animation); + const int32_t loopCount = ffImageAnimationLoopCount(animation); + + if (frameCount == 1) { + // Nothing to animate. Rendering it through the static path keeps the cache entry, and + // therefore the bytes written to the terminal, identical to the default rendering. + FFImageFrame frame = {}; + bool ok = ffImageAnimationGetFrame(animation, 0, &frame, error); + ffImageAnimationClose(animation); + if (!ok) { + return false; + } + + FFImageBuffer buffer = { + .data = frame.data, + .width = requestData->logoPixelWidth, + .height = requestData->logoPixelHeight, + }; + ok = printImageKitty(requestData, &buffer); + ffImageDestroy(&buffer); + return ok; + } + + FFKittyAnimationFrame* frames = calloc(frameCount, sizeof(*frames)); + if (frames == nullptr) { + ffImageAnimationClose(animation); + *error = "out of memory"; + return false; + } + + const size_t frameSize = (size_t) requestData->logoPixelWidth * requestData->logoPixelHeight * 4; + FF_STRBUF_AUTO_DESTROY payload = ffStrbufCreate(); + bool ok = true; + + for (uint32_t i = 0; i < frameCount && ok; ++i) { + FFImageFrame frame = {}; + if (!ffImageAnimationGetFrame(animation, i, &frame, error)) { + ok = false; + break; + } + + frames[i].delayMs = frame.delayMs; + + FF_AUTO_FREE void* blob = malloc(frameSize); + if (blob == nullptr) { + ffImageFrameDestroy(&frame); + *error = "out of memory"; + ok = false; + break; + } + memcpy(blob, frame.data, frameSize); + ffImageFrameDestroy(&frame); + + size_t blobLength = frameSize; + #ifdef FF_HAVE_ZLIB + frames[i].compressed = compressBlob(&blob, &blobLength); + #else + frames[i].compressed = false; + #endif + + FF_STRBUF_AUTO_DESTROY base64 = ffStrbufCreateA((uint32_t) (10 + blobLength * 4 / 3)); + ffBase64EncodeRaw((uint32_t) blobLength, (const char*) blob, &base64.length, base64.chars); + frames[i].payloadLength = base64.length; + ffStrbufAppend(&payload, &base64); + } + + if (ok) { + FFKittyAnimation built = { + .width = requestData->logoPixelWidth, + .height = requestData->logoPixelHeight, + .frameCount = frameCount, + .loopCount = loopCount, + .frames = frames, + .payload = payload.chars, + .payloadLength = payload.length, + }; + + FF_STRBUF_AUTO_DESTROY serialized = ffStrbufCreate(); + serializeKittyAnimation(&serialized, &built); + writeCacheData(requestData, serialized.chars, serialized.length, FF_CACHE_FILE_KITTY_ANIMATION); + + FF_STRBUF_AUTO_DESTROY result = ffStrbufCreateA(serialized.length + frameCount * 128 + 512); + emitKittyAnimation(&result, &built, getKittyImageId()); + printImagePixelsNoCache(requestData, &result); + } + + free(frames); + ffImageAnimationClose(animation); + return ok; +} + +// Maps --logo-animation-frame to a 0-based frame index. Negative values count back from the end, +// so -1 is the last frame. Anything out of range clamps instead of failing: the frame count of the +// source is only known once it has been opened, and a typo in a logo option is not worth aborting +// the run over (see §10.2). +static uint32_t getAnimationFrameIndex(int32_t requested, uint32_t frameCount) { + if (frameCount == 0) { + return 0; + } + + if (requested < 0) { + uint32_t back = (uint32_t) (-(int64_t) requested); + return back <= frameCount ? frameCount - back : 0; + } + + uint32_t index = (uint32_t) (requested - 1); + return index < frameCount ? index : frameCount - 1; +} + +// Renders one frame of an animated source as a still image. `N` is 1-based and negative values +// count from the end, matching --logo-animation-frame. Every protocol that goes through this slow +// path is served: the decoded frame is handed to the same renderers the static path uses, and the +// result is cached exactly like the first frame is (see FF_CACHE_FILE_FRAME). +static bool printAnimationFrame(FFLogoRequestData* requestData, const char** error) { + FFImageAnimation* animation = nullptr; + if (!ffImageAnimationOpen(requestData, &animation, error)) { + return false; + } + + fillCharacterDimensions(requestData); + + const uint32_t frameCount = ffImageAnimationFrameCount(animation); + FFImageFrame frame = {}; + bool ok = ffImageAnimationGetFrame(animation, getAnimationFrameIndex(instance.config.logo.animationFrame, frameCount), &frame, error); + ffImageAnimationClose(animation); + if (!ok) { + return false; + } + + FFImageBuffer buffer = { + .data = frame.data, + .width = requestData->logoPixelWidth, + .height = requestData->logoPixelHeight, + }; + + if (requestData->type == FF_LOGO_TYPE_IMAGE_SIXEL) { + // The sixel encoder belongs to the backend, so it does not go through ffImageCreate -- but + // it does have to be handed the frame that was just composed. ffImageSixelEncode would + // re-read the source and encode the first frame instead of the selected one. + FF_STRBUF_AUTO_DESTROY result = ffStrbufCreate(); + ok = ffImageSixelEncodeBuffer(&buffer, &result, error) && printImageSixel(requestData, &result); + } else if (requestData->type == FF_LOGO_TYPE_IMAGE_KITTY) { + ok = printImageKitty(requestData, &buffer); + } +#if FF_HAVE_CHAFA + else if (requestData->type == FF_LOGO_TYPE_IMAGE_CHAFA) { + ok = printImageChafa(requestData, &buffer); + } +#endif + else { + *error = "this image protocol can not render a selected frame"; + ok = false; + } + + ffImageDestroy(&buffer); + return ok; +} + static bool printCachedPixel(FFLogoRequestData* requestData) { FFOptionsLogo* options = &instance.config.logo; @@ -886,10 +1506,26 @@ static bool printCachedPixel(FFLogoRequestData* requestData) { } } + if (requestData->type == FF_LOGO_TYPE_IMAGE_KITTY && + options->animationFrame == FF_LOGO_ANIMATION_FRAME_ANIMATE) { + // An animation entry holds payloads, not an envelope, so it can not be streamed out like + // the static ones. It is also never allowed to fall back to a static entry: doing so would + // silently show a still logo to a user who asked for an animation. + return printCachedKittyAnimation(requestData); + } + + // A selected frame shares the entry with the first frame; the sidecar is what tells the two + // apart. This also covers the animation selector: no still rendering may answer it. + if (options->animationFrame != readCachedFrame(requestData, FF_CACHE_FILE_FRAME)) { + return false; + } + FF_AUTO_CLOSE_FD FFNativeFD fd = FF_INVALID_FD; if (requestData->type == FF_LOGO_TYPE_IMAGE_KITTY) { fd = getCacheFD(requestData, FF_CACHE_FILE_KITTY_COMPRESSED); if (!ffIsValidNativeFD(fd)) { + // The pre-existing pair. Falling back between them is fine, they hold the same + // rendering; falling back to anything else is not (see §7.1). fd = getCacheFD(requestData, FF_CACHE_FILE_KITTY_UNCOMPRESSED); } } else if (requestData->type == FF_LOGO_TYPE_IMAGE_SIXEL) { @@ -1017,29 +1653,47 @@ static bool printImageIfExistsSlowPath(FFLogoType type, bool printError) { // 0 means the mtime could not be read, in which case the entry is never trusted. const uint64_t sourceMtime = ffPathGetMtime(instance.config.logo.source.chars); - if (instance.config.logo.cache == FF_LOGO_CACHE_ON && - sourceMtime != 0 && - readCachedUint64(&requestData, FF_CACHE_FILE_MTIME) == sourceMtime) { - bool cacheValid = requestData.type == FF_LOGO_TYPE_IMAGE_CHAFA - ? printCachedChars(&requestData, FF_CACHE_FILE_CHAFA) - : printCachedPixel(&requestData); - if (cacheValid) { - ffStrbufDestroy(&requestData.cacheDir); - return true; + bool sourceUnchanged = false; + if (instance.config.logo.cache == FF_LOGO_CACHE_ON && sourceMtime != 0) { + sourceUnchanged = readCachedUint64(&requestData, FF_CACHE_FILE_MTIME) == sourceMtime; + if (sourceUnchanged) { + bool cacheValid = requestData.type == FF_LOGO_TYPE_IMAGE_CHAFA + ? printCachedChars(&requestData, FF_CACHE_FILE_CHAFA) + : printCachedPixel(&requestData); + if (cacheValid) { + ffStrbufDestroy(&requestData.cacheDir); + return true; + } } } - // Cache miss. The entry directory is keyed on the source path and the pixel size only, so - // it is reused when the source is edited; drop what the previous version left behind. + // Cache miss. The entry directory is keyed on the source path and the pixel size only, so it + // is reused when the source is edited; drop what the previous version left behind. This only + // happens when the source actually changed: a miss on one entry of an unchanged source just + // means that rendering was never asked for before, and clearing the directory would throw + // away the sibling entries every time the requested rendering changes. // With the cache turned off the directory is left alone entirely. - if (instance.config.logo.cache != FF_LOGO_CACHE_OFF) { + if (instance.config.logo.cache != FF_LOGO_CACHE_OFF && !sourceUnchanged) { removeCachedFiles(&requestData); } const char* error = nullptr; bool printSuccessful = false; - if (requestData.type == FF_LOGO_TYPE_IMAGE_SIXEL) { + if (instance.config.logo.animationFrame == FF_LOGO_ANIMATION_FRAME_ANIMATE && + requestData.type != FF_LOGO_TYPE_IMAGE_KITTY) { + // Only the kitty graphics protocol can play an animation. Falling through to the static + // path would silently show a still logo to a user who asked for an animation. + error = "the kitty graphics protocol is the only one that can play an animation"; + } else if (instance.config.logo.animationFrame == FF_LOGO_ANIMATION_FRAME_ANIMATE) { + // Decided before anything is decoded: the static path never enters a loop over frames, + // and this path never goes through ffImageCreate. + printSuccessful = encodeKittyAnimation(&requestData, &error); + } else if (instance.config.logo.animationFrame != FF_LOGO_ANIMATION_FRAME_FIRST) { + // A frame other than the first. This must not fall through to the static path: that one + // only ever produces the first frame, so it would silently hand back the wrong image. + printSuccessful = printAnimationFrame(&requestData, &error); + } else if (requestData.type == FF_LOGO_TYPE_IMAGE_SIXEL) { // The sixel encoder belongs to the backend, so it is not fed through ffImageCreate FF_STRBUF_AUTO_DESTROY result = ffStrbufCreate(); if (ffImageSixelEncode(&requestData, &result, &error)) { diff --git a/src/logo/image/image.h b/src/logo/image/image.h index f096dd7a8..25a6f4e9f 100644 --- a/src/logo/image/image.h +++ b/src/logo/image/image.h @@ -44,11 +44,64 @@ void ffImageDestroy(FFImageBuffer* buffer); // the embedded libsixel on Windows. bool ffImageSixelEncode(FFLogoRequestData* requestData, FFstrbuf* out, const char** error); +// The same, for pixels the caller already has. The selected-frame path needs this: it composes the +// frame itself, and ffImageSixelEncode would go back to the source and encode a different one. +bool ffImageSixelEncodeBuffer(const FFImageBuffer* buffer, FFstrbuf* out, const char** error); + +// One frame of an animation: a fully composed canvas, RGBA8, straight (unassociated) alpha, +// no row padding, exactly requestData->logoPixelWidth x logoPixelHeight. +typedef struct FFImageFrame { + uint8_t* data; + int32_t delayMs; // >0: wait this many milliseconds; -1: gapless; 0: unspecified +} FFImageFrame; + +void ffImageFrameDestroy(FFImageFrame* frame); + +// Animation session. Opaque outside src/logo/image/. +// +// Contract: the backend hands out *composed* full-canvas frames through a sequential iterator. +// Composition belongs to the backend (ImageIO already does it, WIC does not), never to the +// output layer. Taking a frame transfers its ownership to the caller, so the caller never holds +// more than one frame. Frames must be taken in non-decreasing index order: the composition of +// frame i depends on frames 0..i-1. +// +// That says nothing about what the backend holds, though. ImageMagick's CoalesceImages composes +// every frame up front and keeps the list, so its peak is the whole animation rather than one +// frame -- 60 frames of 500x500 measured 532 MiB. Bounding that would mean writing the compositor +// by hand, which is the cost reusing the library's composition exists to avoid, so it is accepted +// and recorded instead. See doc/kitty-animation.md 5.4 and 11-E. +typedef struct FFImageAnimation FFImageAnimation; + +// Open a session. Like ffImageCreate, it must fill in requestData->logoPixelWidth / +// logoPixelHeight, because every frame has those dimensions. frameCount / loopCount must be +// known without decoding a frame, because a negative --logo-animation-frame index is resolved +// before the first frame is taken. +bool ffImageAnimationOpen(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error); +uint32_t ffImageAnimationFrameCount(const FFImageAnimation* animation); +int32_t ffImageAnimationLoopCount(const FFImageAnimation* animation); // 0 = infinite; -1 = the source declares none +bool ffImageAnimationGetFrame(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error); +void ffImageAnimationClose(FFImageAnimation* animation); + +// Called by the backends once they have a session ready. `destroy` must release the session and +// its backend state; `impl` is handed back to them untouched. +FFImageAnimation* ffImageAnimationCreate(uint32_t frameCount, int32_t loopCount, void* impl, + bool (*getFrame)(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error), + void (*destroy)(FFImageAnimation* animation)); + +// The session is opaque to the backends; this is how the callbacks they supply get their own +// state back out of it. Never null for a session that ffImageAnimationCreate built. +void* ffImageAnimationGetImpl(const FFImageAnimation* animation); + #endif #ifdef FF_HAVE_IMAGEMAGICK7 bool ffImageCreateIM7(FFLogoRequestData* requestData, FFImageBuffer* out, const char** error); bool ffImageSixelEncodeIM7(FFLogoRequestData* requestData, FFstrbuf* out, const char** error); +bool ffImageSixelEncodeBufferIM7(const FFImageBuffer* buffer, FFstrbuf* out, const char** error); +// ImageMagick 7 is the only ImageMagick version that gets animation: 6 would need its own port +// (PixelPacket / opacity instead of PixelInfo / alpha) and there is no way to test it here, so it +// is left unimplemented rather than written blind. See doc/kitty-animation.md §11-E. +bool ffImageAnimationOpenIM7(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error); #endif #ifdef FF_HAVE_IMAGEMAGICK6 @@ -58,10 +111,12 @@ bool ffImageSixelEncodeIM6(FFLogoRequestData* requestData, FFstrbuf* out, const #ifdef _WIN32 bool ffImageCreateWIC(FFLogoRequestData* requestData, FFImageBuffer* out, const char** error); +bool ffImageAnimationOpenWIC(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error); #endif #ifdef __APPLE__ bool ffImageCreateImageIO(FFLogoRequestData* requestData, FFImageBuffer* out, const char** error); +bool ffImageAnimationOpenImageIO(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error); #endif #ifdef FF_HAVE_SIXEL diff --git a/src/logo/image/imageio.c b/src/logo/image/imageio.c index a86e0ccc8..3d3086d95 100644 --- a/src/logo/image/imageio.c +++ b/src/logo/image/imageio.c @@ -6,6 +6,149 @@ #include #include +// --------------------------------------------------------------------------------------------- +// Shared with the still path +// --------------------------------------------------------------------------------------------- + +// Fills in the missing dimension, keeping the source aspect ratio (same as the IM path). +static bool imageIOResolveTargetSize(FFLogoRequestData* requestData, uint32_t sourceWidth, uint32_t sourceHeight, const char** error) { + uint32_t width = requestData->logoPixelWidth; + uint32_t height = requestData->logoPixelHeight; + if (width == 0 && height == 0) { + width = sourceWidth; + height = sourceHeight; + } else if (width == 0) { + width = (uint32_t) ((double) sourceWidth / (double) sourceHeight * height); + } else if (height == 0) { + height = (uint32_t) ((double) sourceHeight / (double) sourceWidth * width); + } + + if (width == 0 || height == 0) { + if (error) { + *error = "invalid target dimensions"; + } + return false; + } + + requestData->logoPixelWidth = width; + requestData->logoPixelHeight = height; + return true; +} + +// Decodes one CGImage into tightly packed straight (unassociated) RGBA8888 at width x height. +// Both paths need exactly this -- the still path once, the animation path once per frame. +static bool imageIOConvert(CGImageRef image, uint32_t width, uint32_t height, uint8_t** outPixels, const char** error) { + const bool sameSize = width == CGImageGetWidth(image) && height == CGImageGetHeight(image); + + // Decode straight into RGBA for the same-size fast path. For resizing, decode + // into premultiplied ARGB so interpolation does not bleed transparent RGB into + // visible edge pixels. + // vImageBuffer_InitWithCGImage handles format conversion, color management and + // byte order in one call. NULL colorSpace means sRGB, matching kCGColorSpaceSRGB. + const vImage_CGImageFormat format = { + .bitsPerComponent = 8, + .bitsPerPixel = 32, + .colorSpace = nullptr, + .bitmapInfo = (CGBitmapInfo) ((sameSize ? kCGImageAlphaLast : kCGImageAlphaPremultipliedFirst) | kCGImageByteOrder32Big), + .version = 0, + .decode = nullptr, + .renderingIntent = kCGRenderingIntentDefault + }; + + vImage_Buffer src = {}; + vImage_Error vErr = vImageBuffer_InitWithCGImage(&src, (vImage_CGImageFormat*) &format, nullptr, image, kvImageNoFlags); + if (vErr != kvImageNoError) { + if (error) { + *error = "failed to decode the image with vImage"; + } + return false; + } + + // Final output buffer: straight (unassociated) RGBA8888 at the target size. + const size_t dstStride = (size_t) width * 4; + if (sameSize) { + // The requested format is already straight RGBA. If vImage did not add + // row padding, transfer its allocation directly to the caller; otherwise + // copy rows into the required tightly packed allocation. + if (src.rowBytes == dstStride) { + *outPixels = (uint8_t*) src.data; + return true; + } + + uint8_t* pixels = (uint8_t*) malloc(dstStride * (size_t) height); + if (pixels == nullptr) { + free(src.data); + if (error) { + *error = "out of memory"; + } + return false; + } + for (uint32_t y = 0; y < height; ++y) { + memcpy(pixels + (size_t) y * dstStride, + (const uint8_t*) src.data + (size_t) y * src.rowBytes, + dstStride); + } + free(src.data); + *outPixels = pixels; + return true; + } + + uint8_t* pixels = (uint8_t*) malloc(dstStride * (size_t) height); + if (pixels == nullptr) { + free(src.data); + if (error) { + *error = "out of memory"; + } + return false; + } + + const vImage_Buffer dst = { + .data = pixels, + .width = width, + .height = height, + .rowBytes = dstStride + }; + + // Resample while still premultiplied: interpolating premultiplied data is the + // correct way to scale (it avoids the dark fringes you get from averaging + // straight-alpha RGB). NULL temp buffer lets vImage allocate internally. + vErr = vImageScale_ARGB8888(&src, &dst, nullptr, kvImageHighQualityResampling); + free(src.data); + if (vErr != kvImageNoError) { + free(pixels); + if (error) { + *error = "failed to scale the image"; + } + return false; + } + + // Un-premultiply in place (pointwise, alpha == 0 is handled safely) so the result + // matches the kitty f=32 / chafa CHAFA_PIXEL_RGBA8_UNASSOCIATED contract. + vErr = vImageUnpremultiplyData_ARGB8888(&dst, &dst, kvImageNoFlags); + if (vErr != kvImageNoError) { + free(pixels); + if (error) { + *error = "failed to un-premultiply the image"; + } + return false; + } + + // Reorder ARGB -> RGBA in place (permute supports in-place when data/rowBytes match). + // The downstream consumers and the WIC backend all expect R,G,B,A byte order. + const uint8_t permuteMap[4] = { 1, 2, 3, 0 }; // A,R,G,B -> R,G,B,A + vErr = vImagePermuteChannels_ARGB8888(&dst, &dst, permuteMap, kvImageNoFlags); + if (vErr != kvImageNoError) { + free(pixels); + if (error) { + *error = "failed to reorder image channels"; + } + return false; + } + + *outPixels = pixels; + return true; +} + bool ffImageCreateImageIO(FFLogoRequestData* requestData, FFImageBuffer* out, const char** error) { FF_CFTYPE_AUTO_RELEASE CFURLRef url = CFURLCreateFromFileSystemRepresentation( kCFAllocatorDefault, @@ -36,8 +179,8 @@ bool ffImageCreateImageIO(FFLogoRequestData* requestData, FFImageBuffer* out, co return false; } - size_t sourceWidth = CGImageGetWidth(image); - size_t sourceHeight = CGImageGetHeight(image); + const size_t sourceWidth = CGImageGetWidth(image); + const size_t sourceHeight = CGImageGetHeight(image); if (sourceWidth == 0 || sourceHeight == 0) { if (error) { *error = "invalid image dimensions"; @@ -45,141 +188,291 @@ bool ffImageCreateImageIO(FFLogoRequestData* requestData, FFImageBuffer* out, co return false; } - // Fill in the missing dimension, keeping the source aspect ratio (same as the IM path) - uint32_t width = requestData->logoPixelWidth; - uint32_t height = requestData->logoPixelHeight; - if (width == 0 && height == 0) { - width = (uint32_t) sourceWidth; - height = (uint32_t) sourceHeight; - } else if (width == 0) { - width = (uint32_t) ((double) sourceWidth / (double) sourceHeight * height); - } else if (height == 0) { - height = (uint32_t) ((double) sourceHeight / (double) sourceWidth * width); - } - - if (width == 0 || height == 0) { - if (error) { - *error = "invalid target dimensions"; - } + if (!imageIOResolveTargetSize(requestData, (uint32_t) sourceWidth, (uint32_t) sourceHeight, error)) { return false; } - requestData->logoPixelWidth = width; - requestData->logoPixelHeight = height; - - const bool sameSize = width == sourceWidth && height == sourceHeight; - - // Decode straight into RGBA for the same-size fast path. For resizing, decode - // into premultiplied ARGB so interpolation does not bleed transparent RGB into - // visible edge pixels. - // vImageBuffer_InitWithCGImage handles format conversion, color management and - // byte order in one call. NULL colorSpace means sRGB, matching kCGColorSpaceSRGB. - const vImage_CGImageFormat format = { - .bitsPerComponent = 8, - .bitsPerPixel = 32, - .colorSpace = nullptr, - .bitmapInfo = (CGBitmapInfo) ((sameSize ? kCGImageAlphaLast : kCGImageAlphaPremultipliedFirst) | kCGImageByteOrder32Big), - .version = 0, - .decode = nullptr, - .renderingIntent = kCGRenderingIntentDefault - }; - - vImage_Buffer src = {}; - vImage_Error vErr = vImageBuffer_InitWithCGImage(&src, (vImage_CGImageFormat*) &format, nullptr, image, kvImageNoFlags); - if (vErr != kvImageNoError) { - if (error) { - *error = "failed to decode the image with vImage"; - } + if (!imageIOConvert(image, requestData->logoPixelWidth, requestData->logoPixelHeight, &out->data, error)) { return false; } - // Final output buffer: straight (unassociated) RGBA8888 at the target size. - const size_t dstStride = (size_t) width * 4; - if (sameSize) { - // The requested format is already straight RGBA. If vImage did not add - // row padding, transfer its allocation directly to the caller; otherwise - // copy rows into the required tightly packed FFImageBuffer allocation. - if (src.rowBytes == dstStride) { - out->data = (uint8_t*) src.data; - out->width = width; - out->height = height; - src.data = nullptr; - return true; + out->width = requestData->logoPixelWidth; + out->height = requestData->logoPixelHeight; + return true; +} + +// --------------------------------------------------------------------------------------------- +// Animation +// +// ImageIO hands out *composed* canvas-sized frames: CGImageSourceCreateImageAtIndex does the +// disposal work the Windows backend has to do itself, so the session here is thin -- it keeps the +// source open, resolves the canvas size and the per-frame gaps, and converts one frame per call. +// +// Two things it does not do, both measured rather than assumed (doc/kitty-animation.md 5.1.1): +// +// * A `disposal = background` region is cleared to opaque black, where browsers, kitty and +// ImageMagick leave it transparent. It can not be corrected: ImageIO exposes no disposal +// metadata at any level, so there is nothing to branch on. Accepted and documented. +// * Such a region is cleared even when the frame it belongs to is never displayed (delay 0), +// where browsers and kitty keep the frame before it. Same cause, same outcome -- and the +// common case is unaffected, because every ImageMagick optimiser emits disposal none/undefined. +// +// The frame count, the canvas size and every gap come from *metadata*: no frame is decoded before +// the first one is asked for, which is what the session contract requires (a negative +// --logo-animation-frame has to be resolved before the first frame is taken). +// --------------------------------------------------------------------------------------------- + +typedef struct FFImageIOAnimation { + CGImageSourceRef source; + uint32_t nextIndex; + uint32_t outputWidth; + uint32_t outputHeight; + int32_t minGap; + int32_t* delaysCs; // one per frame, read once at open time; a full scan is needed for minGap anyway +} FFImageIOAnimation; + +// Thin wrappers over common/apple/cf_helpers. They exist for two reasons the shared helpers +// deliberately do not cover: an absent dictionary has to be *tolerated* rather than queried +// (CFDictionaryGetValue on NULL is undefined), and every caller here treats "absent" and "of the +// wrong type" the same way, so the descriptive error string is dropped. +static CFDictionaryRef imageIOSubdict(CFDictionaryRef dict, CFStringRef key) { + CFDictionaryRef result = nullptr; + if (dict == nullptr || ffCfDictGetDict(dict, key, &result) != nullptr) { + return nullptr; + } + + return result; +} + +// Both report false when the key is absent, which is how "the source declares nothing" is told +// apart from "the source declares zero". +static bool imageIODictGetDouble(CFDictionaryRef dict, CFStringRef key, double* out) { + return dict != nullptr && ffCfDictGetDouble(dict, key, out) == nullptr; +} + +static bool imageIODictGetInt(CFDictionaryRef dict, CFStringRef key, int64_t* out) { + return dict != nullptr && ffCfDictGetInt64(dict, key, out) == nullptr; +} + +// The frame's delay in centiseconds, which is the unit both GIF and APNG store. +// +// kCGImagePropertyGIFUnclampedDelayTime is the one to read, not kCGImagePropertyGIFDelayTime: +// ImageIO clamps the latter at 100 ms (and the APNG one at 50 ms), so a source whose frames are +// [0, 5, 0] centiseconds comes back as [10, 5, 10]. That would defeat kitty's rule, which floors at +// 100 ms only when *every* frame is zero and otherwise treats a zero as "gapless". Measured on +// macOS 27: mixed_delay.gif reads 0.10/0.05/0.10 clamped against 0.00/0.05/0.00 unclamped, and the +// unclamped values are the ones written into the file. +static int32_t imageIOFrameDelayCs(CFDictionaryRef frameProperties) { + // A GIF keeps its timing in the GIF dictionary, an animated PNG in the PNG one, and the two + // are never both present. + CFDictionaryRef gif = imageIOSubdict(frameProperties, kCGImagePropertyGIFDictionary); + CFDictionaryRef png = imageIOSubdict(frameProperties, kCGImagePropertyPNGDictionary); + + double seconds = 0; + if (imageIODictGetDouble(gif, kCGImagePropertyGIFUnclampedDelayTime, &seconds) || + imageIODictGetDouble(gif, kCGImagePropertyGIFDelayTime, &seconds) || + imageIODictGetDouble(png, kCGImagePropertyAPNGUnclampedDelayTime, &seconds) || + imageIODictGetDouble(png, kCGImagePropertyAPNGDelayTime, &seconds)) { + if (seconds > 0) { + return (int32_t) lround(seconds * 100.0); + } + } + + return 0; +} + +// How often the animation repeats after its first play; 0 means forever and -1 means the source +// declared nothing. Neither is what ImageIO reports -- its two dictionaries count different things +// (measured on macOS 27): +// +// GIF : number of *plays*. NETSCAPE 3 -> 4, NETSCAPE 0 (forever) -> 0, no NETSCAPE at all -> 1. +// That is ImageMagick's `iterations` to the digit, and the same -1 conversion applies. +// Note that "no NETSCAPE" arrives as 1, *not* as 0: reading 0 as the missing value, or +// passing 1 straight through, would turn "loop forever" into "play twice". +// APNG : number of *repeats*. acTL num_plays 3 -> 3, 0 (forever) -> 0. +// +// Which dictionary the value came from is therefore the format check; there is no need to ask +// ImageIO for a UTI and string-compare it. +static int32_t imageIOLoopCount(CGImageSourceRef source) { + FF_CFTYPE_AUTO_RELEASE CFDictionaryRef properties = CGImageSourceCopyProperties(source, nullptr); + if (properties == nullptr) { + return -1; + } + + int64_t value = 0; + if (imageIODictGetInt(imageIOSubdict(properties, kCGImagePropertyGIFDictionary), kCGImagePropertyGIFLoopCount, &value)) { + if (value == 0) { + return 0; + } + if (value == 1) { + return -1; } - FF_AUTO_FREE uint8_t* pixels = (uint8_t*) malloc(dstStride * (size_t) height); - if (pixels == nullptr) { - free(src.data); - if (error) { - *error = "out of memory"; - } - return false; - } - for (uint32_t y = 0; y < height; ++y) { - memcpy(pixels + (size_t) y * dstStride, - (const uint8_t*) src.data + (size_t) y * src.rowBytes, - dstStride); - } - free(src.data); - out->data = pixels; - out->width = width; - out->height = height; - pixels = nullptr; - return true; - } else { - FF_AUTO_FREE uint8_t* pixels = (uint8_t*) malloc(dstStride * (size_t) height); - if (pixels == nullptr) { - free(src.data); - if (error) { - *error = "out of memory"; - } - return false; + const int64_t loops = value - 1; + return loops > INT32_MAX ? INT32_MAX : (int32_t) loops; + } + + if (imageIODictGetInt(imageIOSubdict(properties, kCGImagePropertyPNGDictionary), kCGImagePropertyAPNGLoopCount, &value)) { + if (value == 0) { + return 0; } - const vImage_Buffer dst = { - .data = pixels, - .width = width, - .height = height, - .rowBytes = dstStride - }; + return value > INT32_MAX ? INT32_MAX : (int32_t) value; + } - // Resample while still premultiplied: interpolating premultiplied data is the - // correct way to scale (it avoids the dark fringes you get from averaging - // straight-alpha RGB). NULL temp buffer lets vImage allocate internally. - vErr = vImageScale_ARGB8888(&src, &dst, nullptr, kvImageHighQualityResampling); - free(src.data); - if (vErr != kvImageNoError) { - if (error) { - *error = "failed to scale the image"; - } - return false; - } + return -1; +} - // Un-premultiply in place (pointwise, alpha == 0 is handled safely) so the result - // matches the kitty f=32 / chafa CHAFA_PIXEL_RGBA8_UNASSOCIATED contract. - vErr = vImageUnpremultiplyData_ARGB8888(&dst, &dst, kvImageNoFlags); - if (vErr != kvImageNoError) { - if (error) { - *error = "failed to un-premultiply the image"; - } - return false; - } +static bool imageIOGetFrame(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error) { + FFImageIOAnimation* session = (FFImageIOAnimation*) ffImageAnimationGetImpl(animation); - // Reorder ARGB -> RGBA in place (permute supports in-place when data/rowBytes match). - // The downstream consumers and the WIC backend all expect R,G,B,A byte order. - const uint8_t permuteMap[4] = { 1, 2, 3, 0 }; // A,R,G,B -> R,G,B,A - vErr = vImagePermuteChannels_ARGB8888(&dst, &dst, permuteMap, kvImageNoFlags); - if (vErr != kvImageNoError) { - if (error) { - *error = "failed to reorder image channels"; - } - return false; - } + // ImageIO can decode an arbitrary frame on demand, unlike the Windows backend, but the session + // contract is a sequential iterator and the composition of frame i does depend on 0..i-1, so an + // out-of-order caller is a bug worth reporting rather than something to paper over. + if (index < session->nextIndex) { + *error = "animation frames must be taken in order"; + return false; + } - out->data = pixels; - out->width = width; - out->height = height; - pixels = nullptr; // Ownership is transferred to `out` - return true; + // kitty's mapping, which the design settled on: the raw delay is in centiseconds, the 100 ms + // floor exists only for sources whose frames are *all* zero, and whatever is left at <= 0 means + // "gapless" rather than 100 ms. + const int32_t rawDelay = session->delaysCs[index]; + const int32_t gap = (rawDelay > session->minGap ? rawDelay : session->minGap) * 10; + out->delayMs = gap > 0 ? gap : -1; + + FF_CFTYPE_AUTO_RELEASE CGImageRef image = CGImageSourceCreateImageAtIndex(session->source, index, nullptr); + if (image == nullptr) { + *error = "failed to decode the animation frame"; + return false; + } + + if (!imageIOConvert(image, session->outputWidth, session->outputHeight, &out->data, error)) { + return false; + } + + session->nextIndex = index + 1; + return true; +} + +static void imageIOFreeSession(FFImageIOAnimation* session) { + if (session->source != nullptr) { + CFRelease(session->source); + } + free(session->delaysCs); + free(session); +} + +static void imageIODestroyAnimation(FFImageAnimation* animation) { + FFImageIOAnimation* session = (FFImageIOAnimation*) ffImageAnimationGetImpl(animation); + if (session != nullptr) { + imageIOFreeSession(session); } } + +bool ffImageAnimationOpenImageIO(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error) { + FF_CFTYPE_AUTO_RELEASE CFURLRef url = CFURLCreateFromFileSystemRepresentation( + kCFAllocatorDefault, + (const UInt8*) instance.config.logo.source.chars, + (CFIndex) instance.config.logo.source.length, + false); + if (url == nullptr) { + if (error) { + *error = "failed to create the image URL"; + } + return false; + } + + // Frames are handed out one at a time and released by the caller, so there is nothing to gain + // from letting ImageIO keep its own copy: without this a long animation is decoded into its + // cache as it is played through, and the peak stops matching the still path's. + const void* optionKeys[] = { kCGImageSourceShouldCache }; + const void* optionValues[] = { kCFBooleanFalse }; + FF_CFTYPE_AUTO_RELEASE CFDictionaryRef options = CFDictionaryCreate(kCFAllocatorDefault, + optionKeys, optionValues, 1, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + + // Not auto-released: the session keeps it open until the last frame has been handed out. + CGImageSourceRef source = CGImageSourceCreateWithURL(url, options); + if (source == nullptr) { + if (error) { + *error = "unsupported or unreadable image format"; + } + return false; + } + + const size_t frameCount = CGImageSourceGetCount(source); + if (frameCount == 0) { + if (error) { + *error = "the image source has no frames"; + } + CFRelease(source); + return false; + } + + FFImageIOAnimation* session = (FFImageIOAnimation*) calloc(1, sizeof(*session)); + if (session == nullptr) { + if (error) { + *error = "out of memory"; + } + CFRelease(source); + return false; + } + session->source = source; // the session owns it from here on; every failure below frees it + + // The canvas size comes from frame 0's *properties*, not from decoding it: ImageIO hands out + // canvas-sized frames, so this is the same value the still path reads off the decoded CGImage, + // and reading it here keeps the contract's "no frame is decoded to open a session". + FF_CFTYPE_AUTO_RELEASE CFDictionaryRef firstProperties = CGImageSourceCopyPropertiesAtIndex(source, 0, nullptr); + int64_t sourceWidth = 0; + int64_t sourceHeight = 0; + if (!imageIODictGetInt(firstProperties, kCGImagePropertyPixelWidth, &sourceWidth) || + !imageIODictGetInt(firstProperties, kCGImagePropertyPixelHeight, &sourceHeight) || + sourceWidth <= 0 || sourceHeight <= 0) { + if (error) { + *error = "failed to read the animation canvas size"; + } + imageIOFreeSession(session); + return false; + } + + if (!imageIOResolveTargetSize(requestData, (uint32_t) sourceWidth, (uint32_t) sourceHeight, error)) { + imageIOFreeSession(session); + return false; + } + session->outputWidth = requestData->logoPixelWidth; + session->outputHeight = requestData->logoPixelHeight; + + session->delaysCs = (int32_t*) malloc(frameCount * sizeof(*session->delaysCs)); + if (session->delaysCs == nullptr) { + if (error) { + *error = "out of memory"; + } + imageIOFreeSession(session); + return false; + } + + // kitty's rule: the 100 ms floor applies only when every frame's raw delay is <= 0. One frame + // with a delay settles the floor, but every frame is still visited here so that its delay is + // known by the time it is handed out. + session->minGap = 10; + for (size_t i = 0; i < frameCount; ++i) { + FF_CFTYPE_AUTO_RELEASE CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(source, i, nullptr); + session->delaysCs[i] = imageIOFrameDelayCs(properties); + if (session->delaysCs[i] > 0) { + session->minGap = 0; + } + } + + FFImageAnimation* animation = ffImageAnimationCreate((uint32_t) frameCount, imageIOLoopCount(source), + session, imageIOGetFrame, imageIODestroyAnimation); + if (animation == nullptr) { + if (error) { + *error = "out of memory"; + } + imageIOFreeSession(session); + return false; + } + + *out = animation; + return true; +} diff --git a/src/logo/image/wic.cpp b/src/logo/image/wic.cpp index cac0b2b1d..d2ca3c4f7 100644 --- a/src/logo/image/wic.cpp +++ b/src/logo/image/wic.cpp @@ -9,54 +9,139 @@ extern "C" { #include #include -bool ffImageCreateWIC(FFLogoRequestData* requestData, FFImageBuffer* out, const char** error) { - const char* comError = ffInitCom(); - if (comError) { - if (error) *error = comError; +// --------------------------------------------------------------------------------------------- +// Metadata +// +// `QueryInterface(IID_IWICMetadataQueryReader)` returns E_NOINTERFACE on this platform for every +// decoder and every frame, so the interface has to be reached through the method instead. Copying +// the QueryInterface out of Microsoft's own WicAnimatedGif.cpp sample reads no data at all here. +// +// The two readers are not interchangeable: `/logscrdesc/*` and `/appext/*` exist only on the +// decoder's reader, `/imgdesc/*` and `/grctlext/*` only on a frame's. +// --------------------------------------------------------------------------------------------- + +static bool readMetadata(IWICMetadataQueryReader* reader, const wchar_t* name, PROPVARIANT* out) { + if (reader == nullptr) { + return false; + } + + PropVariantInit(out); + if (FAILED(reader->GetMetadataByName(name, out))) { + PropVariantClear(out); + return false; + } + + return true; +} + +// Every integer path used here is one of these; the width varies with the item. +static bool readUint(IWICMetadataQueryReader* reader, const wchar_t* name, uint32_t* out) { + PROPVARIANT value; + if (!readMetadata(reader, name, &value)) { + return false; + } + + bool ok = true; + switch (value.vt) { + case VT_UI1: *out = value.bVal; break; + case VT_UI2: *out = value.uiVal; break; + case VT_UI4: *out = value.ulVal; break; + case VT_I2: *out = (uint32_t) value.iVal; break; + case VT_I4: *out = (uint32_t) value.lVal; break; + default: ok = false; break; + } + + PropVariantClear(&value); + return ok; +} + +// The loop count lives in the NETSCAPE2.0 application extension: `[0]=size=3`, `[1]=sub-block +// id=1`, `[2..3]=LE16 count`. `/logscrdesc/LoopCount` and `/logscrdesc/Iterations` do not exist. +// A source without the extension declares no loop count at all, which is -1, not 0. +static bool readLoopCount(IWICMetadataQueryReader* reader, int32_t* out) { + PROPVARIANT value; + if (!readMetadata(reader, L"/appext/Data", &value)) { + return false; + } + + bool ok = false; + if (value.vt == (VARTYPE) (VT_VECTOR | VT_UI1) && value.caub.cElems >= 4 && + value.caub.pElems[0] == 3 && value.caub.pElems[1] == 1) { + *out = (int32_t) ((uint32_t) value.caub.pElems[2] | ((uint32_t) value.caub.pElems[3] << 8)); + ok = true; + } + + PropVariantClear(&value); + return ok; +} + +// --------------------------------------------------------------------------------------------- +// Pixels +// --------------------------------------------------------------------------------------------- + +// Scales `source` to `outWidth` x `outHeight` and converts it to straight-alpha RGBA8, which is +// what kitty (f=32) and chafa (CHAFA_PIXEL_RGBA8_UNASSOCIATED) both consume. Ownership of +// `*outPixels` passes to the caller. +static bool convertToRGBA(IWICImagingFactory* factory, IWICBitmapSource* source, + uint32_t outWidth, uint32_t outHeight, uint8_t** outPixels, const char** error) { + UINT sourceWidth = 0, sourceHeight = 0; + if (FAILED(source->GetSize(&sourceWidth, &sourceHeight))) { + if (error) *error = "failed to query the image dimensions"; return false; } - FF_AUTO_RELEASE_COM_OBJECT IWICImagingFactory* factory = nullptr; - FF_AUTO_RELEASE_COM_OBJECT IWICBitmapDecoder* decoder = nullptr; - FF_AUTO_RELEASE_COM_OBJECT IWICBitmapFrameDecode* frame = nullptr; FF_AUTO_RELEASE_COM_OBJECT IWICBitmapScaler* scaler = nullptr; FF_AUTO_RELEASE_COM_OBJECT IWICFormatConverter* premultiplyConverter = nullptr; FF_AUTO_RELEASE_COM_OBJECT IWICFormatConverter* converter = nullptr; - if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, - IID_IWICImagingFactory, (void**) &factory))) { - if (error) *error = "WIC imaging factory is unavailable"; + IWICBitmapSource* sized = source; + if (outWidth != sourceWidth || outHeight != sourceHeight) { + // Same size: don't resample. ImageMagick clones the image in this case too, + // and resampling would only blur it. + // + // Otherwise scale premultiplied alpha to avoid transparent RGB values bleeding into + // the visible edge pixels. The final output is converted back to straight alpha below. + if (FAILED(factory->CreateFormatConverter(&premultiplyConverter)) || + FAILED(premultiplyConverter->Initialize(source, GUID_WICPixelFormat32bppPBGRA, + WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) || + FAILED(factory->CreateBitmapScaler(&scaler)) || + FAILED(scaler->Initialize(premultiplyConverter, outWidth, outHeight, + (WICBitmapInterpolationMode) 0x4 /* WICBitmapInterpolationModeHighQualityCubic */))) { + if (error) *error = "image scaling failed"; + return false; + } + sized = scaler; + } + + // Normalize to straight-alpha RGBA8: kitty (f=32) and chafa + // (CHAFA_PIXEL_RGBA8_UNASSOCIATED) both consume exactly this + if (FAILED(factory->CreateFormatConverter(&converter)) || + FAILED(converter->Initialize(sized, GUID_WICPixelFormat32bppRGBA, + WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom))) { + if (error) *error = "pixel format conversion failed"; return false; } - // The source path is UTF-8, WIC only accepts UTF-16 - wchar_t widePath[MAX_PATH + 1]; - if (!NT_SUCCESS(RtlUTF8ToUnicodeN(widePath, (ULONG) sizeof(widePath), nullptr, - instance.config.logo.source.chars, (ULONG) instance.config.logo.source.length + 1))) { - if (error) *error = "failed to convert the image path to UTF-16"; + UINT stride = outWidth * 4; + uint8_t* pixels = (uint8_t*) malloc((size_t) stride * outHeight); + if (pixels == nullptr) { + if (error) *error = "out of memory"; return false; } - if (FAILED(factory->CreateDecoderFromFilename(widePath, nullptr, GENERIC_READ, - WICDecodeMetadataCacheOnDemand, &decoder))) { - if (error) *error = "unsupported or unreadable image format"; + // prc == nullptr means the whole image; WIC fills the buffer using the stride we pass in + if (FAILED(converter->CopyPixels(nullptr, stride, stride * outHeight, pixels))) { + free(pixels); + if (error) *error = "pixel copy failed"; return false; } - // Only the first frame, matching ImageMagick's ReadImage (neither handles GIF animation) - if (FAILED(decoder->GetFrame(0, &frame))) { - if (error) *error = "failed to get the first frame"; - return false; - } + *outPixels = pixels; + return true; +} - UINT sourceWidth = 0, sourceHeight = 0; - frame->GetSize(&sourceWidth, &sourceHeight); - if (sourceWidth == 0 || sourceHeight == 0) { - if (error) *error = "invalid image dimensions"; - return false; - } - - // Fill in the missing dimension, keeping the source aspect ratio (same as the IM path) +// Fills in the missing dimension, keeping the source aspect ratio (same as the IM path) +static bool resolveTargetSize(FFLogoRequestData* requestData, uint32_t sourceWidth, uint32_t sourceHeight, const char** error) { uint32_t width = requestData->logoPixelWidth; uint32_t height = requestData->logoPixelHeight; if (width == 0 && height == 0) { @@ -75,53 +160,412 @@ bool ffImageCreateWIC(FFLogoRequestData* requestData, FFImageBuffer* out, const requestData->logoPixelWidth = width; requestData->logoPixelHeight = height; + return true; +} - IWICBitmapSource* source = nullptr; - if (width == sourceWidth && height == sourceHeight) { - // Same size: don't resample. ImageMagick clones the image in this case too, - // and resampling would only blur it - source = frame; - } else { - // Scale premultiplied alpha to avoid transparent RGB values bleeding into - // the visible edge pixels. The final output is converted back to straight - // alpha below, matching FFImageBuffer's RGBA8 contract. - if (FAILED(factory->CreateFormatConverter(&premultiplyConverter)) || - FAILED(premultiplyConverter->Initialize(frame, GUID_WICPixelFormat32bppPBGRA, - WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) || - FAILED(factory->CreateBitmapScaler(&scaler)) || - FAILED(scaler->Initialize(premultiplyConverter, width, height, - (WICBitmapInterpolationMode) 0x4 /* WICBitmapInterpolationModeHighQualityCubic */))) { - if (error) *error = "image scaling failed"; - return false; - } - source = scaler; - } - - // Normalize to straight-alpha RGBA8: kitty (f=32) and chafa - // (CHAFA_PIXEL_RGBA8_UNASSOCIATED) both consume exactly this - if (FAILED(factory->CreateFormatConverter(&converter)) || - FAILED(converter->Initialize(source, GUID_WICPixelFormat32bppRGBA, - WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom))) { - if (error) *error = "pixel format conversion failed"; +// The source path is UTF-8, WIC only accepts UTF-16 +static bool createDecoder(IWICImagingFactory* factory, IWICBitmapDecoder** out, const char** error) { + wchar_t widePath[MAX_PATH + 1]; + if (!NT_SUCCESS(RtlUTF8ToUnicodeN(widePath, (ULONG) sizeof(widePath), nullptr, + instance.config.logo.source.chars, (ULONG) instance.config.logo.source.length + 1))) { + if (error) *error = "failed to convert the image path to UTF-16"; return false; } - UINT stride = width * 4; - FF_AUTO_FREE uint8_t* pixels = (uint8_t*) malloc((size_t) stride * height); - if (pixels == nullptr) { - if (error) *error = "out of memory"; + if (FAILED(factory->CreateDecoderFromFilename(widePath, nullptr, GENERIC_READ, + WICDecodeMetadataCacheOnDemand, out))) { + if (error) *error = "unsupported or unreadable image format"; return false; } - // prc == nullptr means the whole image; WIC fills the buffer using the stride we pass in - if (FAILED(converter->CopyPixels(nullptr, stride, stride * height, pixels))) { - if (error) *error = "pixel copy failed"; + return true; +} + +bool ffImageCreateWIC(FFLogoRequestData* requestData, FFImageBuffer* out, const char** error) { + const char* comError = ffInitCom(); + if (comError) { + if (error) *error = comError; + return false; + } + + FF_AUTO_RELEASE_COM_OBJECT IWICImagingFactory* factory = nullptr; + FF_AUTO_RELEASE_COM_OBJECT IWICBitmapDecoder* decoder = nullptr; + FF_AUTO_RELEASE_COM_OBJECT IWICBitmapFrameDecode* frame = nullptr; + + if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_IWICImagingFactory, (void**) &factory))) { + if (error) *error = "WIC imaging factory is unavailable"; + return false; + } + + if (!createDecoder(factory, &decoder, error)) { + return false; + } + + // Only the first frame, matching ImageMagick's ReadImage (neither handles GIF animation) + if (FAILED(decoder->GetFrame(0, &frame))) { + if (error) *error = "failed to get the first frame"; + return false; + } + + UINT sourceWidth = 0, sourceHeight = 0; + frame->GetSize(&sourceWidth, &sourceHeight); + if (sourceWidth == 0 || sourceHeight == 0) { + if (error) *error = "invalid image dimensions"; + return false; + } + + if (!resolveTargetSize(requestData, sourceWidth, sourceHeight, error)) { + return false; + } + + uint8_t* pixels = nullptr; + if (!convertToRGBA(factory, frame, requestData->logoPixelWidth, requestData->logoPixelHeight, &pixels, error)) { return false; } out->data = pixels; - out->width = width; - out->height = height; - pixels = nullptr; // Ownership is transferred to `out` + out->width = requestData->logoPixelWidth; + out->height = requestData->logoPixelHeight; + return true; +} + +// --------------------------------------------------------------------------------------------- +// Animation +// +// WIC does not compose GIF frames: it hands out the raw sub-frame plus the metadata describing how +// to place it, so the canvas and the disposal handling are ours. The loop below follows the GIF +// model, which is also what Microsoft's own sample implements: +// +// 1. apply the *previous* frame's disposal to the canvas (0/1 keep, 2 clear its rectangle, +// 3 restore the snapshot taken before it was drawn); +// 2. if the current frame's disposal is 3, snapshot the canvas as it stands; +// 3. draw the current frame's pixels at its own (left, top). +// +// The canvas is scaled once per frame on the way out, never per sub-frame: scaling in sub-frame +// coordinates would introduce edge errors. +// --------------------------------------------------------------------------------------------- + +struct FFWicAnimation { + IWICImagingFactory* factory; + IWICBitmapDecoder* decoder; + IWICMetadataQueryReader* decoderReader; // container level; nullptr for formats without one + + uint32_t canvasWidth; + uint32_t canvasHeight; + uint8_t* canvas; // RGBA8, the composed canvas + uint8_t* snapshot; // the canvas as it was before a disposal=3 frame was drawn + + uint32_t outputWidth; + uint32_t outputHeight; + + uint32_t previousLeft; + uint32_t previousTop; + uint32_t previousWidth; + uint32_t previousHeight; + uint32_t previousDisposal; + uint32_t previousRawDelay; // centiseconds, as the source declares it + bool hasPrevious; + + uint32_t nextIndex; // frames are handed out in order; frame i depends on 0..i-1 + int32_t minGap; // 0, or 10 when every frame's raw delay is <= 0 +}; + +static void clearRect(FFWicAnimation* animation, uint32_t left, uint32_t top, uint32_t width, uint32_t height) { + const size_t rowBytes = (size_t) width * 4; + for (uint32_t y = 0; y < height; ++y) { + memset(animation->canvas + (((size_t) (top + y) * animation->canvasWidth) + left) * 4, 0, rowBytes); + } +} + +// Reads frame `index`'s metadata, applies the previous frame's disposal, snapshots if this frame +// asks for it, and draws the frame onto the canvas. Reports the frame's gap in milliseconds. +static bool composeFrame(FFWicAnimation* animation, uint32_t index, int32_t* delayMs, const char** error) { + FF_AUTO_RELEASE_COM_OBJECT IWICBitmapFrameDecode* frame = nullptr; + FF_AUTO_RELEASE_COM_OBJECT IWICMetadataQueryReader* reader = nullptr; + + if (FAILED(animation->decoder->GetFrame(index, &frame)) || + FAILED(frame->GetMetadataQueryReader(&reader))) { + *error = "failed to read the frame metadata"; + return false; + } + + uint32_t left = 0, top = 0, width = 0, height = 0, disposal = 0, rawDelay = 0; + if (!readUint(reader, L"/imgdesc/Left", &left) || !readUint(reader, L"/imgdesc/Top", &top) || + !readUint(reader, L"/imgdesc/Width", &width) || !readUint(reader, L"/imgdesc/Height", &height)) { + // Not a GIF: there is no placement metadata at all and the frame is the whole canvas. + UINT frameWidth = 0, frameHeight = 0; + if (FAILED(frame->GetSize(&frameWidth, &frameHeight))) { + *error = "failed to read the frame rectangle"; + return false; + } + left = 0; + top = 0; + width = frameWidth; + height = frameHeight; + } + + if (width == 0 || height == 0 || + left + width > animation->canvasWidth || top + height > animation->canvasHeight) { + *error = "the frame rectangle does not fit the canvas"; + return false; + } + + // Both are absent on anything that is not a GIF, in which case the frame covers the canvas and + // there is nothing to animate. + readUint(reader, L"/grctlext/Disposal", &disposal); + readUint(reader, L"/grctlext/Delay", &rawDelay); + + // kitty's mapping, which the design settled on: the raw delay is in centiseconds, the 100 ms + // floor exists only for sources whose frames are *all* zero, and whatever is left at <= 0 means + // "gapless" rather than 100 ms. Not the "<90 -> 90" floor of the WIC sample, which its own + // comment admits destroys legitimate zero-gap frames. + const int32_t gap = (int32_t) (rawDelay > (uint32_t) animation->minGap ? rawDelay : (uint32_t) animation->minGap) * 10; + *delayMs = gap > 0 ? gap : -1; + + if (animation->hasPrevious) { + if (animation->previousDisposal == 2 && animation->previousRawDelay != 0) { + // No colour table is reachable through WIC - only BackgroundColorIndex, never a palette + // - so "restore to background" can only mean transparent. + // + // A previous frame with a zero delay keeps its pixels instead: it was never displayed + // for any length of time, so erasing it would only flicker. This is the rule the + // reference compositor was validated against. + clearRect(animation, animation->previousLeft, animation->previousTop, + animation->previousWidth, animation->previousHeight); + } else if (animation->previousDisposal == 3) { + memcpy(animation->canvas, animation->snapshot, + (size_t) animation->canvasWidth * animation->canvasHeight * 4); + } + } + + if (disposal == 3) { + const size_t canvasSize = (size_t) animation->canvasWidth * animation->canvasHeight * 4; + if (animation->snapshot == nullptr) { + animation->snapshot = (uint8_t*) malloc(canvasSize); + if (animation->snapshot == nullptr) { + *error = "out of memory"; + return false; + } + } + memcpy(animation->snapshot, animation->canvas, canvasSize); + } + + // Decode the sub-frame at its own size; the canvas is scaled on the way out instead. + FF_AUTO_FREE uint8_t* pixels = nullptr; + if (!convertToRGBA(animation->factory, frame, width, height, &pixels, error)) { + return false; + } + + for (uint32_t y = 0; y < height; ++y) { + const uint8_t* source = pixels + (size_t) y * width * 4; + uint8_t* target = animation->canvas + (((size_t) (top + y) * animation->canvasWidth) + left) * 4; + + for (uint32_t x = 0; x < width; ++x, source += 4, target += 4) { + const uint8_t alpha = source[3]; + if (alpha == 0) { + // GIF transparency is one bit, so "keep" is exact and needs no arithmetic at all. + continue; + } + if (alpha == 255) { + memcpy(target, source, 4); + continue; + } + + // Not a GIF. Composite over, so a source with real alpha still lands sanely; for the + // one-bit alpha above this branch is never taken. + for (uint32_t channel = 0; channel < 3; ++channel) { + target[channel] = (uint8_t) (((uint32_t) source[channel] * alpha + + (uint32_t) target[channel] * (255 - alpha) + 127) / 255); + } + target[3] = (uint8_t) (alpha + (uint32_t) target[3] * (255 - alpha) / 255); + } + } + + animation->previousLeft = left; + animation->previousTop = top; + animation->previousWidth = width; + animation->previousHeight = height; + animation->previousDisposal = disposal; + animation->previousRawDelay = rawDelay; + animation->hasPrevious = true; + return true; +} + +// Scales the composed canvas to the output size and hands it over. +static bool outputCanvas(FFWicAnimation* animation, FFImageFrame* out, const char** error) { + const UINT stride = animation->canvasWidth * 4; + + FF_AUTO_RELEASE_COM_OBJECT IWICBitmap* bitmap = nullptr; + if (FAILED(animation->factory->CreateBitmapFromMemory(animation->canvasWidth, animation->canvasHeight, + GUID_WICPixelFormat32bppRGBA, stride, stride * animation->canvasHeight, animation->canvas, &bitmap))) { + *error = "failed to wrap the composed canvas"; + return false; + } + + return convertToRGBA(animation->factory, bitmap, animation->outputWidth, animation->outputHeight, + &out->data, error); +} + +static bool wicGetFrame(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error) { + FFWicAnimation* session = (FFWicAnimation*) ffImageAnimationGetImpl(animation); + + // The composition of frame i depends on frames 0..i-1, so they are walked in order and the + // intermediate canvases are simply discarded. + if (index < session->nextIndex) { + *error = "animation frames must be taken in order"; + return false; + } + + int32_t delayMs = -1; + for (uint32_t i = session->nextIndex; i <= index; ++i) { + if (!composeFrame(session, i, &delayMs, error)) { + return false; + } + session->nextIndex = i + 1; + } + + if (!outputCanvas(session, out, error)) { + return false; + } + + out->delayMs = delayMs; + return true; +} + +static void wicDestroyAnimation(FFImageAnimation* animation) { + FFWicAnimation* session = (FFWicAnimation*) ffImageAnimationGetImpl(animation); + if (session == nullptr) { + return; + } + + free(session->canvas); + free(session->snapshot); + if (session->decoderReader) session->decoderReader->Release(); + if (session->decoder) session->decoder->Release(); + if (session->factory) session->factory->Release(); + free(session); +} + +// Reads one frame's raw delay, for the all-zero check that decides the 100 ms floor. +static bool readFrameDelay(IWICBitmapDecoder* decoder, uint32_t index, uint32_t* out) { + FF_AUTO_RELEASE_COM_OBJECT IWICBitmapFrameDecode* frame = nullptr; + FF_AUTO_RELEASE_COM_OBJECT IWICMetadataQueryReader* reader = nullptr; + + if (FAILED(decoder->GetFrame(index, &frame)) || FAILED(frame->GetMetadataQueryReader(&reader))) { + return false; + } + + return readUint(reader, L"/grctlext/Delay", out); +} + +bool ffImageAnimationOpenWIC(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error) { + const char* comError = ffInitCom(); + if (comError) { + if (error) *error = comError; + return false; + } + + FF_AUTO_RELEASE_COM_OBJECT IWICImagingFactory* factory = nullptr; + FF_AUTO_RELEASE_COM_OBJECT IWICBitmapDecoder* decoder = nullptr; + FF_AUTO_RELEASE_COM_OBJECT IWICBitmapFrameDecode* firstFrame = nullptr; + FF_AUTO_RELEASE_COM_OBJECT IWICMetadataQueryReader* decoderReader = nullptr; + + if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_IWICImagingFactory, (void**) &factory))) { + if (error) *error = "WIC imaging factory is unavailable"; + return false; + } + + if (!createDecoder(factory, &decoder, error)) { + return false; + } + + // Cheap: WICDecodeMetadataCacheOnDemand reads only the header, so the frame count - which a + // negative --logo-animation-frame index is resolved against - needs no decoding. + UINT frameCount = 0; + if (FAILED(decoder->GetFrameCount(&frameCount)) || frameCount == 0) { + if (error) *error = "the image source has no frames"; + return false; + } + + if (FAILED(decoder->GetFrame(0, &firstFrame))) { + if (error) *error = "failed to get the first frame"; + return false; + } + + // Absent for every format without a query reader (PNG, JPEG, ...), which is not an error: the + // frame is the canvas then. + decoder->GetMetadataQueryReader(&decoderReader); + + // GetSize() on a frame gives the *sub-frame* rectangle, never the canvas, so the canvas has to + // come from the logical screen descriptor. + uint32_t canvasWidth = 0, canvasHeight = 0; + if (!readUint(decoderReader, L"/logscrdesc/Width", &canvasWidth) || + !readUint(decoderReader, L"/logscrdesc/Height", &canvasHeight)) { + UINT width = 0, height = 0; + if (FAILED(firstFrame->GetSize(&width, &height)) || width == 0 || height == 0) { + if (error) *error = "invalid image dimensions"; + return false; + } + canvasWidth = width; + canvasHeight = height; + } + + if (!resolveTargetSize(requestData, canvasWidth, canvasHeight, error)) { + return false; + } + + // 0 means "loop forever", -1 that the source declares no loop count at all. + int32_t loopCount = -1; + readLoopCount(decoderReader, &loopCount); + + // kitty's rule: the 100 ms floor applies only when every frame's raw delay is <= 0. One frame + // with a delay settles it, so the normal case stops after the first read. + int32_t minGap = 10; + for (UINT i = 0; i < frameCount; ++i) { + uint32_t rawDelay = 0; + if (!readFrameDelay(decoder, i, &rawDelay) || rawDelay > 0) { + minGap = 0; + break; + } + } + + FFWicAnimation* session = (FFWicAnimation*) calloc(1, sizeof(*session)); + if (session == nullptr) { + if (error) *error = "out of memory"; + return false; + } + + session->canvas = (uint8_t*) calloc((size_t) canvasWidth * canvasHeight * 4, 1); + if (session->canvas == nullptr) { + free(session); + if (error) *error = "out of memory"; + return false; + } + + session->factory = factory; + session->decoder = decoder; + session->decoderReader = decoderReader; + session->canvasWidth = canvasWidth; + session->canvasHeight = canvasHeight; + session->outputWidth = requestData->logoPixelWidth; + session->outputHeight = requestData->logoPixelHeight; + session->minGap = minGap; + session->nextIndex = 0; + + FFImageAnimation* animation = ffImageAnimationCreate(frameCount, loopCount, session, wicGetFrame, wicDestroyAnimation); + if (animation == nullptr) { + free(session->canvas); + free(session); + if (error) *error = "out of memory"; + return false; + } + + // The session owns these from here on, so the cleanup guards must not release them. + factory = nullptr; + decoder = nullptr; + decoderReader = nullptr; + *out = animation; return true; } diff --git a/src/options/logo.c b/src/options/logo.c index 08a35cc45..dc64cc607 100644 --- a/src/options/logo.c +++ b/src/options/logo.c @@ -19,6 +19,7 @@ void ffOptionsInitLogo(FFOptionsLogo* options) { options->preserveAspectRatio = false; options->cache = FF_LOGO_CACHE_ON; options->position = FF_LOGO_POSITION_LEFT; + options->animationFrame = FF_LOGO_ANIMATION_FRAME_FIRST; #if FF_HAVE_CHAFA options->chafaFgOnly = false; @@ -124,6 +125,8 @@ bool ffOptionsParseLogoCommandLine(FFOptionsLogo* options, const char* key, cons { "top", FF_LOGO_POSITION_TOP }, {}, }); + } else if (ffStrEqualsIgnCase(subKey, "animation-frame")) { + options->animationFrame = ffOptionParseInt32(key, value); } else { return false; } @@ -365,6 +368,12 @@ const char* ffOptionsParseLogoJsonConfig(FFOptionsLogo* options, yyjson_val* roo } options->position = (FFLogoPosition) value; continue; + } else if (unsafe_yyjson_equals_str(key, "animationFrame")) { + if (!yyjson_is_int(val)) { + return "Property 'logo.animationFrame' must be an integer"; + } + options->animationFrame = (int32_t) yyjson_get_sint(val); + continue; } else if (unsafe_yyjson_equals_str(key, "chafa")) { #if FF_HAVE_CHAFA if (!yyjson_is_obj(val)) { @@ -553,6 +562,8 @@ void ffOptionsGenerateLogoJsonConfig(FFdata* data, FFOptionsLogo* options) { "right", })[options->position]); + yyjson_mut_obj_add_int(doc, obj, "animationFrame", options->animationFrame); + #if FF_HAVE_CHAFA { yyjson_mut_val* chafa = yyjson_mut_obj(doc); diff --git a/src/options/logo.h b/src/options/logo.h index c6e1eb9f6..503afa992 100644 --- a/src/options/logo.h +++ b/src/options/logo.h @@ -36,6 +36,12 @@ typedef enum FFLogoCacheStrategy: uint8_t { FF_LOGO_CACHE_REGEN, // ignore any existing cached rendering and regenerate it } FFLogoCacheStrategy; +// Which frame of the image source to render. 0 is the only value that prints an animation; the +// others all produce a static image, which keeps the default (1, the first frame) byte-identical +// to the behaviour before animations existed. +#define FF_LOGO_ANIMATION_FRAME_FIRST 1 +#define FF_LOGO_ANIMATION_FRAME_ANIMATE 0 + typedef struct FFOptionsLogo { FFstrbuf source; FFLogoType type; @@ -50,6 +56,10 @@ typedef struct FFOptionsLogo { bool printRemaining; bool preserveAspectRatio; FFLogoCacheStrategy cache; + // 0 = animate, N > 0 = the N-th frame (1-based), N < 0 = the |N|-th frame from the end. + // Kept as given by the user: the cache entry name is built from it before the frame count + // is known, and negative values stay meaningful across runs (see image.c). + int32_t animationFrame; #if FF_HAVE_CHAFA bool chafaFgOnly; diff --git a/tests/base64.c b/tests/base64.c new file mode 100644 index 000000000..5f05f55de --- /dev/null +++ b/tests/base64.c @@ -0,0 +1,153 @@ +#include "common/base64.h" +#include "common/strutil.h" +#include "common/textModifier.h" + +#include +#include +#include +#include + +static void verify(bool expression, const char* expressionStr, int lineNo) { + if (expression) { + return; + } + + fprintf(stderr, FASTFETCH_TEXT_MODIFIER_ERROR "[%d] %s\n" FASTFETCH_TEXT_MODIFIER_RESET, lineNo, expressionStr); + exit(1); +} + +#define VERIFY(expression) verify((expression), #expression, __LINE__) + +// The empty string is a valid case and yields no bytes. +static uint32_t fromHex(const char* hex, uint8_t* out) { + uint32_t length = 0; + for (const char* p = hex; p[0] != '\0' && p[1] != '\0'; p += 2) { + unsigned value = 0; + if (sscanf(p, "%2x", &value) != 1) { + return 0; + } + out[length++] = (uint8_t) value; + } + return length; +} + +typedef struct FFBase64EncodingCase { + const char* name; + const char* hex; + const char* expected; +} FFBase64EncodingCase; + +// The RFC 4648 vectors cover the whole groups. The rest cover the partial group at the end of a +// source whose length is not a multiple of three, which is where the encoder used to go wrong: a +// plain `char` is signed on the supported platforms, so widening a byte with the high bit set into +// the 64 bit accumulator sign extended it, and the six bit fields read back out of that accumulator +// no longer held the byte's own bits. +// +// Only the two byte partial group was affected. One byte left over is shifted into place and never +// OR-ed into, so its sign extension lands above the fields that are read; two bytes left over OR the +// second byte in after shifting it by 8, which drags the sign bits down into them. +static const FFBase64EncodingCase ENCODING_CASES[] = { + // RFC 4648 test vectors + { "rfc4648-empty", "", "" }, + { "rfc4648-f", "66", "Zg==" }, + { "rfc4648-fo", "666f", "Zm8=" }, + { "rfc4648-foo", "666f6f", "Zm9v" }, + { "rfc4648-foob", "666f6f62", "Zm9vYg==" }, + { "rfc4648-fooba", "666f6f6261", "Zm9vYmE=" }, + { "rfc4648-foobar", "666f6f626172", "Zm9vYmFy" }, + + // One byte left over (length % 3 == 1) + { "single-80", "41414180", "QUFBgA==" }, + { "single-b9", "414141b9", "QUFBuQ==" }, + { "single-c6", "414141c6", "QUFBxg==" }, + { "single-ff", "414141ff", "QUFB/w==" }, + + // Two bytes left over (length % 3 == 2), with the high bit on the second byte + { "pair-second-80", "4141414180", "QUFBQYA=" }, + { "pair-second-9c", "414141419c", "QUFBQZw=" }, + { "pair-second-b9", "41414141b9", "QUFBQbk=" }, + { "pair-second-c6", "41414141c6", "QUFBQcY=" }, + { "pair-second-ff", "41414141ff", "QUFBQf8=" }, + + // Two bytes left over (length % 3 == 2), with the high bit on the first byte + { "pair-first-80", "4141418041", "QUFBgEE=" }, + { "pair-first-9c", "4141419c41", "QUFBnEE=" }, + { "pair-first-b9", "414141b941", "QUFBuUE=" }, + { "pair-first-c6", "414141c641", "QUFBxkE=" }, + { "pair-first-ff", "414141ff41", "QUFB/0E=" }, + + // Two bytes left over (length % 3 == 2), with the high bit on both + { "pair-both-80", "4141418080", "QUFBgIA=" }, + { "pair-both-b9", "414141b9b9", "QUFBubk=" }, + { "pair-both-c6", "414141c6c6", "QUFBxsY=" }, + { "pair-both-ff", "414141ffff", "QUFB//8=" }, + + // The shape the kitty animation encoder hands over: a zlib stream for one 100x100 RGBA frame. + // Its last two bytes (0x79 0xb9) are what the sign extension used to mangle. The terminal then + // rejected the frame with a zlib checksum error, so the animation was transmitted correctly but + // never played. + { "kitty-animation-frame", "78daedd1310d00000cc3b0f227dd91a876f9308124490300000000000000000000000000000000000000231dd3d40f3ffcf0c30f3ffcf0c30f3ff0c30ffcf0c30f3ffcf0c30f3ffcf0033ffcc00f0000000000000000000000f876fa5e79b9", "eNrt0TENAAAMw7DyJ92RqHb5MIEkSQMAAAAAAAAAAAAAAAAAAAAAAAAAIx3T1A8//PDDDz/88MMPP/DDD/zwww8//PDDDz/88AM//MAPAAAAAAAAAAAAAAD4dvpeebk=" }, +}; + +static void verifyEncodingCase(const FFBase64EncodingCase* testCase, int lineNo) { + static uint8_t source[512]; + const uint32_t sourceLength = fromHex(testCase->hex, source); + VERIFY(sourceLength <= sizeof(source)); + + static char encoded[1024]; + uint32_t encodedLength = 0; + memset(encoded, 0, sizeof(encoded)); + ffBase64EncodeRaw(sourceLength, (const char*) source, &encodedLength, encoded); + + if (!ffStrEquals(encoded, testCase->expected)) { + fprintf(stderr, FASTFETCH_TEXT_MODIFIER_ERROR "[%d] %s: expected \"%s\", got \"%s\"\n" FASTFETCH_TEXT_MODIFIER_RESET, + lineNo, testCase->name, testCase->expected, encoded); + exit(1); + } + + // The strbuf wrapper has to agree with the raw function + FF_STRBUF_AUTO_DESTROY input = ffStrbufCreateNS(sourceLength, (const char*) source); + FF_STRBUF_AUTO_DESTROY wrapped = ffBase64EncodeStrbuf(&input); + if (!ffStrbufEqualS(&wrapped, testCase->expected)) { + fprintf(stderr, FASTFETCH_TEXT_MODIFIER_ERROR "[%d] %s: strbuf wrapper got \"%s\"\n" FASTFETCH_TEXT_MODIFIER_RESET, + lineNo, testCase->name, wrapped.chars); + exit(1); + } +} + +// Covers every length, so a break in any of the three code paths (whole groups, one byte left +// over, two bytes left over) shows up at the length that reaches it. The pattern puts the high bit +// on roughly half of the bytes, which is what the partial groups are sensitive to. +static void verifyRoundTrip(void) { + static uint8_t source[512]; + static char encoded[1024]; + static char decoded[512]; + + for (uint32_t length = 0; length <= 400; ++length) { + for (uint32_t i = 0; i < length; ++i) { + source[i] = (uint8_t) (i * 37 + 0x80); + } + + uint32_t encodedLength = 0; + memset(encoded, 0, sizeof(encoded)); + ffBase64EncodeRaw(length, (const char*) source, &encodedLength, encoded); + VERIFY(encodedLength == (length + 2) / 3 * 4); + + uint32_t decodedLength = 0; + memset(decoded, 0, sizeof(decoded)); + VERIFY(ffBase64DecodeRaw(encodedLength, encoded, &decodedLength, decoded)); + VERIFY(decodedLength == length); + VERIFY(memcmp(decoded, source, length) == 0); + } +} + +int main(void) { + for (size_t i = 0; i < sizeof(ENCODING_CASES) / sizeof(ENCODING_CASES[0]); ++i) { + verifyEncodingCase(&ENCODING_CASES[i], __LINE__); + } + + verifyRoundTrip(); + + puts("\033[32mAll tests passed!" FASTFETCH_TEXT_MODIFIER_RESET); + return 0; +}