@octane-xplat/image 0.0.0-stage → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -2
  3. package/dist/native/Image.tsrx.js +264 -0
  4. package/dist/native/cache.js +64 -0
  5. package/dist/native/hash-image.js +285 -0
  6. package/dist/native/image-binding.js +103 -0
  7. package/dist/native/image-content.js +207 -0
  8. package/dist/native/index.js +3 -0
  9. package/dist/native/octane-client-build.json +8 -0
  10. package/dist/native/prefetch-batch.js +27 -0
  11. package/dist/web/Image.web.tsrx.js +123 -0
  12. package/dist/web/cache.web.js +35 -0
  13. package/dist/web/hash-image.js +285 -0
  14. package/dist/web/image-content.js +134 -0
  15. package/dist/web/index.js +3 -0
  16. package/dist/web/octane-client-build.json +8 -0
  17. package/dist/web/prefetch-batch.js +27 -0
  18. package/package.json +83 -3
  19. package/src/Image.macos.tsrx +24 -0
  20. package/src/Image.tsrx +269 -0
  21. package/src/Image.web.tsrx +176 -0
  22. package/src/cache.macos.ts +31 -0
  23. package/src/cache.ts +93 -0
  24. package/src/cache.web.ts +49 -0
  25. package/src/hash-image.ts +313 -0
  26. package/src/image-binding.ts +170 -0
  27. package/src/image-content.ts +310 -0
  28. package/src/index.macos.ts +26 -0
  29. package/src/index.ts +28 -0
  30. package/src/index.web.ts +28 -0
  31. package/src/native-elements.d.ts +8 -0
  32. package/src/prefetch-batch.ts +50 -0
  33. package/src/prefetch.web.test.ts +105 -0
  34. package/src/props.ts +119 -0
  35. package/types/generated/.tsrx-typegen-manifest.json +16 -0
  36. package/types/generated/Image.d.ts +16 -0
  37. package/types/generated/Image.web.d.ts +12 -0
  38. package/types/generated/cache.d.ts +28 -0
  39. package/types/generated/cache.web.d.ts +16 -0
  40. package/types/generated/hash-image.d.ts +14 -0
  41. package/types/generated/image-binding.d.ts +40 -0
  42. package/types/generated/image-content.d.ts +57 -0
  43. package/types/generated/index.d.ts +3 -0
  44. package/types/generated/index.web.d.ts +3 -0
  45. package/types/generated/prefetch-batch.d.ts +10 -0
  46. package/types/generated/props.d.ts +99 -0
  47. package/types/index.macos.d.ts +28 -0
@@ -0,0 +1,49 @@
1
+ import { prefetchBatch } from './prefetch-batch';
2
+ import type { ImageCacheConfig, ImageCacheQueryOptions, ImageCacheState, PrefetchOptions } from './props';
3
+
4
+ /** No-op — the browser owns its HTTP cache; there is no sizing API. */
5
+ export function initializeImageCache(_config?: ImageCacheConfig): void {}
6
+
7
+ /** Warm the browser HTTP cache by loading each `src` through a throwaway
8
+ * <img> — the response must land (`load`/`error` awaited) before the promise
9
+ * resolves. Decode still happens at display, matching the native
10
+ * disk-prefetch contract. `options.headers` is ignored — an <img> request
11
+ * cannot carry custom headers. Resolves `false` when any URL fails. */
12
+ export function prefetch(srcs: string | string[], options?: PrefetchOptions): Promise<boolean> {
13
+ return prefetchBatch(
14
+ srcs,
15
+ options?.concurrency,
16
+ (url) =>
17
+ new Promise<boolean>((resolve) => {
18
+ const image = new globalThis.Image();
19
+ image.onload = () => resolve(true);
20
+ image.onerror = () => resolve(false);
21
+ image.src = url;
22
+ }),
23
+ );
24
+ }
25
+
26
+ /** No-op — browsers expose no per-URL cache eviction for <img>. */
27
+ export function evictImage(_src: string): Promise<boolean> {
28
+ return Promise.resolve(false);
29
+ }
30
+
31
+ /** No-op — browsers expose no cache clearing for <img>. */
32
+ export function clearImageCaches(): Promise<void> {
33
+ return Promise.resolve();
34
+ }
35
+
36
+ /** Always 'none' — the browser cache is not queryable. Returns the same
37
+ * `Record` shape as native, keyed by each requested URL. */
38
+ export function isImageCached(
39
+ srcs: string | readonly string[],
40
+ _options?: ImageCacheQueryOptions,
41
+ ): Promise<Record<string, ImageCacheState>> {
42
+ const list = typeof srcs === 'string' ? [srcs] : srcs;
43
+ const states: Record<string, ImageCacheState> = {};
44
+ for (const src of list) {
45
+ states[src] = 'none';
46
+ }
47
+
48
+ return Promise.resolve(states);
49
+ }
@@ -0,0 +1,313 @@
1
+ /** VENDORED from packages/ui/src/hash-image.ts — keep in sync.
2
+ * Placeholder-hash decoders shared by the Image leaves: `blurhash:` and
3
+ * `thumbhash:` URIs decode to a PNG in pure JS so a placeholder is a real
4
+ * image on every platform — ImageSource.fromBase64Sync on native, a
5
+ * data:image/png URL on web. Decode sizes stay small (~32px); the view's
6
+ * own fit/stretch scales the result, same as the final image.
7
+ *
8
+ * blurhash decode is a port of the Wolt reference decoder (MIT);
9
+ * thumbHashToRGBA and rgbaToPng are ports of Evan Wallace's thumbhash.js
10
+ * (MIT), with the data-URL/btoa tail replaced by byte output so this file
11
+ * carries no DOM or platform APIs. */
12
+
13
+ export function isHashPlaceholder(src: string): boolean {
14
+ return src.startsWith('blurhash:') || src.startsWith('thumbhash:')
15
+ }
16
+
17
+ /** `blurhash:<hash>` / `thumbhash:<base64>` → PNG bytes as base64, or null. */
18
+ export function hashToPngBase64(src: string): string | null {
19
+ if (src.startsWith('blurhash:')) {
20
+ const rgba = decodeBlurHash(src.slice(9), 32, 32)
21
+ return rgba ? base64Encode(rgbaToPng(32, 32, rgba)) : null
22
+ }
23
+
24
+ if (src.startsWith('thumbhash:')) {
25
+ const hash = base64Decode(src.slice(10))
26
+ const decoded = hash ? thumbHashToRGBA(hash) : null
27
+ return decoded ? base64Encode(rgbaToPng(decoded.w, decoded.h, decoded.rgba)) : null
28
+ }
29
+
30
+ return null
31
+ }
32
+
33
+ // ---------- blurhash (Wolt reference, MIT) ----------
34
+
35
+ const BASE83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'
36
+
37
+ function decode83(str: string, start: number, end: number): number {
38
+ let value = 0
39
+ for (let i = start; i < end; i++) {
40
+ const digit = BASE83.indexOf(str[i])
41
+ if (digit === -1) {return -1}
42
+ value = value * 83 + digit
43
+ }
44
+
45
+ return value
46
+ }
47
+
48
+ function srgbToLinear(value: number): number {
49
+ const v = value / 255
50
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
51
+ }
52
+
53
+ function linearToSrgb(value: number): number {
54
+ const v = Math.max(0, Math.min(1, value))
55
+ return v <= 0.0031308
56
+ ? Math.round(v * 12.92 * 255 + 0.5)
57
+ : Math.round((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5)
58
+ }
59
+
60
+ function signPow(value: number, exp: number): number {
61
+ return Math.sign(value) * Math.pow(Math.abs(value), exp)
62
+ }
63
+
64
+ function decodeBlurHash(hash: string, width: number, height: number): Uint8ClampedArray | null {
65
+ if (hash.length < 6) {return null}
66
+ const sizeFlag = decode83(hash, 0, 1)
67
+ const quantisedMax = decode83(hash, 1, 2)
68
+ const dcValue = decode83(hash, 2, 6)
69
+ if (sizeFlag < 0 || quantisedMax < 0 || dcValue < 0) {return null}
70
+ const numY = Math.floor(sizeFlag / 9) + 1
71
+ const numX = (sizeFlag % 9) + 1
72
+ if (hash.length !== 4 + 2 * numX * numY) {return null}
73
+ const maximumValue = (quantisedMax + 1) / 166
74
+ const colors: [number, number, number][] = new Array(numX * numY)
75
+ colors[0] = [
76
+ srgbToLinear(dcValue >> 16),
77
+ srgbToLinear((dcValue >> 8) & 255),
78
+ srgbToLinear(dcValue & 255),
79
+ ]
80
+
81
+ for (let i = 1; i < numX * numY; i++) {
82
+ const value = decode83(hash, 4 + i * 2, 6 + i * 2)
83
+ if (value < 0) {return null}
84
+ const quantR = Math.floor(value / (19 * 19))
85
+ const quantG = Math.floor(value / 19) % 19
86
+ const quantB = value % 19
87
+ colors[i] = [
88
+ signPow((quantR - 9) / 9, 2) * maximumValue,
89
+ signPow((quantG - 9) / 9, 2) * maximumValue,
90
+ signPow((quantB - 9) / 9, 2) * maximumValue,
91
+ ]
92
+ }
93
+
94
+ const pixels = new Uint8ClampedArray(width * height * 4)
95
+ for (let y = 0; y < height; y++) {
96
+ for (let x = 0; x < width; x++) {
97
+ let r = 0
98
+ let g = 0
99
+ let b = 0
100
+ for (let j = 0; j < numY; j++) {
101
+ for (let i = 0; i < numX; i++) {
102
+ const basis =
103
+ Math.cos((Math.PI * x * i) / width) * Math.cos((Math.PI * y * j) / height)
104
+
105
+ const color = colors[i + j * numX]
106
+ r += color[0] * basis
107
+ g += color[1] * basis
108
+ b += color[2] * basis
109
+ }
110
+ }
111
+
112
+ const o = (x + y * width) * 4
113
+ pixels[o] = linearToSrgb(r)
114
+ pixels[o + 1] = linearToSrgb(g)
115
+ pixels[o + 2] = linearToSrgb(b)
116
+ pixels[o + 3] = 255
117
+ }
118
+ }
119
+
120
+ return pixels
121
+ }
122
+
123
+ // ---------- thumbhash (Evan Wallace reference, MIT) ----------
124
+
125
+ function thumbHashToApproximateAspectRatio(hash: Uint8Array): number {
126
+ const header = hash[3]
127
+ const hasAlpha = (hash[2] & 0x80) !== 0
128
+ const isLandscape = (hash[4] & 0x80) !== 0
129
+ const lx = isLandscape ? (hasAlpha ? 5 : 7) : header & 7
130
+ const ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
131
+ return lx / ly
132
+ }
133
+
134
+ function thumbHashToRGBA(hash: Uint8Array): { w: number; h: number; rgba: Uint8Array } | null {
135
+ if (hash.length < 5) {return null}
136
+ const { PI, min, max, cos, round } = Math
137
+ const header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16)
138
+ const header16 = hash[3] | (hash[4] << 8)
139
+ const l_dc = (header24 & 63) / 63
140
+ const p_dc = ((header24 >> 6) & 63) / 31.5 - 1
141
+ const q_dc = ((header24 >> 12) & 63) / 31.5 - 1
142
+ const l_scale = ((header24 >> 18) & 31) / 31
143
+ const hasAlpha = header24 >> 23 !== 0
144
+ const p_scale = ((header16 >> 3) & 63) / 63
145
+ const q_scale = ((header16 >> 9) & 63) / 63
146
+ const isLandscape = header16 >> 15 !== 0
147
+ const lx = max(3, isLandscape ? (hasAlpha ? 5 : 7) : header16 & 7)
148
+ const ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
149
+ const a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1
150
+ const a_scale = (hash[5] >> 4) / 15
151
+ const ac_start = hasAlpha ? 6 : 5
152
+ let ac_index = 0
153
+ const decodeChannel = (nx: number, ny: number, scale: number) => {
154
+ const ac: number[] = []
155
+ for (let cy = 0; cy < ny; cy++)
156
+ {for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
157
+ const byte = hash[ac_start + (ac_index >> 1)]
158
+ if (byte === undefined) {return null}
159
+ ac.push((((byte >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale)
160
+ }}
161
+
162
+ return ac
163
+ }
164
+
165
+ const l_ac = decodeChannel(lx, ly, l_scale)
166
+ const p_ac = decodeChannel(3, 3, p_scale * 1.25)
167
+ const q_ac = decodeChannel(3, 3, q_scale * 1.25)
168
+ const a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : null
169
+ if (!l_ac || !p_ac || !q_ac || (hasAlpha && !a_ac)) {return null}
170
+
171
+ const ratio = thumbHashToApproximateAspectRatio(hash)
172
+ const w = round(ratio > 1 ? 32 : 32 * ratio)
173
+ const h = round(ratio > 1 ? 32 / ratio : 32)
174
+ const rgba = new Uint8Array(w * h * 4)
175
+ const fx: number[] = []
176
+ const fy: number[] = []
177
+ for (let y = 0, i = 0; y < h; y++) {
178
+ for (let x = 0; x < w; x++, i += 4) {
179
+ let l = l_dc
180
+ let p = p_dc
181
+ let q = q_dc
182
+ let a = a_dc
183
+ for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++)
184
+ {fx[cx] = cos((PI / w) * (x + 0.5) * cx)}
185
+
186
+ for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++)
187
+ {fy[cy] = cos((PI / h) * (y + 0.5) * cy)}
188
+
189
+ for (let cy = 0, j = 0; cy < ly; cy++)
190
+ {for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++)
191
+ {l += l_ac[j] * fx[cx] * fy2}}
192
+
193
+ for (let cy = 0, j = 0; cy < 3; cy++) {
194
+ for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
195
+ const f = fx[cx] * fy2
196
+ p += p_ac[j] * f
197
+ q += q_ac[j] * f
198
+ }
199
+ }
200
+
201
+ if (hasAlpha && a_ac) {
202
+ for (let cy = 0, j = 0; cy < 5; cy++)
203
+ {for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) {
204
+ a += a_ac[j] * fx[cx] * fy2
205
+ }}
206
+ }
207
+
208
+ const b = l - (2 / 3) * p
209
+ const r = (3 * l - b + q) / 2
210
+ const g = r - q
211
+ rgba[i] = max(0, 255 * min(1, r))
212
+ rgba[i + 1] = max(0, 255 * min(1, g))
213
+ rgba[i + 2] = max(0, 255 * min(1, b))
214
+ rgba[i + 3] = max(0, 255 * min(1, a))
215
+ }
216
+ }
217
+
218
+ return { w, h, rgba }
219
+ }
220
+
221
+ // ---------- PNG encode + base64 (Wallace rgbaToDataURL port, MIT) ----------
222
+
223
+ const PNG_CRC_TABLE = [
224
+ 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, 1342533948,
225
+ -306674912, -267414716, -690576408, -882789492, -1687895376, -2032938284, -1609899400,
226
+ -1111625188,
227
+ ]
228
+
229
+ /** Uncompressed PNG (zlib store blocks) — correctness over size; inputs are
230
+ * ~32px placeholders so the payload stays small. */
231
+ function rgbaToPng(w: number, h: number, rgba: Uint8ClampedArray | Uint8Array): number[] {
232
+ const row = w * 4 + 1
233
+ const idat = 6 + h * (5 + row)
234
+ const bytes = [
235
+ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, w >> 8,
236
+ w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0, idat >>> 24,
237
+ (idat >> 16) & 255, (idat >> 8) & 255, idat & 255, 73, 68, 65, 84, 120, 1,
238
+ ]
239
+
240
+ let a = 1
241
+ let b = 0
242
+ for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
243
+ bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0)
244
+ for (b = (b + a) % 65521; i < end; i++) {
245
+ const u = rgba[i] & 255
246
+ bytes.push(u)
247
+ a = (a + u) % 65521
248
+ b = (b + a) % 65521
249
+ }
250
+ }
251
+
252
+ bytes.push(
253
+ b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0,
254
+ 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
255
+ )
256
+
257
+ for (let [start, end] of [
258
+ [12, 29],
259
+ [37, 41 + idat],
260
+ ]) {
261
+ let c = ~0
262
+ for (let i = start; i < end; i++) {
263
+ c ^= bytes[i]
264
+ c = (c >>> 4) ^ PNG_CRC_TABLE[c & 15]
265
+ c = (c >>> 4) ^ PNG_CRC_TABLE[c & 15]
266
+ }
267
+
268
+ c = ~c
269
+ bytes[end++] = c >>> 24
270
+ bytes[end++] = (c >> 16) & 255
271
+ bytes[end++] = (c >> 8) & 255
272
+ bytes[end++] = c & 255
273
+ }
274
+
275
+ return bytes
276
+ }
277
+
278
+ const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
279
+
280
+ function base64Encode(bytes: number[] | Uint8Array): string {
281
+ let out = ''
282
+ for (let i = 0; i < bytes.length; i += 3) {
283
+ const b0 = bytes[i]
284
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0
285
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0
286
+ out += BASE64_CHARS[b0 >> 2]
287
+ out += BASE64_CHARS[((b0 & 3) << 4) | (b1 >> 4)]
288
+ out += i + 1 < bytes.length ? BASE64_CHARS[((b1 & 15) << 2) | (b2 >> 6)] : '='
289
+ out += i + 2 < bytes.length ? BASE64_CHARS[b2 & 63] : '='
290
+ }
291
+
292
+ return out
293
+ }
294
+
295
+ function base64Decode(text: string): Uint8Array | null {
296
+ const clean = text.replace(/-/g, '+').replace(/_/g, '/').replace(/[\s=]/g, '')
297
+ const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4))
298
+ let out = 0
299
+ let acc = 0
300
+ let bits = 0
301
+ for (const ch of clean) {
302
+ const digit = BASE64_CHARS.indexOf(ch)
303
+ if (digit === -1) {return null}
304
+ acc = (acc << 6) | digit
305
+ bits += 6
306
+ if (bits >= 8) {
307
+ bits -= 8
308
+ if (out < bytes.length) {bytes[out++] = (acc >> bits) & 255}
309
+ }
310
+ }
311
+
312
+ return bytes.subarray(0, out)
313
+ }
@@ -0,0 +1,170 @@
1
+ import type { ImageContentFit, ImageContentPositionObject, ImageSourceLike } from './props';
2
+ import { bestImageSource, contentRect } from './image-content';
3
+
4
+ /** View-sized decode + srcset selection + content-rect layout for the
5
+ * ui-image `Img` leaf. Mirrors the core Image binding semantics
6
+ * (packages/ui/src/image-sizing.ts + image-content-binding.ts):
7
+ *
8
+ * `src` never goes through JSX — the binding holds it until the view's
9
+ * first real layout, writes decodeWidth/decodeHeight in device pixels,
10
+ * then assigns it. Without that hold the engine decodes at full source
11
+ * resolution (the 5 MB-cache jank this package exists to fix is half
12
+ * cache size and half oversized decodes — study L1+L2). A later resize
13
+ * re-issues at the new size, except 'fill'/'none' fits where the decode
14
+ * can't improve. 'none' never downsamples — its contract is source pixels.
15
+ *
16
+ * `update` must be idempotent: ref callbacks re-fire on every render, and
17
+ * same view + same src + same size is a no-op. */
18
+ export function createImageBinding(options: {
19
+ getFit: () => ImageContentFit;
20
+ getPosition: () => ImageContentPositionObject;
21
+ getSrc: () => { source: any; sources: ImageSourceLike[] | null };
22
+ /** Explicit decode bounds — the caller's decodeWidth/decodeHeight props;
23
+ * when set they win over the measured view size. */
24
+ getDecodeBounds: () => { width?: number; height?: number };
25
+ /** Device-pixel scale (Screen.mainScreen.scale) — injected so this
26
+ * module stays platform-free. */
27
+ screenScale: () => number;
28
+ }) {
29
+ let view: any = null;
30
+ let host: any = null; // computed-rect path: the absolutelayout wrapper
31
+ let listening = false;
32
+ let pending: { source: any; sources: ImageSourceLike[] | null } = {
33
+ source: null,
34
+ sources: null,
35
+ };
36
+
37
+ let appliedSrc: any;
38
+ let applied = false;
39
+ let width = 0;
40
+ let height = 0;
41
+ let imageWidth = 0; // intrinsic bitmap dips
42
+ let imageHeight = 0;
43
+
44
+ const scale = () => options.screenScale() || 1;
45
+
46
+ const issue = (next: any) => {
47
+ const fit = options.getFit();
48
+ const bounds = options.getDecodeBounds();
49
+ // 'none' draws the source at intrinsic size — downsampling would
50
+ // shrink it (core parity; see ui/src/image-sizing.ts).
51
+ const downscale = fit !== 'none';
52
+ view.decodeWidth = bounds.width ?? (downscale ? width : 0);
53
+ view.decodeHeight = bounds.height ?? (downscale ? height : 0);
54
+ view.src = next;
55
+ appliedSrc = next;
56
+ applied = true;
57
+ };
58
+
59
+ const applyRect = () => {
60
+ if (!host || !view || imageWidth <= 0 || imageHeight <= 0) {
61
+ return;
62
+ }
63
+
64
+ const viewWidth = (host.getMeasuredWidth?.() ?? 0) / scale();
65
+ const viewHeight = (host.getMeasuredHeight?.() ?? 0) / scale();
66
+ if (viewWidth <= 0 || viewHeight <= 0) {
67
+ return;
68
+ }
69
+
70
+ const rect = contentRect(
71
+ options.getFit(),
72
+ options.getPosition(),
73
+ viewWidth,
74
+ viewHeight,
75
+ imageWidth,
76
+ imageHeight,
77
+ );
78
+
79
+ view.left = rect.x;
80
+ view.top = rect.y;
81
+ view.width = rect.width;
82
+ view.height = rect.height;
83
+ };
84
+
85
+ const evaluate = () => {
86
+ const sizeView = host ?? view;
87
+ if (!view || !sizeView) {
88
+ return;
89
+ }
90
+
91
+ const measuredWidth = sizeView.getMeasuredWidth?.() ?? 0;
92
+ const measuredHeight = sizeView.getMeasuredHeight?.() ?? 0;
93
+ // Hold the request until a real layout — an earlier write decodes at
94
+ // the default 0 dims (full source resolution).
95
+ if (measuredWidth <= 0 || measuredHeight <= 0) {
96
+ return;
97
+ }
98
+
99
+ let next = pending.source;
100
+ if (pending.sources) {
101
+ next =
102
+ bestImageSource(
103
+ pending.sources,
104
+ measuredWidth / scale(),
105
+ measuredHeight / scale(),
106
+ scale(),
107
+ )?.uri ?? pending.sources[0]?.uri;
108
+ }
109
+
110
+ if (next == null || next === '') {
111
+ return;
112
+ }
113
+
114
+ const resized = applied && (measuredWidth !== width || measuredHeight !== height);
115
+ if (applied && next === appliedSrc && !resized) {
116
+ applyRect();
117
+ return;
118
+ }
119
+
120
+ const fit = options.getFit();
121
+ // A same-src resize re-issue only matters where the decode target
122
+ // changes the result — 'fill'/'none' keep the existing bitmap.
123
+ if (applied && next === appliedSrc && (fit === 'fill' || fit === 'none')) {
124
+ applyRect();
125
+ return;
126
+ }
127
+
128
+ width = measuredWidth;
129
+ height = measuredHeight;
130
+ issue(next);
131
+ applyRect();
132
+ };
133
+
134
+ const onLayout = () => evaluate();
135
+
136
+ const listen = (target: any) => {
137
+ if (listening || !target) {
138
+ return;
139
+ }
140
+
141
+ listening = true;
142
+ target.on('layoutChanged', onLayout);
143
+ };
144
+
145
+ return {
146
+ /** Called from the ref callbacks — image view first, then the computed
147
+ * path's wrapper host. Refreshes the pending src from props and
148
+ * evaluates; idempotent. */
149
+ update(imageView: any, hostView: any) {
150
+ if (imageView) {
151
+ view = imageView;
152
+ }
153
+
154
+ if (hostView) {
155
+ host = hostView;
156
+ }
157
+
158
+ pending = options.getSrc();
159
+ listen(host ?? view);
160
+ evaluate();
161
+ },
162
+ /** Engine set the final bitmap — record intrinsic size for the
163
+ * content-rect path. */
164
+ setIntrinsic(widthPx: number, heightPx: number) {
165
+ imageWidth = widthPx / scale();
166
+ imageHeight = heightPx / scale();
167
+ applyRect();
168
+ },
169
+ };
170
+ }