mirror of
https://github.com/fastfetch-cli/fastfetch.git
synced 2026-10-28 02:31:25 +01:00
Logo (Image): adds animation (gif / apng) support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
##################
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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, ¤tPos, &remainingLength, false);
|
||||
appendKittyChunk(&result, ¤tPos, &remainingLength, false, nullptr);
|
||||
while (remainingLength > 0) {
|
||||
appendKittyChunk(&result, ¤tPos, &remainingLength, true);
|
||||
appendKittyChunk(&result, ¤tPos, &remainingLength, true, nullptr);
|
||||
}
|
||||
|
||||
printImagePixels(requestData, &result, isCompressed ? FF_CACHE_FILE_KITTY_COMPRESSED : FF_CACHE_FILE_KITTY_UNCOMPRESSED);
|
||||
writeFrameCache(requestData);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -700,6 +780,7 @@ static bool printImageChafa(FFLogoRequestData* requestData, const FFImageBuffer*
|
||||
|
||||
ffLogoPrintChars(result.chars, false);
|
||||
writeCacheData(requestData, result.chars, result.length, FF_CACHE_FILE_CHAFA);
|
||||
writeFrameCache(requestData);
|
||||
|
||||
// FIXME: These functions must be imported from `libglib` dlls on Windows
|
||||
FF_LIBRARY_LOAD_SYMBOL_LAZY(chafa, g_string_free);
|
||||
@@ -748,6 +829,91 @@ void ffImageDestroy(FFImageBuffer* buffer) {
|
||||
buffer->height = 0;
|
||||
}
|
||||
|
||||
void ffImageFrameDestroy(FFImageFrame* frame) {
|
||||
free(frame->data);
|
||||
frame->data = nullptr;
|
||||
frame->delayMs = 0;
|
||||
}
|
||||
|
||||
// The session itself is platform independent; a backend only has to fill it in through
|
||||
// ffImageAnimationCreate and implement the two callbacks.
|
||||
struct FFImageAnimation {
|
||||
uint32_t frameCount;
|
||||
int32_t loopCount;
|
||||
void* impl;
|
||||
bool (*getFrame)(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error);
|
||||
void (*destroy)(FFImageAnimation* animation);
|
||||
};
|
||||
|
||||
FFImageAnimation* ffImageAnimationCreate(uint32_t frameCount, int32_t loopCount, void* impl,
|
||||
bool (*getFrame)(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error),
|
||||
void (*destroy)(FFImageAnimation* animation)) {
|
||||
FFImageAnimation* animation = malloc(sizeof(*animation));
|
||||
if (animation == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
*animation = (FFImageAnimation) {
|
||||
.frameCount = frameCount,
|
||||
.loopCount = loopCount,
|
||||
.impl = impl,
|
||||
.getFrame = getFrame,
|
||||
.destroy = destroy,
|
||||
};
|
||||
return animation;
|
||||
}
|
||||
|
||||
void* ffImageAnimationGetImpl(const FFImageAnimation* animation) {
|
||||
return animation->impl;
|
||||
}
|
||||
|
||||
bool ffImageAnimationOpen(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error) {
|
||||
// The sessions belong to the backends: ImageIO composes frames internally, WIC does not and
|
||||
// has to keep a canvas, ImageMagick needs CoalesceImages. Windows, macOS and ImageMagick 7 are
|
||||
// wired up; ImageMagick 6 is deliberately left out (see image.h), and a build with none of them
|
||||
// keeps failing loudly rather than falling back to a still image.
|
||||
//
|
||||
// The order mirrors ffImageCreate: on macOS ImageIO is the decoder even when ImageMagick is
|
||||
// available too, so the animation has to come from the same place the stills do.
|
||||
#ifdef _WIN32
|
||||
return ffImageAnimationOpenWIC(requestData, out, error);
|
||||
#elif defined(__APPLE__)
|
||||
return ffImageAnimationOpenImageIO(requestData, out, error);
|
||||
#elif defined(FF_HAVE_IMAGEMAGICK7)
|
||||
return ffImageAnimationOpenIM7(requestData, out, error);
|
||||
#else
|
||||
FF_UNUSED(requestData, out);
|
||||
*error = "the image source can not be animated by this build";
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t ffImageAnimationFrameCount(const FFImageAnimation* animation) {
|
||||
return animation->frameCount;
|
||||
}
|
||||
|
||||
int32_t ffImageAnimationLoopCount(const FFImageAnimation* animation) {
|
||||
return animation->loopCount;
|
||||
}
|
||||
|
||||
bool ffImageAnimationGetFrame(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error) {
|
||||
if (index >= animation->frameCount) {
|
||||
*error = "the requested frame is out of range";
|
||||
return false;
|
||||
}
|
||||
|
||||
return animation->getFrame(animation, index, out, error);
|
||||
}
|
||||
|
||||
void ffImageAnimationClose(FFImageAnimation* animation) {
|
||||
if (animation == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
animation->destroy(animation);
|
||||
free(animation);
|
||||
}
|
||||
|
||||
bool ffImageSixelEncode(FFLogoRequestData* requestData, FFstrbuf* out, const char** error) {
|
||||
// Windows (WIC) and macOS (ImageIO) decode and resize to RGBA first, then the embedded
|
||||
// libsixel encoder takes over. Other platforms let ImageMagick encode straight from the
|
||||
@@ -783,6 +949,35 @@ bool ffImageSixelEncode(FFLogoRequestData* requestData, FFstrbuf* out, const cha
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ffImageSixelEncodeBuffer(const FFImageBuffer* buffer, FFstrbuf* out, const char** error) {
|
||||
// Windows and macOS hand the pixels to the embedded encoder. ImageMagick has no way to encode
|
||||
// pixels it was not given an Image for, so its SIXEL coder is reached through a ConstituteImage
|
||||
// round trip instead.
|
||||
#if defined(_WIN32) || defined(__APPLE__)
|
||||
#ifdef FF_HAVE_SIXEL
|
||||
return ffSixelEncode(buffer, out, error);
|
||||
#else
|
||||
FF_UNUSED(buffer, out);
|
||||
if (error) {
|
||||
*error = "sixel support is not compiled in";
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
#else
|
||||
#ifdef FF_HAVE_IMAGEMAGICK7
|
||||
return ffImageSixelEncodeBufferIM7(buffer, out, error);
|
||||
#else
|
||||
// Only the animation path calls this, and only ImageMagick 7 has an animation backend, so a
|
||||
// build without it never gets here with something to encode.
|
||||
FF_UNUSED(buffer, out);
|
||||
if (error) {
|
||||
*error = "sixel support is not compiled in";
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
static FFNativeFD getCacheFD(FFLogoRequestData* requestData, const char* fileName) {
|
||||
uint32_t cacheDirLength = requestData->cacheDir.length;
|
||||
ffStrbufAppendS(&requestData->cacheDir, fileName);
|
||||
@@ -833,6 +1028,15 @@ static uint64_t readCachedUint64(FFLogoRequestData* requestData, const char* cac
|
||||
return result;
|
||||
}
|
||||
|
||||
// The frame the payload in the same directory was rendered from. A payload with no sidecar predates
|
||||
// the sidecar and holds the first frame, which is exactly what the default selector asks for, so
|
||||
// treating it as such keeps the caches of older builds usable.
|
||||
static int32_t readCachedFrame(FFLogoRequestData* requestData, const char* frameFileName) {
|
||||
int32_t frame = FF_LOGO_ANIMATION_FRAME_FIRST;
|
||||
readCachedData(requestData, &frame, sizeof(frame), frameFileName);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Drops everything a previous version of the source left in the entry directory.
|
||||
// The directory is keyed on the source path and the pixel size only, so it is reused across
|
||||
// edits; without this, a payload written for another logo type would be read back.
|
||||
@@ -844,9 +1048,13 @@ static void removeCachedFiles(FFLogoRequestData* requestData) {
|
||||
FF_CACHE_FILE_SIXEL,
|
||||
FF_CACHE_FILE_KITTY_COMPRESSED,
|
||||
FF_CACHE_FILE_KITTY_UNCOMPRESSED,
|
||||
FF_CACHE_FILE_KITTY_ANIMATION,
|
||||
FF_CACHE_FILE_CHAFA,
|
||||
FF_CACHE_FILE_FRAME,
|
||||
};
|
||||
|
||||
// Every entry that is ever written is listed here; an entry left out would survive a source
|
||||
// change and be read back for the new source.
|
||||
uint32_t cacheDirLength = requestData->cacheDir.length;
|
||||
for (uint32_t i = 0; i < ARRAY_SIZE(files); ++i) {
|
||||
ffStrbufAppendS(&requestData->cacheDir, files[i]);
|
||||
@@ -856,6 +1064,13 @@ static void removeCachedFiles(FFLogoRequestData* requestData) {
|
||||
}
|
||||
|
||||
static bool printCachedChars(FFLogoRequestData* requestData, const char* cacheFileName) {
|
||||
if (instance.config.logo.animationFrame != readCachedFrame(requestData, FF_CACHE_FILE_FRAME)) {
|
||||
// The entry holds the rendering of another frame, or -- for the animation selector -- of
|
||||
// no single frame at all. Reporting a miss here is what sends the slow path off to render
|
||||
// the one that was asked for.
|
||||
return false;
|
||||
}
|
||||
|
||||
FF_STRBUF_AUTO_DESTROY content = ffStrbufCreate();
|
||||
readCachedStrbuf(requestData, &content, cacheFileName);
|
||||
|
||||
@@ -867,6 +1082,411 @@ static bool printCachedChars(FFLogoRequestData* requestData, const char* cacheFi
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// kitty animations
|
||||
//
|
||||
// A `kittyanim` entry holds only what is independent of the image id: the pixel size, the frame
|
||||
// count, each frame's gap, the loop count, and the frames' base64 payloads. The envelope is
|
||||
// rebuilt on every run, because it carries the id and that id has to differ between runs.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static const char FF_KITTY_ANIMATION_MAGIC[4] = { 'F', 'K', 'A', '1' };
|
||||
|
||||
typedef struct FFKittyAnimationFrame {
|
||||
int32_t delayMs;
|
||||
uint32_t payloadLength; // base64 bytes this frame occupies inside the payload block
|
||||
bool compressed; // the frame's base64 decodes to a zlib stream, so it needs `o=z`
|
||||
} FFKittyAnimationFrame;
|
||||
|
||||
typedef struct FFKittyAnimation {
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t frameCount;
|
||||
int32_t loopCount; // 0 = infinite; -1 = the source declares none
|
||||
FFKittyAnimationFrame* frames;
|
||||
char* payload; // base64 of every frame, concatenated in frame order
|
||||
uint32_t payloadLength;
|
||||
} FFKittyAnimation;
|
||||
|
||||
static void destroyKittyAnimation(FFKittyAnimation* animation) {
|
||||
free(animation->frames);
|
||||
free(animation->payload);
|
||||
animation->frames = nullptr;
|
||||
animation->payload = nullptr;
|
||||
}
|
||||
|
||||
// The entry is written and read by the same machine, so the host's own representation is used.
|
||||
static void appendRaw(FFstrbuf* result, const void* value, uint32_t size) {
|
||||
ffStrbufAppendNS(result, size, (const char*) value);
|
||||
}
|
||||
|
||||
static void appendUint32(FFstrbuf* result, uint32_t value) {
|
||||
appendRaw(result, &value, sizeof(value));
|
||||
}
|
||||
|
||||
static void appendInt32(FFstrbuf* result, int32_t value) {
|
||||
appendRaw(result, &value, sizeof(value));
|
||||
}
|
||||
|
||||
typedef struct FFKittyAnimationReader {
|
||||
const char* data;
|
||||
uint32_t length;
|
||||
uint32_t offset;
|
||||
} FFKittyAnimationReader;
|
||||
|
||||
static bool readRaw(FFKittyAnimationReader* reader, void* out, uint32_t size) {
|
||||
if (size > reader->length - reader->offset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(out, reader->data + reader->offset, size);
|
||||
reader->offset += size;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool readUint32(FFKittyAnimationReader* reader, uint32_t* out) {
|
||||
return readRaw(reader, out, sizeof(*out));
|
||||
}
|
||||
|
||||
static bool readInt32(FFKittyAnimationReader* reader, int32_t* out) {
|
||||
return readRaw(reader, out, sizeof(*out));
|
||||
}
|
||||
|
||||
static void serializeKittyAnimation(FFstrbuf* result, const FFKittyAnimation* animation) {
|
||||
appendRaw(result, FF_KITTY_ANIMATION_MAGIC, sizeof(FF_KITTY_ANIMATION_MAGIC));
|
||||
appendUint32(result, animation->width);
|
||||
appendUint32(result, animation->height);
|
||||
appendUint32(result, animation->frameCount);
|
||||
appendInt32(result, animation->loopCount);
|
||||
|
||||
for (uint32_t i = 0; i < animation->frameCount; ++i) {
|
||||
const FFKittyAnimationFrame* frame = &animation->frames[i];
|
||||
uint8_t flags = frame->compressed ? 1 : 0;
|
||||
appendInt32(result, frame->delayMs);
|
||||
appendUint32(result, frame->payloadLength);
|
||||
appendRaw(result, &flags, sizeof(flags));
|
||||
}
|
||||
|
||||
appendRaw(result, animation->payload, animation->payloadLength);
|
||||
}
|
||||
|
||||
static bool parseKittyAnimation(const char* data, uint32_t length, FFKittyAnimation* out, const char** error) {
|
||||
FFKittyAnimationReader reader = { .data = data, .length = length, .offset = 0 };
|
||||
|
||||
char magic[sizeof(FF_KITTY_ANIMATION_MAGIC)];
|
||||
if (!readRaw(&reader, magic, sizeof(magic)) || memcmp(magic, FF_KITTY_ANIMATION_MAGIC, sizeof(magic)) != 0) {
|
||||
*error = "the cached animation has an unknown format";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!readUint32(&reader, &out->width) || !readUint32(&reader, &out->height) ||
|
||||
!readUint32(&reader, &out->frameCount) || !readInt32(&reader, &out->loopCount)) {
|
||||
*error = "the cached animation is truncated";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (out->frameCount == 0) {
|
||||
*error = "the cached animation has no frames";
|
||||
return false;
|
||||
}
|
||||
|
||||
out->frames = calloc(out->frameCount, sizeof(*out->frames));
|
||||
if (out->frames == nullptr) {
|
||||
*error = "out of memory";
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t totalPayloadLength = 0;
|
||||
for (uint32_t i = 0; i < out->frameCount; ++i) {
|
||||
FFKittyAnimationFrame* frame = &out->frames[i];
|
||||
uint8_t flags = 0;
|
||||
if (!readInt32(&reader, &frame->delayMs) || !readUint32(&reader, &frame->payloadLength) ||
|
||||
!readRaw(&reader, &flags, sizeof(flags))) {
|
||||
*error = "the cached animation is truncated";
|
||||
return false;
|
||||
}
|
||||
|
||||
frame->compressed = flags != 0;
|
||||
|
||||
if (totalPayloadLength > UINT32_MAX - frame->payloadLength) {
|
||||
*error = "the cached animation is too large";
|
||||
return false;
|
||||
}
|
||||
totalPayloadLength += frame->payloadLength;
|
||||
}
|
||||
|
||||
if (totalPayloadLength > reader.length - reader.offset) {
|
||||
*error = "the cached animation is truncated";
|
||||
return false;
|
||||
}
|
||||
|
||||
out->payloadLength = totalPayloadLength;
|
||||
out->payload = malloc((size_t) totalPayloadLength + 1);
|
||||
if (out->payload == nullptr) {
|
||||
*error = "out of memory";
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(out->payload, reader.data + reader.offset, totalPayloadLength);
|
||||
out->payload[totalPayloadLength] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// The id has to differ on every run: reusing one makes the terminal append the new frames to the
|
||||
// image the previous run left in its storage, which doubles the frame count. The low 24 bits take
|
||||
// part in the colour / decoration encoding, so they are kept non-zero (kitten icat does the same
|
||||
// in next_random()). The entropy comes from the clock, the pid and a stack address, which is
|
||||
// plenty for a process that runs once.
|
||||
static uint32_t getKittyImageId(void) {
|
||||
uint32_t id = (uint32_t) ffTimeGetNow();
|
||||
id ^= (uint32_t) getpid() * 2654435761u;
|
||||
id ^= (uint32_t) ((uintptr_t) &id >> 4);
|
||||
id &= 0xFFFFFF;
|
||||
return id != 0 ? id : 1;
|
||||
}
|
||||
|
||||
// The envelope, per the kitty protocol: the root frame is transmitted with `a=T` and has no gap
|
||||
// of its own, so its gap is set with a separate `a=a` command; every further frame is an `a=f`
|
||||
// command that replaces the pixels (`X=1`) because the backend already composed a full canvas.
|
||||
// Every command carries the image id and `q=2`, so nothing is ever written back to the tty.
|
||||
static void emitKittyAnimation(FFstrbuf* result, const FFKittyAnimation* animation, uint32_t imageId) {
|
||||
const char* currentPos = animation->payload;
|
||||
|
||||
for (uint32_t i = 0; i < animation->frameCount; ++i) {
|
||||
const FFKittyAnimationFrame* frame = &animation->frames[i];
|
||||
size_t frameLength = frame->payloadLength;
|
||||
|
||||
if (i == 0) {
|
||||
// Here s / v are the source rectangle, not the animation state
|
||||
ffStrbufAppendF(result, "\033_Ga=T,f=32,s=%u,v=%u,i=%u,q=2", animation->width, animation->height, imageId);
|
||||
} else {
|
||||
// A frame that covers the whole image is transmitted exactly like image data, plus the
|
||||
// frame keys. s / v are not optional here even though they always repeat the image size:
|
||||
// the terminal sizes the frame's canvas from them and rejects the command outright when
|
||||
// they are missing.
|
||||
ffStrbufAppendF(result, "\033_Ga=f,f=32,s=%u,v=%u,i=%u,z=%d,X=1,q=2",
|
||||
animation->width, animation->height, imageId, (int) frame->delayMs);
|
||||
}
|
||||
|
||||
if (frame->compressed) {
|
||||
ffStrbufAppendS(result, ",o=z");
|
||||
}
|
||||
|
||||
appendKittyChunk(result, ¤tPos, &frameLength, false, nullptr);
|
||||
while (frameLength > 0) {
|
||||
// "When sending animation frame data, subsequent chunks must also specify the a=f key"
|
||||
appendKittyChunk(result, ¤tPos, &frameLength, true, i == 0 ? "q=2" : "a=f,q=2");
|
||||
}
|
||||
|
||||
if (i == 0) {
|
||||
// "the first frame or root frame is created with the base image data and has no gap,
|
||||
// so its gap must be set using this control code"
|
||||
ffStrbufAppendF(result, "\033_Ga=a,i=%u,r=1,z=%d,q=2\033\\", imageId, (int) frame->delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
// v is the loop count here: 1 loops forever, N loops N-1 times
|
||||
int32_t loops = 1;
|
||||
if (animation->loopCount > 0) {
|
||||
loops = animation->loopCount < INT32_MAX ? animation->loopCount + 1 : INT32_MAX;
|
||||
}
|
||||
ffStrbufAppendF(result, "\033_Ga=a,i=%u,v=%d,q=2\033\\", imageId, (int) loops);
|
||||
ffStrbufAppendF(result, "\033_Ga=a,i=%u,s=3,q=2\033\\", imageId);
|
||||
}
|
||||
|
||||
static bool printCachedKittyAnimation(FFLogoRequestData* requestData) {
|
||||
FF_STRBUF_AUTO_DESTROY content = ffStrbufCreate();
|
||||
if (!readCachedStrbuf(requestData, &content, FF_CACHE_FILE_KITTY_ANIMATION) || content.length == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FFKittyAnimation animation = {};
|
||||
const char* error = "the cached animation is corrupt";
|
||||
if (!parseKittyAnimation(content.chars, content.length, &animation, &error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FF_STRBUF_AUTO_DESTROY result = ffStrbufCreateA(animation.payloadLength + animation.frameCount * 128 + 512);
|
||||
emitKittyAnimation(&result, &animation, getKittyImageId());
|
||||
|
||||
const FFOptionsLogo* options = &instance.config.logo;
|
||||
instance.state.logoWidth = requestData->logoCharacterWidth + options->paddingLeft + options->paddingRight;
|
||||
instance.state.logoHeight = requestData->logoCharacterHeight + options->paddingTop;
|
||||
printImageResult(requestData, &result);
|
||||
|
||||
destroyKittyAnimation(&animation);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decodes the source frame by frame, encodes every frame, and stores the result as a `kittyanim`
|
||||
// entry. Frames are taken, encoded and released one at a time: keeping them all would cost
|
||||
// frames * W * H * 4 bytes, which a tool that prints a logo once can not afford.
|
||||
static bool encodeKittyAnimation(FFLogoRequestData* requestData, const char** error) {
|
||||
FFImageAnimation* animation = nullptr;
|
||||
if (!ffImageAnimationOpen(requestData, &animation, error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fillCharacterDimensions(requestData);
|
||||
|
||||
const uint32_t frameCount = ffImageAnimationFrameCount(animation);
|
||||
const int32_t loopCount = ffImageAnimationLoopCount(animation);
|
||||
|
||||
if (frameCount == 1) {
|
||||
// Nothing to animate. Rendering it through the static path keeps the cache entry, and
|
||||
// therefore the bytes written to the terminal, identical to the default rendering.
|
||||
FFImageFrame frame = {};
|
||||
bool ok = ffImageAnimationGetFrame(animation, 0, &frame, error);
|
||||
ffImageAnimationClose(animation);
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FFImageBuffer buffer = {
|
||||
.data = frame.data,
|
||||
.width = requestData->logoPixelWidth,
|
||||
.height = requestData->logoPixelHeight,
|
||||
};
|
||||
ok = printImageKitty(requestData, &buffer);
|
||||
ffImageDestroy(&buffer);
|
||||
return ok;
|
||||
}
|
||||
|
||||
FFKittyAnimationFrame* frames = calloc(frameCount, sizeof(*frames));
|
||||
if (frames == nullptr) {
|
||||
ffImageAnimationClose(animation);
|
||||
*error = "out of memory";
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t frameSize = (size_t) requestData->logoPixelWidth * requestData->logoPixelHeight * 4;
|
||||
FF_STRBUF_AUTO_DESTROY payload = ffStrbufCreate();
|
||||
bool ok = true;
|
||||
|
||||
for (uint32_t i = 0; i < frameCount && ok; ++i) {
|
||||
FFImageFrame frame = {};
|
||||
if (!ffImageAnimationGetFrame(animation, i, &frame, error)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
|
||||
frames[i].delayMs = frame.delayMs;
|
||||
|
||||
FF_AUTO_FREE void* blob = malloc(frameSize);
|
||||
if (blob == nullptr) {
|
||||
ffImageFrameDestroy(&frame);
|
||||
*error = "out of memory";
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
memcpy(blob, frame.data, frameSize);
|
||||
ffImageFrameDestroy(&frame);
|
||||
|
||||
size_t blobLength = frameSize;
|
||||
#ifdef FF_HAVE_ZLIB
|
||||
frames[i].compressed = compressBlob(&blob, &blobLength);
|
||||
#else
|
||||
frames[i].compressed = false;
|
||||
#endif
|
||||
|
||||
FF_STRBUF_AUTO_DESTROY base64 = ffStrbufCreateA((uint32_t) (10 + blobLength * 4 / 3));
|
||||
ffBase64EncodeRaw((uint32_t) blobLength, (const char*) blob, &base64.length, base64.chars);
|
||||
frames[i].payloadLength = base64.length;
|
||||
ffStrbufAppend(&payload, &base64);
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
FFKittyAnimation built = {
|
||||
.width = requestData->logoPixelWidth,
|
||||
.height = requestData->logoPixelHeight,
|
||||
.frameCount = frameCount,
|
||||
.loopCount = loopCount,
|
||||
.frames = frames,
|
||||
.payload = payload.chars,
|
||||
.payloadLength = payload.length,
|
||||
};
|
||||
|
||||
FF_STRBUF_AUTO_DESTROY serialized = ffStrbufCreate();
|
||||
serializeKittyAnimation(&serialized, &built);
|
||||
writeCacheData(requestData, serialized.chars, serialized.length, FF_CACHE_FILE_KITTY_ANIMATION);
|
||||
|
||||
FF_STRBUF_AUTO_DESTROY result = ffStrbufCreateA(serialized.length + frameCount * 128 + 512);
|
||||
emitKittyAnimation(&result, &built, getKittyImageId());
|
||||
printImagePixelsNoCache(requestData, &result);
|
||||
}
|
||||
|
||||
free(frames);
|
||||
ffImageAnimationClose(animation);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Maps --logo-animation-frame to a 0-based frame index. Negative values count back from the end,
|
||||
// so -1 is the last frame. Anything out of range clamps instead of failing: the frame count of the
|
||||
// source is only known once it has been opened, and a typo in a logo option is not worth aborting
|
||||
// the run over (see §10.2).
|
||||
static uint32_t getAnimationFrameIndex(int32_t requested, uint32_t frameCount) {
|
||||
if (frameCount == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (requested < 0) {
|
||||
uint32_t back = (uint32_t) (-(int64_t) requested);
|
||||
return back <= frameCount ? frameCount - back : 0;
|
||||
}
|
||||
|
||||
uint32_t index = (uint32_t) (requested - 1);
|
||||
return index < frameCount ? index : frameCount - 1;
|
||||
}
|
||||
|
||||
// Renders one frame of an animated source as a still image. `N` is 1-based and negative values
|
||||
// count from the end, matching --logo-animation-frame. Every protocol that goes through this slow
|
||||
// path is served: the decoded frame is handed to the same renderers the static path uses, and the
|
||||
// result is cached exactly like the first frame is (see FF_CACHE_FILE_FRAME).
|
||||
static bool printAnimationFrame(FFLogoRequestData* requestData, const char** error) {
|
||||
FFImageAnimation* animation = nullptr;
|
||||
if (!ffImageAnimationOpen(requestData, &animation, error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fillCharacterDimensions(requestData);
|
||||
|
||||
const uint32_t frameCount = ffImageAnimationFrameCount(animation);
|
||||
FFImageFrame frame = {};
|
||||
bool ok = ffImageAnimationGetFrame(animation, getAnimationFrameIndex(instance.config.logo.animationFrame, frameCount), &frame, error);
|
||||
ffImageAnimationClose(animation);
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FFImageBuffer buffer = {
|
||||
.data = frame.data,
|
||||
.width = requestData->logoPixelWidth,
|
||||
.height = requestData->logoPixelHeight,
|
||||
};
|
||||
|
||||
if (requestData->type == FF_LOGO_TYPE_IMAGE_SIXEL) {
|
||||
// The sixel encoder belongs to the backend, so it does not go through ffImageCreate -- but
|
||||
// it does have to be handed the frame that was just composed. ffImageSixelEncode would
|
||||
// re-read the source and encode the first frame instead of the selected one.
|
||||
FF_STRBUF_AUTO_DESTROY result = ffStrbufCreate();
|
||||
ok = ffImageSixelEncodeBuffer(&buffer, &result, error) && printImageSixel(requestData, &result);
|
||||
} else if (requestData->type == FF_LOGO_TYPE_IMAGE_KITTY) {
|
||||
ok = printImageKitty(requestData, &buffer);
|
||||
}
|
||||
#if FF_HAVE_CHAFA
|
||||
else if (requestData->type == FF_LOGO_TYPE_IMAGE_CHAFA) {
|
||||
ok = printImageChafa(requestData, &buffer);
|
||||
}
|
||||
#endif
|
||||
else {
|
||||
*error = "this image protocol can not render a selected frame";
|
||||
ok = false;
|
||||
}
|
||||
|
||||
ffImageDestroy(&buffer);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool printCachedPixel(FFLogoRequestData* requestData) {
|
||||
FFOptionsLogo* options = &instance.config.logo;
|
||||
|
||||
@@ -886,10 +1506,26 @@ static bool printCachedPixel(FFLogoRequestData* requestData) {
|
||||
}
|
||||
}
|
||||
|
||||
if (requestData->type == FF_LOGO_TYPE_IMAGE_KITTY &&
|
||||
options->animationFrame == FF_LOGO_ANIMATION_FRAME_ANIMATE) {
|
||||
// An animation entry holds payloads, not an envelope, so it can not be streamed out like
|
||||
// the static ones. It is also never allowed to fall back to a static entry: doing so would
|
||||
// silently show a still logo to a user who asked for an animation.
|
||||
return printCachedKittyAnimation(requestData);
|
||||
}
|
||||
|
||||
// A selected frame shares the entry with the first frame; the sidecar is what tells the two
|
||||
// apart. This also covers the animation selector: no still rendering may answer it.
|
||||
if (options->animationFrame != readCachedFrame(requestData, FF_CACHE_FILE_FRAME)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FF_AUTO_CLOSE_FD FFNativeFD fd = FF_INVALID_FD;
|
||||
if (requestData->type == FF_LOGO_TYPE_IMAGE_KITTY) {
|
||||
fd = getCacheFD(requestData, FF_CACHE_FILE_KITTY_COMPRESSED);
|
||||
if (!ffIsValidNativeFD(fd)) {
|
||||
// The pre-existing pair. Falling back between them is fine, they hold the same
|
||||
// rendering; falling back to anything else is not (see §7.1).
|
||||
fd = getCacheFD(requestData, FF_CACHE_FILE_KITTY_UNCOMPRESSED);
|
||||
}
|
||||
} else if (requestData->type == FF_LOGO_TYPE_IMAGE_SIXEL) {
|
||||
@@ -1017,29 +1653,47 @@ static bool printImageIfExistsSlowPath(FFLogoType type, bool printError) {
|
||||
// 0 means the mtime could not be read, in which case the entry is never trusted.
|
||||
const uint64_t sourceMtime = ffPathGetMtime(instance.config.logo.source.chars);
|
||||
|
||||
if (instance.config.logo.cache == FF_LOGO_CACHE_ON &&
|
||||
sourceMtime != 0 &&
|
||||
readCachedUint64(&requestData, FF_CACHE_FILE_MTIME) == sourceMtime) {
|
||||
bool cacheValid = requestData.type == FF_LOGO_TYPE_IMAGE_CHAFA
|
||||
? printCachedChars(&requestData, FF_CACHE_FILE_CHAFA)
|
||||
: printCachedPixel(&requestData);
|
||||
if (cacheValid) {
|
||||
ffStrbufDestroy(&requestData.cacheDir);
|
||||
return true;
|
||||
bool sourceUnchanged = false;
|
||||
if (instance.config.logo.cache == FF_LOGO_CACHE_ON && sourceMtime != 0) {
|
||||
sourceUnchanged = readCachedUint64(&requestData, FF_CACHE_FILE_MTIME) == sourceMtime;
|
||||
if (sourceUnchanged) {
|
||||
bool cacheValid = requestData.type == FF_LOGO_TYPE_IMAGE_CHAFA
|
||||
? printCachedChars(&requestData, FF_CACHE_FILE_CHAFA)
|
||||
: printCachedPixel(&requestData);
|
||||
if (cacheValid) {
|
||||
ffStrbufDestroy(&requestData.cacheDir);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss. The entry directory is keyed on the source path and the pixel size only, so
|
||||
// it is reused when the source is edited; drop what the previous version left behind.
|
||||
// Cache miss. The entry directory is keyed on the source path and the pixel size only, so it
|
||||
// is reused when the source is edited; drop what the previous version left behind. This only
|
||||
// happens when the source actually changed: a miss on one entry of an unchanged source just
|
||||
// means that rendering was never asked for before, and clearing the directory would throw
|
||||
// away the sibling entries every time the requested rendering changes.
|
||||
// With the cache turned off the directory is left alone entirely.
|
||||
if (instance.config.logo.cache != FF_LOGO_CACHE_OFF) {
|
||||
if (instance.config.logo.cache != FF_LOGO_CACHE_OFF && !sourceUnchanged) {
|
||||
removeCachedFiles(&requestData);
|
||||
}
|
||||
|
||||
const char* error = nullptr;
|
||||
bool printSuccessful = false;
|
||||
|
||||
if (requestData.type == FF_LOGO_TYPE_IMAGE_SIXEL) {
|
||||
if (instance.config.logo.animationFrame == FF_LOGO_ANIMATION_FRAME_ANIMATE &&
|
||||
requestData.type != FF_LOGO_TYPE_IMAGE_KITTY) {
|
||||
// Only the kitty graphics protocol can play an animation. Falling through to the static
|
||||
// path would silently show a still logo to a user who asked for an animation.
|
||||
error = "the kitty graphics protocol is the only one that can play an animation";
|
||||
} else if (instance.config.logo.animationFrame == FF_LOGO_ANIMATION_FRAME_ANIMATE) {
|
||||
// Decided before anything is decoded: the static path never enters a loop over frames,
|
||||
// and this path never goes through ffImageCreate.
|
||||
printSuccessful = encodeKittyAnimation(&requestData, &error);
|
||||
} else if (instance.config.logo.animationFrame != FF_LOGO_ANIMATION_FRAME_FIRST) {
|
||||
// A frame other than the first. This must not fall through to the static path: that one
|
||||
// only ever produces the first frame, so it would silently hand back the wrong image.
|
||||
printSuccessful = printAnimationFrame(&requestData, &error);
|
||||
} else if (requestData.type == FF_LOGO_TYPE_IMAGE_SIXEL) {
|
||||
// The sixel encoder belongs to the backend, so it is not fed through ffImageCreate
|
||||
FF_STRBUF_AUTO_DESTROY result = ffStrbufCreate();
|
||||
if (ffImageSixelEncode(&requestData, &result, &error)) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user