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}