react-native-fast-image 10.1.0 → 10.2.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/README.md +84 -10
- package/android/src/main/java/com/fastimage/FastImageBlur.java +3 -2
- package/android/src/main/java/com/fastimage/FastImageThumbHash.java +246 -0
- package/android/src/main/java/com/fastimage/FastImageViewConverter.java +1 -1
- package/android/src/main/java/com/fastimage/FastImageViewManager.java +20 -5
- package/android/src/main/java/com/fastimage/FastImageViewWithUrl.java +125 -48
- package/dist/index.cjs.js +47 -9
- package/dist/index.cjs.js.flow +9 -0
- package/dist/index.cjs.web.js +49 -6
- package/dist/index.cjs.web.js.flow +9 -0
- package/dist/index.d.ts +56 -13
- package/dist/index.js +48 -10
- package/dist/index.js.flow +9 -0
- package/dist/index.web.js +49 -6
- package/dist/index.web.js.flow +9 -0
- package/dist/placeholders.d.ts +4 -0
- package/dist/specs/FastImageViewNativeComponent.d.ts +2 -1
- package/dist/thumbhash.cjs.js +241 -0
- package/dist/thumbhash.d.ts +12 -0
- package/dist/thumbhash.js +240 -0
- package/ios/FastImage/FFFBoxBlur.h +7 -0
- package/ios/FastImage/FFFBoxBlur.m +29 -0
- package/ios/FastImage/FFFThumbHash.h +24 -0
- package/ios/FastImage/FFFThumbHash.m +269 -0
- package/ios/FastImage/FFFastImageView.h +7 -1
- package/ios/FastImage/FFFastImageView.m +100 -46
- package/ios/FastImage/FFFastImageViewComponentView.mm +11 -4
- package/package.json +4 -2
- package/src/index.js.flow +9 -0
- package/src/index.tsx +142 -31
- package/src/index.web.tsx +119 -11
- package/src/placeholders.ts +45 -0
- package/src/specs/FastImageViewNativeComponent.ts +8 -2
- package/src/thumbhash.ts +363 -0
- package/thumbhash/package.json +8 -0
package/src/index.web.tsx
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
//
|
|
6
6
|
// The View (a div) has FastImage's style, ref and View props. The <img> fills
|
|
7
7
|
// it inside its borders, with object-fit for objectFit (see Picture).
|
|
8
|
-
//
|
|
8
|
+
// The placeholder is a second <img> under it until the image has loaded (the
|
|
9
9
|
// browser draws the image over it as it arrives), and tintColor is an SVG
|
|
10
10
|
// filter next to them.
|
|
11
11
|
//
|
|
12
|
-
// Works: source (uri, require(), or several sizes: see sizedSources
|
|
13
|
-
//
|
|
12
|
+
// Works: source (uri, require(), or several sizes: see sizedSources, and
|
|
13
|
+
// thumbhash and blurDataUri), placeholder (and defaultSource), objectFit,
|
|
14
|
+
// objectPosition, tintColor, blurRadius, style,
|
|
14
15
|
// onLoadStart, onLoad, onError, onLoadEnd, and View props (testID,
|
|
15
16
|
// accessibility, onLayout, pointerEvents). The native-only props (cache,
|
|
16
17
|
// priority, headers, transition, downsample, loop, paused, imageRendering,
|
|
@@ -29,7 +30,8 @@ import { getAssetByID } from 'react-native-web/dist/modules/AssetRegistry'
|
|
|
29
30
|
import { cacheControl, priority, resizeMode } from './constants'
|
|
30
31
|
import { resolveObjectFit } from './objectFit'
|
|
31
32
|
import { cssPosition, objectPositionOrCentered } from './objectPosition'
|
|
32
|
-
import {
|
|
33
|
+
import { dataUri, hasSourcePlaceholder } from './placeholders'
|
|
34
|
+
import { warnIfChildren, warnOnce } from './warnings'
|
|
33
35
|
import type {
|
|
34
36
|
CachePathResult,
|
|
35
37
|
CacheState,
|
|
@@ -39,6 +41,7 @@ import type {
|
|
|
39
41
|
ObjectFit,
|
|
40
42
|
PreloadResult,
|
|
41
43
|
Source,
|
|
44
|
+
SourcePlaceholder,
|
|
42
45
|
} from './index'
|
|
43
46
|
|
|
44
47
|
export type * from './index'
|
|
@@ -71,6 +74,7 @@ declare const __DEV__: boolean | undefined
|
|
|
71
74
|
|
|
72
75
|
function FastImageBase({
|
|
73
76
|
source,
|
|
77
|
+
placeholder,
|
|
74
78
|
defaultSource,
|
|
75
79
|
tintColor,
|
|
76
80
|
blurRadius,
|
|
@@ -121,13 +125,28 @@ function FastImageBase({
|
|
|
121
125
|
// one doesn't stay while it loads; with several sizes, the <img> stays,
|
|
122
126
|
// and the browser shows the previous image until the new one has loaded.
|
|
123
127
|
const key = srcSet ?? src
|
|
124
|
-
|
|
128
|
+
// The source's blurDataUri, or its ThumbHash made into one (with
|
|
129
|
+
// `react-native-fast-image/thumbhash`), shows blurred (see PreviewBlur)
|
|
130
|
+
// in place of `placeholder` (or `placeholder`'s, for a source without its
|
|
131
|
+
// own). With several sizes the browser picks one itself (from the srcset,
|
|
132
|
+
// as it loads), so they only show if all the sizes have the same one.
|
|
133
|
+
const preview = hasSourcePlaceholder(placeholder) ? placeholder : undefined
|
|
134
|
+
const blurUri =
|
|
135
|
+
resolveUri(dataUri(shared(source, 'blurDataUri', preview))) ??
|
|
136
|
+
thumbhashUri(shared(source, 'thumbhash', preview))
|
|
137
|
+
// A thumbhash or blurDataUri in `placeholder` isn't an image to show as it
|
|
138
|
+
// is: defaultSource still is.
|
|
139
|
+
const defaultUri =
|
|
140
|
+
blurUri ??
|
|
141
|
+
resolveUri(preview ? defaultSource : (placeholder ?? defaultSource))
|
|
125
142
|
// Whether the image for `key` loaded, or failed.
|
|
126
143
|
const [result, setResult] = useState<{ key: string; ok: boolean }>()
|
|
127
144
|
const ok = result && result.key === key ? result.ok : undefined
|
|
128
145
|
// The same on the server and in the browser. React's ids have characters
|
|
129
146
|
// an SVG id can't (:r0: in React 18, «r0» in React 19).
|
|
130
|
-
const
|
|
147
|
+
const svgId = useSvgId().replace(/[^\w-]/g, '')
|
|
148
|
+
const tintId = `fast-image-tint-${svgId}`
|
|
149
|
+
const blurId = `fast-image-blur-${svgId}`
|
|
131
150
|
// objectFit (the prop, then style's).
|
|
132
151
|
const fit = resolveObjectFit(objectFit, style)
|
|
133
152
|
const position = cssPosition(objectPositionOrCentered(objectPosition))
|
|
@@ -138,7 +157,7 @@ function FastImageBase({
|
|
|
138
157
|
]
|
|
139
158
|
.filter(Boolean)
|
|
140
159
|
.join(' ') || undefined
|
|
141
|
-
//
|
|
160
|
+
// The placeholder shows until the image has loaded, and if it fails.
|
|
142
161
|
const showDefault = !!defaultUri && ok !== true
|
|
143
162
|
|
|
144
163
|
useClientLayoutEffect(() => {
|
|
@@ -160,9 +179,28 @@ function FastImageBase({
|
|
|
160
179
|
<Picture
|
|
161
180
|
key={`default ${defaultUri}`}
|
|
162
181
|
src={defaultUri}
|
|
163
|
-
|
|
182
|
+
// A source's placeholder stands for an image whose size
|
|
183
|
+
// isn't known yet: as contain for scale-down and none,
|
|
184
|
+
// which show an image at its own size.
|
|
185
|
+
fit={
|
|
186
|
+
blurUri && (fit === 'scale-down' || fit === 'none')
|
|
187
|
+
? 'contain'
|
|
188
|
+
: fit
|
|
189
|
+
}
|
|
164
190
|
position={position}
|
|
165
|
-
|
|
191
|
+
// A source's preview gets its own blur and the tint, not
|
|
192
|
+
// blurRadius (it's blurred already), as on iOS and
|
|
193
|
+
// Android.
|
|
194
|
+
filter={
|
|
195
|
+
blurUri
|
|
196
|
+
? [
|
|
197
|
+
`url(#${blurId})`,
|
|
198
|
+
tintColor ? `url(#${tintId})` : '',
|
|
199
|
+
]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(' ')
|
|
202
|
+
: filter
|
|
203
|
+
}
|
|
166
204
|
// The image has the label, if there is one.
|
|
167
205
|
alt={key ? '' : (label ?? '')}
|
|
168
206
|
/>
|
|
@@ -176,7 +214,7 @@ function FastImageBase({
|
|
|
176
214
|
fit={fit}
|
|
177
215
|
position={position}
|
|
178
216
|
filter={filter}
|
|
179
|
-
// Shown as it loads, over
|
|
217
|
+
// Shown as it loads, over the placeholder, and not if it
|
|
180
218
|
// failed (no broken image icon or alt text).
|
|
181
219
|
hidden={ok === false}
|
|
182
220
|
alt={label ?? ''}
|
|
@@ -195,11 +233,52 @@ function FastImageBase({
|
|
|
195
233
|
/>
|
|
196
234
|
) : null}
|
|
197
235
|
{tintColor ? <TintFilter id={tintId} color={tintColor} /> : null}
|
|
236
|
+
{blurUri && showDefault ? <PreviewBlur id={blurId} /> : null}
|
|
198
237
|
</View>
|
|
199
238
|
)
|
|
200
239
|
}
|
|
201
240
|
|
|
202
|
-
// A
|
|
241
|
+
// A ThumbHash as a blurDataUri, with `react-native-fast-image/thumbhash`
|
|
242
|
+
// imported (see thumbhash.ts). Without it, ignored, with a warning in
|
|
243
|
+
// development: FastImage doesn't bundle the decoder on the web.
|
|
244
|
+
function thumbhashUri(hash: string | undefined): string | undefined {
|
|
245
|
+
if (!hash) return undefined
|
|
246
|
+
const decode = (globalThis as Record<symbol, unknown>)[
|
|
247
|
+
Symbol.for('react-native-fast-image/thumbhash')
|
|
248
|
+
] as ((hash: string) => string | undefined) | undefined
|
|
249
|
+
if (decode) return decode(hash)
|
|
250
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
251
|
+
warnOnce(
|
|
252
|
+
'thumbhash-web',
|
|
253
|
+
"react-native-fast-image: on the web, a `thumbhash` shows only with `import 'react-native-fast-image/thumbhash'` (it decodes them). Or make it a `blurDataUri` before it reaches the browser (e.g. on your server) with that module's thumbHashToBlurDataUri, which needs no JavaScript in the browser.",
|
|
254
|
+
'warn',
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The thumbhash or blurDataUri a source shows: its own, or `placeholder`'s
|
|
261
|
+
// (preview) if it has neither. With several sources, the one they all show.
|
|
262
|
+
function shared(
|
|
263
|
+
source: FastImageProps['source'],
|
|
264
|
+
key: 'thumbhash' | 'blurDataUri',
|
|
265
|
+
preview: SourcePlaceholder | undefined,
|
|
266
|
+
): string | undefined {
|
|
267
|
+
const values = (Array.isArray(source) ? source : [source]).map((entry) => {
|
|
268
|
+
const own =
|
|
269
|
+
entry && typeof entry === 'object' ? (entry as Source) : undefined
|
|
270
|
+
// A blurDataUri that isn't a data uri is ignored (with a warning), as
|
|
271
|
+
// on iOS and Android.
|
|
272
|
+
return (own?.thumbhash || dataUri(own?.blurDataUri) ? own : preview)?.[
|
|
273
|
+
key
|
|
274
|
+
]
|
|
275
|
+
})
|
|
276
|
+
return values.every((value) => value && value === values[0])
|
|
277
|
+
? values[0]
|
|
278
|
+
: undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// A source's (or a placeholder's) uri. Expo's web builds make a require()d
|
|
203
282
|
// image a { uri, width, height } object (or a string). Other setups can make
|
|
204
283
|
// it a number in react-native-web's asset registry, resolved as its Image
|
|
205
284
|
// does: the file for the scale closest to the screen's.
|
|
@@ -439,6 +518,35 @@ function TintFilter({ id, color }: { id: string; color: unknown }) {
|
|
|
439
518
|
)
|
|
440
519
|
}
|
|
441
520
|
|
|
521
|
+
// A source's blurDataUri, a few pixels, blurred as it's scaled up: by about
|
|
522
|
+
// half a pixel of an 8 px image, in units of its size, so the same filter
|
|
523
|
+
// works for any view, also rendered on a server (no layout needed). The blur
|
|
524
|
+
// fades the edges, which the alpha table restores (transparent areas stay
|
|
525
|
+
// transparent): Chrome ignores edgeMode, and forcing alpha to 1 would fill
|
|
526
|
+
// a transparent image's background.
|
|
527
|
+
function PreviewBlur({ id }: { id: string }) {
|
|
528
|
+
return (
|
|
529
|
+
<svg style={tintStyle}>
|
|
530
|
+
<defs>
|
|
531
|
+
<filter
|
|
532
|
+
id={id}
|
|
533
|
+
x="0"
|
|
534
|
+
y="0"
|
|
535
|
+
width="1"
|
|
536
|
+
height="1"
|
|
537
|
+
primitiveUnits="objectBoundingBox"
|
|
538
|
+
colorInterpolationFilters="sRGB"
|
|
539
|
+
>
|
|
540
|
+
<feGaussianBlur stdDeviation="0.06" />
|
|
541
|
+
<feComponentTransfer>
|
|
542
|
+
<feFuncA type="table" tableValues="0 1 1 1 1" />
|
|
543
|
+
</feComponentTransfer>
|
|
544
|
+
</filter>
|
|
545
|
+
</defs>
|
|
546
|
+
</svg>
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
442
550
|
const tintStyle = {
|
|
443
551
|
position: 'absolute',
|
|
444
552
|
width: 0,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Placeholders given as strings (`placeholder`, and a source's `blurDataUri`)
|
|
2
|
+
// are data uris, shared by the native (index.tsx) and web (index.web.tsx)
|
|
3
|
+
// versions, or for `placeholder`, the name of an image bundled with the app.
|
|
4
|
+
// They're decoded at once, so they show in the view's first frame; a url
|
|
5
|
+
// would load like an image (and on iOS and Android, not at all), so it's
|
|
6
|
+
// ignored, with a warning in development.
|
|
7
|
+
import type { SourcePlaceholder } from './index'
|
|
8
|
+
import { warnOnce } from './warnings'
|
|
9
|
+
|
|
10
|
+
// Metro's (and React Native's Jest preset's) development flag; other web
|
|
11
|
+
// bundlers may not define it.
|
|
12
|
+
declare const __DEV__: boolean | undefined
|
|
13
|
+
|
|
14
|
+
// Whether `placeholder` is the name of an image bundled with the app (an iOS
|
|
15
|
+
// asset catalog image, an Android drawable): no scheme, not a path.
|
|
16
|
+
export function isImageName(value: string): boolean {
|
|
17
|
+
return !/^[a-z][a-z\d+.-]*:/i.test(value) && !value.startsWith('/')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Whether a source has a thumbhash or blurDataUri, or `placeholder` is one
|
|
21
|
+
// (for every size of the source) rather than an image.
|
|
22
|
+
export function hasSourcePlaceholder(
|
|
23
|
+
value: unknown,
|
|
24
|
+
): value is SourcePlaceholder {
|
|
25
|
+
return (
|
|
26
|
+
!!value &&
|
|
27
|
+
typeof value === 'object' &&
|
|
28
|
+
('thumbhash' in value || 'blurDataUri' in value)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The value if it's a data uri, otherwise undefined (with a warning for
|
|
33
|
+
// another string).
|
|
34
|
+
export function dataUri(value: unknown): string | undefined {
|
|
35
|
+
if (typeof value !== 'string' || value === '') return undefined
|
|
36
|
+
if (/^data:/i.test(value)) return value
|
|
37
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
38
|
+
warnOnce(
|
|
39
|
+
'placeholder-url',
|
|
40
|
+
`react-native-fast-image: \`placeholder\` and \`blurDataUri\` don't load urls: "${value.slice(0, 60)}" is ignored. Use a data uri (data:image/…), or for \`placeholder\`, a require()d image or the name of an image bundled with the app.`,
|
|
41
|
+
'warn',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
@@ -45,8 +45,14 @@ export interface NativeProps extends ViewProps {
|
|
|
45
45
|
source?: CodegenTypes.UnsafeMixed
|
|
46
46
|
// Several sources (sizes of one image), picked from for the view's size.
|
|
47
47
|
sources?: CodegenTypes.UnsafeMixed
|
|
48
|
-
//
|
|
49
|
-
|
|
48
|
+
// The `placeholder` prop: a resolved asset source ({ uri, width, height,
|
|
49
|
+
// scale }) or a data uri ({ uri } on iOS; the uri on Android).
|
|
50
|
+
placeholder?: CodegenTypes.UnsafeMixed
|
|
51
|
+
// What shows in place of placeholder while a source loads: its ThumbHash,
|
|
52
|
+
// or its blurDataUri (blurred natively), by the source's index ('' for
|
|
53
|
+
// none; one for a single source). Apart from the sources, so a new one
|
|
54
|
+
// for the same image doesn't load it again.
|
|
55
|
+
sourcePlaceholders?: ReadonlyArray<string>
|
|
50
56
|
// objectFit, resolved (fill, contain, cover, none or scale-down).
|
|
51
57
|
objectFit?: CodegenTypes.WithDefault<string, 'cover'>
|
|
52
58
|
// objectPosition, as CSS computes it: on each axis the image's left or top
|
package/src/thumbhash.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// ThumbHashes (https://evanw.github.io/thumbhash/) on the web, as
|
|
2
|
+
// `react-native-fast-image/thumbhash`: importing it makes the web version
|
|
3
|
+
// show sources' thumbhashes (iOS and Android decode them natively), and
|
|
4
|
+
// thumbHashToBlurDataUri makes a source's `blurDataUri` from one, e.g. on a
|
|
5
|
+
// server, so a page shows it without any JavaScript. FastImage doesn't import
|
|
6
|
+
// it, so web bundles only have it if the app does.
|
|
7
|
+
//
|
|
8
|
+
// The data uri is a PNG of at most 8 × 8 pixels: a ThumbHash holds at most 7
|
|
9
|
+
// frequencies on its longer side, so more pixels add bytes, not detail. The
|
|
10
|
+
// web version blurs it as it scales it up, as it does every blurDataUri.
|
|
11
|
+
//
|
|
12
|
+
// thumbHashToRGBA and rgbaToDataURL are Evan Wallace's reference code
|
|
13
|
+
// (https://github.com/evanw/thumbhash, js/thumbhash.js), typed, with a check
|
|
14
|
+
// that the hash is long enough for what its header says it holds:
|
|
15
|
+
//
|
|
16
|
+
// Copyright (c) 2023 Evan Wallace
|
|
17
|
+
//
|
|
18
|
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
19
|
+
// of this software and associated documentation files (the "Software"), to
|
|
20
|
+
// deal in the Software without restriction, including without limitation the
|
|
21
|
+
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
22
|
+
// sell copies of the Software, and to permit persons to whom the Software is
|
|
23
|
+
// furnished to do so, subject to the following conditions:
|
|
24
|
+
//
|
|
25
|
+
// The above copyright notice and this permission notice shall be included in
|
|
26
|
+
// all copies or substantial portions of the Software.
|
|
27
|
+
//
|
|
28
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
29
|
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
30
|
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
31
|
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
32
|
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
33
|
+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
34
|
+
// IN THE SOFTWARE.
|
|
35
|
+
|
|
36
|
+
// Recently made data uris, by their hash, the least recently used dropped.
|
|
37
|
+
const cache = new Map<string, string | undefined>()
|
|
38
|
+
const CACHE_SIZE = 256
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Makes a source's `blurDataUri` from a ThumbHash, e.g. on your server, so
|
|
42
|
+
* the page shows the preview without any JavaScript. Undefined for an invalid
|
|
43
|
+
* hash. Works in browsers, Node and React Native.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```js
|
|
47
|
+
* // e.g. on your server, so the page shows the preview without any JavaScript
|
|
48
|
+
* const source = { uri: photo.url, blurDataUri: thumbHashToBlurDataUri(photo.thumbhash) }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function thumbHashToBlurDataUri(
|
|
52
|
+
thumbhash: string | undefined,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
if (!thumbhash) return undefined
|
|
55
|
+
if (cache.has(thumbhash)) {
|
|
56
|
+
const uri = cache.get(thumbhash)
|
|
57
|
+
cache.delete(thumbhash)
|
|
58
|
+
cache.set(thumbhash, uri)
|
|
59
|
+
return uri
|
|
60
|
+
}
|
|
61
|
+
const hash = base64Bytes(thumbhash)
|
|
62
|
+
const image = hash ? thumbHashToRGBA(hash) : undefined
|
|
63
|
+
const small = image ? shrink(image.w, image.h, image.rgba, 8) : undefined
|
|
64
|
+
const uri = small ? rgbaToDataURL(small.w, small.h, small.rgba) : undefined
|
|
65
|
+
if (cache.size >= CACHE_SIZE) {
|
|
66
|
+
cache.delete(cache.keys().next().value as string)
|
|
67
|
+
}
|
|
68
|
+
cache.set(thumbhash, uri)
|
|
69
|
+
return uri
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The web version's decoder, which index.web.tsx finds here without importing
|
|
73
|
+
// this module (the same key from any copy of the package).
|
|
74
|
+
;(globalThis as Record<symbol, unknown>)[
|
|
75
|
+
Symbol.for('react-native-fast-image/thumbhash')
|
|
76
|
+
] = thumbHashToBlurDataUri
|
|
77
|
+
|
|
78
|
+
// The pixels averaged down so the longer side is at most max.
|
|
79
|
+
function shrink(w: number, h: number, rgba: Uint8Array, max: number) {
|
|
80
|
+
const scale = Math.max(w, h) / max
|
|
81
|
+
if (scale <= 1) return { w, h, rgba }
|
|
82
|
+
const sw = Math.max(1, Math.round(w / scale))
|
|
83
|
+
const sh = Math.max(1, Math.round(h / scale))
|
|
84
|
+
const out = new Uint8Array(sw * sh * 4)
|
|
85
|
+
for (let y = 0; y < sh; y++) {
|
|
86
|
+
const y0 = Math.floor((y * h) / sh)
|
|
87
|
+
const y1 = Math.floor(((y + 1) * h) / sh)
|
|
88
|
+
for (let x = 0; x < sw; x++) {
|
|
89
|
+
const x0 = Math.floor((x * w) / sw)
|
|
90
|
+
const x1 = Math.floor(((x + 1) * w) / sw)
|
|
91
|
+
for (let c = 0; c < 4; c++) {
|
|
92
|
+
let sum = 0
|
|
93
|
+
for (let yy = y0; yy < y1; yy++)
|
|
94
|
+
for (let xx = x0; xx < x1; xx++)
|
|
95
|
+
sum += rgba[(yy * w + xx) * 4 + c]
|
|
96
|
+
out[(y * sw + x) * 4 + c] = Math.round(
|
|
97
|
+
sum / ((y1 - y0) * (x1 - x0)),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { w: sw, h: sh, rgba: out }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A ThumbHash's bytes: base64, standard or url-safe, with or without
|
|
106
|
+
// padding.
|
|
107
|
+
function base64Bytes(base64: string): Uint8Array | undefined {
|
|
108
|
+
const standard = base64.trim().replace(/-/g, '+').replace(/_/g, '/')
|
|
109
|
+
if (!/^[A-Za-z0-9+/]*=*$/.test(standard)) return undefined
|
|
110
|
+
try {
|
|
111
|
+
const binary = atob(
|
|
112
|
+
standard.replace(/=+$/, '') +
|
|
113
|
+
'='.repeat((4 - (standard.replace(/=+$/, '').length % 4)) % 4),
|
|
114
|
+
)
|
|
115
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// How many AC factors a channel of nx × ny has.
|
|
122
|
+
function count(nx: number, ny: number) {
|
|
123
|
+
let n = 0
|
|
124
|
+
for (let cy = 0; cy < ny; cy++)
|
|
125
|
+
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) n++
|
|
126
|
+
return n
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function thumbHashToRGBA(
|
|
130
|
+
hash: Uint8Array,
|
|
131
|
+
): { w: number; h: number; rgba: Uint8Array } | undefined {
|
|
132
|
+
const { PI, min, max, cos, round } = Math
|
|
133
|
+
if (hash.length < 5) return undefined
|
|
134
|
+
|
|
135
|
+
// Read the constants
|
|
136
|
+
const header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16)
|
|
137
|
+
const header16 = hash[3] | (hash[4] << 8)
|
|
138
|
+
const l_dc = (header24 & 63) / 63
|
|
139
|
+
const p_dc = ((header24 >> 6) & 63) / 31.5 - 1
|
|
140
|
+
const q_dc = ((header24 >> 12) & 63) / 31.5 - 1
|
|
141
|
+
const l_scale = ((header24 >> 18) & 31) / 31
|
|
142
|
+
const hasAlpha = header24 >> 23
|
|
143
|
+
const p_scale = ((header16 >> 3) & 63) / 63
|
|
144
|
+
const q_scale = ((header16 >> 9) & 63) / 63
|
|
145
|
+
const isLandscape = header16 >> 15
|
|
146
|
+
const lx = max(3, isLandscape ? (hasAlpha ? 5 : 7) : header16 & 7)
|
|
147
|
+
const ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
|
|
148
|
+
const ac_start = hasAlpha ? 6 : 5
|
|
149
|
+
const ac_count =
|
|
150
|
+
count(lx, ly) + 2 * count(3, 3) + (hasAlpha ? count(5, 5) : 0)
|
|
151
|
+
if (hash.length < ac_start + Math.ceil(ac_count / 2)) return undefined
|
|
152
|
+
const a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1
|
|
153
|
+
const a_scale = (hash[5] >> 4) / 15
|
|
154
|
+
|
|
155
|
+
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
|
156
|
+
let ac_index = 0
|
|
157
|
+
const decodeChannel = (nx: number, ny: number, scale: number) => {
|
|
158
|
+
const ac: number[] = []
|
|
159
|
+
for (let cy = 0; cy < ny; cy++)
|
|
160
|
+
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
|
|
161
|
+
ac.push(
|
|
162
|
+
(((hash[ac_start + (ac_index >> 1)] >>
|
|
163
|
+
((ac_index++ & 1) << 2)) &
|
|
164
|
+
15) /
|
|
165
|
+
7.5 -
|
|
166
|
+
1) *
|
|
167
|
+
scale,
|
|
168
|
+
)
|
|
169
|
+
return ac
|
|
170
|
+
}
|
|
171
|
+
const l_ac = decodeChannel(lx, ly, l_scale)
|
|
172
|
+
const p_ac = decodeChannel(3, 3, p_scale * 1.25)
|
|
173
|
+
const q_ac = decodeChannel(3, 3, q_scale * 1.25)
|
|
174
|
+
const a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
|
|
175
|
+
|
|
176
|
+
// Decode using the DCT into RGB
|
|
177
|
+
const ratio = thumbHashToApproximateAspectRatio(hash)
|
|
178
|
+
const w = max(1, round(ratio > 1 ? 32 : 32 * ratio))
|
|
179
|
+
const h = max(1, round(ratio > 1 ? 32 / ratio : 32))
|
|
180
|
+
const rgba = new Uint8Array(w * h * 4)
|
|
181
|
+
const fx: number[] = []
|
|
182
|
+
const fy: number[] = []
|
|
183
|
+
for (let y = 0, i = 0; y < h; y++) {
|
|
184
|
+
for (let x = 0; x < w; x++, i += 4) {
|
|
185
|
+
let l = l_dc,
|
|
186
|
+
p = p_dc,
|
|
187
|
+
q = q_dc,
|
|
188
|
+
a = a_dc
|
|
189
|
+
|
|
190
|
+
// Precompute the coefficients
|
|
191
|
+
for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++)
|
|
192
|
+
fx[cx] = cos((PI / w) * (x + 0.5) * cx)
|
|
193
|
+
for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++)
|
|
194
|
+
fy[cy] = cos((PI / h) * (y + 0.5) * cy)
|
|
195
|
+
|
|
196
|
+
// Decode L
|
|
197
|
+
for (let cy = 0, j = 0; cy < ly; cy++)
|
|
198
|
+
for (
|
|
199
|
+
let cx = cy ? 0 : 1, fy2 = fy[cy] * 2;
|
|
200
|
+
cx * ly < lx * (ly - cy);
|
|
201
|
+
cx++, j++
|
|
202
|
+
)
|
|
203
|
+
l += l_ac[j] * fx[cx] * fy2
|
|
204
|
+
|
|
205
|
+
// Decode P and Q
|
|
206
|
+
for (let cy = 0, j = 0; cy < 3; cy++) {
|
|
207
|
+
for (
|
|
208
|
+
let cx = cy ? 0 : 1, fy2 = fy[cy] * 2;
|
|
209
|
+
cx < 3 - cy;
|
|
210
|
+
cx++, j++
|
|
211
|
+
) {
|
|
212
|
+
const f = fx[cx] * fy2
|
|
213
|
+
p += p_ac[j] * f
|
|
214
|
+
q += q_ac[j] * f
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Decode A
|
|
219
|
+
if (hasAlpha)
|
|
220
|
+
for (let cy = 0, j = 0; cy < 5; cy++)
|
|
221
|
+
for (
|
|
222
|
+
let cx = cy ? 0 : 1, fy2 = fy[cy] * 2;
|
|
223
|
+
cx < 5 - cy;
|
|
224
|
+
cx++, j++
|
|
225
|
+
)
|
|
226
|
+
a += a_ac[j] * fx[cx] * fy2
|
|
227
|
+
|
|
228
|
+
// Convert to RGB
|
|
229
|
+
const b = l - (2 / 3) * p
|
|
230
|
+
const r = (3 * l - b + q) / 2
|
|
231
|
+
const g = r - q
|
|
232
|
+
rgba[i] = max(0, 255 * min(1, r))
|
|
233
|
+
rgba[i + 1] = max(0, 255 * min(1, g))
|
|
234
|
+
rgba[i + 2] = max(0, 255 * min(1, b))
|
|
235
|
+
rgba[i + 3] = max(0, 255 * min(1, a))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { w, h, rgba }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function thumbHashToApproximateAspectRatio(hash: Uint8Array) {
|
|
242
|
+
const header = hash[3]
|
|
243
|
+
const hasAlpha = hash[2] & 0x80
|
|
244
|
+
const isLandscape = hash[4] & 0x80
|
|
245
|
+
const lx = isLandscape ? (hasAlpha ? 5 : 7) : header & 7
|
|
246
|
+
const ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
|
|
247
|
+
return ly ? lx / ly : 1
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A PNG (uncompressed) of the pixels, as a data uri.
|
|
251
|
+
function rgbaToDataURL(w: number, h: number, rgba: Uint8Array) {
|
|
252
|
+
const row = w * 4 + 1
|
|
253
|
+
const idat = 6 + h * (5 + row)
|
|
254
|
+
const bytes = [
|
|
255
|
+
137,
|
|
256
|
+
80,
|
|
257
|
+
78,
|
|
258
|
+
71,
|
|
259
|
+
13,
|
|
260
|
+
10,
|
|
261
|
+
26,
|
|
262
|
+
10,
|
|
263
|
+
0,
|
|
264
|
+
0,
|
|
265
|
+
0,
|
|
266
|
+
13,
|
|
267
|
+
73,
|
|
268
|
+
72,
|
|
269
|
+
68,
|
|
270
|
+
82,
|
|
271
|
+
0,
|
|
272
|
+
0,
|
|
273
|
+
w >> 8,
|
|
274
|
+
w & 255,
|
|
275
|
+
0,
|
|
276
|
+
0,
|
|
277
|
+
h >> 8,
|
|
278
|
+
h & 255,
|
|
279
|
+
8,
|
|
280
|
+
6,
|
|
281
|
+
0,
|
|
282
|
+
0,
|
|
283
|
+
0,
|
|
284
|
+
0,
|
|
285
|
+
0,
|
|
286
|
+
0,
|
|
287
|
+
0,
|
|
288
|
+
idat >>> 24,
|
|
289
|
+
(idat >> 16) & 255,
|
|
290
|
+
(idat >> 8) & 255,
|
|
291
|
+
idat & 255,
|
|
292
|
+
73,
|
|
293
|
+
68,
|
|
294
|
+
65,
|
|
295
|
+
84,
|
|
296
|
+
120,
|
|
297
|
+
1,
|
|
298
|
+
]
|
|
299
|
+
const table = [
|
|
300
|
+
0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960,
|
|
301
|
+
1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376,
|
|
302
|
+
-2032938284, -1609899400, -1111625188,
|
|
303
|
+
]
|
|
304
|
+
let a = 1,
|
|
305
|
+
b = 0
|
|
306
|
+
for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
|
|
307
|
+
bytes.push(
|
|
308
|
+
y + 1 < h ? 0 : 1,
|
|
309
|
+
row & 255,
|
|
310
|
+
row >> 8,
|
|
311
|
+
~row & 255,
|
|
312
|
+
(row >> 8) ^ 255,
|
|
313
|
+
0,
|
|
314
|
+
)
|
|
315
|
+
for (b = (b + a) % 65521; i < end; i++) {
|
|
316
|
+
const u = rgba[i] & 255
|
|
317
|
+
bytes.push(u)
|
|
318
|
+
a = (a + u) % 65521
|
|
319
|
+
b = (b + a) % 65521
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
bytes.push(
|
|
323
|
+
b >> 8,
|
|
324
|
+
b & 255,
|
|
325
|
+
a >> 8,
|
|
326
|
+
a & 255,
|
|
327
|
+
0,
|
|
328
|
+
0,
|
|
329
|
+
0,
|
|
330
|
+
0,
|
|
331
|
+
0,
|
|
332
|
+
0,
|
|
333
|
+
0,
|
|
334
|
+
0,
|
|
335
|
+
73,
|
|
336
|
+
69,
|
|
337
|
+
78,
|
|
338
|
+
68,
|
|
339
|
+
174,
|
|
340
|
+
66,
|
|
341
|
+
96,
|
|
342
|
+
130,
|
|
343
|
+
)
|
|
344
|
+
for (let [start, end] of [
|
|
345
|
+
[12, 29],
|
|
346
|
+
[37, 41 + idat],
|
|
347
|
+
]) {
|
|
348
|
+
let c = ~0
|
|
349
|
+
for (let i = start; i < end; i++) {
|
|
350
|
+
c ^= bytes[i]
|
|
351
|
+
c = (c >>> 4) ^ table[c & 15]
|
|
352
|
+
c = (c >>> 4) ^ table[c & 15]
|
|
353
|
+
}
|
|
354
|
+
c = ~c
|
|
355
|
+
bytes[end++] = c >>> 24
|
|
356
|
+
bytes[end++] = (c >> 16) & 255
|
|
357
|
+
bytes[end++] = (c >> 8) & 255
|
|
358
|
+
bytes[end++] = c & 255
|
|
359
|
+
}
|
|
360
|
+
let binary = ''
|
|
361
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
362
|
+
return 'data:image/png;base64,' + btoa(binary)
|
|
363
|
+
}
|