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,127 @@
|
|
|
1
|
+
import type { Device, ProgressCallback, ProgressInfo } from '../core/index.ts'
|
|
2
|
+
import { imageToPipelineInput, loadPipeline } from '../core/pipeline.ts'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MODEL = 'onnx-community/depth-anything-v2-small'
|
|
5
|
+
|
|
6
|
+
export type DepthOptions = {
|
|
7
|
+
model?: string
|
|
8
|
+
device?: Device
|
|
9
|
+
onProgress?: ProgressCallback
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DepthImageInput = Blob | File | string | HTMLImageElement | HTMLCanvasElement | ImageData
|
|
13
|
+
|
|
14
|
+
export type DepthResult = {
|
|
15
|
+
/** Grayscale depth visualization as PNG. */
|
|
16
|
+
depth: Blob
|
|
17
|
+
width: number
|
|
18
|
+
height: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Monocular depth estimation in the browser (Depth Anything).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { DepthEstimator } from 'runonweb/depth'
|
|
27
|
+
*
|
|
28
|
+
* const depth = new DepthEstimator()
|
|
29
|
+
* await depth.load()
|
|
30
|
+
* const { depth: png } = await depth.estimate(imageFile)
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class DepthEstimator {
|
|
34
|
+
#model: string
|
|
35
|
+
#device: Device
|
|
36
|
+
#onProgress?: ProgressCallback
|
|
37
|
+
#pipe: (CallableFunction & { dispose?: () => Promise<void> }) | null = null
|
|
38
|
+
#loading: Promise<void> | null = null
|
|
39
|
+
#resolvedDevice: 'webgpu' | 'wasm' | null = null
|
|
40
|
+
|
|
41
|
+
constructor(options: DepthOptions = {}) {
|
|
42
|
+
this.#model = options.model ?? DEFAULT_MODEL
|
|
43
|
+
this.#device = options.device ?? 'auto'
|
|
44
|
+
this.#onProgress = options.onProgress
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get device(): 'webgpu' | 'wasm' | null {
|
|
48
|
+
return this.#resolvedDevice
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async load(): Promise<void> {
|
|
52
|
+
if (this.#pipe) return
|
|
53
|
+
if (this.#loading) return this.#loading
|
|
54
|
+
|
|
55
|
+
this.#loading = (async () => {
|
|
56
|
+
const { pipe, device } = await loadPipeline({
|
|
57
|
+
task: 'depth-estimation',
|
|
58
|
+
model: this.#model,
|
|
59
|
+
device: this.#device,
|
|
60
|
+
// fp16 on WebGPU (~50 MB); q8 on WASM (~27 MB)
|
|
61
|
+
dtype: (d) => (d === 'webgpu' ? 'fp16' : 'q8'),
|
|
62
|
+
onProgress: this.#onProgress,
|
|
63
|
+
})
|
|
64
|
+
this.#pipe = pipe
|
|
65
|
+
this.#resolvedDevice = device
|
|
66
|
+
})()
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await this.#loading
|
|
70
|
+
} finally {
|
|
71
|
+
this.#loading = null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async estimate(image: DepthImageInput): Promise<DepthResult> {
|
|
76
|
+
await this.load()
|
|
77
|
+
if (!this.#pipe) throw new Error('DepthEstimator model failed to load')
|
|
78
|
+
|
|
79
|
+
this.#onProgress?.({ status: 'processing' })
|
|
80
|
+
const input = await imageToPipelineInput(image)
|
|
81
|
+
const raw = (await this.#pipe(input)) as {
|
|
82
|
+
depth: {
|
|
83
|
+
width: number
|
|
84
|
+
height: number
|
|
85
|
+
toBlob?: () => Promise<Blob>
|
|
86
|
+
toCanvas?: () => HTMLCanvasElement
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const depthImage = raw.depth
|
|
91
|
+
let blob: Blob
|
|
92
|
+
if (typeof depthImage.toBlob === 'function') {
|
|
93
|
+
blob = await depthImage.toBlob()
|
|
94
|
+
} else if (typeof depthImage.toCanvas === 'function') {
|
|
95
|
+
const canvas = depthImage.toCanvas()
|
|
96
|
+
blob = await new Promise<Blob>((resolve, reject) => {
|
|
97
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
|
|
98
|
+
})
|
|
99
|
+
} else {
|
|
100
|
+
throw new Error('Depth output has no toBlob/toCanvas')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.#onProgress?.({ status: 'done' })
|
|
104
|
+
return { depth: blob, width: depthImage.width, height: depthImage.height }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
dispose(): void {
|
|
108
|
+
const pipe = this.#pipe
|
|
109
|
+
this.#pipe = null
|
|
110
|
+
this.#resolvedDevice = null
|
|
111
|
+
void pipe?.dispose?.()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function estimateDepth(
|
|
116
|
+
image: DepthImageInput,
|
|
117
|
+
options?: DepthOptions
|
|
118
|
+
): Promise<DepthResult> {
|
|
119
|
+
const estimator = new DepthEstimator(options)
|
|
120
|
+
try {
|
|
121
|
+
return await estimator.estimate(image)
|
|
122
|
+
} finally {
|
|
123
|
+
estimator.dispose()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type { ProgressInfo, Device }
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { Device, ProgressCallback, ProgressInfo } from '../core/index.ts'
|
|
2
|
+
import { imageToPipelineInput, loadPipeline } from '../core/pipeline.ts'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MODEL = 'onnx-community/rfdetr_nano-ONNX'
|
|
5
|
+
|
|
6
|
+
/** RT-DETR family uses per-class sigmoid, not DETR's softmax + background class. */
|
|
7
|
+
const SIGMOID_TYPES = new Set(['rf_detr', 'rt_detr', 'rt_detr_v2', 'd_fine'])
|
|
8
|
+
|
|
9
|
+
function isSigmoidModelId(model: string): boolean {
|
|
10
|
+
return /rfdetr|rt_detr|rtdetr|dfine|d_fine/i.test(model)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DetectOptions = {
|
|
14
|
+
model?: string
|
|
15
|
+
device?: Device
|
|
16
|
+
/** Minimum confidence score (0–1). Default 0.5. */
|
|
17
|
+
threshold?: number
|
|
18
|
+
/** Return boxes as fractions of the image (0–1) instead of pixels. Default false. */
|
|
19
|
+
normalized?: boolean
|
|
20
|
+
onProgress?: ProgressCallback
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DetectImageInput = Blob | File | string | HTMLImageElement | HTMLCanvasElement | ImageData
|
|
24
|
+
|
|
25
|
+
/** Box in pixel coordinates of the input image (or 0–1 fractions with `normalized: true`). */
|
|
26
|
+
export type DetectionBox = {
|
|
27
|
+
xmin: number
|
|
28
|
+
ymin: number
|
|
29
|
+
xmax: number
|
|
30
|
+
ymax: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Detection = {
|
|
34
|
+
label: string
|
|
35
|
+
score: number
|
|
36
|
+
box: DetectionBox
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Object detection in the browser (RF-DETR Nano by default).
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { ObjectDetector } from 'runonweb/detect'
|
|
45
|
+
*
|
|
46
|
+
* const detector = new ObjectDetector()
|
|
47
|
+
* await detector.load()
|
|
48
|
+
* const objects = await detector.detect(imageFile)
|
|
49
|
+
* // [{ label: 'cat', score: 0.98, box: { xmin, ymin, xmax, ymax } }]
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export class ObjectDetector {
|
|
53
|
+
#model: string
|
|
54
|
+
#device: Device
|
|
55
|
+
#threshold: number
|
|
56
|
+
#normalized: boolean
|
|
57
|
+
#onProgress?: ProgressCallback
|
|
58
|
+
#pipe: DetectionPipe | null = null
|
|
59
|
+
#loading: Promise<void> | null = null
|
|
60
|
+
#resolvedDevice: 'webgpu' | 'wasm' | null = null
|
|
61
|
+
|
|
62
|
+
constructor(options: DetectOptions = {}) {
|
|
63
|
+
this.#model = options.model ?? DEFAULT_MODEL
|
|
64
|
+
this.#device = options.device ?? 'auto'
|
|
65
|
+
this.#threshold = options.threshold ?? 0.5
|
|
66
|
+
this.#normalized = options.normalized ?? false
|
|
67
|
+
this.#onProgress = options.onProgress
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get device(): 'webgpu' | 'wasm' | null {
|
|
71
|
+
return this.#resolvedDevice
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async load(): Promise<void> {
|
|
75
|
+
if (this.#pipe) return
|
|
76
|
+
if (this.#loading) return this.#loading
|
|
77
|
+
|
|
78
|
+
this.#loading = (async () => {
|
|
79
|
+
const { pipe, device } = await loadPipeline({
|
|
80
|
+
task: 'object-detection',
|
|
81
|
+
model: this.#model,
|
|
82
|
+
device: this.#device,
|
|
83
|
+
// These ONNX exports collapse logits on WebGPU with the current runtime.
|
|
84
|
+
...(isSigmoidModelId(this.#model)
|
|
85
|
+
? { supportedDevices: ['wasm'] as const, dtype: 'q8' as const }
|
|
86
|
+
: { dtype: (d: 'webgpu' | 'wasm') => (d === 'webgpu' ? 'fp16' : 'q8') }),
|
|
87
|
+
onProgress: this.#onProgress,
|
|
88
|
+
})
|
|
89
|
+
this.#pipe = pipe as DetectionPipe
|
|
90
|
+
this.#resolvedDevice = device
|
|
91
|
+
})()
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await this.#loading
|
|
95
|
+
} finally {
|
|
96
|
+
this.#loading = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async detect(image: DetectImageInput): Promise<Detection[]> {
|
|
101
|
+
await this.load()
|
|
102
|
+
if (!this.#pipe) throw new Error('ObjectDetector model failed to load')
|
|
103
|
+
|
|
104
|
+
this.#onProgress?.({ status: 'processing' })
|
|
105
|
+
const input = await imageToPipelineInput(image)
|
|
106
|
+
const detections = usesSigmoid(this.#pipe)
|
|
107
|
+
? await detectSigmoid(this.#pipe, input, this.#threshold, this.#normalized)
|
|
108
|
+
: await detectSoftmax(this.#pipe, input, this.#threshold, this.#normalized)
|
|
109
|
+
|
|
110
|
+
this.#onProgress?.({ status: 'done' })
|
|
111
|
+
return detections
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
dispose(): void {
|
|
115
|
+
const pipe = this.#pipe
|
|
116
|
+
this.#pipe = null
|
|
117
|
+
this.#resolvedDevice = null
|
|
118
|
+
void pipe?.dispose?.()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function detect(
|
|
123
|
+
image: DetectImageInput,
|
|
124
|
+
options?: DetectOptions
|
|
125
|
+
): Promise<Detection[]> {
|
|
126
|
+
const detector = new ObjectDetector(options)
|
|
127
|
+
try {
|
|
128
|
+
return await detector.detect(image)
|
|
129
|
+
} finally {
|
|
130
|
+
detector.dispose()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type { ProgressInfo, Device }
|
|
135
|
+
|
|
136
|
+
type DetectionPipe = CallableFunction & {
|
|
137
|
+
dispose?: () => Promise<void>
|
|
138
|
+
model?: {
|
|
139
|
+
config?: { model_type?: string; id2label?: Record<string, string> }
|
|
140
|
+
(inputs: Record<string, unknown>): Promise<{ logits: DetTensor; pred_boxes: DetTensor }>
|
|
141
|
+
}
|
|
142
|
+
processor?: (images: unknown) => Promise<{
|
|
143
|
+
pixel_values: unknown
|
|
144
|
+
pixel_mask?: unknown
|
|
145
|
+
original_sizes?: Array<[number, number]>
|
|
146
|
+
}>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type DetTensor = {
|
|
150
|
+
dims: number[]
|
|
151
|
+
data: ArrayLike<number>
|
|
152
|
+
[index: number]: DetTensor
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function usesSigmoid(pipe: DetectionPipe): boolean {
|
|
156
|
+
const type = pipe.model?.config?.model_type
|
|
157
|
+
return Boolean(type && SIGMOID_TYPES.has(type) && pipe.model && pipe.processor)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function detectSoftmax(
|
|
161
|
+
pipe: DetectionPipe,
|
|
162
|
+
input: unknown,
|
|
163
|
+
threshold: number,
|
|
164
|
+
normalized: boolean
|
|
165
|
+
): Promise<Detection[]> {
|
|
166
|
+
const raw = (await pipe(input, {
|
|
167
|
+
threshold,
|
|
168
|
+
percentage: normalized,
|
|
169
|
+
})) as Array<{ label: string; score: number; box: DetectionBox }>
|
|
170
|
+
return raw.map((d) => ({ label: d.label, score: d.score, box: d.box }))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function detectSigmoid(
|
|
174
|
+
pipe: DetectionPipe,
|
|
175
|
+
input: unknown,
|
|
176
|
+
threshold: number,
|
|
177
|
+
normalized: boolean
|
|
178
|
+
): Promise<Detection[]> {
|
|
179
|
+
const images = Array.isArray(input) ? input : [input]
|
|
180
|
+
const processed = await pipe.processor!(images)
|
|
181
|
+
const output = await pipe.model!({
|
|
182
|
+
pixel_values: processed.pixel_values,
|
|
183
|
+
pixel_mask: processed.pixel_mask,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const size = normalized
|
|
187
|
+
? null
|
|
188
|
+
: sizeFrom(input, processed.original_sizes)
|
|
189
|
+
return decodeSigmoid(output.logits, output.pred_boxes, {
|
|
190
|
+
threshold,
|
|
191
|
+
id2label: pipe.model!.config?.id2label ?? {},
|
|
192
|
+
size,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sizeFrom(
|
|
197
|
+
input: unknown,
|
|
198
|
+
originalSizes?: Array<[number, number]>
|
|
199
|
+
): { width: number; height: number } | null {
|
|
200
|
+
if (originalSizes?.[0]) {
|
|
201
|
+
const [height, width] = originalSizes[0]
|
|
202
|
+
return { width, height }
|
|
203
|
+
}
|
|
204
|
+
if (input && typeof input === 'object' && 'width' in input && 'height' in input) {
|
|
205
|
+
const { width, height } = input as { width: number; height: number }
|
|
206
|
+
if (width && height) return { width, height }
|
|
207
|
+
}
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function decodeSigmoid(
|
|
212
|
+
logits: DetTensor,
|
|
213
|
+
boxes: DetTensor,
|
|
214
|
+
options: {
|
|
215
|
+
threshold: number
|
|
216
|
+
id2label: Record<string, string>
|
|
217
|
+
size: { width: number; height: number } | null
|
|
218
|
+
}
|
|
219
|
+
): Detection[] {
|
|
220
|
+
const dims = logits.dims
|
|
221
|
+
const numQueries = dims.length === 3 ? dims[1]! : dims[0]!
|
|
222
|
+
const numClasses = dims.length === 3 ? dims[2]! : dims[1]!
|
|
223
|
+
const batch = dims.length === 3 ? logits[0]! : logits
|
|
224
|
+
const boxBatch = dims.length === 3 ? boxes[0]! : boxes
|
|
225
|
+
const labels = labelList(options.id2label, numClasses)
|
|
226
|
+
|
|
227
|
+
const out: Detection[] = []
|
|
228
|
+
for (let j = 0; j < numQueries; j++) {
|
|
229
|
+
const scores = batch[j]!.data
|
|
230
|
+
let best = -Infinity
|
|
231
|
+
let bestIdx = 0
|
|
232
|
+
for (let k = 0; k < numClasses; k++) {
|
|
233
|
+
const p = 1 / (1 + Math.exp(-scores[k]!))
|
|
234
|
+
if (p > best) {
|
|
235
|
+
best = p
|
|
236
|
+
bestIdx = k
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (best < options.threshold) continue
|
|
240
|
+
const label = labels[bestIdx]
|
|
241
|
+
if (!label) continue
|
|
242
|
+
|
|
243
|
+
const [cx, cy, bw, bh] = Array.from(boxBatch[j]!.data)
|
|
244
|
+
let xmin = cx! - bw! / 2
|
|
245
|
+
let ymin = cy! - bh! / 2
|
|
246
|
+
let xmax = cx! + bw! / 2
|
|
247
|
+
let ymax = cy! + bh! / 2
|
|
248
|
+
if (options.size) {
|
|
249
|
+
xmin *= options.size.width
|
|
250
|
+
xmax *= options.size.width
|
|
251
|
+
ymin *= options.size.height
|
|
252
|
+
ymax *= options.size.height
|
|
253
|
+
}
|
|
254
|
+
out.push({
|
|
255
|
+
label,
|
|
256
|
+
score: best,
|
|
257
|
+
box: { xmin, ymin, xmax, ymax },
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
return out
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function labelList(id2label: Record<string, string>, numClasses: number): Array<string | undefined> {
|
|
264
|
+
const byId = Object.entries(id2label)
|
|
265
|
+
.map(([id, name]) => [Number(id), name] as const)
|
|
266
|
+
.sort((a, b) => a[0] - b[0])
|
|
267
|
+
if (byId.length === numClasses) return byId.map(([, name]) => name)
|
|
268
|
+
|
|
269
|
+
const sparse: Array<string | undefined> = Array.from({ length: numClasses })
|
|
270
|
+
for (const [id, name] of byId) {
|
|
271
|
+
if (id >= 0 && id < numClasses) sparse[id] = name
|
|
272
|
+
}
|
|
273
|
+
return sparse
|
|
274
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { Device, ProgressCallback, ProgressInfo } from '../core/index.ts'
|
|
2
|
+
import { loadPipeline } from '../core/pipeline.ts'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MODEL = 'Xenova/all-MiniLM-L6-v2'
|
|
5
|
+
|
|
6
|
+
export type EmbedOptions = {
|
|
7
|
+
model?: string
|
|
8
|
+
device?: Device
|
|
9
|
+
/** Pooling strategy. Default `mean`. */
|
|
10
|
+
pooling?: 'mean' | 'cls' | 'none'
|
|
11
|
+
/** L2-normalize vectors. Default true. */
|
|
12
|
+
normalize?: boolean
|
|
13
|
+
onProgress?: ProgressCallback
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type EmbedResult = {
|
|
17
|
+
/** Embedding vector(s). One vector if input was a string; one per item if array. */
|
|
18
|
+
embeddings: Float32Array[]
|
|
19
|
+
dimensions: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Text embeddings in the browser (sentence-transformers style).
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { TextEmbedder } from 'runonweb/embed'
|
|
28
|
+
*
|
|
29
|
+
* const embedder = new TextEmbedder()
|
|
30
|
+
* await embedder.load()
|
|
31
|
+
* const { embeddings } = await embedder.embed('hello world')
|
|
32
|
+
* const sim = cosineSimilarity(embeddings[0], other)
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export class TextEmbedder {
|
|
36
|
+
#model: string
|
|
37
|
+
#device: Device
|
|
38
|
+
#pooling: 'mean' | 'cls' | 'none'
|
|
39
|
+
#normalize: boolean
|
|
40
|
+
#onProgress?: ProgressCallback
|
|
41
|
+
#pipe: (CallableFunction & { dispose?: () => Promise<void> }) | null = null
|
|
42
|
+
#loading: Promise<void> | null = null
|
|
43
|
+
#resolvedDevice: 'webgpu' | 'wasm' | null = null
|
|
44
|
+
|
|
45
|
+
constructor(options: EmbedOptions = {}) {
|
|
46
|
+
this.#model = options.model ?? DEFAULT_MODEL
|
|
47
|
+
this.#device = options.device ?? 'auto'
|
|
48
|
+
this.#pooling = options.pooling ?? 'mean'
|
|
49
|
+
this.#normalize = options.normalize ?? true
|
|
50
|
+
this.#onProgress = options.onProgress
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get device(): 'webgpu' | 'wasm' | null {
|
|
54
|
+
return this.#resolvedDevice
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async load(): Promise<void> {
|
|
58
|
+
if (this.#pipe) return
|
|
59
|
+
if (this.#loading) return this.#loading
|
|
60
|
+
|
|
61
|
+
this.#loading = (async () => {
|
|
62
|
+
const { pipe, device } = await loadPipeline({
|
|
63
|
+
task: 'feature-extraction',
|
|
64
|
+
model: this.#model,
|
|
65
|
+
device: this.#device,
|
|
66
|
+
// fp16 on WebGPU (~45 MB); q8 on WASM (~23 MB).
|
|
67
|
+
// q8 on WebGPU produces garbage similarities. Never use it there.
|
|
68
|
+
dtype: (d) => (d === 'webgpu' ? 'fp16' : 'q8'),
|
|
69
|
+
onProgress: this.#onProgress,
|
|
70
|
+
})
|
|
71
|
+
this.#pipe = pipe
|
|
72
|
+
this.#resolvedDevice = device
|
|
73
|
+
})()
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await this.#loading
|
|
77
|
+
} finally {
|
|
78
|
+
this.#loading = null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async embed(text: string | string[]): Promise<EmbedResult> {
|
|
83
|
+
await this.load()
|
|
84
|
+
if (!this.#pipe) throw new Error('TextEmbedder model failed to load')
|
|
85
|
+
|
|
86
|
+
this.#onProgress?.({ status: 'embedding' })
|
|
87
|
+
const inputs = Array.isArray(text) ? text : [text]
|
|
88
|
+
if (inputs.length === 0) throw new Error('No text provided')
|
|
89
|
+
|
|
90
|
+
const embeddings: Float32Array[] = []
|
|
91
|
+
for (const item of inputs) {
|
|
92
|
+
const output = await this.#pipe(item, {
|
|
93
|
+
pooling: this.#pooling,
|
|
94
|
+
normalize: this.#normalize,
|
|
95
|
+
})
|
|
96
|
+
// Transformers.js returns a Tensor-like with .data or nested arrays
|
|
97
|
+
const vector = tensorToFloat32(output)
|
|
98
|
+
embeddings.push(vector)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.#onProgress?.({ status: 'done' })
|
|
102
|
+
return {
|
|
103
|
+
embeddings,
|
|
104
|
+
dimensions: embeddings[0]?.length ?? 0,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
dispose(): void {
|
|
109
|
+
const pipe = this.#pipe
|
|
110
|
+
this.#pipe = null
|
|
111
|
+
this.#resolvedDevice = null
|
|
112
|
+
void pipe?.dispose?.()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function embed(
|
|
117
|
+
text: string | string[],
|
|
118
|
+
options?: EmbedOptions
|
|
119
|
+
): Promise<EmbedResult> {
|
|
120
|
+
const embedder = new TextEmbedder(options)
|
|
121
|
+
try {
|
|
122
|
+
return await embedder.embed(text)
|
|
123
|
+
} finally {
|
|
124
|
+
embedder.dispose()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Cosine similarity between two equal-length vectors. */
|
|
129
|
+
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
130
|
+
if (a.length !== b.length) throw new Error('Vector length mismatch')
|
|
131
|
+
let dot = 0
|
|
132
|
+
let na = 0
|
|
133
|
+
let nb = 0
|
|
134
|
+
for (let i = 0; i < a.length; i++) {
|
|
135
|
+
const x = a[i]!
|
|
136
|
+
const y = b[i]!
|
|
137
|
+
dot += x * y
|
|
138
|
+
na += x * x
|
|
139
|
+
nb += y * y
|
|
140
|
+
}
|
|
141
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb)
|
|
142
|
+
return denom === 0 ? 0 : dot / denom
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type { ProgressInfo, Device }
|
|
146
|
+
|
|
147
|
+
function tensorToFloat32(output: unknown): Float32Array {
|
|
148
|
+
if (output instanceof Float32Array) return output
|
|
149
|
+
if (output && typeof output === 'object') {
|
|
150
|
+
const o = output as { data?: ArrayLike<number>; tolist?: () => unknown }
|
|
151
|
+
if (o.data) return new Float32Array(o.data as ArrayLike<number>)
|
|
152
|
+
if (typeof o.tolist === 'function') {
|
|
153
|
+
const list = o.tolist()
|
|
154
|
+
return new Float32Array(flattenNumbers(list))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(output)) {
|
|
158
|
+
return new Float32Array(flattenNumbers(output))
|
|
159
|
+
}
|
|
160
|
+
throw new Error('Unexpected embedding output shape')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function flattenNumbers(value: unknown): number[] {
|
|
164
|
+
if (typeof value === 'number') return [value]
|
|
165
|
+
if (!Array.isArray(value)) return []
|
|
166
|
+
const out: number[] = []
|
|
167
|
+
for (const item of value) {
|
|
168
|
+
if (typeof item === 'number') out.push(item)
|
|
169
|
+
else out.push(...flattenNumbers(item))
|
|
170
|
+
}
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Device, ProgressCallback, ProgressInfo } from '../core/index.ts'
|
|
2
|
+
import { loadPipeline } from '../core/pipeline.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default weights: `text2emoji-tiny`, a 2.4M-parameter T5 trained from scratch for runonweb
|
|
6
|
+
* on the Text2Emoji dataset. 3.9 MB as q8 (encoder 1.7 MB + decoder 2.1 MB), MIT.
|
|
7
|
+
* Trained with `training/text2emoji` in the runonweb repo.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_MODEL = 'text2emoji-tiny'
|
|
10
|
+
|
|
11
|
+
export type EmojiOptions = {
|
|
12
|
+
/** Model folder name (under `modelPath`) or Hugging Face repo id. */
|
|
13
|
+
model?: string
|
|
14
|
+
/**
|
|
15
|
+
* Base URL the weights are served from, e.g. `/models/`. The runonweb site self-hosts them
|
|
16
|
+
* in `public/models/`. Omit to download `model` from the Hugging Face Hub.
|
|
17
|
+
*/
|
|
18
|
+
modelPath?: string
|
|
19
|
+
device?: Device
|
|
20
|
+
/** Maximum number of emojis to generate. Default 12. */
|
|
21
|
+
maxEmojis?: number
|
|
22
|
+
onProgress?: ProgressCallback
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type EmojiResult = {
|
|
26
|
+
/** Emojis joined without separators, e.g. "🍕❤️🐶". */
|
|
27
|
+
text: string
|
|
28
|
+
/** One entry per emoji (grapheme cluster). */
|
|
29
|
+
emojis: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Text → emoji translation in the browser with a 4 MB model.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { Emojifier } from 'runonweb/emoji'
|
|
38
|
+
*
|
|
39
|
+
* const emojifier = new Emojifier({ modelPath: '/models/' })
|
|
40
|
+
* await emojifier.load()
|
|
41
|
+
* const { text } = await emojifier.emojify('I love pizza and my dog')
|
|
42
|
+
* // "🍕❤️🐶"
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class Emojifier {
|
|
46
|
+
#model: string
|
|
47
|
+
#modelPath?: string
|
|
48
|
+
#device: Device
|
|
49
|
+
#maxEmojis: number
|
|
50
|
+
#onProgress?: ProgressCallback
|
|
51
|
+
#pipe: (CallableFunction & { dispose?: () => Promise<void> }) | null = null
|
|
52
|
+
#loading: Promise<void> | null = null
|
|
53
|
+
#resolvedDevice: 'webgpu' | 'wasm' | null = null
|
|
54
|
+
|
|
55
|
+
constructor(options: EmojiOptions = {}) {
|
|
56
|
+
this.#model = options.model ?? DEFAULT_MODEL
|
|
57
|
+
this.#modelPath = options.modelPath
|
|
58
|
+
this.#device = options.device ?? 'auto'
|
|
59
|
+
this.#maxEmojis = options.maxEmojis ?? 12
|
|
60
|
+
this.#onProgress = options.onProgress
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get device(): 'webgpu' | 'wasm' | null {
|
|
64
|
+
return this.#resolvedDevice
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async load(): Promise<void> {
|
|
68
|
+
if (this.#pipe) return
|
|
69
|
+
if (this.#loading) return this.#loading
|
|
70
|
+
|
|
71
|
+
this.#loading = (async () => {
|
|
72
|
+
const { pipe, device } = await loadPipeline({
|
|
73
|
+
task: 'text2text-generation',
|
|
74
|
+
model: this.#model,
|
|
75
|
+
modelPath: this.#modelPath,
|
|
76
|
+
device: this.#device,
|
|
77
|
+
// q8 everywhere: the whole model is 3.9 MB and encoder-decoder graphs fail on WebGPU.
|
|
78
|
+
dtype: 'q8',
|
|
79
|
+
supportedDevices: ['wasm'],
|
|
80
|
+
onProgress: this.#onProgress,
|
|
81
|
+
})
|
|
82
|
+
this.#pipe = pipe
|
|
83
|
+
this.#resolvedDevice = device
|
|
84
|
+
})()
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await this.#loading
|
|
88
|
+
} finally {
|
|
89
|
+
this.#loading = null
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Translate a sentence (English) into a short emoji sequence. */
|
|
94
|
+
async emojify(text: string): Promise<EmojiResult> {
|
|
95
|
+
await this.load()
|
|
96
|
+
if (!this.#pipe) throw new Error('Emoji model failed to load')
|
|
97
|
+
if (!text.trim()) throw new Error('Text is empty')
|
|
98
|
+
|
|
99
|
+
this.#onProgress?.({ status: 'translating' })
|
|
100
|
+
|
|
101
|
+
const raw = (await this.#pipe(text, {
|
|
102
|
+
max_new_tokens: this.#maxEmojis + 1,
|
|
103
|
+
// Each emoji is one token; never emit the same emoji twice.
|
|
104
|
+
no_repeat_ngram_size: 1,
|
|
105
|
+
})) as Array<{ generated_text: string }> | { generated_text: string }
|
|
106
|
+
|
|
107
|
+
const out = Array.isArray(raw) ? raw[0] : raw
|
|
108
|
+
// The tokenizer separates emojis with spaces; drop them and split back into graphemes.
|
|
109
|
+
const emojis = (out?.generated_text ?? '').split(/\s+/).filter(Boolean)
|
|
110
|
+
|
|
111
|
+
this.#onProgress?.({ status: 'done' })
|
|
112
|
+
return { text: emojis.join(''), emojis }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
dispose(): void {
|
|
116
|
+
const pipe = this.#pipe
|
|
117
|
+
this.#pipe = null
|
|
118
|
+
this.#resolvedDevice = null
|
|
119
|
+
void pipe?.dispose?.()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function emojify(text: string, options?: EmojiOptions): Promise<EmojiResult> {
|
|
124
|
+
const emojifier = new Emojifier(options)
|
|
125
|
+
try {
|
|
126
|
+
return await emojifier.emojify(text)
|
|
127
|
+
} finally {
|
|
128
|
+
emojifier.dispose()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type { ProgressInfo, Device }
|