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,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 }