@dejadex/web 0.0.0-stage → 0.1.1
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/CHANGELOG.md +35 -0
- package/README.md +222 -2
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/geometry/homography.d.ts +75 -0
- package/dist/geometry/homography.d.ts.map +1 -0
- package/dist/geometry/homography.js +146 -0
- package/dist/geometry/homography.js.map +1 -0
- package/dist/identify/assets.d.ts +39 -0
- package/dist/identify/assets.d.ts.map +1 -0
- package/dist/identify/assets.js +167 -0
- package/dist/identify/assets.js.map +1 -0
- package/dist/identify/decode.d.ts +6 -0
- package/dist/identify/decode.d.ts.map +1 -0
- package/dist/identify/decode.js +53 -0
- package/dist/identify/decode.js.map +1 -0
- package/dist/identify/engine.d.ts +32 -0
- package/dist/identify/engine.d.ts.map +1 -0
- package/dist/identify/engine.js +127 -0
- package/dist/identify/engine.js.map +1 -0
- package/dist/identify/identifier.d.ts +91 -0
- package/dist/identify/identifier.d.ts.map +1 -0
- package/dist/identify/identifier.js +332 -0
- package/dist/identify/identifier.js.map +1 -0
- package/dist/identify/manifest.d.ts +60 -0
- package/dist/identify/manifest.d.ts.map +1 -0
- package/dist/identify/manifest.js +211 -0
- package/dist/identify/manifest.js.map +1 -0
- package/dist/identify/preprocess.d.ts +52 -0
- package/dist/identify/preprocess.d.ts.map +1 -0
- package/dist/identify/preprocess.js +195 -0
- package/dist/identify/preprocess.js.map +1 -0
- package/dist/identify/router.d.ts +60 -0
- package/dist/identify/router.d.ts.map +1 -0
- package/dist/identify/router.js +83 -0
- package/dist/identify/router.js.map +1 -0
- package/dist/identify/runtime.d.ts +69 -0
- package/dist/identify/runtime.d.ts.map +1 -0
- package/dist/identify/runtime.js +140 -0
- package/dist/identify/runtime.js.map +1 -0
- package/dist/identify/search.d.ts +35 -0
- package/dist/identify/search.d.ts.map +1 -0
- package/dist/identify/search.js +99 -0
- package/dist/identify/search.js.map +1 -0
- package/dist/identify/server.d.ts +60 -0
- package/dist/identify/server.d.ts.map +1 -0
- package/dist/identify/server.js +149 -0
- package/dist/identify/server.js.map +1 -0
- package/dist/identify/shard.d.ts +31 -0
- package/dist/identify/shard.d.ts.map +1 -0
- package/dist/identify/shard.js +146 -0
- package/dist/identify/shard.js.map +1 -0
- package/dist/identify/types.d.ts +107 -0
- package/dist/identify/types.d.ts.map +1 -0
- package/dist/identify/types.js +7 -0
- package/dist/identify/types.js.map +1 -0
- package/dist/identify/worker.d.ts +2 -0
- package/dist/identify/worker.d.ts.map +1 -0
- package/dist/identify/worker.js +13 -0
- package/dist/identify/worker.js.map +1 -0
- package/dist/identify/workerProtocol.d.ts +54 -0
- package/dist/identify/workerProtocol.d.ts.map +1 -0
- package/dist/identify/workerProtocol.js +113 -0
- package/dist/identify/workerProtocol.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/lease/verify.d.ts +69 -0
- package/dist/lease/verify.d.ts.map +1 -0
- package/dist/lease/verify.js +235 -0
- package/dist/lease/verify.js.map +1 -0
- package/dist/scanner.d.ts +10 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +58 -0
- package/dist/scanner.js.map +1 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/usage/meter.d.ts +83 -0
- package/dist/usage/meter.d.ts.map +1 -0
- package/dist/usage/meter.js +111 -0
- package/dist/usage/meter.js.map +1 -0
- package/dist/usage/store.d.ts +40 -0
- package/dist/usage/store.d.ts.map +1 -0
- package/dist/usage/store.js +248 -0
- package/dist/usage/store.js.map +1 -0
- package/package.json +64 -4
- package/src/errors.ts +24 -0
- package/src/geometry/homography.ts +178 -0
- package/src/identify/assets.ts +189 -0
- package/src/identify/decode.ts +54 -0
- package/src/identify/engine.ts +167 -0
- package/src/identify/identifier.ts +431 -0
- package/src/identify/manifest.ts +240 -0
- package/src/identify/preprocess.ts +227 -0
- package/src/identify/router.ts +115 -0
- package/src/identify/runtime.ts +197 -0
- package/src/identify/search.ts +115 -0
- package/src/identify/server.ts +167 -0
- package/src/identify/shard.ts +154 -0
- package/src/identify/types.ts +122 -0
- package/src/identify/worker.ts +13 -0
- package/src/identify/workerProtocol.ts +136 -0
- package/src/index.ts +71 -0
- package/src/lease/verify.ts +252 -0
- package/src/scanner.ts +56 -0
- package/src/types.ts +196 -0
- package/src/usage/meter.ts +165 -0
- package/src/usage/store.ts +282 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planar homographies for AR labels: the projective map from a card's own
|
|
3
|
+
* unit rectangle to the four corners the detector found on screen, and its
|
|
4
|
+
* CSS `matrix3d` form, so a host page can lay a DOM element on the card's
|
|
5
|
+
* plane with nothing but `transform`.
|
|
6
|
+
*
|
|
7
|
+
* Conventions, fixed for every consumer:
|
|
8
|
+
*
|
|
9
|
+
* - Card space is the unit square: (0,0) top-left, (1,0) top-right, (1,1)
|
|
10
|
+
* bottom-right, (0,1) bottom-left of the card as printed (portrait).
|
|
11
|
+
* - Screen space is CSS pixels of the element the corners were measured in,
|
|
12
|
+
* y pointing down.
|
|
13
|
+
* - A `Quad` lists the screen corners in that same order: TL, TR, BR, BL.
|
|
14
|
+
* - A `Homography` is a 3×3 matrix in ROW-MAJOR order, normalised so the
|
|
15
|
+
* bottom-right entry is 1. It maps (u, v, 1) to (x·w, y·w, w).
|
|
16
|
+
*
|
|
17
|
+
* The square-to-quad solution is the closed form from P. Heckbert,
|
|
18
|
+
* "Fundamentals of Texture Mapping and Image Warping" (1989), §2.2.3: no
|
|
19
|
+
* linear solve, exact for any convex quad, and cheap enough to run per track
|
|
20
|
+
* per frame.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface Point {
|
|
24
|
+
readonly x: number
|
|
25
|
+
readonly y: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Screen corners in card order: top-left, top-right, bottom-right, bottom-left. */
|
|
29
|
+
export type Quad = readonly [Point, Point, Point, Point]
|
|
30
|
+
|
|
31
|
+
/** Row-major 3×3 matrix, `[a, b, c, d, e, f, g, h, i]` with i = 1 once normalised. */
|
|
32
|
+
export type Homography = readonly [number, number, number, number, number, number, number, number, number]
|
|
33
|
+
|
|
34
|
+
export const IDENTITY: Homography = [1, 0, 0, 0, 1, 0, 0, 0, 1]
|
|
35
|
+
|
|
36
|
+
/** The unit card rectangle's corners, in `Quad` order. */
|
|
37
|
+
export const UNIT_QUAD: Quad = [
|
|
38
|
+
{ x: 0, y: 0 },
|
|
39
|
+
{ x: 1, y: 0 },
|
|
40
|
+
{ x: 1, y: 1 },
|
|
41
|
+
{ x: 0, y: 1 },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** Relative tolerance below which a quad or a matrix counts as degenerate. */
|
|
45
|
+
const DEGENERATE_EPS = 1e-12
|
|
46
|
+
|
|
47
|
+
const isFiniteQuad = (q: Quad): boolean => q.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The homography that maps the unit card rectangle onto `quad`.
|
|
51
|
+
*
|
|
52
|
+
* Throws `RangeError` when no proper homography exists: a non-finite corner,
|
|
53
|
+
* three collinear corners (zero area), or a quad that is not convex — a
|
|
54
|
+
* concave or self-crossing quad would need the projective plane to fold, and
|
|
55
|
+
* the label would be drawn through the horizon.
|
|
56
|
+
*/
|
|
57
|
+
export function unitSquareToQuad(quad: Quad): Homography {
|
|
58
|
+
if (!isFiniteQuad(quad)) throw new RangeError('quad has a non-finite corner')
|
|
59
|
+
const [p0, p1, p2, p3] = quad
|
|
60
|
+
|
|
61
|
+
const dx1 = p1.x - p2.x
|
|
62
|
+
const dx2 = p3.x - p2.x
|
|
63
|
+
const dy1 = p1.y - p2.y
|
|
64
|
+
const dy2 = p3.y - p2.y
|
|
65
|
+
const sx = p0.x - p1.x + p2.x - p3.x
|
|
66
|
+
const sy = p0.y - p1.y + p2.y - p3.y
|
|
67
|
+
|
|
68
|
+
const span = Math.max(
|
|
69
|
+
...quad.map((p) => Math.abs(p.x - p0.x)),
|
|
70
|
+
...quad.map((p) => Math.abs(p.y - p0.y)),
|
|
71
|
+
)
|
|
72
|
+
const den = dx1 * dy2 - dx2 * dy1
|
|
73
|
+
if (span === 0 || Math.abs(den) <= DEGENERATE_EPS * span * span) {
|
|
74
|
+
throw new RangeError('quad is degenerate (zero area or collinear corners)')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const g = (sx * dy2 - dx2 * sy) / den
|
|
78
|
+
const h = (dx1 * sy - sx * dy1) / den
|
|
79
|
+
|
|
80
|
+
// w at each corner: (0,0) → 1, (1,0) → 1+g, (1,1) → 1+g+h, (0,1) → 1+h.
|
|
81
|
+
// All must share a sign, or the quad is not the image of a convex square.
|
|
82
|
+
if (1 + g <= DEGENERATE_EPS || 1 + h <= DEGENERATE_EPS || 1 + g + h <= DEGENERATE_EPS) {
|
|
83
|
+
throw new RangeError('quad is not convex')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
p1.x - p0.x + g * p1.x,
|
|
88
|
+
p3.x - p0.x + h * p3.x,
|
|
89
|
+
p0.x,
|
|
90
|
+
p1.y - p0.y + g * p1.y,
|
|
91
|
+
p3.y - p0.y + h * p3.y,
|
|
92
|
+
p0.y,
|
|
93
|
+
g,
|
|
94
|
+
h,
|
|
95
|
+
1,
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Maps a point through `m`. Throws `RangeError` for a point on the vanishing line. */
|
|
100
|
+
export function applyHomography(m: Homography, p: Point): Point {
|
|
101
|
+
const [a, b, c, d, e, f, g, h, i] = m
|
|
102
|
+
const w = g * p.x + h * p.y + i
|
|
103
|
+
if (Math.abs(w) <= DEGENERATE_EPS) throw new RangeError('point maps to infinity')
|
|
104
|
+
return { x: (a * p.x + b * p.y + c) / w, y: (d * p.x + e * p.y + f) / w }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `a · b` (apply `b` first, then `a`), normalised. */
|
|
108
|
+
export function multiplyHomography(a: Homography, b: Homography): Homography {
|
|
109
|
+
const r = (row: number, col: number): number =>
|
|
110
|
+
a[row * 3]! * b[col]! + a[row * 3 + 1]! * b[3 + col]! + a[row * 3 + 2]! * b[6 + col]!
|
|
111
|
+
return normalise([r(0, 0), r(0, 1), r(0, 2), r(1, 0), r(1, 1), r(1, 2), r(2, 0), r(2, 1), r(2, 2)])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The inverse map, normalised. Throws `RangeError` for a singular matrix. */
|
|
115
|
+
export function invertHomography(m: Homography): Homography {
|
|
116
|
+
const [a, b, c, d, e, f, g, h, i] = m
|
|
117
|
+
const A = e * i - f * h
|
|
118
|
+
const B = -(d * i - f * g)
|
|
119
|
+
const C = d * h - e * g
|
|
120
|
+
const det = a * A + b * B + c * C
|
|
121
|
+
const scale = Math.max(...m.map(Math.abs))
|
|
122
|
+
if (!Number.isFinite(det) || Math.abs(det) <= DEGENERATE_EPS * scale * scale * scale) {
|
|
123
|
+
throw new RangeError('homography is singular')
|
|
124
|
+
}
|
|
125
|
+
// Adjugate (transposed cofactors); the 1/det factor cancels in normalise().
|
|
126
|
+
return normalise([A, -(b * i - c * h), b * f - c * e, B, a * i - c * g, -(a * f - c * d), C, -(a * h - b * g), a * e - b * d])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Scales `m` so its bottom-right entry is 1. */
|
|
130
|
+
export function normalise(m: readonly number[]): Homography {
|
|
131
|
+
const k = m[8]!
|
|
132
|
+
if (Math.abs(k) <= DEGENERATE_EPS) throw new RangeError('homography cannot be normalised (h33 = 0)')
|
|
133
|
+
return m.map((v) => v / k) as unknown as Homography
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ElementSize {
|
|
137
|
+
/** The label element's layout width in CSS pixels (its `offsetWidth`). */
|
|
138
|
+
readonly width: number
|
|
139
|
+
/** The label element's layout height in CSS pixels (its `offsetHeight`). */
|
|
140
|
+
readonly height: number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The CSS `matrix3d(...)` that draws an element of `size` onto the quad that
|
|
145
|
+
* `m` maps the unit card to.
|
|
146
|
+
*
|
|
147
|
+
* The element must be absolutely positioned at the origin of the coordinate
|
|
148
|
+
* space the corners were measured in, with `transform-origin: 0 0`. Its own
|
|
149
|
+
* box (0..width, 0..height) is first scaled to the unit square, then sent
|
|
150
|
+
* through `m`. The 3×3 homography becomes the 4×4
|
|
151
|
+
*
|
|
152
|
+
* | a b 0 c |
|
|
153
|
+
* | d e 0 f |
|
|
154
|
+
* | 0 0 1 0 |
|
|
155
|
+
* | g h 0 i |
|
|
156
|
+
*
|
|
157
|
+
* which leaves z alone, and CSS takes it in COLUMN-major order.
|
|
158
|
+
*/
|
|
159
|
+
export function toCssMatrix3d(m: Homography, size: ElementSize = { width: 1, height: 1 }): string {
|
|
160
|
+
if (!(size.width > 0) || !(size.height > 0)) throw new RangeError('element size must be positive')
|
|
161
|
+
const scaled = multiplyHomography(m, [1 / size.width, 0, 0, 0, 1 / size.height, 0, 0, 0, 1])
|
|
162
|
+
const [a, b, c, d, e, f, g, h, i] = scaled
|
|
163
|
+
const columnMajor = [a, d, 0, g, b, e, 0, h, 0, 0, 1, 0, c, f, 0, i]
|
|
164
|
+
return `matrix3d(${columnMajor.map(formatCssNumber).join(', ')})`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Fixed precision, no `-0`, no exponent: what every engine parses identically. */
|
|
168
|
+
function formatCssNumber(v: number): string {
|
|
169
|
+
if (!Number.isFinite(v)) throw new RangeError('non-finite matrix entry')
|
|
170
|
+
const fixed = Number(v.toFixed(12))
|
|
171
|
+
if (fixed === 0) return '0'
|
|
172
|
+
return fixed.toFixed(12).replace(/\.?0+$/, '')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Convenience for the common case: corners in, CSS transform out. */
|
|
176
|
+
export function quadToCssMatrix3d(quad: Quad, size?: ElementSize): string {
|
|
177
|
+
return toCssMatrix3d(unitSquareToQuad(quad), size)
|
|
178
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloads of the manifest, the model and the shard: time-bounded, checked
|
|
3
|
+
* against the manifest's SHA-256 and size, and kept in the Cache API under
|
|
4
|
+
* their digest (content-addressed). A new manifest version downloads only the
|
|
5
|
+
* files whose digest changed; a corrupted cache entry is noticed on read,
|
|
6
|
+
* dropped and fetched again.
|
|
7
|
+
*
|
|
8
|
+
* The last manifest that loaded is cached too, so the device engine starts
|
|
9
|
+
* offline from a complete cache.
|
|
10
|
+
*/
|
|
11
|
+
import { ScannerError } from '../errors.js'
|
|
12
|
+
import type { ManifestFile } from './manifest.js'
|
|
13
|
+
|
|
14
|
+
/** The Cache API cache the SDK owns. Bump the suffix if the key scheme changes. */
|
|
15
|
+
export const CACHE_NAME = 'dejadex-assets-v1'
|
|
16
|
+
/** Keys are synthetic URLs; nothing is ever fetched from this origin. */
|
|
17
|
+
const KEY_ORIGIN = 'https://cache.dejadex.invalid'
|
|
18
|
+
|
|
19
|
+
/** Smallest download speed assumed when bounding a file's download (bytes/s). */
|
|
20
|
+
const MIN_BYTES_PER_SECOND = 100_000
|
|
21
|
+
const MIN_FILE_TIMEOUT_MS = 30_000
|
|
22
|
+
export const MANIFEST_TIMEOUT_MS = 10_000
|
|
23
|
+
|
|
24
|
+
export type FetchLike = (input: string, init: { signal: AbortSignal; cache?: RequestCache }) => Promise<Response>
|
|
25
|
+
|
|
26
|
+
/** The subset of `CacheStorage` used, so tests and hosts without one can pass their own (or none). */
|
|
27
|
+
export interface CacheLike {
|
|
28
|
+
match(key: string): Promise<Response | undefined>
|
|
29
|
+
put(key: string, response: Response): Promise<void>
|
|
30
|
+
delete(key: string): Promise<boolean>
|
|
31
|
+
keys(): Promise<readonly { readonly url: string }[]>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AssetStoreOptions {
|
|
35
|
+
readonly fetch: FetchLike
|
|
36
|
+
/** null: no cache (every start downloads). */
|
|
37
|
+
readonly cache: CacheLike | null
|
|
38
|
+
/** Overrides the per-file bound (ms); default from the size, at ≥ 100 kB/s. */
|
|
39
|
+
readonly fileTimeoutMs?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AssetStore {
|
|
43
|
+
/** The file's bytes, from the cache or the network, verified. */
|
|
44
|
+
get(file: ManifestFile, signal?: AbortSignal): Promise<ArrayBuffer>
|
|
45
|
+
/** The manifest document from `url`; offline, the last one that loaded from it. */
|
|
46
|
+
manifest(url: string, signal?: AbortSignal): Promise<unknown>
|
|
47
|
+
/** Drops cached files whose digest is not in `keep` (after a new manifest verified). */
|
|
48
|
+
evictExcept(keep: readonly string[]): Promise<number>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function sha256Hex(data: ArrayBuffer): Promise<string> {
|
|
52
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', data))
|
|
53
|
+
let out = ''
|
|
54
|
+
for (const b of digest) out += b.toString(16).padStart(2, '0')
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fileKey = (sha256: string): string => `${KEY_ORIGIN}/sha256/${sha256}`
|
|
59
|
+
const manifestKey = (url: string): string => {
|
|
60
|
+
// A signed URL changes its query every time; the manifest it names does not.
|
|
61
|
+
const u = new URL(url)
|
|
62
|
+
return `${KEY_ORIGIN}/manifest/${encodeURIComponent(u.origin + u.pathname)}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `AbortSignal.any` where the runtime has it, else a controller following both. */
|
|
66
|
+
export function anySignal(signals: readonly (AbortSignal | undefined)[]): AbortSignal {
|
|
67
|
+
const list = signals.filter((s): s is AbortSignal => s !== undefined)
|
|
68
|
+
if (list.length === 1) return list[0] as AbortSignal
|
|
69
|
+
if (typeof AbortSignal.any === 'function') return AbortSignal.any(list)
|
|
70
|
+
const controller = new AbortController()
|
|
71
|
+
for (const s of list) {
|
|
72
|
+
if (s.aborted) {
|
|
73
|
+
controller.abort(s.reason)
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
s.addEventListener('abort', () => controller.abort(s.reason), { once: true })
|
|
77
|
+
}
|
|
78
|
+
return controller.signal
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const errorText = (err: unknown): string => (err instanceof Error ? err.message || err.name : String(err))
|
|
82
|
+
|
|
83
|
+
export function createAssetStore(options: AssetStoreOptions): AssetStore {
|
|
84
|
+
const { fetch: doFetch, cache } = options
|
|
85
|
+
|
|
86
|
+
async function verified(buffer: ArrayBuffer, file: ManifestFile): Promise<boolean> {
|
|
87
|
+
return buffer.byteLength === file.size && (await sha256Hex(buffer)) === file.sha256
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function download(file: ManifestFile, signal: AbortSignal | undefined): Promise<ArrayBuffer> {
|
|
91
|
+
const timeoutMs = options.fileTimeoutMs ?? Math.max(MIN_FILE_TIMEOUT_MS, Math.ceil((file.size / MIN_BYTES_PER_SECOND) * 1000))
|
|
92
|
+
const bound = anySignal([AbortSignal.timeout(timeoutMs), signal])
|
|
93
|
+
try {
|
|
94
|
+
// The body is read inside this try, so the bound covers it too.
|
|
95
|
+
const res = await doFetch(file.url, { signal: bound, cache: 'no-store' })
|
|
96
|
+
if (!res.ok) throw new ScannerError('NETWORK', `${file.url} answered ${res.status}`)
|
|
97
|
+
return await res.arrayBuffer()
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err instanceof ScannerError) throw err
|
|
100
|
+
throw new ScannerError('NETWORK', `download of ${file.url} failed: ${errorText(err)}`, { cause: err })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
async get(file, signal) {
|
|
106
|
+
const key = fileKey(file.sha256)
|
|
107
|
+
if (cache) {
|
|
108
|
+
const hit = await cache.match(key).catch(() => undefined)
|
|
109
|
+
if (hit) {
|
|
110
|
+
const buffer = await hit.arrayBuffer()
|
|
111
|
+
if (await verified(buffer, file)) return buffer
|
|
112
|
+
await cache.delete(key).catch(() => false)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// One retry: a truncated or corrupted transfer gets a second chance.
|
|
116
|
+
for (let attempt = 1; ; attempt++) {
|
|
117
|
+
const buffer = await download(file, signal)
|
|
118
|
+
if (await verified(buffer, file)) {
|
|
119
|
+
if (cache) {
|
|
120
|
+
await cache
|
|
121
|
+
.put(key, new Response(buffer.slice(0), { headers: { 'Content-Type': 'application/octet-stream' } }))
|
|
122
|
+
.catch(() => undefined) // a full disk only costs the next start a download
|
|
123
|
+
}
|
|
124
|
+
return buffer
|
|
125
|
+
}
|
|
126
|
+
if (attempt >= 2) {
|
|
127
|
+
throw new ScannerError('MODEL_INTEGRITY', `${file.url} does not match its manifest checksum or size`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async manifest(url, signal) {
|
|
133
|
+
const bound = anySignal([AbortSignal.timeout(MANIFEST_TIMEOUT_MS), signal])
|
|
134
|
+
let networkError: unknown
|
|
135
|
+
try {
|
|
136
|
+
const res = await doFetch(url, { signal: bound, cache: 'no-store' })
|
|
137
|
+
if (!res.ok) throw new ScannerError('NETWORK', `manifest ${url} answered ${res.status}`)
|
|
138
|
+
const text = await res.text()
|
|
139
|
+
let doc: unknown
|
|
140
|
+
try {
|
|
141
|
+
doc = JSON.parse(text)
|
|
142
|
+
} catch {
|
|
143
|
+
throw new ScannerError('MODEL_INTEGRITY', `manifest ${url} is not JSON`)
|
|
144
|
+
}
|
|
145
|
+
if (cache) {
|
|
146
|
+
await cache.put(manifestKey(url), new Response(text, { headers: { 'Content-Type': 'application/json' } })).catch(() => undefined)
|
|
147
|
+
}
|
|
148
|
+
return doc
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err instanceof ScannerError && err.code === 'MODEL_INTEGRITY') throw err
|
|
151
|
+
if (signal?.aborted) throw err
|
|
152
|
+
networkError = err
|
|
153
|
+
}
|
|
154
|
+
// Offline (or the CDN is down): the last manifest that loaded from this URL.
|
|
155
|
+
const hit = cache ? await cache.match(manifestKey(url)).catch(() => undefined) : undefined
|
|
156
|
+
if (hit) return JSON.parse(await hit.text()) as unknown
|
|
157
|
+
if (networkError instanceof ScannerError) throw networkError
|
|
158
|
+
throw new ScannerError('NETWORK', `manifest ${url} could not be fetched: ${errorText(networkError)}`, { cause: networkError })
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async evictExcept(keep) {
|
|
162
|
+
if (!cache) return 0
|
|
163
|
+
const wanted = new Set(keep.map(fileKey))
|
|
164
|
+
let removed = 0
|
|
165
|
+
for (const req of await cache.keys()) {
|
|
166
|
+
if (req.url.startsWith(`${KEY_ORIGIN}/sha256/`) && !wanted.has(req.url)) {
|
|
167
|
+
if (await cache.delete(req.url)) removed++
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return removed
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The page's Cache API cache, or null where there is none (or it is blocked). */
|
|
176
|
+
export async function openDefaultCache(): Promise<CacheLike | null> {
|
|
177
|
+
try {
|
|
178
|
+
if (typeof caches === 'undefined') return null
|
|
179
|
+
const c = await caches.open(CACHE_NAME)
|
|
180
|
+
return {
|
|
181
|
+
match: (key) => c.match(key),
|
|
182
|
+
put: (key, response) => c.put(key, response),
|
|
183
|
+
delete: (key) => c.delete(key),
|
|
184
|
+
keys: () => c.keys(),
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crop `Blob` → pixels, in a Worker or on the main thread.
|
|
3
|
+
*
|
|
4
|
+
* Decoded without colour management (`colorSpaceConversion: 'none'`) and
|
|
5
|
+
* without premultiplied alpha, as the Python reference (PIL) reads a JPEG: an
|
|
6
|
+
* embedded ICC profile would otherwise shift every value the gallery was
|
|
7
|
+
* embedded from. JPEG decoders still differ by a unit here and there between
|
|
8
|
+
* libjpeg builds; that difference is the decoder's, not the resize's.
|
|
9
|
+
*/
|
|
10
|
+
import { ScannerError } from '../errors.js'
|
|
11
|
+
import type { PixelImage } from './preprocess.js'
|
|
12
|
+
|
|
13
|
+
/** A crop this large is not one card; refuse it before allocating its pixels. */
|
|
14
|
+
export const MAX_CROP_PIXELS = 40_000_000
|
|
15
|
+
|
|
16
|
+
export type CropDecoder = (crop: Blob) => Promise<PixelImage>
|
|
17
|
+
|
|
18
|
+
export const decodeCrop: CropDecoder = async (crop) => {
|
|
19
|
+
if (typeof createImageBitmap !== 'function') throw new ScannerError('UNSUPPORTED_DEVICE', 'createImageBitmap is not available')
|
|
20
|
+
let bitmap: ImageBitmap
|
|
21
|
+
try {
|
|
22
|
+
bitmap = await createImageBitmap(crop, { colorSpaceConversion: 'none', premultiplyAlpha: 'none' })
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new ScannerError('INVALID_OPTIONS', 'a crop could not be decoded as an image', { cause: err })
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const { width, height } = bitmap
|
|
28
|
+
if (width * height > MAX_CROP_PIXELS) throw new ScannerError('INVALID_OPTIONS', `a crop of ${width}x${height} is too large`)
|
|
29
|
+
const ctx = context2d(width, height)
|
|
30
|
+
ctx.drawImage(bitmap, 0, 0)
|
|
31
|
+
const image = ctx.getImageData(0, 0, width, height)
|
|
32
|
+
return { width, height, channels: 4, data: image.data }
|
|
33
|
+
} finally {
|
|
34
|
+
bitmap.close()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Context2d = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D
|
|
39
|
+
|
|
40
|
+
function context2d(width: number, height: number): Context2d {
|
|
41
|
+
const opts = { willReadFrequently: true, alpha: false } as const
|
|
42
|
+
if (typeof OffscreenCanvas !== 'undefined') {
|
|
43
|
+
const ctx = new OffscreenCanvas(width, height).getContext('2d', opts)
|
|
44
|
+
if (ctx) return ctx
|
|
45
|
+
}
|
|
46
|
+
if (typeof document !== 'undefined') {
|
|
47
|
+
const canvas = document.createElement('canvas')
|
|
48
|
+
canvas.width = width
|
|
49
|
+
canvas.height = height
|
|
50
|
+
const ctx = canvas.getContext('2d', opts) as CanvasRenderingContext2D | null
|
|
51
|
+
if (ctx) return ctx
|
|
52
|
+
}
|
|
53
|
+
throw new ScannerError('UNSUPPORTED_DEVICE', 'no 2D canvas to decode crops with')
|
|
54
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The on-device identification engine: manifest → model session + shard →
|
|
3
|
+
* crops → answers. Runs wherever it is constructed; `worker.ts` runs it in a
|
|
4
|
+
* dedicated Worker (the default), `identifier.ts` talks to it through the
|
|
5
|
+
* `DeviceEngine` interface either way.
|
|
6
|
+
*/
|
|
7
|
+
import { ScannerError } from '../errors.js'
|
|
8
|
+
import { createAssetStore, openDefaultCache, type AssetStore, type FetchLike } from './assets.js'
|
|
9
|
+
import { decodeCrop, type CropDecoder } from './decode.js'
|
|
10
|
+
import { parseManifest, type Manifest } from './manifest.js'
|
|
11
|
+
import { preprocessCrop, type PixelImage } from './preprocess.js'
|
|
12
|
+
import { createEmbedBackend, defaultRuntimeEnv, type EmbedBackend, type RuntimeEnv, type RuntimeOptions } from './runtime.js'
|
|
13
|
+
import { decodeShard, parseIds, type Shard } from './shard.js'
|
|
14
|
+
import { toResult, topK } from './search.js'
|
|
15
|
+
import type { DeviceEngineInfo, IdentifyResult } from './types.js'
|
|
16
|
+
|
|
17
|
+
/** What the engine must load: plain data, so it crosses a Worker boundary. */
|
|
18
|
+
export interface EngineConfig {
|
|
19
|
+
readonly manifestUrl: string
|
|
20
|
+
/** The lease's versions and shard; a manifest that differs is refused. */
|
|
21
|
+
readonly expect: { readonly modelVersion: string; readonly indexVersion: string; readonly shard: string }
|
|
22
|
+
readonly runtime: RuntimeOptions
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DeviceEngine {
|
|
26
|
+
readonly info: DeviceEngineInfo
|
|
27
|
+
identify(crops: readonly Blob[], top: number): Promise<IdentifyResult[]>
|
|
28
|
+
release(): Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface EngineDeps {
|
|
32
|
+
readonly assets: AssetStore
|
|
33
|
+
readonly runtimeEnv: RuntimeEnv
|
|
34
|
+
readonly decode: CropDecoder
|
|
35
|
+
readonly now: () => number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function defaultEngineDeps(): Promise<EngineDeps> {
|
|
39
|
+
const doFetch: FetchLike = (url, init) => fetch(url, init)
|
|
40
|
+
return {
|
|
41
|
+
assets: createAssetStore({ fetch: doFetch, cache: await openDefaultCache() }),
|
|
42
|
+
runtimeEnv: defaultRuntimeEnv(),
|
|
43
|
+
decode: decodeCrop,
|
|
44
|
+
now: () => performance.now(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Checks the manifest against what the lease grants (versions are locked together). */
|
|
49
|
+
export function checkManifestAgainstLease(manifest: Manifest, expect: EngineConfig['expect']): void {
|
|
50
|
+
if (manifest.shard !== expect.shard) {
|
|
51
|
+
throw new ScannerError('DOMAIN_NOT_ALLOWED', `manifest is for shard ${manifest.shard}, the lease grants ${expect.shard}`)
|
|
52
|
+
}
|
|
53
|
+
if (manifest.modelVersion !== expect.modelVersion || manifest.indexVersion !== expect.indexVersion) {
|
|
54
|
+
throw new ScannerError(
|
|
55
|
+
'MODEL_INTEGRITY',
|
|
56
|
+
`manifest is model ${manifest.modelVersion} / index ${manifest.indexVersion}, the lease is ${expect.modelVersion} / ${expect.indexVersion}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function loadDeviceEngine(config: EngineConfig, deps: EngineDeps): Promise<DeviceEngine> {
|
|
62
|
+
const { assets } = deps
|
|
63
|
+
const manifest = parseManifest(await assets.manifest(config.manifestUrl), config.manifestUrl)
|
|
64
|
+
checkManifestAgainstLease(manifest, config.expect)
|
|
65
|
+
|
|
66
|
+
const [indexBytes, idsBytes] = await Promise.all([assets.get(manifest.index), assets.get(manifest.ids)])
|
|
67
|
+
let idsDoc: unknown
|
|
68
|
+
try {
|
|
69
|
+
idsDoc = JSON.parse(new TextDecoder().decode(idsBytes))
|
|
70
|
+
} catch {
|
|
71
|
+
throw new ScannerError('MODEL_INTEGRITY', 'ids sidecar is not JSON')
|
|
72
|
+
}
|
|
73
|
+
const expectShard = {
|
|
74
|
+
domain: manifest.shard,
|
|
75
|
+
modelVersion: manifest.modelVersion,
|
|
76
|
+
indexVersion: manifest.indexVersion,
|
|
77
|
+
rows: manifest.rows,
|
|
78
|
+
dim: manifest.dims,
|
|
79
|
+
}
|
|
80
|
+
const shard = decodeShard(indexBytes, parseIds(idsDoc, expectShard), expectShard)
|
|
81
|
+
|
|
82
|
+
const used: string[] = [manifest.index.sha256, manifest.ids.sha256]
|
|
83
|
+
const backend = await createEmbedBackend(
|
|
84
|
+
manifest,
|
|
85
|
+
async (model) => {
|
|
86
|
+
used.push(model.sha256)
|
|
87
|
+
return assets.get(model)
|
|
88
|
+
},
|
|
89
|
+
config.runtime,
|
|
90
|
+
deps.runtimeEnv,
|
|
91
|
+
)
|
|
92
|
+
// Older versions' files go only once this one has loaded.
|
|
93
|
+
await assets.evictExcept(used).catch(() => 0)
|
|
94
|
+
|
|
95
|
+
const engine = makeEngine(manifest, shard, backend, deps)
|
|
96
|
+
await engine.warmUp()
|
|
97
|
+
return engine
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface WarmEngine extends DeviceEngine {
|
|
101
|
+
warmUp(): Promise<void>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeEngine(manifest: Manifest, shard: Shard, backend: EmbedBackend, deps: EngineDeps): WarmEngine {
|
|
105
|
+
const { width, height, mean, std, orientations } = manifest.preprocessing
|
|
106
|
+
const norm = { mean, std }
|
|
107
|
+
const plane = 3 * width * height
|
|
108
|
+
const perCrop = orientations.length
|
|
109
|
+
const thresholds = { notACardBelow: manifest.notACardBelow, confidentMargin: manifest.confidentMargin }
|
|
110
|
+
let perCropMs = Number.NaN
|
|
111
|
+
let released = false
|
|
112
|
+
|
|
113
|
+
const info = (): DeviceEngineInfo => ({
|
|
114
|
+
backend: backend.backend,
|
|
115
|
+
precision: backend.precision,
|
|
116
|
+
threads: backend.threads,
|
|
117
|
+
perCropMs,
|
|
118
|
+
modelVersion: manifest.modelVersion,
|
|
119
|
+
indexVersion: manifest.indexVersion,
|
|
120
|
+
shard: manifest.shard,
|
|
121
|
+
rows: shard.rows,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
async function run(images: readonly PixelImage[], top: number): Promise<IdentifyResult[]> {
|
|
125
|
+
if (released) throw new ScannerError('INVALID_OPTIONS', 'the device engine was released')
|
|
126
|
+
const n = images.length
|
|
127
|
+
if (n === 0) return []
|
|
128
|
+
const input = new Float32Array(n * perCrop * plane)
|
|
129
|
+
images.forEach((img, i) => preprocessCrop(img, { width, height }, norm, orientations, input, i * perCrop * plane))
|
|
130
|
+
const emb = await backend.embed(input, n * perCrop)
|
|
131
|
+
const dims = manifest.dims
|
|
132
|
+
const k = Math.max(top, 2)
|
|
133
|
+
const results: IdentifyResult[] = []
|
|
134
|
+
for (let i = 0; i < n; i++) {
|
|
135
|
+
const queries = emb.subarray(i * perCrop * dims, (i + 1) * perCrop * dims)
|
|
136
|
+
results.push(toResult(i, topK(shard.matrix, dims, queries, k), shard.ids, top, thresholds))
|
|
137
|
+
}
|
|
138
|
+
return results
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
get info() {
|
|
143
|
+
return info()
|
|
144
|
+
},
|
|
145
|
+
async identify(crops, top) {
|
|
146
|
+
const images = await Promise.all(crops.map((c) => deps.decode(c)))
|
|
147
|
+
const t0 = deps.now()
|
|
148
|
+
const results = await run(images, top)
|
|
149
|
+
// Keep the per-crop time current: a device that heats up slows down.
|
|
150
|
+
const ms = (deps.now() - t0) / Math.max(1, crops.length)
|
|
151
|
+
perCropMs = Number.isFinite(perCropMs) ? perCropMs * 0.7 + ms * 0.3 : ms
|
|
152
|
+
return results
|
|
153
|
+
},
|
|
154
|
+
async warmUp() {
|
|
155
|
+
// The first run pays for shader compilation and allocation; time the second.
|
|
156
|
+
const blank = { width, height, channels: 3 as const, data: new Uint8Array(width * height * 3) }
|
|
157
|
+
await run([blank], 2)
|
|
158
|
+
const t0 = deps.now()
|
|
159
|
+
await run([blank], 2)
|
|
160
|
+
perCropMs = deps.now() - t0
|
|
161
|
+
},
|
|
162
|
+
async release() {
|
|
163
|
+
released = true
|
|
164
|
+
await backend.release()
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|