@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +222 -2
  3. package/dist/errors.d.ts +8 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +9 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/geometry/homography.d.ts +75 -0
  8. package/dist/geometry/homography.d.ts.map +1 -0
  9. package/dist/geometry/homography.js +146 -0
  10. package/dist/geometry/homography.js.map +1 -0
  11. package/dist/identify/assets.d.ts +39 -0
  12. package/dist/identify/assets.d.ts.map +1 -0
  13. package/dist/identify/assets.js +167 -0
  14. package/dist/identify/assets.js.map +1 -0
  15. package/dist/identify/decode.d.ts +6 -0
  16. package/dist/identify/decode.d.ts.map +1 -0
  17. package/dist/identify/decode.js +53 -0
  18. package/dist/identify/decode.js.map +1 -0
  19. package/dist/identify/engine.d.ts +32 -0
  20. package/dist/identify/engine.d.ts.map +1 -0
  21. package/dist/identify/engine.js +127 -0
  22. package/dist/identify/engine.js.map +1 -0
  23. package/dist/identify/identifier.d.ts +91 -0
  24. package/dist/identify/identifier.d.ts.map +1 -0
  25. package/dist/identify/identifier.js +332 -0
  26. package/dist/identify/identifier.js.map +1 -0
  27. package/dist/identify/manifest.d.ts +60 -0
  28. package/dist/identify/manifest.d.ts.map +1 -0
  29. package/dist/identify/manifest.js +211 -0
  30. package/dist/identify/manifest.js.map +1 -0
  31. package/dist/identify/preprocess.d.ts +52 -0
  32. package/dist/identify/preprocess.d.ts.map +1 -0
  33. package/dist/identify/preprocess.js +195 -0
  34. package/dist/identify/preprocess.js.map +1 -0
  35. package/dist/identify/router.d.ts +60 -0
  36. package/dist/identify/router.d.ts.map +1 -0
  37. package/dist/identify/router.js +83 -0
  38. package/dist/identify/router.js.map +1 -0
  39. package/dist/identify/runtime.d.ts +69 -0
  40. package/dist/identify/runtime.d.ts.map +1 -0
  41. package/dist/identify/runtime.js +140 -0
  42. package/dist/identify/runtime.js.map +1 -0
  43. package/dist/identify/search.d.ts +35 -0
  44. package/dist/identify/search.d.ts.map +1 -0
  45. package/dist/identify/search.js +99 -0
  46. package/dist/identify/search.js.map +1 -0
  47. package/dist/identify/server.d.ts +60 -0
  48. package/dist/identify/server.d.ts.map +1 -0
  49. package/dist/identify/server.js +149 -0
  50. package/dist/identify/server.js.map +1 -0
  51. package/dist/identify/shard.d.ts +31 -0
  52. package/dist/identify/shard.d.ts.map +1 -0
  53. package/dist/identify/shard.js +146 -0
  54. package/dist/identify/shard.js.map +1 -0
  55. package/dist/identify/types.d.ts +107 -0
  56. package/dist/identify/types.d.ts.map +1 -0
  57. package/dist/identify/types.js +7 -0
  58. package/dist/identify/types.js.map +1 -0
  59. package/dist/identify/worker.d.ts +2 -0
  60. package/dist/identify/worker.d.ts.map +1 -0
  61. package/dist/identify/worker.js +13 -0
  62. package/dist/identify/worker.js.map +1 -0
  63. package/dist/identify/workerProtocol.d.ts +54 -0
  64. package/dist/identify/workerProtocol.d.ts.map +1 -0
  65. package/dist/identify/workerProtocol.js +113 -0
  66. package/dist/identify/workerProtocol.js.map +1 -0
  67. package/dist/index.d.ts +20 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +16 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/lease/verify.d.ts +69 -0
  72. package/dist/lease/verify.d.ts.map +1 -0
  73. package/dist/lease/verify.js +235 -0
  74. package/dist/lease/verify.js.map +1 -0
  75. package/dist/scanner.d.ts +10 -0
  76. package/dist/scanner.d.ts.map +1 -0
  77. package/dist/scanner.js +58 -0
  78. package/dist/scanner.js.map +1 -0
  79. package/dist/types.d.ts +180 -0
  80. package/dist/types.d.ts.map +1 -0
  81. package/dist/types.js +2 -0
  82. package/dist/types.js.map +1 -0
  83. package/dist/usage/meter.d.ts +83 -0
  84. package/dist/usage/meter.d.ts.map +1 -0
  85. package/dist/usage/meter.js +111 -0
  86. package/dist/usage/meter.js.map +1 -0
  87. package/dist/usage/store.d.ts +40 -0
  88. package/dist/usage/store.d.ts.map +1 -0
  89. package/dist/usage/store.js +248 -0
  90. package/dist/usage/store.js.map +1 -0
  91. package/package.json +64 -4
  92. package/src/errors.ts +24 -0
  93. package/src/geometry/homography.ts +178 -0
  94. package/src/identify/assets.ts +189 -0
  95. package/src/identify/decode.ts +54 -0
  96. package/src/identify/engine.ts +167 -0
  97. package/src/identify/identifier.ts +431 -0
  98. package/src/identify/manifest.ts +240 -0
  99. package/src/identify/preprocess.ts +227 -0
  100. package/src/identify/router.ts +115 -0
  101. package/src/identify/runtime.ts +197 -0
  102. package/src/identify/search.ts +115 -0
  103. package/src/identify/server.ts +167 -0
  104. package/src/identify/shard.ts +154 -0
  105. package/src/identify/types.ts +122 -0
  106. package/src/identify/worker.ts +13 -0
  107. package/src/identify/workerProtocol.ts +136 -0
  108. package/src/index.ts +71 -0
  109. package/src/lease/verify.ts +252 -0
  110. package/src/scanner.ts +56 -0
  111. package/src/types.ts +196 -0
  112. package/src/usage/meter.ts +165 -0
  113. 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
+ }