@octane-xplat/image 0.0.1 → 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 +124 -0
  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 +84 -4
  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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alec Larson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # `@octane-xplat/image`
2
+
3
+ ```sh
4
+ pnpm add @octane-xplat/image
5
+ ```
6
+
7
+ Cached remote/local images for Octane xplat apps: a plain `<img>` on web and
8
+ [`@nativescript-community/ui-image`](https://github.com/nativescript-community/ui-image)
9
+ v5 on native — **Glide + okhttp3 on Android** (sized LRU memory cache, bitmap
10
+ pool, disk cache, decode-to-view-size) and **SDWebImage on iOS**. This is the
11
+ fix for the core `image`'s 5 MB `LruCache`, which evicts visible images
12
+ mid-scroll and re-decodes them.
13
+
14
+ **Recommended image path for apps with native targets.** `Image` is a drop-in
15
+ for `@octane-xplat/ui`'s `Image` — the same name and the same prop contract
16
+ (`src` incl. `ImageSourceLike[]` multi-source, `alt`, `placeholder`,
17
+ `recyclingKey`, `contentFit`, `contentPosition`, layout-child props,
18
+ `ios`/`android`/`web` bags). Switching engines is an import-path change:
19
+
20
+ ```tsx
21
+ import { Image } from '@octane-xplat/image' // was '@octane-xplat/ui'
22
+ ```
23
+
24
+ plus the engine's own knobs on top:
25
+
26
+ ```tsx
27
+ import { Image, initializeImageCache, prefetch } from '@octane-xplat/image'
28
+
29
+ // Optional, app entry: size the memory cache before the first render.
30
+ initializeImageCache({ memoryCacheScreens: 2 })
31
+
32
+ ;<Image
33
+ src="https://example.com/photo.jpg"
34
+ alt="Trailhead"
35
+ contentFit="cover" // 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
36
+ placeholder="blurhash:LEHV6nWB2yk8pyo0adR*.7kCMdnj" // or res://, ~/, file, data:
37
+ cachePolicy="memory-disk" // or 'none' to bypass both caches (native)
38
+ failureImage="~/failed.png" // shown on load failure (native)
39
+ headers={{ Authorization: 'Bearer …' }} // extra request headers (native)
40
+ decodeWidth={600} // explicit decode bounds in device px; default = view size (native)
41
+ onLoad={(e) => console.log(e.width, e.height, e.source)} // 'network'|'memory'|'disk'|'local' (native)
42
+ onError={(e) => console.warn(e.error)}
43
+ />
44
+
45
+ // Warm the disk cache ahead of a feed scroll (bytes only — decode at
46
+ // display). One URL or a batch; resolves false if any URL fails.
47
+ await prefetch('https://example.com/next.jpg')
48
+ await prefetch(feed.map((post) => post.imageUrl), {
49
+ headers: { Authorization: 'Bearer …' }, // Android only — see below
50
+ concurrency: 5, // max parallel fetches (default)
51
+ })
52
+ ```
53
+
54
+ `onLoad`'s `source` field reports which cache level served the image
55
+ (`'memory'` / `'disk'` / `'network'` / `'local'`) — the cheapest way to prove
56
+ the cache is doing its job.
57
+
58
+ Cache functions: `prefetch`, `evictImage`, `clearImageCaches`,
59
+ `isImageCached`, `initializeImageCache` — the same contract
60
+ `@octane-xplat/gif`'s `prefetch` carries. On web they degrade honestly:
61
+ `prefetch` warms the browser HTTP cache through a throwaway `<img>` per URL,
62
+ awaited until the response lands (resolves `false` if any URL fails); the
63
+ rest are documented no-ops, and macOS `prefetch` resolves `false` since the
64
+ AppKit host has no image pipeline.
65
+
66
+ `prefetch` options: `headers` forwards request headers to the engine —
67
+ honored on Android and iOS with the framework patch —
68
+ and ignored on web, where a plain `<img>` cannot send custom headers.
69
+ `concurrency` caps parallel fetches JS-side (default 5).
70
+
71
+ iOS parity note: `ui-image` 5.x dropped the 4.x wiring that let prefetch honor
72
+ its cache level and forward request `headers`. The framework patch set
73
+ (`@nativescript-community__ui-image@5.0.17.patch`, applied automatically via
74
+ `xplat patches apply` / `@octane-xplat/patches`) restores both — a disk prefetch
75
+ writes encoded bytes to disk without decoding or warming memory (matching
76
+ Android's Glide `downloadOnly`), a memory prefetch stays off disk, and a
77
+ prefetch carrying `headers` sends them on iOS the same as Android.
78
+
79
+ `isImageCached` takes one URL or a list and always resolves a `Record` keyed by URL — the RN `queryCache`
80
+ shape:
81
+
82
+ ```ts
83
+ const states = await isImageCached([
84
+ 'https://example.com/a.jpg',
85
+ 'https://example.com/b.jpg',
86
+ ])
87
+ // states['https://example.com/a.jpg'] === 'memory' | 'disk' | 'none'
88
+ ```
89
+
90
+ On iOS the cache key includes the decode bounds a display load used, so
91
+ probe with the same `decodeWidth`/`decodeHeight` to match it:
92
+
93
+ ```ts
94
+ await isImageCached(url, { decodeWidth: 600, decodeHeight: 400 })
95
+ ```
96
+
97
+ Android caveat: the plugin's probe can't see Glide's active resources, so a
98
+ bitmap still on screen may report `'none'` — treat `'none'` as "not provably
99
+ cached", not "absent".
100
+
101
+ ## Divergences from core `Image`
102
+
103
+ - **No SVG sources** — Glide/SDWebImage don't decode SVG. Keep core `Image`
104
+ for `svg` markup/data URIs/`.svg` URLs.
105
+ - **`cachePolicy` has only two honest values.** The plugin exposes a single
106
+ all-off switch (`noCache` → Glide `skipMemoryCache` +
107
+ `DiskCacheStrategy.NONE`, SDWebImage `FromLoaderOnly`), so
108
+ `'memory'`/`'disk'` splits are not representable — only `'memory-disk'`
109
+ (default) and `'none'`.
110
+ - **`decoding='sync'` is not honorably supported.** Glide/SDWebImage have no
111
+ synchronous decode mode — the ui-image plugin's `loadMode` prop is
112
+ registered but never read — so 'sync' degrades to the async default with
113
+ a console warning. If synchronous decode is a hard requirement, use core
114
+ `Image` (NS `loadMode` honors it for file/resource/data srcs). On web the
115
+ prop maps 1:1 to the HTML `decoding` attribute, as it does on core.
116
+ - Engine extras (`failureImage`, `headers`, `decodeWidth`, `decodeHeight`,
117
+ `progressive`, `fadeDuration`, `cachePolicy`) are native-only and ignored on
118
+ web.
119
+ - macOS renders through the same AppKit `<image>` path as core `Image` — no
120
+ engine there; cache helpers resolve as no-ops.
121
+
122
+ Per-target limits are recorded in
123
+ [known limits](../../docs/verify/known-limits.md). Exercised by
124
+ [`CachedImageDemo`](../demos/src/CachedImageDemo.tsrx).
@@ -0,0 +1,264 @@
1
+ import { hashToPngBase64 } from "./hash-image.js";
2
+ import { contentFitToStretch, needsContentRect, resolveContentFit, resolveContentPosition } from "./image-content.js";
3
+ import { createImageBinding } from "./image-binding.js";
4
+ import { defineUniversalComponent, hookSlots, registerElement, universalIf, universalPlan, universalProps, universalValue, useMemo, useRef } from "@nativescript-community/octane";
5
+ import { ImageSource, Screen, isAndroid, isIOS } from "@nativescript/core";
6
+ import { Img, initialize } from "@nativescript-community/ui-image";
7
+ //#region src/Image.tsrx
8
+ var _hs$ = /* @__PURE__ */ hookSlots(5);
9
+ var _h$0 = /* @__PURE__ */ Symbol(_hs$);
10
+ var _h$1 = /* @__PURE__ */ Symbol(_hs$ + 1);
11
+ var _h$2 = /* @__PURE__ */ Symbol(_hs$ + 2);
12
+ var _h$3 = /* @__PURE__ */ Symbol(_hs$ + 3);
13
+ var _h$4 = /* @__PURE__ */ Symbol(_hs$ + 4);
14
+ var __octaneUniversalPlan0 = universalPlan("nativescript", {
15
+ "kind": "host",
16
+ "type": "absolutelayout",
17
+ "propsSlot": 0,
18
+ "children": [{
19
+ "kind": "host",
20
+ "type": "cachedimage",
21
+ "props": { "stretch": "fill" },
22
+ "bindings": [
23
+ ["ref", 1],
24
+ ["failureImageUri", 2],
25
+ ["headers", 3],
26
+ ["noCache", 4],
27
+ ["progressiveRenderingEnabled", 5],
28
+ ["fadeDuration", 6],
29
+ ["accessibilityLabel", 7]
30
+ ]
31
+ }]
32
+ });
33
+ var __octaneUniversalPlan1 = universalPlan("nativescript", {
34
+ "kind": "host",
35
+ "type": "cachedimage",
36
+ "propsSlot": 0
37
+ });
38
+ var __octaneUniversalPlan2 = universalPlan("nativescript", {
39
+ "kind": "slot",
40
+ "slot": 0
41
+ });
42
+ registerElement("cachedimage", Img);
43
+ var syncDecodeWarned = false;
44
+ var engineStarted = false;
45
+ function ensureEngine() {
46
+ if (!engineStarted) {
47
+ initialize();
48
+ engineStarted = true;
49
+ }
50
+ }
51
+ var LAYOUT_KEYS = [
52
+ "row",
53
+ "col",
54
+ "rowSpan",
55
+ "colSpan",
56
+ "dock",
57
+ "left",
58
+ "top",
59
+ "flexGrow",
60
+ "flexShrink",
61
+ "alignSelf",
62
+ "order"
63
+ ];
64
+ function layoutChildAttrs(props) {
65
+ const result = {};
66
+ for (const key of LAYOUT_KEYS) if (props[key] !== void 0) result[key] = props[key];
67
+ return result;
68
+ }
69
+ function applyEscapeProps(view, props) {
70
+ const bag = isIOS ? props.ios : isAndroid ? props.android : void 0;
71
+ if (bag) Object.assign(view, bag);
72
+ }
73
+ function resolveSrc(src) {
74
+ const comma = src.indexOf(",");
75
+ if (src.startsWith("data:") && comma > 0 && src.slice(0, comma).endsWith(";base64")) return ImageSource.fromBase64Sync(src.slice(comma + 1));
76
+ return src;
77
+ }
78
+ function placeholderValue(placeholder) {
79
+ const png = hashToPngBase64(placeholder);
80
+ if (png) return ImageSource.fromBase64Sync(png);
81
+ if (/^https?:\/\//.test(placeholder)) return null;
82
+ return resolveSrc(placeholder);
83
+ }
84
+ var Image = defineUniversalComponent("nativescript", function Image(props) {
85
+ ensureEngine();
86
+ if (props.decoding === "sync" && !syncDecodeWarned) {
87
+ syncDecodeWarned = true;
88
+ console.warn("[octane-xplat] Image: decoding='sync' is not supported by the Glide/SDWebImage engine — decode stays async.");
89
+ }
90
+ const latest = useRef(props, _h$0);
91
+ latest.current = props;
92
+ const fit = resolveContentFit(props.contentFit);
93
+ const position = resolveContentPosition(props.contentPosition);
94
+ const computed = needsContentRect(fit, position);
95
+ const imageRef = useRef(null, _h$1);
96
+ const hostRef = useRef(null, _h$2);
97
+ const binding = useMemo(() => createImageBinding({
98
+ getFit: () => resolveContentFit(latest.current.contentFit),
99
+ getPosition: () => resolveContentPosition(latest.current.contentPosition),
100
+ getSrc: () => {
101
+ const src = latest.current.src;
102
+ return Array.isArray(src) ? {
103
+ source: null,
104
+ sources: src
105
+ } : {
106
+ source: resolveSrc(src),
107
+ sources: null
108
+ };
109
+ },
110
+ getDecodeBounds: () => ({
111
+ width: latest.current.decodeWidth,
112
+ height: latest.current.decodeHeight
113
+ }),
114
+ screenScale: () => Screen.mainScreen?.scale ?? 1
115
+ }), [], _h$3);
116
+ const boundRef = useRef({
117
+ key: props.recyclingKey,
118
+ src: props.src
119
+ }, _h$4);
120
+ const bound = boundRef.current;
121
+ boundRef.current = {
122
+ key: props.recyclingKey,
123
+ src: props.src
124
+ };
125
+ if (bound.key != null && props.recyclingKey != null && bound.key !== props.recyclingKey && bound.src !== props.src && imageRef.current) {
126
+ const nativeView = imageRef.current.nativeViewProtected;
127
+ if (nativeView) {
128
+ if (isIOS) nativeView.image = null;
129
+ else nativeView.setImageDrawable?.(null);
130
+ }
131
+ }
132
+ const attach = (view) => {
133
+ if (!view) return;
134
+ imageRef.current = view;
135
+ applyEscapeProps(view, props);
136
+ const ph = latest.current.placeholder;
137
+ if (ph) {
138
+ const resolved = placeholderValue(ph);
139
+ if (resolved != null) view.placeholderImageUri = resolved;
140
+ }
141
+ if (!view.__xplatImageWired) {
142
+ view.__xplatImageWired = true;
143
+ view.on(Img.finalImageSetEvent, (e) => {
144
+ const info = e.imageInfo;
145
+ const w = info?.getWidth?.() ?? info?.width ?? 0;
146
+ const h = info?.getHeight?.() ?? info?.height ?? 0;
147
+ binding.setIntrinsic(w, h);
148
+ latest.current.onLoad?.({
149
+ width: w,
150
+ height: h,
151
+ source: e.source
152
+ });
153
+ });
154
+ view.on(Img.failureEvent, (e) => {
155
+ latest.current.onError?.({ error: e.error });
156
+ });
157
+ }
158
+ };
159
+ return universalValue(__octaneUniversalPlan2, [universalIf(computed, () => {
160
+ return universalValue(__octaneUniversalPlan0, [
161
+ universalProps([
162
+ [
163
+ "set",
164
+ "ref",
165
+ (v) => {
166
+ hostRef.current = v;
167
+ if (v && isIOS && v.ios) v.ios.clipsToBounds = true;
168
+ if (v) binding.update(imageRef.current, v);
169
+ }
170
+ ],
171
+ [
172
+ "set",
173
+ "id",
174
+ props.id
175
+ ],
176
+ [
177
+ "set",
178
+ "className",
179
+ props.className
180
+ ],
181
+ [
182
+ "set",
183
+ "style",
184
+ props.style
185
+ ],
186
+ ["spread", layoutChildAttrs(props)]
187
+ ]),
188
+ (v) => {
189
+ attach(v);
190
+ if (v) binding.update(v, hostRef.current);
191
+ },
192
+ props.failureImage,
193
+ props.headers,
194
+ props.cachePolicy === "none",
195
+ props.progressive,
196
+ props.fadeDuration,
197
+ props.alt
198
+ ]);
199
+ }, () => {
200
+ return universalValue(__octaneUniversalPlan1, [universalProps([
201
+ [
202
+ "set",
203
+ "ref",
204
+ (v) => {
205
+ attach(v);
206
+ if (v) binding.update(v, null);
207
+ }
208
+ ],
209
+ [
210
+ "set",
211
+ "id",
212
+ props.id
213
+ ],
214
+ [
215
+ "set",
216
+ "className",
217
+ props.className
218
+ ],
219
+ [
220
+ "set",
221
+ "style",
222
+ props.style
223
+ ],
224
+ ["spread", layoutChildAttrs(props)],
225
+ [
226
+ "set",
227
+ "stretch",
228
+ contentFitToStretch(fit)
229
+ ],
230
+ [
231
+ "set",
232
+ "failureImageUri",
233
+ props.failureImage
234
+ ],
235
+ [
236
+ "set",
237
+ "headers",
238
+ props.headers
239
+ ],
240
+ [
241
+ "set",
242
+ "noCache",
243
+ props.cachePolicy === "none"
244
+ ],
245
+ [
246
+ "set",
247
+ "progressiveRenderingEnabled",
248
+ props.progressive
249
+ ],
250
+ [
251
+ "set",
252
+ "fadeDuration",
253
+ props.fadeDuration
254
+ ],
255
+ [
256
+ "set",
257
+ "accessibilityLabel",
258
+ props.alt
259
+ ]
260
+ ])]);
261
+ })]);
262
+ }, { "module": "@nativescript-community/octane" });
263
+ //#endregion
264
+ export { Image };
@@ -0,0 +1,64 @@
1
+ import { prefetchBatch } from "./prefetch-batch.js";
2
+ import { ImagePipeline, getImagePipeline, initialize } from "@nativescript-community/ui-image";
3
+ //#region src/cache.ts
4
+ /** Apply engine-level cache configuration. Call from the app entry before the
5
+ * first <Image> renders — the plugin initializes Glide lazily, so sizing must
6
+ * arrive before the first view creates the engine. A later call is a no-op
7
+ * (the plugin guards on its own `initialized` flag). Native only. */
8
+ function initializeImageCache(config) {
9
+ initialize({
10
+ memoryCacheSize: config?.memoryCacheSize,
11
+ memoryCacheScreens: config?.memoryCacheScreens,
12
+ usePersistentCacheKeyStore: config?.persistentCacheKeys,
13
+ globalSignatureKey: config?.globalSignatureKey
14
+ });
15
+ if (config?.iosComplexCacheEviction !== void 0) ImagePipeline.iosComplexCacheEviction = config.iosComplexCacheEviction;
16
+ }
17
+ /** Warm the disk cache without decoding — bytes are fetched and stored, decode
18
+ * cost stays at display time. One URL or an array; resolves `true` when all
19
+ * warmed, `false` if any URL fails or the pipeline is not up yet (e.g. called
20
+ * before app launch). Same contract as `@octane-xplat/gif`'s `prefetch`.
21
+ *
22
+ * `options.headers` is forwarded on Android and iOS (via the framework
23
+ * patch). `options.concurrency` caps parallel fetches. */
24
+ function prefetch(srcs, options) {
25
+ try {
26
+ initialize();
27
+ const pipeline = getImagePipeline();
28
+ const { concurrency, ...engineOptions } = options ?? {};
29
+ return prefetchBatch(srcs, concurrency, (url) => pipeline.prefetchToDiskCache(url, engineOptions).then(() => true, () => false));
30
+ } catch {
31
+ return Promise.resolve(false);
32
+ }
33
+ }
34
+ /** Remove `src` from memory and disk caches. */
35
+ function evictImage(src) {
36
+ initialize();
37
+ return getImagePipeline().evictFromCache(src);
38
+ }
39
+ /** Drop every cached image (memory + disk). */
40
+ function clearImageCaches() {
41
+ initialize();
42
+ return getImagePipeline().clearCaches();
43
+ }
44
+ /** Report which cache level currently holds each `src` — 'memory', 'disk',
45
+ * or 'none' (not cached / unknown). Accepts a single URL or a list; the
46
+ * result is always a `Record` keyed by URL (the RN `queryCache` shape).
47
+ * `options` repeats the display load's decode bounds: on iOS the cache key
48
+ * includes the transform context, so an image displayed with
49
+ * decodeWidth/decodeHeight only reports its level when the probe passes
50
+ * the same values. Android caveat: the plugin's probe cannot see Glide's
51
+ * active resources, so a bitmap still on screen may report 'none' —
52
+ * treat 'none' as "not provably cached", not "absent". */
53
+ async function isImageCached(srcs, options) {
54
+ initialize();
55
+ const pipeline = getImagePipeline();
56
+ const list = typeof srcs === "string" ? [srcs] : srcs;
57
+ const states = {};
58
+ await Promise.all(list.map(async (src) => {
59
+ states[src] = pipeline.isInBitmapMemoryCache(src, options) ? "memory" : await pipeline.isInDiskCache(src, options) ? "disk" : "none";
60
+ }));
61
+ return states;
62
+ }
63
+ //#endregion
64
+ export { clearImageCaches, evictImage, initializeImageCache, isImageCached, prefetch };