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,263 @@
1
+ import type { Device, ProgressCallback, ProgressInfo } from '../core/index.ts'
2
+ import { loadPipeline } from '../core/pipeline.ts'
3
+
4
+ /**
5
+ * Default weights: Superwhisper S1-mini as ONNX (Qwen3-0.6B fine-tune).
6
+ * q4f16 on WebGPU (~339 MB) · q4 on WASM (~385 MB).
7
+ */
8
+ export const DEFAULT_MODEL = 'onnx-community/s1-mini-ONNX'
9
+
10
+ /** Exact system prompt S1-mini was trained with. Do not reword. */
11
+ export const SYSTEM_PROMPT =
12
+ 'You are a text normalizer for speech-to-text transcripts. The input begins ' +
13
+ 'with a control line specifying the styling, structure, and context settings; ' +
14
+ 'clean the transcript to match those settings and output only the cleaned text.'
15
+
16
+ export const STYLING = ['casual', 'semi-casual', 'semi-formal', 'formal'] as const
17
+ export const STRUCTURE = ['prose', 'lists'] as const
18
+ export const CONTEXT = ['general', 'email'] as const
19
+
20
+ export type Styling = (typeof STYLING)[number]
21
+ export type Structure = (typeof STRUCTURE)[number]
22
+ export type Context = (typeof CONTEXT)[number]
23
+
24
+ export type CleanOptions = {
25
+ /** Hugging Face model id. Defaults to `onnx-community/s1-mini-ONNX`. */
26
+ model?: string
27
+ device?: Device
28
+ /** Written register. Default `semi-formal`. */
29
+ styling?: Styling
30
+ /** `lists` may emit Markdown bullets when there are 3+ items. Default `prose`. */
31
+ structure?: Structure
32
+ /** `email` adds greeting / body / sign-off layout. Default `general`. */
33
+ context?: Context
34
+ onProgress?: ProgressCallback
35
+ }
36
+
37
+ export type CleanRunOptions = {
38
+ styling?: Styling
39
+ structure?: Structure
40
+ context?: Context
41
+ /** Incremental decoded tokens as they arrive (think-block stripped). */
42
+ onPartial?: (text: string) => void
43
+ }
44
+
45
+ export type CleanResult = {
46
+ /** Cleaned written text. Empty when the input was only filler or noise. */
47
+ text: string
48
+ }
49
+
50
+ type Tokenizer = {
51
+ apply_chat_template: (messages: unknown, opts: Record<string, unknown>) => string
52
+ (text: string): { input_ids?: { size?: number; dims?: number[] } }
53
+ }
54
+
55
+ type GenPipe = CallableFunction & {
56
+ tokenizer?: Tokenizer
57
+ dispose?: () => Promise<void>
58
+ }
59
+
60
+ const THINK_BLOCK = /<think>[\s\S]*?<\/think>\s*/g
61
+
62
+ function controlLine(styling: Styling, structure: Structure, context: Context): string {
63
+ return `[Styling: ${styling}] [Structure: ${structure}] [Context: ${context}]`
64
+ }
65
+
66
+ function tokenCount(tokenizer: Tokenizer | undefined, text: string): number {
67
+ if (!tokenizer) return Math.max(8, text.split(/\s+/).length)
68
+ const ids = tokenizer(text).input_ids
69
+ return ids?.dims?.at(-1) ?? ids?.size ?? Math.max(8, text.split(/\s+/).length)
70
+ }
71
+
72
+ function stripThink(text: string): string {
73
+ return text.replace(THINK_BLOCK, '').trim()
74
+ }
75
+
76
+ function generatedText(raw: unknown): string {
77
+ if (typeof raw === 'string') return raw
78
+ if (Array.isArray(raw)) return generatedText(raw[0])
79
+ if (raw && typeof raw === 'object' && 'generated_text' in raw) {
80
+ const g = (raw as { generated_text: unknown }).generated_text
81
+ if (typeof g === 'string') return g
82
+ if (Array.isArray(g)) {
83
+ const last = g.at(-1) as { content?: string } | undefined
84
+ if (typeof last?.content === 'string') return last.content
85
+ }
86
+ }
87
+ return ''
88
+ }
89
+
90
+ /**
91
+ * Clean a raw speech-to-text transcript in the browser (S1-mini by Superwhisper).
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * import { TranscriptCleaner } from 'runonweb/clean'
96
+ *
97
+ * const cleaner = new TranscriptCleaner()
98
+ * await cleaner.load()
99
+ *
100
+ * const { text } = await cleaner.clean(
101
+ * 'so um i need to like send the the report by uh friday no wait make that thursday'
102
+ * )
103
+ * // "I need to send the report by Thursday."
104
+ * ```
105
+ */
106
+ export class TranscriptCleaner {
107
+ #model: string
108
+ #device: Device
109
+ #styling: Styling
110
+ #structure: Structure
111
+ #context: Context
112
+ #onProgress?: ProgressCallback
113
+ #pipe: GenPipe | null = null
114
+ #loading: Promise<void> | null = null
115
+ #resolvedDevice: 'webgpu' | 'wasm' | null = null
116
+
117
+ constructor(options: CleanOptions = {}) {
118
+ this.#model = options.model ?? DEFAULT_MODEL
119
+ this.#device = options.device ?? 'auto'
120
+ this.#styling = options.styling ?? 'semi-formal'
121
+ this.#structure = options.structure ?? 'prose'
122
+ this.#context = options.context ?? 'general'
123
+ this.#onProgress = options.onProgress
124
+ }
125
+
126
+ get device(): 'webgpu' | 'wasm' | null {
127
+ return this.#resolvedDevice
128
+ }
129
+
130
+ async load(): Promise<void> {
131
+ if (this.#pipe) return
132
+ if (this.#loading) return this.#loading
133
+
134
+ this.#loading = (async () => {
135
+ const attempts =
136
+ this.#device === 'wasm'
137
+ ? [{ device: 'wasm' as const, dtype: 'q4' as const }]
138
+ : this.#device === 'webgpu'
139
+ ? [
140
+ { device: 'webgpu' as const, dtype: 'q4f16' as const },
141
+ { device: 'webgpu' as const, dtype: 'q4' as const },
142
+ ]
143
+ : [
144
+ { device: 'webgpu' as const, dtype: 'q4f16' as const },
145
+ { device: 'webgpu' as const, dtype: 'q4' as const },
146
+ { device: 'wasm' as const, dtype: 'q4' as const },
147
+ ]
148
+
149
+ let lastError: unknown
150
+ for (const attempt of attempts) {
151
+ try {
152
+ const { pipe, device } = await loadPipeline({
153
+ task: 'text-generation',
154
+ model: this.#model,
155
+ device: attempt.device,
156
+ // q4f16 matches the official WebGPU demo; q4 is the compatible fallback.
157
+ dtype: attempt.dtype,
158
+ supportedDevices: [attempt.device],
159
+ onProgress: this.#onProgress,
160
+ })
161
+ this.#pipe = pipe
162
+ this.#resolvedDevice = device
163
+ return
164
+ } catch (err) {
165
+ lastError = err
166
+ }
167
+ }
168
+ const message = lastError instanceof Error ? lastError.message : String(lastError)
169
+ throw new Error(
170
+ /^\d+$/.test(message)
171
+ ? `Could not start S1-mini on this device (${message}). Try again or pass device: "wasm".`
172
+ : message
173
+ )
174
+ })()
175
+
176
+ try {
177
+ await this.#loading
178
+ } finally {
179
+ this.#loading = null
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Rewrite a raw ASR transcript as clean written text.
185
+ * Empty / whitespace-only input throws. Filler-only input returns `{ text: "" }`.
186
+ */
187
+ async clean(text: string, options: CleanRunOptions = {}): Promise<CleanResult> {
188
+ await this.load()
189
+ if (!this.#pipe) throw new Error('Cleaner model failed to load')
190
+ if (!text.trim()) throw new Error('Text is empty')
191
+
192
+ const styling = options.styling ?? this.#styling
193
+ const structure = options.structure ?? this.#structure
194
+ const context = options.context ?? this.#context
195
+ const tokenizer = this.#pipe.tokenizer
196
+
197
+ const messages = [
198
+ { role: 'system', content: SYSTEM_PROMPT },
199
+ { role: 'user', content: `${controlLine(styling, structure, context)}\n${text}` },
200
+ ]
201
+
202
+ // S1-mini was trained with thinking off. The pipeline does not pass this flag,
203
+ // so the chat template must be applied here or the model emits an empty think block.
204
+ const prompt = tokenizer
205
+ ? tokenizer.apply_chat_template(messages, {
206
+ tokenize: false,
207
+ add_generation_prompt: true,
208
+ enable_thinking: false,
209
+ })
210
+ : `${SYSTEM_PROMPT}\n${controlLine(styling, structure, context)}\n${text}`
211
+
212
+ const maxNewTokens = Math.min(1024, Math.max(64, Math.ceil(tokenCount(tokenizer, text) * 1.3) + 32))
213
+
214
+ this.#onProgress?.({ status: 'cleaning' })
215
+
216
+ let streamed = ''
217
+ let streamer: unknown
218
+ if (options.onPartial && tokenizer) {
219
+ const { TextStreamer } = await import('@huggingface/transformers')
220
+ streamer = new TextStreamer(tokenizer as never, {
221
+ skip_prompt: true,
222
+ skip_special_tokens: true,
223
+ callback_function: (chunk: string) => {
224
+ streamed += chunk
225
+ options.onPartial?.(stripThink(streamed))
226
+ },
227
+ })
228
+ }
229
+
230
+ const raw = await this.#pipe(prompt, {
231
+ max_new_tokens: maxNewTokens,
232
+ do_sample: false,
233
+ return_full_text: false,
234
+ ...(streamer ? { streamer } : {}),
235
+ })
236
+
237
+ const result = stripThink(generatedText(raw) || streamed)
238
+
239
+ this.#onProgress?.({ status: 'done' })
240
+ return { text: result }
241
+ }
242
+
243
+ dispose(): void {
244
+ const pipe = this.#pipe
245
+ this.#pipe = null
246
+ this.#resolvedDevice = null
247
+ void pipe?.dispose?.()
248
+ }
249
+ }
250
+
251
+ export async function clean(
252
+ text: string,
253
+ options?: CleanOptions & CleanRunOptions
254
+ ): Promise<CleanResult> {
255
+ const cleaner = new TranscriptCleaner(options)
256
+ try {
257
+ return await cleaner.clean(text, options)
258
+ } finally {
259
+ cleaner.dispose()
260
+ }
261
+ }
262
+
263
+ export type { ProgressInfo, Device }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Inspect and clear the model weights Transformers.js stores in the browser's Cache Storage.
3
+ * Everything runonweb downloads lives in the `transformers-cache` bucket, keyed by the
4
+ * Hugging Face URL, so a model id maps to a set of cached requests.
5
+ */
6
+
7
+ export const CACHE_NAME = 'transformers-cache'
8
+
9
+ /** Cache Storage bucket used by `runonweb/translate` for Firefox Translations models. */
10
+ export const TRANSLATE_CACHE_NAME = 'runonweb-translate'
11
+
12
+ /** Cache Storage bucket used by Bonsai Image (range-request leftovers). */
13
+ export const BONSAI_CACHE_NAME = 'bonsai-pipeline-v1'
14
+ /** IndexedDB used by the Bonsai Image WebGPU engine for safetensor chunks. */
15
+ export const BONSAI_IDB_NAME = 'bonsai-image-v1'
16
+
17
+ const EXTRA_CACHE_NAMES = [BONSAI_CACHE_NAME, TRANSLATE_CACHE_NAME]
18
+
19
+ export type CachedFile = {
20
+ url: string
21
+ /** Path inside the repo, e.g. `onnx/encoder_model_fp16.onnx`. */
22
+ path: string
23
+ bytes: number
24
+ }
25
+
26
+ export type CachedModel = {
27
+ /** Hugging Face repo id, e.g. `onnx-community/whisper-tiny.en`. */
28
+ id: string
29
+ files: CachedFile[]
30
+ bytes: number
31
+ }
32
+
33
+ const HF_RE = /^https?:\/\/[^/]+\/(?:datasets\/)?([^/]+\/[^/]+)\/resolve\/[^/]+\/(.+)$/
34
+ /** `<base>/<from>-<to>/<model|lex|vocab file>?rev=…` as written by `runonweb/translate`. */
35
+ const TRANSLATE_RE = /\/([a-z]{2,3}(?:-Han[st])?-[a-z]{2,3}(?:-Han[st])?)\/((?:model|lex|vocab|srcvocab|trgvocab)\.[^/?]+)(?:\?.*)?$/
36
+
37
+ /** Split a cached request URL into a model id and a path inside it. */
38
+ function identify(url: string): { id: string; path: string } {
39
+ const t = TRANSLATE_RE.exec(url)
40
+ if (t) return { id: `firefox-translations/${t[1]}`, path: t[2] }
41
+ const m = HF_RE.exec(url)
42
+ return { id: m?.[1] ?? 'other', path: m?.[2] ?? url }
43
+ }
44
+
45
+ function cachesAvailable(): boolean {
46
+ return typeof caches !== 'undefined'
47
+ }
48
+
49
+ async function responseSize(res: Response): Promise<number> {
50
+ const len = res.headers.get('content-length')
51
+ if (len && Number(len) > 0) return Number(len)
52
+ try {
53
+ return (await res.clone().blob()).size
54
+ } catch {
55
+ return 0
56
+ }
57
+ }
58
+
59
+ async function collectCacheBucket(cacheName: string, byModel: Map<string, CachedModel>): Promise<void> {
60
+ if (!cachesAvailable()) return
61
+ let cache: Cache
62
+ try {
63
+ cache = await caches.open(cacheName)
64
+ } catch {
65
+ return
66
+ }
67
+ const requests = await cache.keys()
68
+ for (const req of requests) {
69
+ const { id, path } = identify(req.url)
70
+ const res = await cache.match(req)
71
+ const bytes = res ? await responseSize(res) : 0
72
+ const entry = byModel.get(id) ?? { id, files: [], bytes: 0 }
73
+ entry.files.push({ url: req.url, path, bytes })
74
+ entry.bytes += bytes
75
+ byModel.set(id, entry)
76
+ }
77
+ }
78
+
79
+ async function idbExists(name: string): Promise<boolean> {
80
+ if (typeof indexedDB === 'undefined' || !indexedDB.databases) return false
81
+ try {
82
+ const dbs = await indexedDB.databases()
83
+ return dbs.some((db) => db.name === name)
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /** Sum Bonsai Image chunks stored in IndexedDB (the engine's real weight cache). */
90
+ async function listBonsaiIndexedDb(): Promise<CachedModel | null> {
91
+ if (typeof indexedDB === 'undefined') return null
92
+ if (!(await idbExists(BONSAI_IDB_NAME))) return null
93
+
94
+ return new Promise((resolve) => {
95
+ const req = indexedDB.open(BONSAI_IDB_NAME)
96
+ req.onerror = () => resolve(null)
97
+ req.onsuccess = () => {
98
+ const db = req.result
99
+ if (!db.objectStoreNames.contains('chunks')) {
100
+ db.close()
101
+ resolve(null)
102
+ return
103
+ }
104
+ const names = [...db.objectStoreNames]
105
+ const tx = db.transaction(names, 'readonly')
106
+ let bytes = 0
107
+ let id = 'prism-ml/bonsai-image'
108
+
109
+ if (names.includes('meta')) {
110
+ const keysReq = tx.objectStore('meta').getAllKeys()
111
+ keysReq.onsuccess = () => {
112
+ for (const key of keysReq.result) {
113
+ const m = HF_RE.exec(String(key))
114
+ if (m?.[1]) {
115
+ id = m[1]
116
+ break
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ const cursorReq = tx.objectStore('chunks').openCursor()
123
+ cursorReq.onsuccess = () => {
124
+ const cursor = cursorReq.result
125
+ if (!cursor) return
126
+ const value = cursor.value as unknown
127
+ if (value instanceof Blob) bytes += value.size
128
+ else if (value instanceof ArrayBuffer) bytes += value.byteLength
129
+ cursor.continue()
130
+ }
131
+
132
+ tx.oncomplete = () => {
133
+ db.close()
134
+ resolve(
135
+ bytes > 0
136
+ ? { id, files: [{ url: `indexeddb://${BONSAI_IDB_NAME}`, path: 'chunks', bytes }], bytes }
137
+ : null
138
+ )
139
+ }
140
+ tx.onerror = () => {
141
+ db.close()
142
+ resolve(null)
143
+ }
144
+ }
145
+ })
146
+ }
147
+
148
+ /** List every model cached in this origin, largest first. */
149
+ export async function listCachedModels(): Promise<CachedModel[]> {
150
+ const byModel = new Map<string, CachedModel>()
151
+ await collectCacheBucket(CACHE_NAME, byModel)
152
+ for (const name of EXTRA_CACHE_NAMES) {
153
+ await collectCacheBucket(name, byModel)
154
+ }
155
+
156
+ const bonsai = await listBonsaiIndexedDb()
157
+ if (bonsai) {
158
+ const existing = byModel.get(bonsai.id)
159
+ if (existing) {
160
+ existing.files.push(...bonsai.files)
161
+ existing.bytes += bonsai.bytes
162
+ } else {
163
+ byModel.set(bonsai.id, bonsai)
164
+ }
165
+ }
166
+
167
+ return [...byModel.values()].sort((a, b) => b.bytes - a.bytes)
168
+ }
169
+
170
+ async function deleteIndexedDb(name: string): Promise<boolean> {
171
+ if (typeof indexedDB === 'undefined') return false
172
+ if (!(await idbExists(name))) return false
173
+ return new Promise((resolve) => {
174
+ const req = indexedDB.deleteDatabase(name)
175
+ req.onsuccess = () => resolve(true)
176
+ req.onerror = () => resolve(false)
177
+ req.onblocked = () => resolve(true)
178
+ })
179
+ }
180
+
181
+ async function clearCacheBucketForModel(cacheName: string, modelId: string): Promise<number> {
182
+ if (!cachesAvailable()) return 0
183
+ let cache: Cache
184
+ try {
185
+ cache = await caches.open(cacheName)
186
+ } catch {
187
+ return 0
188
+ }
189
+ let removed = 0
190
+ for (const req of await cache.keys()) {
191
+ if (identify(req.url).id === modelId && (await cache.delete(req))) removed++
192
+ }
193
+ return removed
194
+ }
195
+
196
+ /** Delete the cached files of one model. Returns the number of entries removed. */
197
+ export async function clearModelCache(modelId: string): Promise<number> {
198
+ let removed = await clearCacheBucketForModel(CACHE_NAME, modelId)
199
+ for (const name of EXTRA_CACHE_NAMES) {
200
+ removed += await clearCacheBucketForModel(name, modelId)
201
+ }
202
+ if (modelId.includes('bonsai-image') && (await deleteIndexedDb(BONSAI_IDB_NAME))) {
203
+ removed += 1
204
+ }
205
+ return removed
206
+ }
207
+
208
+ /** Delete every cached model. Returns the number of entries removed. */
209
+ export async function clearAllModelCache(): Promise<number> {
210
+ let removed = 0
211
+ if (cachesAvailable()) {
212
+ for (const name of [CACHE_NAME, ...EXTRA_CACHE_NAMES]) {
213
+ try {
214
+ const cache = await caches.open(name)
215
+ removed += (await cache.keys()).length
216
+ await caches.delete(name)
217
+ } catch {
218
+ // Bucket may not exist.
219
+ }
220
+ }
221
+ }
222
+ if (await deleteIndexedDb(BONSAI_IDB_NAME)) removed += 1
223
+ return removed
224
+ }
225
+
226
+ /** Bytes used / available for this origin, when the browser reports it. */
227
+ export async function storageEstimate(): Promise<{ usage: number; quota: number } | null> {
228
+ try {
229
+ const est = await navigator.storage?.estimate?.()
230
+ if (!est) return null
231
+ return { usage: est.usage ?? 0, quota: est.quota ?? 0 }
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
237
+ /** Human-readable bytes: 23 MB, 1.2 GB. */
238
+ export function formatBytes(bytes: number): string {
239
+ if (bytes < 1024) return `${bytes} B`
240
+ const units = ['KB', 'MB', 'GB']
241
+ let v = bytes / 1024
242
+ let i = 0
243
+ while (v >= 1024 && i < units.length - 1) {
244
+ v /= 1024
245
+ i++
246
+ }
247
+ return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
248
+ }
@@ -0,0 +1,34 @@
1
+ export type Device = 'webgpu' | 'wasm' | 'auto'
2
+ export type ResolvedDevice = 'webgpu' | 'wasm'
3
+
4
+ let webgpuCache: boolean | null = null
5
+
6
+ /**
7
+ * Detect whether WebGPU is available in this environment.
8
+ * Result is cached after the first successful check.
9
+ */
10
+ export async function isWebGPUAvailable(): Promise<boolean> {
11
+ if (webgpuCache !== null) return webgpuCache
12
+
13
+ try {
14
+ const gpu = (globalThis as { navigator?: { gpu?: GPU } }).navigator?.gpu
15
+ if (!gpu) {
16
+ webgpuCache = false
17
+ return false
18
+ }
19
+ const adapter = await gpu.requestAdapter()
20
+ webgpuCache = adapter != null
21
+ return webgpuCache
22
+ } catch {
23
+ webgpuCache = false
24
+ return false
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Resolve `auto` to `webgpu` when available, otherwise `wasm`.
30
+ */
31
+ export async function resolveDevice(device: Device = 'auto'): Promise<ResolvedDevice> {
32
+ if (device === 'webgpu' || device === 'wasm') return device
33
+ return (await isWebGPUAvailable()) ? 'webgpu' : 'wasm'
34
+ }
@@ -0,0 +1,18 @@
1
+ export type { Device, ResolvedDevice } from './device.ts'
2
+ export { isWebGPUAvailable, resolveDevice } from './device.ts'
3
+
4
+ export type { ProgressInfo, ProgressCallback } from './progress.ts'
5
+ export { toProgressInfo } from './progress.ts'
6
+
7
+ export type { CachedFile, CachedModel } from './cache.ts'
8
+ export {
9
+ CACHE_NAME,
10
+ TRANSLATE_CACHE_NAME,
11
+ BONSAI_CACHE_NAME,
12
+ BONSAI_IDB_NAME,
13
+ clearAllModelCache,
14
+ clearModelCache,
15
+ formatBytes,
16
+ listCachedModels,
17
+ storageEstimate,
18
+ } from './cache.ts'
@@ -0,0 +1,108 @@
1
+ import {
2
+ resolveDevice,
3
+ toProgressInfo,
4
+ type Device,
5
+ type ProgressCallback,
6
+ type ResolvedDevice,
7
+ } from './index.ts'
8
+
9
+ export type DTypeName = 'auto' | 'fp32' | 'fp16' | 'q8' | 'int8' | 'uint8' | 'q4' | 'bnb4' | 'q4f16'
10
+ export type DType = DTypeName | Record<string, DTypeName>
11
+
12
+ export type PipelineLoadOptions = {
13
+ task: string
14
+ model: string
15
+ /** Defaults to `auto` (WebGPU when available, else WASM). */
16
+ device?: Device
17
+ /** Quantization. A function receives the resolved device so modules can pick per backend. */
18
+ dtype?: DType | ((device: ResolvedDevice) => DType)
19
+ /**
20
+ * Backends this model is known to work on. If the resolved device is not listed,
21
+ * the loader falls back to WASM instead of failing at session creation.
22
+ */
23
+ supportedDevices?: ResolvedDevice[]
24
+ /**
25
+ * Base URL for self-hosted weights (e.g. `/models/`). When set, `model` is resolved as
26
+ * `<modelPath>/<model>/…` instead of the Hugging Face Hub.
27
+ */
28
+ modelPath?: string
29
+ onProgress?: ProgressCallback
30
+ pipelineOptions?: Record<string, unknown>
31
+ }
32
+
33
+ export type LoadedPipeline = {
34
+ pipe: CallableFunction & { dispose?: () => Promise<void> }
35
+ device: 'webgpu' | 'wasm'
36
+ }
37
+
38
+ /**
39
+ * Shared loader for Transformers.js pipelines with device resolution + progress.
40
+ */
41
+ export async function loadPipeline(options: PipelineLoadOptions): Promise<LoadedPipeline> {
42
+ let device = await resolveDevice(options.device ?? 'auto')
43
+ if (options.supportedDevices && !options.supportedDevices.includes(device)) {
44
+ device = 'wasm'
45
+ }
46
+ options.onProgress?.({ status: 'loading', progress: 0 })
47
+
48
+ const { pipeline, env } = await import('@huggingface/transformers')
49
+ if (options.modelPath) {
50
+ env.allowLocalModels = true
51
+ env.allowRemoteModels = false
52
+ env.localModelPath = options.modelPath
53
+ } else {
54
+ env.allowLocalModels = false
55
+ env.allowRemoteModels = true
56
+ }
57
+
58
+ const dtype =
59
+ typeof options.dtype === 'function'
60
+ ? options.dtype(device)
61
+ : (options.dtype ?? (device === 'webgpu' ? 'fp32' : 'q8'))
62
+
63
+ const pipe = await pipeline(options.task as never, options.model, {
64
+ device,
65
+ dtype,
66
+ progress_callback: (data: Record<string, unknown>) => {
67
+ options.onProgress?.(toProgressInfo(data))
68
+ },
69
+ ...options.pipelineOptions,
70
+ })
71
+
72
+ options.onProgress?.({ status: 'ready', progress: 100 })
73
+ return { pipe: pipe as LoadedPipeline['pipe'], device }
74
+ }
75
+
76
+ export async function imageToPipelineInput(
77
+ image: Blob | File | string | HTMLImageElement | HTMLCanvasElement | ImageData
78
+ ): Promise<string | import('@huggingface/transformers').RawImage> {
79
+ if (typeof image === 'string') return image
80
+
81
+ const { RawImage } = await import('@huggingface/transformers')
82
+
83
+ if (image instanceof HTMLImageElement) {
84
+ if (image.src && !image.src.startsWith('blob:')) return image.src
85
+ const canvas = document.createElement('canvas')
86
+ canvas.width = image.naturalWidth || image.width
87
+ canvas.height = image.naturalHeight || image.height
88
+ const ctx = canvas.getContext('2d')
89
+ if (!ctx) throw new Error('Could not get 2d context')
90
+ ctx.drawImage(image, 0, 0)
91
+ return RawImage.fromCanvas(canvas)
92
+ }
93
+
94
+ if (image instanceof HTMLCanvasElement) {
95
+ return RawImage.fromCanvas(image)
96
+ }
97
+
98
+ if (image instanceof ImageData) {
99
+ return new RawImage(image.data, image.width, image.height, 4)
100
+ }
101
+
102
+ const url = URL.createObjectURL(image)
103
+ try {
104
+ return await RawImage.fromURL(url)
105
+ } finally {
106
+ URL.revokeObjectURL(url)
107
+ }
108
+ }
@@ -0,0 +1,20 @@
1
+ export type ProgressInfo = {
2
+ status: string
3
+ progress?: number
4
+ file?: string
5
+ }
6
+
7
+ export type ProgressCallback = (info: ProgressInfo) => void
8
+
9
+ /** Normalize Transformers.js progress callbacks into a simple shape. */
10
+ export function toProgressInfo(data: Record<string, unknown>): ProgressInfo {
11
+ const status = typeof data.status === 'string' ? data.status : 'progress'
12
+ const progress =
13
+ typeof data.progress === 'number'
14
+ ? data.progress
15
+ : typeof data.loaded === 'number' && typeof data.total === 'number' && data.total > 0
16
+ ? (data.loaded / data.total) * 100
17
+ : undefined
18
+ const file = typeof data.file === 'string' ? data.file : undefined
19
+ return { status, progress, file }
20
+ }