master ohmu/fastfetch-openbsd / src / logo / image / imageio.c
ohmu  ·  2026-10-15
  1#include "image.h"
  2#include "common/mallocHelper.h"
  3#include "common/apple/cf_helpers.h"
  4
  5#include <Accelerate/Accelerate.h>
  6#include <CoreFoundation/CoreFoundation.h>
  7#include <ImageIO/ImageIO.h>
  8
  9// ---------------------------------------------------------------------------------------------
 10// Shared with the still path
 11// ---------------------------------------------------------------------------------------------
 12
 13// Fills in the missing dimension, keeping the source aspect ratio (same as the IM path).
 14static bool imageIOResolveTargetSize(FFLogoRequestData* requestData, uint32_t sourceWidth, uint32_t sourceHeight, const char** error) {
 15    uint32_t width = requestData->logoPixelWidth;
 16    uint32_t height = requestData->logoPixelHeight;
 17    if (width == 0 && height == 0) {
 18        width = sourceWidth;
 19        height = sourceHeight;
 20    } else if (width == 0) {
 21        width = (uint32_t) ((double) sourceWidth / (double) sourceHeight * height);
 22    } else if (height == 0) {
 23        height = (uint32_t) ((double) sourceHeight / (double) sourceWidth * width);
 24    }
 25
 26    if (width == 0 || height == 0) {
 27        if (error) {
 28            *error = "invalid target dimensions";
 29        }
 30        return false;
 31    }
 32
 33    requestData->logoPixelWidth = width;
 34    requestData->logoPixelHeight = height;
 35    return true;
 36}
 37
 38// Decodes one CGImage into tightly packed straight (unassociated) RGBA8888 at width x height.
 39// Both paths need exactly this -- the still path once, the animation path once per frame.
 40static bool imageIOConvert(CGImageRef image, uint32_t width, uint32_t height, uint8_t** outPixels, const char** error) {
 41    const bool sameSize = width == CGImageGetWidth(image) && height == CGImageGetHeight(image);
 42
 43    // Decode straight into RGBA for the same-size fast path. For resizing, decode
 44    // into premultiplied ARGB so interpolation does not bleed transparent RGB into
 45    // visible edge pixels.
 46    // vImageBuffer_InitWithCGImage handles format conversion, color management and
 47    // byte order in one call. NULL colorSpace means sRGB, matching kCGColorSpaceSRGB.
 48    const vImage_CGImageFormat format = {
 49        .bitsPerComponent = 8,
 50        .bitsPerPixel = 32,
 51        .colorSpace = nullptr,
 52        .bitmapInfo = (CGBitmapInfo) ((sameSize ? kCGImageAlphaLast : kCGImageAlphaPremultipliedFirst) | kCGImageByteOrder32Big),
 53        .version = 0,
 54        .decode = nullptr,
 55        .renderingIntent = kCGRenderingIntentDefault
 56    };
 57
 58    vImage_Buffer src = {};
 59    vImage_Error vErr = vImageBuffer_InitWithCGImage(&src, (vImage_CGImageFormat*) &format, nullptr, image, kvImageNoFlags);
 60    if (vErr != kvImageNoError) {
 61        if (error) {
 62            *error = "failed to decode the image with vImage";
 63        }
 64        return false;
 65    }
 66
 67    // Final output buffer: straight (unassociated) RGBA8888 at the target size.
 68    const size_t dstStride = (size_t) width * 4;
 69    if (sameSize) {
 70        // The requested format is already straight RGBA. If vImage did not add
 71        // row padding, transfer its allocation directly to the caller; otherwise
 72        // copy rows into the required tightly packed allocation.
 73        if (src.rowBytes == dstStride) {
 74            *outPixels = (uint8_t*) src.data;
 75            return true;
 76        }
 77
 78        uint8_t* pixels = (uint8_t*) malloc(dstStride * (size_t) height);
 79        if (pixels == nullptr) {
 80            free(src.data);
 81            if (error) {
 82                *error = "out of memory";
 83            }
 84            return false;
 85        }
 86        for (uint32_t y = 0; y < height; ++y) {
 87            memcpy(pixels + (size_t) y * dstStride,
 88                (const uint8_t*) src.data + (size_t) y * src.rowBytes,
 89                dstStride);
 90        }
 91        free(src.data);
 92        *outPixels = pixels;
 93        return true;
 94    }
 95
 96    uint8_t* pixels = (uint8_t*) malloc(dstStride * (size_t) height);
 97    if (pixels == nullptr) {
 98        free(src.data);
 99        if (error) {
100            *error = "out of memory";
101        }
102        return false;
103    }
104
105    const vImage_Buffer dst = {
106        .data = pixels,
107        .width = width,
108        .height = height,
109        .rowBytes = dstStride
110    };
111
112    // Resample while still premultiplied: interpolating premultiplied data is the
113    // correct way to scale (it avoids the dark fringes you get from averaging
114    // straight-alpha RGB). NULL temp buffer lets vImage allocate internally.
115    vErr = vImageScale_ARGB8888(&src, &dst, nullptr, kvImageHighQualityResampling);
116    free(src.data);
117    if (vErr != kvImageNoError) {
118        free(pixels);
119        if (error) {
120            *error = "failed to scale the image";
121        }
122        return false;
123    }
124
125    // Un-premultiply in place (pointwise, alpha == 0 is handled safely) so the result
126    // matches the kitty f=32 / chafa CHAFA_PIXEL_RGBA8_UNASSOCIATED contract.
127    vErr = vImageUnpremultiplyData_ARGB8888(&dst, &dst, kvImageNoFlags);
128    if (vErr != kvImageNoError) {
129        free(pixels);
130        if (error) {
131            *error = "failed to un-premultiply the image";
132        }
133        return false;
134    }
135
136    // Reorder ARGB -> RGBA in place (permute supports in-place when data/rowBytes match).
137    // The downstream consumers and the WIC backend all expect R,G,B,A byte order.
138    const uint8_t permuteMap[4] = { 1, 2, 3, 0 }; // A,R,G,B -> R,G,B,A
139    vErr = vImagePermuteChannels_ARGB8888(&dst, &dst, permuteMap, kvImageNoFlags);
140    if (vErr != kvImageNoError) {
141        free(pixels);
142        if (error) {
143            *error = "failed to reorder image channels";
144        }
145        return false;
146    }
147
148    *outPixels = pixels;
149    return true;
150}
151
152bool ffImageCreateImageIO(FFLogoRequestData* requestData, FFImageBuffer* out, const char** error) {
153    FF_CFTYPE_AUTO_RELEASE CFURLRef url = CFURLCreateFromFileSystemRepresentation(
154        kCFAllocatorDefault,
155        (const UInt8*) instance.config.logo.source.chars,
156        (CFIndex) instance.config.logo.source.length,
157        false);
158    if (url == nullptr) {
159        if (error) {
160            *error = "failed to create the image URL";
161        }
162        return false;
163    }
164
165    FF_CFTYPE_AUTO_RELEASE CGImageSourceRef source = CGImageSourceCreateWithURL(url, nullptr);
166    if (source == nullptr) {
167        if (error) {
168            *error = "unsupported or unreadable image format";
169        }
170        return false;
171    }
172
173    // Only the first frame, matching ImageMagick's ReadImage (neither handles GIF animation)
174    FF_CFTYPE_AUTO_RELEASE CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, nullptr);
175    if (image == nullptr) {
176        if (error) {
177            *error = "failed to get the first frame";
178        }
179        return false;
180    }
181
182    const size_t sourceWidth = CGImageGetWidth(image);
183    const size_t sourceHeight = CGImageGetHeight(image);
184    if (sourceWidth == 0 || sourceHeight == 0) {
185        if (error) {
186            *error = "invalid image dimensions";
187        }
188        return false;
189    }
190
191    if (!imageIOResolveTargetSize(requestData, (uint32_t) sourceWidth, (uint32_t) sourceHeight, error)) {
192        return false;
193    }
194
195    if (!imageIOConvert(image, requestData->logoPixelWidth, requestData->logoPixelHeight, &out->data, error)) {
196        return false;
197    }
198
199    out->width = requestData->logoPixelWidth;
200    out->height = requestData->logoPixelHeight;
201    return true;
202}
203
204// ---------------------------------------------------------------------------------------------
205// Animation
206//
207// ImageIO hands out *composed* canvas-sized frames: CGImageSourceCreateImageAtIndex does the
208// disposal work the Windows backend has to do itself, so the session here is thin -- it keeps the
209// source open, resolves the canvas size and the per-frame gaps, and converts one frame per call.
210//
211// Two things it does not do, both measured rather than assumed (doc/kitty-animation.md 5.1.1):
212//
213//  * A `disposal = background` region is cleared to opaque black, where browsers, kitty and
214//    ImageMagick leave it transparent. It can not be corrected: ImageIO exposes no disposal
215//    metadata at any level, so there is nothing to branch on. Accepted and documented.
216//  * Such a region is cleared even when the frame it belongs to is never displayed (delay 0),
217//    where browsers and kitty keep the frame before it. Same cause, same outcome -- and the
218//    common case is unaffected, because every ImageMagick optimiser emits disposal none/undefined.
219//
220// The frame count, the canvas size and every gap come from *metadata*: no frame is decoded before
221// the first one is asked for, which is what the session contract requires (a negative
222// --logo-animation-frame has to be resolved before the first frame is taken).
223// ---------------------------------------------------------------------------------------------
224
225typedef struct FFImageIOAnimation {
226    CGImageSourceRef source;
227    uint32_t nextIndex;
228    uint32_t outputWidth;
229    uint32_t outputHeight;
230    int32_t minGap;
231    int32_t* delaysCs; // one per frame, read once at open time; a full scan is needed for minGap anyway
232} FFImageIOAnimation;
233
234// Thin wrappers over common/apple/cf_helpers. They exist for two reasons the shared helpers
235// deliberately do not cover: an absent dictionary has to be *tolerated* rather than queried
236// (CFDictionaryGetValue on NULL is undefined), and every caller here treats "absent" and "of the
237// wrong type" the same way, so the descriptive error string is dropped.
238static CFDictionaryRef imageIOSubdict(CFDictionaryRef dict, CFStringRef key) {
239    CFDictionaryRef result = nullptr;
240    if (dict == nullptr || ffCfDictGetDict(dict, key, &result) != nullptr) {
241        return nullptr;
242    }
243
244    return result;
245}
246
247// Both report false when the key is absent, which is how "the source declares nothing" is told
248// apart from "the source declares zero".
249static bool imageIODictGetDouble(CFDictionaryRef dict, CFStringRef key, double* out) {
250    return dict != nullptr && ffCfDictGetDouble(dict, key, out) == nullptr;
251}
252
253static bool imageIODictGetInt(CFDictionaryRef dict, CFStringRef key, int64_t* out) {
254    return dict != nullptr && ffCfDictGetInt64(dict, key, out) == nullptr;
255}
256
257// The frame's delay in centiseconds, which is the unit both GIF and APNG store.
258//
259// kCGImagePropertyGIFUnclampedDelayTime is the one to read, not kCGImagePropertyGIFDelayTime:
260// ImageIO clamps the latter at 100 ms (and the APNG one at 50 ms), so a source whose frames are
261// [0, 5, 0] centiseconds comes back as [10, 5, 10]. That would defeat kitty's rule, which floors at
262// 100 ms only when *every* frame is zero and otherwise treats a zero as "gapless". Measured on
263// macOS 27: mixed_delay.gif reads 0.10/0.05/0.10 clamped against 0.00/0.05/0.00 unclamped, and the
264// unclamped values are the ones written into the file.
265static int32_t imageIOFrameDelayCs(CFDictionaryRef frameProperties) {
266    // A GIF keeps its timing in the GIF dictionary, an animated PNG in the PNG one, and the two
267    // are never both present.
268    CFDictionaryRef gif = imageIOSubdict(frameProperties, kCGImagePropertyGIFDictionary);
269    CFDictionaryRef png = imageIOSubdict(frameProperties, kCGImagePropertyPNGDictionary);
270
271    double seconds = 0;
272    if (imageIODictGetDouble(gif, kCGImagePropertyGIFUnclampedDelayTime, &seconds) ||
273        imageIODictGetDouble(gif, kCGImagePropertyGIFDelayTime, &seconds) ||
274        imageIODictGetDouble(png, kCGImagePropertyAPNGUnclampedDelayTime, &seconds) ||
275        imageIODictGetDouble(png, kCGImagePropertyAPNGDelayTime, &seconds)) {
276        if (seconds > 0) {
277            return (int32_t) lround(seconds * 100.0);
278        }
279    }
280
281    return 0;
282}
283
284// How often the animation repeats after its first play; 0 means forever and -1 means the source
285// declared nothing. Neither is what ImageIO reports -- its two dictionaries count different things
286// (measured on macOS 27):
287//
288//   GIF  : number of *plays*. NETSCAPE 3 -> 4, NETSCAPE 0 (forever) -> 0, no NETSCAPE at all -> 1.
289//          That is ImageMagick's `iterations` to the digit, and the same -1 conversion applies.
290//          Note that "no NETSCAPE" arrives as 1, *not* as 0: reading 0 as the missing value, or
291//          passing 1 straight through, would turn "loop forever" into "play twice".
292//   APNG : number of *repeats*. acTL num_plays 3 -> 3, 0 (forever) -> 0.
293//
294// Which dictionary the value came from is therefore the format check; there is no need to ask
295// ImageIO for a UTI and string-compare it.
296static int32_t imageIOLoopCount(CGImageSourceRef source) {
297    FF_CFTYPE_AUTO_RELEASE CFDictionaryRef properties = CGImageSourceCopyProperties(source, nullptr);
298    if (properties == nullptr) {
299        return -1;
300    }
301
302    int64_t value = 0;
303    if (imageIODictGetInt(imageIOSubdict(properties, kCGImagePropertyGIFDictionary), kCGImagePropertyGIFLoopCount, &value)) {
304        if (value == 0) {
305            return 0;
306        }
307        if (value == 1) {
308            return -1;
309        }
310
311        const int64_t loops = value - 1;
312        return loops > INT32_MAX ? INT32_MAX : (int32_t) loops;
313    }
314
315    if (imageIODictGetInt(imageIOSubdict(properties, kCGImagePropertyPNGDictionary), kCGImagePropertyAPNGLoopCount, &value)) {
316        if (value == 0) {
317            return 0;
318        }
319
320        return value > INT32_MAX ? INT32_MAX : (int32_t) value;
321    }
322
323    return -1;
324}
325
326static bool imageIOGetFrame(FFImageAnimation* animation, uint32_t index, FFImageFrame* out, const char** error) {
327    FFImageIOAnimation* session = (FFImageIOAnimation*) ffImageAnimationGetImpl(animation);
328
329    // ImageIO can decode an arbitrary frame on demand, unlike the Windows backend, but the session
330    // contract is a sequential iterator and the composition of frame i does depend on 0..i-1, so an
331    // out-of-order caller is a bug worth reporting rather than something to paper over.
332    if (index < session->nextIndex) {
333        *error = "animation frames must be taken in order";
334        return false;
335    }
336
337    // kitty's mapping, which the design settled on: the raw delay is in centiseconds, the 100 ms
338    // floor exists only for sources whose frames are *all* zero, and whatever is left at <= 0 means
339    // "gapless" rather than 100 ms.
340    const int32_t rawDelay = session->delaysCs[index];
341    const int32_t gap = (rawDelay > session->minGap ? rawDelay : session->minGap) * 10;
342    out->delayMs = gap > 0 ? gap : -1;
343
344    FF_CFTYPE_AUTO_RELEASE CGImageRef image = CGImageSourceCreateImageAtIndex(session->source, index, nullptr);
345    if (image == nullptr) {
346        *error = "failed to decode the animation frame";
347        return false;
348    }
349
350    if (!imageIOConvert(image, session->outputWidth, session->outputHeight, &out->data, error)) {
351        return false;
352    }
353
354    session->nextIndex = index + 1;
355    return true;
356}
357
358static void imageIOFreeSession(FFImageIOAnimation* session) {
359    if (session->source != nullptr) {
360        CFRelease(session->source);
361    }
362    free(session->delaysCs);
363    free(session);
364}
365
366static void imageIODestroyAnimation(FFImageAnimation* animation) {
367    FFImageIOAnimation* session = (FFImageIOAnimation*) ffImageAnimationGetImpl(animation);
368    if (session != nullptr) {
369        imageIOFreeSession(session);
370    }
371}
372
373bool ffImageAnimationOpenImageIO(FFLogoRequestData* requestData, FFImageAnimation** out, const char** error) {
374    FF_CFTYPE_AUTO_RELEASE CFURLRef url = CFURLCreateFromFileSystemRepresentation(
375        kCFAllocatorDefault,
376        (const UInt8*) instance.config.logo.source.chars,
377        (CFIndex) instance.config.logo.source.length,
378        false);
379    if (url == nullptr) {
380        if (error) {
381            *error = "failed to create the image URL";
382        }
383        return false;
384    }
385
386    // Frames are handed out one at a time and released by the caller, so there is nothing to gain
387    // from letting ImageIO keep its own copy: without this a long animation is decoded into its
388    // cache as it is played through, and the peak stops matching the still path's.
389    const void* optionKeys[] = { kCGImageSourceShouldCache };
390    const void* optionValues[] = { kCFBooleanFalse };
391    FF_CFTYPE_AUTO_RELEASE CFDictionaryRef options = CFDictionaryCreate(kCFAllocatorDefault,
392        optionKeys, optionValues, 1, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
393
394    // Not auto-released: the session keeps it open until the last frame has been handed out.
395    CGImageSourceRef source = CGImageSourceCreateWithURL(url, options);
396    if (source == nullptr) {
397        if (error) {
398            *error = "unsupported or unreadable image format";
399        }
400        return false;
401    }
402
403    const size_t frameCount = CGImageSourceGetCount(source);
404    if (frameCount == 0) {
405        if (error) {
406            *error = "the image source has no frames";
407        }
408        CFRelease(source);
409        return false;
410    }
411
412    FFImageIOAnimation* session = (FFImageIOAnimation*) calloc(1, sizeof(*session));
413    if (session == nullptr) {
414        if (error) {
415            *error = "out of memory";
416        }
417        CFRelease(source);
418        return false;
419    }
420    session->source = source; // the session owns it from here on; every failure below frees it
421
422    // The canvas size comes from frame 0's *properties*, not from decoding it: ImageIO hands out
423    // canvas-sized frames, so this is the same value the still path reads off the decoded CGImage,
424    // and reading it here keeps the contract's "no frame is decoded to open a session".
425    FF_CFTYPE_AUTO_RELEASE CFDictionaryRef firstProperties = CGImageSourceCopyPropertiesAtIndex(source, 0, nullptr);
426    int64_t sourceWidth = 0;
427    int64_t sourceHeight = 0;
428    if (!imageIODictGetInt(firstProperties, kCGImagePropertyPixelWidth, &sourceWidth) ||
429        !imageIODictGetInt(firstProperties, kCGImagePropertyPixelHeight, &sourceHeight) ||
430        sourceWidth <= 0 || sourceHeight <= 0) {
431        if (error) {
432            *error = "failed to read the animation canvas size";
433        }
434        imageIOFreeSession(session);
435        return false;
436    }
437
438    if (!imageIOResolveTargetSize(requestData, (uint32_t) sourceWidth, (uint32_t) sourceHeight, error)) {
439        imageIOFreeSession(session);
440        return false;
441    }
442    session->outputWidth = requestData->logoPixelWidth;
443    session->outputHeight = requestData->logoPixelHeight;
444
445    session->delaysCs = (int32_t*) malloc(frameCount * sizeof(*session->delaysCs));
446    if (session->delaysCs == nullptr) {
447        if (error) {
448            *error = "out of memory";
449        }
450        imageIOFreeSession(session);
451        return false;
452    }
453
454    // kitty's rule: the 100 ms floor applies only when every frame's raw delay is <= 0. One frame
455    // with a delay settles the floor, but every frame is still visited here so that its delay is
456    // known by the time it is handed out.
457    session->minGap = 10;
458    for (size_t i = 0; i < frameCount; ++i) {
459        FF_CFTYPE_AUTO_RELEASE CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(source, i, nullptr);
460        session->delaysCs[i] = imageIOFrameDelayCs(properties);
461        if (session->delaysCs[i] > 0) {
462            session->minGap = 0;
463        }
464    }
465
466    FFImageAnimation* animation = ffImageAnimationCreate((uint32_t) frameCount, imageIOLoopCount(source),
467        session, imageIOGetFrame, imageIODestroyAnimation);
468    if (animation == nullptr) {
469        if (error) {
470            *error = "out of memory";
471        }
472        imageIOFreeSession(session);
473        return false;
474    }
475
476    *out = animation;
477    return true;
478}