@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/native/Image.tsrx.js +264 -0
- package/dist/native/cache.js +64 -0
- package/dist/native/hash-image.js +285 -0
- package/dist/native/image-binding.js +103 -0
- package/dist/native/image-content.js +207 -0
- package/dist/native/index.js +3 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/prefetch-batch.js +27 -0
- package/dist/web/Image.web.tsrx.js +123 -0
- package/dist/web/cache.web.js +35 -0
- package/dist/web/hash-image.js +285 -0
- package/dist/web/image-content.js +134 -0
- package/dist/web/index.js +3 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/prefetch-batch.js +27 -0
- package/package.json +84 -4
- package/src/Image.macos.tsrx +24 -0
- package/src/Image.tsrx +269 -0
- package/src/Image.web.tsrx +176 -0
- package/src/cache.macos.ts +31 -0
- package/src/cache.ts +93 -0
- package/src/cache.web.ts +49 -0
- package/src/hash-image.ts +313 -0
- package/src/image-binding.ts +170 -0
- package/src/image-content.ts +310 -0
- package/src/index.macos.ts +26 -0
- package/src/index.ts +28 -0
- package/src/index.web.ts +28 -0
- package/src/native-elements.d.ts +8 -0
- package/src/prefetch-batch.ts +50 -0
- package/src/prefetch.web.test.ts +105 -0
- package/src/props.ts +119 -0
- package/types/generated/.tsrx-typegen-manifest.json +16 -0
- package/types/generated/Image.d.ts +16 -0
- package/types/generated/Image.web.d.ts +12 -0
- package/types/generated/cache.d.ts +28 -0
- package/types/generated/cache.web.d.ts +16 -0
- package/types/generated/hash-image.d.ts +14 -0
- package/types/generated/image-binding.d.ts +40 -0
- package/types/generated/image-content.d.ts +57 -0
- package/types/generated/index.d.ts +3 -0
- package/types/generated/index.web.d.ts +3 -0
- package/types/generated/prefetch-batch.d.ts +10 -0
- package/types/generated/props.d.ts +99 -0
- package/types/index.macos.d.ts +28 -0
package/src/cache.web.ts
ADDED
|
@@ -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
|
+
}
|