Logo (Image): adds animation (gif / apng) support

This commit is contained in:
李通洲
2026-09-16 20:48:02 +08:00
parent b96d7e7bec
commit d1b747192f
12 changed files with 2357 additions and 221 deletions
+51
View File
@@ -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 <bool|regen>`, 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 <N>` 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
+8
View File
@@ -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()
##################
+16
View File
@@ -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 <path>",
+7 -3
View File
@@ -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];
+437
View File
@@ -3,6 +3,7 @@
#include "image.h"
#include "common/library.h"
#include "common/mallocHelper.h"
#include "common/strutil.h"
#include <MagickCore/MagickCore.h>
#include <stdlib.h>
@@ -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
+680 -26
View File
@@ -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 <unistd.h>
#include <fcntl.h>
#include "common/time.h"
#ifndef _WIN32
#include <sys/ioctl.h>
#else
@@ -478,7 +494,14 @@ static bool printImageKittyDirect(bool printError) {
#include <zlib.h>
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, &currentPos, &remainingLength, false);
appendKittyChunk(&result, &currentPos, &remainingLength, false, nullptr);
while (remainingLength > 0) {
appendKittyChunk(&result, &currentPos, &remainingLength, true);
appendKittyChunk(&result, &currentPos, &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, &currentPos, &frameLength, false, nullptr);
while (frameLength > 0) {
// "When sending animation frame data, subsequent chunks must also specify the a=f key"
appendKittyChunk(result, &currentPos, &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)) {
+55
View File
@@ -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
+417 -124
View File
@@ -6,6 +6,149 @@
#include <CoreFoundation/CoreFoundation.h>
#include <ImageIO/ImageIO.h>
// ---------------------------------------------------------------------------------------------
// 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;
}
+512 -68
View File
@@ -9,54 +9,139 @@ extern "C" {
#include <windows.h>
#include <wincodec.h>
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;
}
+11
View File
@@ -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);
+10
View File
@@ -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;
+153
View File
@@ -0,0 +1,153 @@
#include "common/base64.h"
#include "common/strutil.h"
#include "common/textModifier.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}