@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,285 @@
1
+ //#region src/hash-image.ts
2
+ /** `blurhash:<hash>` / `thumbhash:<base64>` → PNG bytes as base64, or null. */
3
+ function hashToPngBase64(src) {
4
+ if (src.startsWith("blurhash:")) {
5
+ const rgba = decodeBlurHash(src.slice(9), 32, 32);
6
+ return rgba ? base64Encode(rgbaToPng(32, 32, rgba)) : null;
7
+ }
8
+ if (src.startsWith("thumbhash:")) {
9
+ const hash = base64Decode(src.slice(10));
10
+ const decoded = hash ? thumbHashToRGBA(hash) : null;
11
+ return decoded ? base64Encode(rgbaToPng(decoded.w, decoded.h, decoded.rgba)) : null;
12
+ }
13
+ return null;
14
+ }
15
+ var BASE83 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
16
+ function decode83(str, start, end) {
17
+ let value = 0;
18
+ for (let i = start; i < end; i++) {
19
+ const digit = BASE83.indexOf(str[i]);
20
+ if (digit === -1) return -1;
21
+ value = value * 83 + digit;
22
+ }
23
+ return value;
24
+ }
25
+ function srgbToLinear(value) {
26
+ const v = value / 255;
27
+ return v <= .04045 ? v / 12.92 : Math.pow((v + .055) / 1.055, 2.4);
28
+ }
29
+ function linearToSrgb(value) {
30
+ const v = Math.max(0, Math.min(1, value));
31
+ return v <= .0031308 ? Math.round(v * 12.92 * 255 + .5) : Math.round((1.055 * Math.pow(v, 1 / 2.4) - .055) * 255 + .5);
32
+ }
33
+ function signPow(value, exp) {
34
+ return Math.sign(value) * Math.pow(Math.abs(value), exp);
35
+ }
36
+ function decodeBlurHash(hash, width, height) {
37
+ if (hash.length < 6) return null;
38
+ const sizeFlag = decode83(hash, 0, 1);
39
+ const quantisedMax = decode83(hash, 1, 2);
40
+ const dcValue = decode83(hash, 2, 6);
41
+ if (sizeFlag < 0 || quantisedMax < 0 || dcValue < 0) return null;
42
+ const numY = Math.floor(sizeFlag / 9) + 1;
43
+ const numX = sizeFlag % 9 + 1;
44
+ if (hash.length !== 4 + 2 * numX * numY) return null;
45
+ const maximumValue = (quantisedMax + 1) / 166;
46
+ const colors = new Array(numX * numY);
47
+ colors[0] = [
48
+ srgbToLinear(dcValue >> 16),
49
+ srgbToLinear(dcValue >> 8 & 255),
50
+ srgbToLinear(dcValue & 255)
51
+ ];
52
+ for (let i = 1; i < numX * numY; i++) {
53
+ const value = decode83(hash, 4 + i * 2, 6 + i * 2);
54
+ if (value < 0) return null;
55
+ const quantR = Math.floor(value / 361);
56
+ const quantG = Math.floor(value / 19) % 19;
57
+ const quantB = value % 19;
58
+ colors[i] = [
59
+ signPow((quantR - 9) / 9, 2) * maximumValue,
60
+ signPow((quantG - 9) / 9, 2) * maximumValue,
61
+ signPow((quantB - 9) / 9, 2) * maximumValue
62
+ ];
63
+ }
64
+ const pixels = new Uint8ClampedArray(width * height * 4);
65
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
66
+ let r = 0;
67
+ let g = 0;
68
+ let b = 0;
69
+ for (let j = 0; j < numY; j++) for (let i = 0; i < numX; i++) {
70
+ const basis = Math.cos(Math.PI * x * i / width) * Math.cos(Math.PI * y * j / height);
71
+ const color = colors[i + j * numX];
72
+ r += color[0] * basis;
73
+ g += color[1] * basis;
74
+ b += color[2] * basis;
75
+ }
76
+ const o = (x + y * width) * 4;
77
+ pixels[o] = linearToSrgb(r);
78
+ pixels[o + 1] = linearToSrgb(g);
79
+ pixels[o + 2] = linearToSrgb(b);
80
+ pixels[o + 3] = 255;
81
+ }
82
+ return pixels;
83
+ }
84
+ function thumbHashToApproximateAspectRatio(hash) {
85
+ const header = hash[3];
86
+ const hasAlpha = (hash[2] & 128) !== 0;
87
+ const isLandscape = (hash[4] & 128) !== 0;
88
+ return (isLandscape ? hasAlpha ? 5 : 7 : header & 7) / (isLandscape ? header & 7 : hasAlpha ? 5 : 7);
89
+ }
90
+ function thumbHashToRGBA(hash) {
91
+ if (hash.length < 5) return null;
92
+ const { PI, min, max, cos, round } = Math;
93
+ const header24 = hash[0] | hash[1] << 8 | hash[2] << 16;
94
+ const header16 = hash[3] | hash[4] << 8;
95
+ const l_dc = (header24 & 63) / 63;
96
+ const p_dc = (header24 >> 6 & 63) / 31.5 - 1;
97
+ const q_dc = (header24 >> 12 & 63) / 31.5 - 1;
98
+ const l_scale = (header24 >> 18 & 31) / 31;
99
+ const hasAlpha = header24 >> 23 !== 0;
100
+ const p_scale = (header16 >> 3 & 63) / 63;
101
+ const q_scale = (header16 >> 9 & 63) / 63;
102
+ const isLandscape = header16 >> 15 !== 0;
103
+ const lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
104
+ const ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
105
+ const a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1;
106
+ const a_scale = (hash[5] >> 4) / 15;
107
+ const ac_start = hasAlpha ? 6 : 5;
108
+ let ac_index = 0;
109
+ const decodeChannel = (nx, ny, scale) => {
110
+ const ac = [];
111
+ for (let cy = 0; cy < ny; cy++) for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
112
+ const byte = hash[ac_start + (ac_index >> 1)];
113
+ if (byte === void 0) return null;
114
+ ac.push(((byte >> ((ac_index++ & 1) << 2) & 15) / 7.5 - 1) * scale);
115
+ }
116
+ return ac;
117
+ };
118
+ const l_ac = decodeChannel(lx, ly, l_scale);
119
+ const p_ac = decodeChannel(3, 3, p_scale * 1.25);
120
+ const q_ac = decodeChannel(3, 3, q_scale * 1.25);
121
+ const a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : null;
122
+ if (!l_ac || !p_ac || !q_ac || hasAlpha && !a_ac) return null;
123
+ const ratio = thumbHashToApproximateAspectRatio(hash);
124
+ const w = round(ratio > 1 ? 32 : 32 * ratio);
125
+ const h = round(ratio > 1 ? 32 / ratio : 32);
126
+ const rgba = new Uint8Array(w * h * 4);
127
+ const fx = [];
128
+ const fy = [];
129
+ for (let y = 0, i = 0; y < h; y++) for (let x = 0; x < w; x++, i += 4) {
130
+ let l = l_dc;
131
+ let p = p_dc;
132
+ let q = q_dc;
133
+ let a = a_dc;
134
+ for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) fx[cx] = cos(PI / w * (x + .5) * cx);
135
+ for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) fy[cy] = cos(PI / h * (y + .5) * cy);
136
+ for (let cy = 0, j = 0; cy < ly; cy++) for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) l += l_ac[j] * fx[cx] * fy2;
137
+ for (let cy = 0, j = 0; cy < 3; cy++) for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
138
+ const f = fx[cx] * fy2;
139
+ p += p_ac[j] * f;
140
+ q += q_ac[j] * f;
141
+ }
142
+ if (hasAlpha && a_ac) for (let cy = 0, j = 0; cy < 5; cy++) for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) a += a_ac[j] * fx[cx] * fy2;
143
+ const b = l - 2 / 3 * p;
144
+ const r = (3 * l - b + q) / 2;
145
+ const g = r - q;
146
+ rgba[i] = max(0, 255 * min(1, r));
147
+ rgba[i + 1] = max(0, 255 * min(1, g));
148
+ rgba[i + 2] = max(0, 255 * min(1, b));
149
+ rgba[i + 3] = max(0, 255 * min(1, a));
150
+ }
151
+ return {
152
+ w,
153
+ h,
154
+ rgba
155
+ };
156
+ }
157
+ var PNG_CRC_TABLE = [
158
+ 0,
159
+ 498536548,
160
+ 997073096,
161
+ 651767980,
162
+ 1994146192,
163
+ 1802195444,
164
+ 1303535960,
165
+ 1342533948,
166
+ -306674912,
167
+ -267414716,
168
+ -690576408,
169
+ -882789492,
170
+ -1687895376,
171
+ -2032938284,
172
+ -1609899400,
173
+ -1111625188
174
+ ];
175
+ /** Uncompressed PNG (zlib store blocks) — correctness over size; inputs are
176
+ * ~32px placeholders so the payload stays small. */
177
+ function rgbaToPng(w, h, rgba) {
178
+ const row = w * 4 + 1;
179
+ const idat = 6 + h * (5 + row);
180
+ const bytes = [
181
+ 137,
182
+ 80,
183
+ 78,
184
+ 71,
185
+ 13,
186
+ 10,
187
+ 26,
188
+ 10,
189
+ 0,
190
+ 0,
191
+ 0,
192
+ 13,
193
+ 73,
194
+ 72,
195
+ 68,
196
+ 82,
197
+ 0,
198
+ 0,
199
+ w >> 8,
200
+ w & 255,
201
+ 0,
202
+ 0,
203
+ h >> 8,
204
+ h & 255,
205
+ 8,
206
+ 6,
207
+ 0,
208
+ 0,
209
+ 0,
210
+ 0,
211
+ 0,
212
+ 0,
213
+ 0,
214
+ idat >>> 24,
215
+ idat >> 16 & 255,
216
+ idat >> 8 & 255,
217
+ idat & 255,
218
+ 73,
219
+ 68,
220
+ 65,
221
+ 84,
222
+ 120,
223
+ 1
224
+ ];
225
+ let a = 1;
226
+ let b = 0;
227
+ for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
228
+ bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, row >> 8 ^ 255, 0);
229
+ for (b = (b + a) % 65521; i < end; i++) {
230
+ const u = rgba[i] & 255;
231
+ bytes.push(u);
232
+ a = (a + u) % 65521;
233
+ b = (b + a) % 65521;
234
+ }
235
+ }
236
+ bytes.push(b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130);
237
+ for (let [start, end] of [[12, 29], [37, 41 + idat]]) {
238
+ let c = -1;
239
+ for (let i = start; i < end; i++) {
240
+ c ^= bytes[i];
241
+ c = c >>> 4 ^ PNG_CRC_TABLE[c & 15];
242
+ c = c >>> 4 ^ PNG_CRC_TABLE[c & 15];
243
+ }
244
+ c = ~c;
245
+ bytes[end++] = c >>> 24;
246
+ bytes[end++] = c >> 16 & 255;
247
+ bytes[end++] = c >> 8 & 255;
248
+ bytes[end++] = c & 255;
249
+ }
250
+ return bytes;
251
+ }
252
+ var BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
253
+ function base64Encode(bytes) {
254
+ let out = "";
255
+ for (let i = 0; i < bytes.length; i += 3) {
256
+ const b0 = bytes[i];
257
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
258
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
259
+ out += BASE64_CHARS[b0 >> 2];
260
+ out += BASE64_CHARS[(b0 & 3) << 4 | b1 >> 4];
261
+ out += i + 1 < bytes.length ? BASE64_CHARS[(b1 & 15) << 2 | b2 >> 6] : "=";
262
+ out += i + 2 < bytes.length ? BASE64_CHARS[b2 & 63] : "=";
263
+ }
264
+ return out;
265
+ }
266
+ function base64Decode(text) {
267
+ const clean = text.replace(/-/g, "+").replace(/_/g, "/").replace(/[\s=]/g, "");
268
+ const bytes = new Uint8Array(Math.floor(clean.length * 3 / 4));
269
+ let out = 0;
270
+ let acc = 0;
271
+ let bits = 0;
272
+ for (const ch of clean) {
273
+ const digit = BASE64_CHARS.indexOf(ch);
274
+ if (digit === -1) return null;
275
+ acc = acc << 6 | digit;
276
+ bits += 6;
277
+ if (bits >= 8) {
278
+ bits -= 8;
279
+ if (out < bytes.length) bytes[out++] = acc >> bits & 255;
280
+ }
281
+ }
282
+ return bytes.subarray(0, out);
283
+ }
284
+ //#endregion
285
+ export { hashToPngBase64 };
@@ -0,0 +1,103 @@
1
+ import { bestImageSource, contentRect } from "./image-content.js";
2
+ //#region src/image-binding.ts
3
+ /** View-sized decode + srcset selection + content-rect layout for the
4
+ * ui-image `Img` leaf. Mirrors the core Image binding semantics
5
+ * (packages/ui/src/image-sizing.ts + image-content-binding.ts):
6
+ *
7
+ * `src` never goes through JSX — the binding holds it until the view's
8
+ * first real layout, writes decodeWidth/decodeHeight in device pixels,
9
+ * then assigns it. Without that hold the engine decodes at full source
10
+ * resolution (the 5 MB-cache jank this package exists to fix is half
11
+ * cache size and half oversized decodes — study L1+L2). A later resize
12
+ * re-issues at the new size, except 'fill'/'none' fits where the decode
13
+ * can't improve. 'none' never downsamples — its contract is source pixels.
14
+ *
15
+ * `update` must be idempotent: ref callbacks re-fire on every render, and
16
+ * same view + same src + same size is a no-op. */
17
+ function createImageBinding(options) {
18
+ let view = null;
19
+ let host = null;
20
+ let listening = false;
21
+ let pending = {
22
+ source: null,
23
+ sources: null
24
+ };
25
+ let appliedSrc;
26
+ let applied = false;
27
+ let width = 0;
28
+ let height = 0;
29
+ let imageWidth = 0;
30
+ let imageHeight = 0;
31
+ const scale = () => options.screenScale() || 1;
32
+ const issue = (next) => {
33
+ const fit = options.getFit();
34
+ const bounds = options.getDecodeBounds();
35
+ const downscale = fit !== "none";
36
+ view.decodeWidth = bounds.width ?? (downscale ? width : 0);
37
+ view.decodeHeight = bounds.height ?? (downscale ? height : 0);
38
+ view.src = next;
39
+ appliedSrc = next;
40
+ applied = true;
41
+ };
42
+ const applyRect = () => {
43
+ if (!host || !view || imageWidth <= 0 || imageHeight <= 0) return;
44
+ const viewWidth = (host.getMeasuredWidth?.() ?? 0) / scale();
45
+ const viewHeight = (host.getMeasuredHeight?.() ?? 0) / scale();
46
+ if (viewWidth <= 0 || viewHeight <= 0) return;
47
+ const rect = contentRect(options.getFit(), options.getPosition(), viewWidth, viewHeight, imageWidth, imageHeight);
48
+ view.left = rect.x;
49
+ view.top = rect.y;
50
+ view.width = rect.width;
51
+ view.height = rect.height;
52
+ };
53
+ const evaluate = () => {
54
+ const sizeView = host ?? view;
55
+ if (!view || !sizeView) return;
56
+ const measuredWidth = sizeView.getMeasuredWidth?.() ?? 0;
57
+ const measuredHeight = sizeView.getMeasuredHeight?.() ?? 0;
58
+ if (measuredWidth <= 0 || measuredHeight <= 0) return;
59
+ let next = pending.source;
60
+ if (pending.sources) next = bestImageSource(pending.sources, measuredWidth / scale(), measuredHeight / scale(), scale())?.uri ?? pending.sources[0]?.uri;
61
+ if (next == null || next === "") return;
62
+ if (applied && next === appliedSrc && !(applied && (measuredWidth !== width || measuredHeight !== height))) {
63
+ applyRect();
64
+ return;
65
+ }
66
+ const fit = options.getFit();
67
+ if (applied && next === appliedSrc && (fit === "fill" || fit === "none")) {
68
+ applyRect();
69
+ return;
70
+ }
71
+ width = measuredWidth;
72
+ height = measuredHeight;
73
+ issue(next);
74
+ applyRect();
75
+ };
76
+ const onLayout = () => evaluate();
77
+ const listen = (target) => {
78
+ if (listening || !target) return;
79
+ listening = true;
80
+ target.on("layoutChanged", onLayout);
81
+ };
82
+ return {
83
+ /** Called from the ref callbacks — image view first, then the computed
84
+ * path's wrapper host. Refreshes the pending src from props and
85
+ * evaluates; idempotent. */
86
+ update(imageView, hostView) {
87
+ if (imageView) view = imageView;
88
+ if (hostView) host = hostView;
89
+ pending = options.getSrc();
90
+ listen(host ?? view);
91
+ evaluate();
92
+ },
93
+ /** Engine set the final bitmap — record intrinsic size for the
94
+ * content-rect path. */
95
+ setIntrinsic(widthPx, heightPx) {
96
+ imageWidth = widthPx / scale();
97
+ imageHeight = heightPx / scale();
98
+ applyRect();
99
+ }
100
+ };
101
+ }
102
+ //#endregion
103
+ export { createImageBinding };
@@ -0,0 +1,207 @@
1
+ //#region src/image-content.ts
2
+ /** VENDORED from packages/ui/src/image-content.ts — keep in sync.
3
+ * Shared `contentFit`/`contentPosition` + multi-source math for `Image` —
4
+ * pure functions used by every leaf (docs/notes/expo-image-study.md L7/L8).
5
+ * Semantics mirror CSS `object-fit`/`object-position`, which is also
6
+ * expo-image's contract: the leaves compute the drawn-content rect
7
+ * themselves instead of delegating to platform scaleType/contentMode,
8
+ * so all platforms get the same answer.
9
+ *
10
+ * The default fit is 'cover' everywhere (the expo/RN default). Today's
11
+ * platform defaults disagree — web <img> is 'fill' and NS stretch is
12
+ * 'aspectFit' — so an un-propped Image was silently divergent. */
13
+ var DEFAULT_CONTENT_FIT = "cover";
14
+ function resolveContentFit(fit) {
15
+ return fit ?? "cover";
16
+ }
17
+ /** expo-image's keyword shorthands; the object form positions relative to
18
+ * any corner pair ({top,left}, {top,right}, {bottom,left}, {bottom,right}). */
19
+ var POSITION_KEYWORDS = {
20
+ center: {
21
+ top: "50%",
22
+ left: "50%"
23
+ },
24
+ top: {
25
+ top: 0,
26
+ left: "50%"
27
+ },
28
+ right: {
29
+ top: "50%",
30
+ right: 0
31
+ },
32
+ bottom: {
33
+ bottom: 0,
34
+ left: "50%"
35
+ },
36
+ left: {
37
+ top: "50%",
38
+ left: 0
39
+ },
40
+ "top center": {
41
+ top: 0,
42
+ left: "50%"
43
+ },
44
+ "top right": {
45
+ top: 0,
46
+ right: 0
47
+ },
48
+ "top left": {
49
+ top: 0,
50
+ left: 0
51
+ },
52
+ "right center": {
53
+ top: "50%",
54
+ right: 0
55
+ },
56
+ "right top": {
57
+ top: 0,
58
+ right: 0
59
+ },
60
+ "right bottom": {
61
+ bottom: 0,
62
+ right: 0
63
+ },
64
+ "bottom center": {
65
+ bottom: 0,
66
+ left: "50%"
67
+ },
68
+ "bottom right": {
69
+ bottom: 0,
70
+ right: 0
71
+ },
72
+ "bottom left": {
73
+ bottom: 0,
74
+ left: 0
75
+ },
76
+ "left center": {
77
+ top: "50%",
78
+ left: 0
79
+ },
80
+ "left top": {
81
+ top: 0,
82
+ left: 0
83
+ },
84
+ "left bottom": {
85
+ bottom: 0,
86
+ left: 0
87
+ }
88
+ };
89
+ function resolveContentPosition(position) {
90
+ if (typeof position === "string") return POSITION_KEYWORDS[position] ?? POSITION_KEYWORDS.center;
91
+ return position ?? POSITION_KEYWORDS.center;
92
+ }
93
+ function parseAxisValue(value) {
94
+ if (value == null) return null;
95
+ if (typeof value === "number") return { dips: value };
96
+ const text = String(value).trim();
97
+ if (text === "center") return { percent: 50 };
98
+ if (text.endsWith("%")) {
99
+ const parsed = Number.parseFloat(text);
100
+ return Number.isNaN(parsed) ? null : { percent: parsed };
101
+ }
102
+ const parsed = Number.parseFloat(text);
103
+ return Number.isNaN(parsed) ? null : { dips: parsed };
104
+ }
105
+ /** Offset of the drawn content's start edge inside the view along one axis.
106
+ * expo-image `calcTranslation`: percentages run over the free space
107
+ * (view − content), absolute dips measure from the named edge, and the
108
+ * reverse edge (right/bottom) counts back from the far side. */
109
+ function contentAxisOffset(viewSize, contentSize, start, end) {
110
+ const from = parseAxisValue(start);
111
+ if (from) return "percent" in from ? from.percent / 100 * (viewSize - contentSize) : from.dips;
112
+ const to = parseAxisValue(end);
113
+ if (to) return "percent" in to ? (1 - to.percent / 100) * (viewSize - contentSize) : viewSize - contentSize - to.dips;
114
+ return (viewSize - contentSize) / 2;
115
+ }
116
+ function centeredAxis(start, end) {
117
+ const from = parseAxisValue(start);
118
+ if (from) return "percent" in from && from.percent === 50;
119
+ const to = parseAxisValue(end);
120
+ if (to) return "percent" in to && to.percent === 50;
121
+ return true;
122
+ }
123
+ function isCenteredContentPosition(position) {
124
+ return centeredAxis(position.left, position.right) && centeredAxis(position.top, position.bottom);
125
+ }
126
+ /** The rect the bitmap occupies inside the view box, in the same units the
127
+ * caller measures with (native: dips; web: CSS px via object-fit anyway —
128
+ * only the native leaf calls this). Every length unit is dips except the
129
+ * intrinsic size, which the leaf must supply in dips too. */
130
+ function contentRect(fit, position, viewWidth, viewHeight, imageWidth, imageHeight) {
131
+ let width;
132
+ let height;
133
+ switch (fit) {
134
+ case "fill":
135
+ width = viewWidth;
136
+ height = viewHeight;
137
+ break;
138
+ case "cover": {
139
+ const scale = Math.max(viewWidth / imageWidth, viewHeight / imageHeight);
140
+ width = imageWidth * scale;
141
+ height = imageHeight * scale;
142
+ break;
143
+ }
144
+ case "scale-down": {
145
+ const scale = Math.min(1, Math.min(viewWidth / imageWidth, viewHeight / imageHeight));
146
+ width = imageWidth * scale;
147
+ height = imageHeight * scale;
148
+ break;
149
+ }
150
+ case "none":
151
+ width = imageWidth;
152
+ height = imageHeight;
153
+ break;
154
+ default: {
155
+ const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight);
156
+ width = imageWidth * scale;
157
+ height = imageHeight * scale;
158
+ }
159
+ }
160
+ return {
161
+ x: contentAxisOffset(viewWidth, width, position.left, position.right),
162
+ y: contentAxisOffset(viewHeight, height, position.top, position.bottom),
163
+ width,
164
+ height
165
+ };
166
+ }
167
+ /** True when no platform stretch/scaleType can express the request and the
168
+ * leaf must measure + lay out the image itself: 'none' (iOS maps NS
169
+ * stretch='none' to TopLeft, not CSS centered), 'scale-down' (needs the
170
+ * intrinsic-vs-view compare), or any non-center position. 'fill' is
171
+ * exempt — its rect is always the whole box, so position can't matter. */
172
+ function needsContentRect(fit, position) {
173
+ if (fit === "none" || fit === "scale-down") return true;
174
+ return fit !== "fill" && !isCenteredContentPosition(position);
175
+ }
176
+ /** NS `stretch` for the fast path — only reached for fits the platform
177
+ * vocabulary expresses identically (contain/cover/fill centered). */
178
+ function contentFitToStretch(fit) {
179
+ switch (fit) {
180
+ case "contain": return "aspectFit";
181
+ case "fill": return "fill";
182
+ default: return "aspectFill";
183
+ }
184
+ }
185
+ /** L8 — expo `getBestSource`: the source whose declared pixel count
186
+ * (`width·height·scale²`, dips × density) is closest to the view's pixel
187
+ * count. Needs a real measured size; one source or no size picks nothing
188
+ * to decide. Unsized sources score worst and are skipped when any sized
189
+ * candidate exists. */
190
+ function bestImageSource(sources, viewWidth, viewHeight, screenScale) {
191
+ if (sources.length === 0 || viewWidth <= 0 || viewHeight <= 0) return null;
192
+ if (sources.length === 1) return sources[0];
193
+ const target = viewWidth * viewHeight * screenScale * screenScale;
194
+ let best = null;
195
+ let bestFit = Infinity;
196
+ for (const source of sources) {
197
+ const pixelCount = (source.width ?? 0) * (source.height ?? 0) * (source.scale ?? 1) ** 2;
198
+ const fit = Math.abs(1 - pixelCount / target);
199
+ if (fit < bestFit) {
200
+ best = source;
201
+ bestFit = fit;
202
+ }
203
+ }
204
+ return best;
205
+ }
206
+ //#endregion
207
+ export { DEFAULT_CONTENT_FIT, bestImageSource, contentAxisOffset, contentFitToStretch, contentRect, isCenteredContentPosition, needsContentRect, resolveContentFit, resolveContentPosition };
@@ -0,0 +1,3 @@
1
+ import { Image } from "./Image.tsrx.js";
2
+ import { clearImageCaches, evictImage, initializeImageCache, isImageCached, prefetch } from "./cache.js";
3
+ export { Image, clearImageCaches, evictImage, initializeImageCache, isImageCached, prefetch };
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "buildId": "740180aa-315b-41af-a9cc-db59344ba125",
4
+ "mode": "production",
5
+ "capabilities": {
6
+ "independentHydration": false
7
+ }
8
+ }
@@ -0,0 +1,27 @@
1
+ function prefetchBatch(srcs, concurrency, warm) {
2
+ const urls = Array.isArray(srcs) ? srcs : [srcs];
3
+ if (urls.length === 0) return Promise.resolve(true);
4
+ const requested = Math.floor(concurrency ?? 5);
5
+ const limit = Number.isFinite(requested) && requested > 0 ? requested : 5;
6
+ return new Promise((resolve) => {
7
+ let next = 0;
8
+ let active = Math.min(limit, urls.length);
9
+ for (let i = 0; i < active; i++) (async () => {
10
+ while (next < urls.length) {
11
+ const url = urls[next++];
12
+ try {
13
+ if (!await warm(url)) {
14
+ resolve(false);
15
+ return;
16
+ }
17
+ } catch {
18
+ resolve(false);
19
+ return;
20
+ }
21
+ }
22
+ if (--active === 0) resolve(true);
23
+ })();
24
+ });
25
+ }
26
+ //#endregion
27
+ export { prefetchBatch };