runonweb 0.0.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.
@@ -0,0 +1,230 @@
1
+ import type { ProgressCallback, ProgressInfo } from '../core/index.ts'
2
+ import { isWebGPUAvailable } from '../core/device.ts'
3
+ import type { BonsaiImagePipeline } from './engine.js'
4
+ import {
5
+ DEFAULT_IMAGE_SIZE,
6
+ IMAGE_MODELS,
7
+ IMAGE_SIZES,
8
+ type ImageSize,
9
+ } from './sizes.ts'
10
+
11
+ export { DEFAULT_IMAGE_SIZE, IMAGE_MODELS, IMAGE_SIZES }
12
+ export type { ImageSize }
13
+ export type { ProgressInfo }
14
+
15
+ const LOAD_WEIGHTS: Record<string, number> = {
16
+ text_encoder: 0.58,
17
+ transformer: 0.38,
18
+ vae: 0.04,
19
+ }
20
+
21
+ const MIN_SIDE = 256
22
+ const MAX_SIDE = 1280
23
+
24
+ export type ImageGeneratorOptions = {
25
+ /** Weight variant. `binary` is smaller; `ternary` is closer to FLUX.2 Klein. */
26
+ size?: ImageSize
27
+ /** Hugging Face repo id. Overrides `size`. Must be an `-mlx-` Bonsai Image variant. */
28
+ model?: string
29
+ /** Default width in pixels (multiple of 16). Default 512. */
30
+ width?: number
31
+ /** Default height in pixels (multiple of 16). Default 512. */
32
+ height?: number
33
+ /** FlowMatch-Euler steps. The model is tuned for 4. */
34
+ steps?: number
35
+ /** Fixed seed. Omit for a random seed on each generate. */
36
+ seed?: number
37
+ onProgress?: ProgressCallback
38
+ }
39
+
40
+ export type GenerateOptions = {
41
+ width?: number
42
+ height?: number
43
+ steps?: number
44
+ seed?: number
45
+ signal?: AbortSignal
46
+ onStep?: (info: { step: number; steps: number }) => void
47
+ }
48
+
49
+ export type ImageResult = {
50
+ /** PNG Blob. */
51
+ image: Blob
52
+ width: number
53
+ height: number
54
+ prompt: string
55
+ seed: number
56
+ }
57
+
58
+ function alignSide(value: number, label: string): number {
59
+ if (!Number.isFinite(value)) throw new Error(`${label} must be a number`)
60
+ const aligned = Math.round(value / 16) * 16
61
+ if (aligned < MIN_SIDE || aligned > MAX_SIDE) {
62
+ throw new Error(`${label} must be between ${MIN_SIDE} and ${MAX_SIDE} (got ${value})`)
63
+ }
64
+ return aligned
65
+ }
66
+
67
+ function randomSeed(): number {
68
+ return Math.floor(Math.random() * 1_000_000_000)
69
+ }
70
+
71
+ function mapLoadProgress(onProgress?: ProgressCallback): (status: Record<string, unknown>) => void {
72
+ const parts: Record<string, number> = {}
73
+ return (status) => {
74
+ const component = typeof status.component === 'string' ? status.component : undefined
75
+ const loaded = typeof status.loaded === 'number' ? status.loaded : 0
76
+ const total = typeof status.total === 'number' ? status.total : 0
77
+ if (component && total > 0) parts[component] = loaded / total
78
+
79
+ let progress = 0
80
+ for (const [name, weight] of Object.entries(LOAD_WEIGHTS)) {
81
+ progress += (parts[name] ?? 0) * weight
82
+ }
83
+
84
+ onProgress?.({
85
+ status: component ? `loading ${component}` : 'loading',
86
+ progress: progress * 100,
87
+ file: component,
88
+ })
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Text-to-image in the browser (Bonsai Image 4B, WebGPU).
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * import { ImageGenerator } from 'runonweb/image'
98
+ *
99
+ * const gen = new ImageGenerator()
100
+ * await gen.load()
101
+ * const { image } = await gen.generate('A bonsai tree in a ceramic studio')
102
+ * ```
103
+ */
104
+ export class ImageGenerator {
105
+ #modelId: string
106
+ #width: number
107
+ #height: number
108
+ #steps: number
109
+ #seed?: number
110
+ #onProgress?: ProgressCallback
111
+ #pipeline: BonsaiImagePipeline | null = null
112
+ #loading: Promise<void> | null = null
113
+ #resolvedDevice: 'webgpu' | null = null
114
+
115
+ constructor(options: ImageGeneratorOptions = {}) {
116
+ const size = options.size ?? DEFAULT_IMAGE_SIZE
117
+ this.#modelId = options.model ?? IMAGE_SIZES[size].model
118
+ this.#width = options.width ?? 512
119
+ this.#height = options.height ?? 512
120
+ this.#steps = options.steps ?? 4
121
+ this.#seed = options.seed
122
+ this.#onProgress = options.onProgress
123
+ }
124
+
125
+ get device(): 'webgpu' | null {
126
+ return this.#resolvedDevice
127
+ }
128
+
129
+ get model(): string {
130
+ return this.#modelId
131
+ }
132
+
133
+ async load(): Promise<void> {
134
+ if (this.#pipeline) return
135
+ if (this.#loading) return this.#loading
136
+
137
+ this.#loading = (async () => {
138
+ const { BonsaiImagePipeline } = await import('./engine.js')
139
+ if (!(await isWebGPUAvailable()) || !(await BonsaiImagePipeline.isSupported())) {
140
+ throw new Error('ImageGenerator requires WebGPU. This model has no WASM fallback.')
141
+ }
142
+
143
+ this.#onProgress?.({ status: 'loading', progress: 0 })
144
+
145
+ const pipeline = await BonsaiImagePipeline.from_pretrained(this.#modelId, {
146
+ onProgress: mapLoadProgress(this.#onProgress),
147
+ })
148
+
149
+ this.#pipeline = pipeline
150
+ this.#resolvedDevice = 'webgpu'
151
+ this.#onProgress?.({ status: 'ready', progress: 100 })
152
+ })()
153
+
154
+ try {
155
+ await this.#loading
156
+ } finally {
157
+ this.#loading = null
158
+ }
159
+ }
160
+
161
+ async generate(prompt: string, options?: GenerateOptions): Promise<ImageResult> {
162
+ await this.load()
163
+ const pipeline = this.#pipeline
164
+ if (!pipeline) throw new Error('ImageGenerator model failed to load')
165
+
166
+ const text = prompt.trim()
167
+ if (!text) throw new Error('Prompt is empty')
168
+
169
+ const width = alignSide(options?.width ?? this.#width, 'width')
170
+ const height = alignSide(options?.height ?? this.#height, 'height')
171
+ const steps = options?.steps ?? this.#steps
172
+ if (!Number.isInteger(steps) || steps < 1 || steps > 50) {
173
+ throw new Error('steps must be an integer between 1 and 50')
174
+ }
175
+ const seed = options?.seed ?? this.#seed ?? randomSeed()
176
+
177
+ this.#onProgress?.({ status: 'generating', progress: 0 })
178
+
179
+ const result = await pipeline.generate({
180
+ prompt: text,
181
+ width,
182
+ height,
183
+ numInferenceSteps: steps,
184
+ seed,
185
+ signal: options?.signal,
186
+ callbackOnStepEnd: (_pipeline, step) => {
187
+ const done = step + 1
188
+ this.#onProgress?.({ status: 'generating', progress: (done / steps) * 100 })
189
+ options?.onStep?.({ step: done, steps })
190
+ },
191
+ })
192
+
193
+ this.#onProgress?.({ status: 'done', progress: 100 })
194
+ return {
195
+ image: result.toBlob(),
196
+ width: result.width,
197
+ height: result.height,
198
+ prompt: result.prompt,
199
+ seed: result.seed,
200
+ }
201
+ }
202
+
203
+ dispose(): void {
204
+ const pipeline = this.#pipeline
205
+ this.#pipeline = null
206
+ this.#resolvedDevice = null
207
+ void pipeline?.destroy()
208
+ }
209
+ }
210
+
211
+ export async function generateImage(
212
+ prompt: string,
213
+ options?: ImageGeneratorOptions
214
+ ): Promise<ImageResult> {
215
+ const generator = new ImageGenerator(options)
216
+ try {
217
+ return await generator.generate(prompt)
218
+ } finally {
219
+ generator.dispose()
220
+ }
221
+ }
222
+
223
+ export async function isImageGenerationSupported(): Promise<boolean> {
224
+ return isWebGPUAvailable()
225
+ }
226
+
227
+ export async function clearImageCache(): Promise<void> {
228
+ const { BonsaiImagePipeline } = await import('./engine.js')
229
+ await BonsaiImagePipeline.clearCache()
230
+ }
@@ -0,0 +1,28 @@
1
+ export type ImageSize = 'binary' | 'ternary'
2
+
3
+ export const IMAGE_MODELS = {
4
+ binary: 'prism-ml/bonsai-image-binary-4B-mlx-1bit',
5
+ ternary: 'prism-ml/bonsai-image-ternary-4B-mlx-2bit',
6
+ } as const
7
+
8
+ export const IMAGE_SIZES: Record<
9
+ ImageSize,
10
+ { label: string; model: string; download: string; quality: string; bits: string }
11
+ > = {
12
+ binary: {
13
+ label: 'Fast',
14
+ model: IMAGE_MODELS.binary,
15
+ download: '~3.4 GB',
16
+ quality: '1-bit transformer, quicker download',
17
+ bits: '1-bit',
18
+ },
19
+ ternary: {
20
+ label: 'Quality',
21
+ model: IMAGE_MODELS.ternary,
22
+ download: '~3.9 GB',
23
+ quality: '1.58-bit transformer, closer to FLUX.2 Klein',
24
+ bits: '1.58-bit',
25
+ },
26
+ }
27
+
28
+ export const DEFAULT_IMAGE_SIZE: ImageSize = 'binary'
@@ -0,0 +1,416 @@
1
+ import type { Device, ProgressCallback, ProgressInfo, ResolvedDevice } from '../core/index.ts'
2
+ import { resolveDevice } from '../core/device.ts'
3
+ import { PaddleOcrService } from 'paddleocr'
4
+ import { DEFAULT_OCR_SIZE, type OCRSize } from './sizes.ts'
5
+
6
+ export { DEFAULT_OCR_SIZE, OCR_SIZES, type OCRSize } from './sizes.ts'
7
+
8
+ const HF = {
9
+ tiny: {
10
+ det: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_tiny_det_onnx/resolve/main/inference.onnx',
11
+ rec: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_tiny_rec_onnx/resolve/main/inference.onnx',
12
+ yml: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_tiny_rec_onnx/resolve/main/inference.yml',
13
+ },
14
+ small: {
15
+ det: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx',
16
+ rec: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx',
17
+ yml: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.yml',
18
+ },
19
+ medium: {
20
+ det: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_medium_det_onnx/resolve/main/inference.onnx',
21
+ rec: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/main/inference.onnx',
22
+ yml: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/main/inference.yml',
23
+ },
24
+ } as const
25
+
26
+ const PRESET = {
27
+ tiny: 'PP-OCRv6_tiny',
28
+ small: 'PP-OCRv6_small',
29
+ medium: 'PP-OCRv6_medium',
30
+ } as const
31
+
32
+ const CACHE = 'runonweb-ppocrv6'
33
+ const ORT_WASM = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/'
34
+
35
+ export type OCROptions = {
36
+ /** Model tier. Defaults to `small` (best size/quality). */
37
+ size?: OCRSize
38
+ /** Inference device. Defaults to `auto` (WebGPU when available, else WASM). */
39
+ device?: Device
40
+ onProgress?: ProgressCallback
41
+ }
42
+
43
+ export type OCRImageInput = Blob | File | string | HTMLImageElement | ImageData | HTMLCanvasElement
44
+
45
+ export type OCRBox = {
46
+ xmin: number
47
+ ymin: number
48
+ xmax: number
49
+ ymax: number
50
+ }
51
+
52
+ export type OCRLine = {
53
+ text: string
54
+ score: number
55
+ box: OCRBox
56
+ }
57
+
58
+ export type OCRResult = {
59
+ text: string
60
+ lines: OCRLine[]
61
+ confidence: number
62
+ }
63
+
64
+ /**
65
+ * On-device OCR with PP-OCRv6 (PaddleOCR, Apache-2.0).
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * import { OCR } from 'runonweb/ocr'
70
+ *
71
+ * const ocr = new OCR() // size: 'small'
72
+ * await ocr.load()
73
+ * const { text, lines } = await ocr.read(imageFile)
74
+ * ```
75
+ */
76
+ export class OCR {
77
+ #size: OCRSize
78
+ #device: Device
79
+ #onProgress?: ProgressCallback
80
+ #service: PaddleOcrService | null = null
81
+ #loading: Promise<void> | null = null
82
+ #pending: Promise<void> = Promise.resolve()
83
+ #resolvedDevice: ResolvedDevice | null = null
84
+ #disposed = false
85
+ #disposeDone: Promise<void> | null = null
86
+
87
+ constructor(options: OCROptions = {}) {
88
+ this.#size = options.size ?? DEFAULT_OCR_SIZE
89
+ this.#device = options.device ?? 'auto'
90
+ this.#onProgress = options.onProgress
91
+ }
92
+
93
+ get size(): OCRSize {
94
+ return this.#size
95
+ }
96
+
97
+ get device(): ResolvedDevice | null {
98
+ return this.#resolvedDevice
99
+ }
100
+
101
+ async load(): Promise<void> {
102
+ if (this.#disposed) throw new Error('OCR model was disposed')
103
+ if (this.#service) return
104
+ if (this.#loading) return this.#loading
105
+
106
+ this.#loading = (async () => {
107
+ const urls = HF[this.#size]
108
+ this.#onProgress?.({ status: 'loading', progress: 0 })
109
+
110
+ const [det, rec, yml] = await Promise.all([
111
+ fetchCached(urls.det, 'det.onnx', this.#onProgress),
112
+ fetchCached(urls.rec, 'rec.onnx', this.#onProgress),
113
+ fetchCachedText(urls.yml),
114
+ ])
115
+
116
+ const charactersDictionary = parseCharacterDict(yml)
117
+ if (charactersDictionary[charactersDictionary.length - 1] !== ' ') {
118
+ charactersDictionary.push(' ')
119
+ }
120
+
121
+ const ort = await loadOrt()
122
+ const preferred = await resolveDevice(this.#device)
123
+ const service = await createService({
124
+ ort,
125
+ size: this.#size,
126
+ det,
127
+ rec,
128
+ charactersDictionary,
129
+ device: preferred,
130
+ })
131
+
132
+ if (this.#disposed) {
133
+ await takeTurn(() => service.ocr.destroy())
134
+ return
135
+ }
136
+
137
+ this.#service = service.ocr
138
+ this.#resolvedDevice = service.device
139
+ this.#onProgress?.({ status: 'ready', progress: 100 })
140
+ })()
141
+
142
+ try {
143
+ await this.#loading
144
+ } finally {
145
+ this.#loading = null
146
+ }
147
+ }
148
+
149
+ read(image: OCRImageInput): Promise<OCRResult> {
150
+ if (this.#disposed) return Promise.reject(new Error('OCR model was disposed'))
151
+ const job = this.#read(image)
152
+ this.#pending = this.#pending.then(
153
+ () => job.then(
154
+ () => undefined,
155
+ () => undefined,
156
+ ),
157
+ )
158
+ return job
159
+ }
160
+
161
+ /**
162
+ * Drop the sessions. Waits for an in-flight `read` so `destroy` cannot
163
+ * release a session that is still inside OrtRun.
164
+ */
165
+ async dispose(): Promise<void> {
166
+ if (this.#disposeDone) return this.#disposeDone
167
+ this.#disposed = true
168
+ this.#disposeDone = (async () => {
169
+ await this.#pending
170
+ await this.#loading
171
+ const service = this.#service
172
+ this.#service = null
173
+ this.#resolvedDevice = null
174
+ if (!service) return
175
+ await takeTurn(() => service.destroy())
176
+ })()
177
+ return this.#disposeDone
178
+ }
179
+
180
+ async #read(image: OCRImageInput): Promise<OCRResult> {
181
+ if (this.#disposed) throw new Error('OCR model was disposed')
182
+ await this.load()
183
+ if (this.#disposed) throw new Error('OCR model was disposed')
184
+ const service = this.#service
185
+ if (!service) throw new Error('OCR model failed to load')
186
+
187
+ this.#onProgress?.({ status: 'recognizing' })
188
+ const pixels = await imageToPixels(image)
189
+ const raw = await takeTurn(() => {
190
+ if (this.#disposed) throw new Error('OCR model was disposed')
191
+ return service.recognize(pixels)
192
+ })
193
+ const processed = service.processRecognition(raw)
194
+
195
+ const lines: OCRLine[] = raw.map((item) => ({
196
+ text: item.text,
197
+ score: item.confidence,
198
+ box: {
199
+ xmin: item.box.x,
200
+ ymin: item.box.y,
201
+ xmax: item.box.x + item.box.width,
202
+ ymax: item.box.y + item.box.height,
203
+ },
204
+ }))
205
+
206
+ this.#onProgress?.({ status: 'done' })
207
+ return {
208
+ text: processed.text,
209
+ lines,
210
+ confidence: processed.confidence,
211
+ }
212
+ }
213
+ }
214
+
215
+ export async function read(image: OCRImageInput, options?: OCROptions): Promise<OCRResult> {
216
+ const ocr = new OCR(options)
217
+ try {
218
+ return await ocr.read(image)
219
+ } finally {
220
+ await ocr.dispose()
221
+ }
222
+ }
223
+
224
+ /**
225
+ * onnxruntime-web's WebGPU backend keeps a single global OrtRun slot.
226
+ * A second run throws "Session already started", and its `finally` clears
227
+ * that slot, so the first run then throws "Session mismatch".
228
+ */
229
+ let ortTurn: Promise<void> = Promise.resolve()
230
+
231
+ function takeTurn<T>(fn: () => Promise<T>): Promise<T> {
232
+ const run = ortTurn.then(fn, fn)
233
+ ortTurn = run.then(
234
+ () => undefined,
235
+ () => undefined,
236
+ )
237
+ return run
238
+ }
239
+
240
+ export type { ProgressInfo, Device }
241
+
242
+ type OrtApi = {
243
+ env: { wasm: { wasmPaths: string; numThreads: number } }
244
+ Tensor: unknown
245
+ InferenceSession: {
246
+ create: (model: ArrayBuffer, options?: { executionProviders?: string[] }) => Promise<unknown>
247
+ }
248
+ }
249
+
250
+ let ortModule: OrtApi | null = null
251
+
252
+ async function loadOrt(): Promise<OrtApi> {
253
+ if (ortModule) return ortModule
254
+ const ort = (await import('onnxruntime-web')) as unknown as OrtApi
255
+ ort.env.wasm.wasmPaths = ORT_WASM
256
+ ort.env.wasm.numThreads = 1
257
+ ortModule = ort
258
+ return ort
259
+ }
260
+
261
+ async function createService(opts: {
262
+ ort: OrtApi
263
+ size: OCRSize
264
+ det: ArrayBuffer
265
+ rec: ArrayBuffer
266
+ charactersDictionary: string[]
267
+ device: ResolvedDevice
268
+ }): Promise<{ ocr: PaddleOcrService; device: ResolvedDevice }> {
269
+ const tryDevice = async (device: ResolvedDevice) => {
270
+ const providers = device === 'webgpu' ? ['webgpu', 'wasm'] : ['wasm']
271
+ const wrapped = {
272
+ Tensor: opts.ort.Tensor,
273
+ InferenceSession: {
274
+ create: (modelBuffer: ArrayBuffer) =>
275
+ opts.ort.InferenceSession.create(modelBuffer, { executionProviders: providers }),
276
+ },
277
+ }
278
+ return PaddleOcrService.createInstance({
279
+ ort: wrapped as never,
280
+ modelPreset: PRESET[opts.size],
281
+ detection: { modelBuffer: opts.det.slice(0) },
282
+ recognition: {
283
+ modelBuffer: opts.rec.slice(0),
284
+ charactersDictionary: opts.charactersDictionary,
285
+ },
286
+ })
287
+ }
288
+
289
+ try {
290
+ return { ocr: await tryDevice(opts.device), device: opts.device }
291
+ } catch (err) {
292
+ if (opts.device === 'wasm') throw err
293
+ return { ocr: await tryDevice('wasm'), device: 'wasm' }
294
+ }
295
+ }
296
+
297
+ async function fetchCached(
298
+ url: string,
299
+ file: string,
300
+ onProgress?: ProgressCallback
301
+ ): Promise<ArrayBuffer> {
302
+ const cache = await caches.open(CACHE).catch(() => null)
303
+ const hit = await cache?.match(url)
304
+ if (hit) {
305
+ onProgress?.({ status: 'progress', progress: 100, file })
306
+ return hit.arrayBuffer()
307
+ }
308
+
309
+ const res = await fetch(url)
310
+ if (!res.ok) throw new Error(`Could not load ${file} (${res.status})`)
311
+
312
+ const total = Number(res.headers.get('content-length') ?? 0)
313
+ if (!res.body || !total) {
314
+ const buf = await res.arrayBuffer()
315
+ await cache?.put(url, new Response(buf.slice(0)))
316
+ return buf
317
+ }
318
+
319
+ const reader = res.body.getReader()
320
+ const chunks: Uint8Array[] = []
321
+ let received = 0
322
+ for (;;) {
323
+ const { done, value } = await reader.read()
324
+ if (done) break
325
+ chunks.push(value)
326
+ received += value.byteLength
327
+ onProgress?.({ status: 'progress', progress: (received / total) * 100, file })
328
+ }
329
+
330
+ const out = new Uint8Array(received)
331
+ let offset = 0
332
+ for (const chunk of chunks) {
333
+ out.set(chunk, offset)
334
+ offset += chunk.byteLength
335
+ }
336
+ const buf = out.buffer
337
+ await cache?.put(url, new Response(buf.slice(0)))
338
+ return buf
339
+ }
340
+
341
+ async function fetchCachedText(url: string): Promise<string> {
342
+ const cache = await caches.open(CACHE).catch(() => null)
343
+ const hit = await cache?.match(url)
344
+ if (hit) return hit.text()
345
+ const res = await fetch(url)
346
+ if (!res.ok) throw new Error(`Could not load dictionary (${res.status})`)
347
+ const text = await res.text()
348
+ await cache?.put(url, new Response(text))
349
+ return text
350
+ }
351
+
352
+ function parseCharacterDict(yml: string): string[] {
353
+ const start = yml.indexOf('character_dict:')
354
+ if (start < 0) throw new Error('PP-OCRv6 dictionary missing character_dict')
355
+ const chars: string[] = []
356
+ for (const rawLine of yml.slice(start).split('\n').slice(1)) {
357
+ const item = rawLine.match(/^\s*-\s+(.*)$/)
358
+ if (!item) {
359
+ if (chars.length && /^\S/.test(rawLine)) break
360
+ continue
361
+ }
362
+ chars.push(unquoteYaml(item[1] ?? ''))
363
+ }
364
+ if (chars.length < 100) throw new Error('PP-OCRv6 dictionary looks empty')
365
+ return chars
366
+ }
367
+
368
+ function unquoteYaml(value: string): string {
369
+ const v = value.trim()
370
+ if (v === "''" || v === '""') return ''
371
+ if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
372
+ const inner = v.slice(1, -1)
373
+ if (v.startsWith("'")) return inner.replace(/''/g, "'")
374
+ return inner.replace(/\\"/g, '"')
375
+ }
376
+ return v
377
+ }
378
+
379
+ async function imageToPixels(image: OCRImageInput): Promise<{ width: number; height: number; data: Uint8Array }> {
380
+ if (image instanceof ImageData) {
381
+ return { width: image.width, height: image.height, data: new Uint8Array(image.data) }
382
+ }
383
+
384
+ let source: CanvasImageSource
385
+ if (typeof image === 'string') {
386
+ source = await loadHtmlImage(image)
387
+ } else if (image instanceof HTMLImageElement) {
388
+ source = image
389
+ } else if (image instanceof HTMLCanvasElement) {
390
+ source = image
391
+ } else {
392
+ source = await createImageBitmap(image)
393
+ }
394
+
395
+ const width = 'naturalWidth' in source ? source.naturalWidth || source.width : source.width
396
+ const height = 'naturalHeight' in source ? source.naturalHeight || source.height : source.height
397
+ const canvas = document.createElement('canvas')
398
+ canvas.width = width
399
+ canvas.height = height
400
+ const ctx = canvas.getContext('2d')
401
+ if (!ctx) throw new Error('Could not get 2d context')
402
+ ctx.drawImage(source, 0, 0)
403
+ const { data } = ctx.getImageData(0, 0, width, height)
404
+ if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) source.close()
405
+ return { width, height, data: new Uint8Array(data) }
406
+ }
407
+
408
+ function loadHtmlImage(src: string): Promise<HTMLImageElement> {
409
+ return new Promise((resolve, reject) => {
410
+ const img = new Image()
411
+ img.crossOrigin = 'anonymous'
412
+ img.onload = () => resolve(img)
413
+ img.onerror = () => reject(new Error(`Could not load image: ${src}`))
414
+ img.src = src
415
+ })
416
+ }
@@ -0,0 +1,12 @@
1
+ export type OCRSize = 'tiny' | 'small' | 'medium'
2
+
3
+ export const OCR_SIZES: Record<
4
+ OCRSize,
5
+ { label: string; params: string; downloadMB: string; detHmean: string; recAcc: string }
6
+ > = {
7
+ tiny: { label: 'Tiny', params: '1.5M', downloadMB: '~6 MB', detHmean: '80.6%', recAcc: '73.5%' },
8
+ small: { label: 'Small', params: '7.7M', downloadMB: '~31 MB', detHmean: '84.1%', recAcc: '81.3%' },
9
+ medium: { label: 'Medium', params: '34.5M', downloadMB: '~139 MB', detHmean: '86.2%', recAcc: '83.2%' },
10
+ }
11
+
12
+ export const DEFAULT_OCR_SIZE: OCRSize = 'small'