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.
- package/README.md +165 -0
- package/package.json +126 -0
- package/src/caption/index.ts +310 -0
- package/src/clean/index.ts +263 -0
- package/src/core/cache.ts +248 -0
- package/src/core/device.ts +34 -0
- package/src/core/index.ts +18 -0
- package/src/core/pipeline.ts +108 -0
- package/src/core/progress.ts +20 -0
- package/src/depth/index.ts +127 -0
- package/src/detect/index.ts +274 -0
- package/src/embed/index.ts +172 -0
- package/src/emoji/index.ts +132 -0
- package/src/image/engine.d.ts +57 -0
- package/src/image/engine.js +14090 -0
- package/src/image/index.ts +230 -0
- package/src/image/sizes.ts +28 -0
- package/src/ocr/index.ts +416 -0
- package/src/ocr/sizes.ts +12 -0
- package/src/remove-bg/index.ts +170 -0
- package/src/stt/index.ts +320 -0
- package/src/translate/bergamot.ts +280 -0
- package/src/translate/index.ts +240 -0
- package/src/translate/registry.ts +133 -0
- package/src/translate/worker.ts +181 -0
- package/src/tts/index.ts +220 -0
- package/src/tts/kitten.ts +280 -0
- package/src/tts/kokoro.ts +140 -0
- package/src/tts/phonemes.ts +150 -0
- package/src/tts/sizes.ts +30 -0
- package/src/tts/split.ts +31 -0
- package/src/tts/supertonic.ts +126 -0
- package/src/tts/types.ts +11 -0
- package/src/tts/voices.ts +131 -0
- package/src/tts/wav.ts +52 -0
|
@@ -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
|
+
}
|