File size: 9,067 Bytes
d562f50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
// SPDX-License-Identifier: Apache-2.0
// Embedding worker: loads a decision-tuned EmbeddingGemma 2 (ONNX, same graph layout as
// onnx-community/embeddinggemma-2-ONNX) with transformers.js and returns L2-normalized
// 768-d embeddings (mean pooling over the attention mask, then L2 normalization).
// The model files are served next to this page (./models/...), so nothing is fetched from elsewhere.
import {
  AutoConfig,
  AutoModel,
  AutoProcessor,
  RawImage,
  RawVideo,
  RawVideoFrame,
  env,
} from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.1/dist/transformers.min.js";

const MODEL_ID = "egemma2-decide";
env.allowLocalModels = true;
env.allowRemoteModels = false;
env.localModelPath = new URL("./models/", self.location.href).pathname; // same-origin path, e.g. "/models/"
if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1; // static hosting is not cross-origin isolated

let processor = null;
let model = null;
let loaded = null; // { device, dtype, audio }
let decision = null; // { scale } from decision.json
let queue = Promise.resolve();

const post = (msg, transfer) => self.postMessage(msg, transfer || []);
const state = (phase, label, extra = {}) => post({ type: "state", phase, label, ...extra });

let gpuF16 = false;
async function hasWebGPU() {
  try {
    if (!("gpu" in navigator)) return false;
    const adapter = await navigator.gpu.requestAdapter();
    gpuF16 = !!adapter?.features?.has("shader-f16");
    return !!adapter;
  } catch {
    return false;
  }
}

async function loadModel({ device = "auto", dtype = "q4", audio = false }) {
  if (loaded && (device === "auto" || device === loaded.device) && dtype === loaded.requested && (!audio || loaded.audio)) {
    return loaded;
  }
  if (model) {
    try { await model.dispose(); } catch {}
    model = null;
    loaded = null;
  }
  let candidates;
  if (device === "auto") candidates = (await hasWebGPU()) ? ["webgpu", "wasm"] : ["wasm"];
  else candidates = [device];

  if (!decision) {
    try { decision = await (await fetch(`${env.localModelPath}${MODEL_ID}/decision.json`)).json(); } catch { decision = {}; }
  }
  if (!processor) {
    state("loading", "Loading processor");
    processor = await AutoProcessor.from_pretrained(MODEL_ID);
  }
  let lastErr = null;
  const requested = dtype;
  if (candidates.includes("webgpu") && device !== "auto") await hasWebGPU();
  for (const dev of candidates) {
    // Files shipped with this page (Space storage is limited to 1 GB):
    //   q4: 4-bit weights with plain 8-bit gathers for the embedding and position tables, so the same files
    //       run on WebGPU and on the WASM (CPU) runtime, which lacks GatherBlockQuantized;
    //   q4f16: 4-bit weights with fp16 math (WebGPU with shader-f16 only);
    //   audio: one 4-bit audio encoder used with both.
    const d = dev === "wasm" ? "q4" : requested === "auto" ? (gpuF16 ? "q4f16" : "q4") : requested;
    dtype = audio ? { model: d, vision_encoder: d, audio_encoder: "q4" } : d;
    try {
      const config = await AutoConfig.from_pretrained(MODEL_ID);
      if (!audio) config.audio_config = null; // text + vision only (video uses the vision encoder)
      state("loading", `Downloading model (${dev}${audio ? ", with audio encoder" : ""})`, { progress: 0, device: dev });
      const t0 = performance.now();
      model = await AutoModel.from_pretrained(MODEL_ID, {
        config,
        device: dev,
        dtype,
        progress_callback: (p) => {
          if (p.status === "progress_total") state("loading", `Downloading model (${dev})`, { progress: p.progress, device: dev, loadedBytes: p.loaded, totalBytes: p.total });
        },
      });
      const sessionMs = performance.now() - t0;
      state("loading", "Warming up", { progress: 100, device: dev });
      const tw = performance.now();
      const warm = await processor(["task: classification | query: warm up"]);
      const out = await model(warm);
      out.sentence_embedding.dispose?.();
      const warmMs = performance.now() - tw;
      const label = typeof dtype === "string" ? dtype : Object.entries(dtype).map(([k, v]) => `${k.replace("_encoder", "")} ${v}`).join(", ");
      loaded = { device: dev, dtype: label, requested, audio, loadMs: performance.now() - t0, sessionMs, warmMs, scale: decision?.scale || null };
      state("ready", `Ready on ${dev.toUpperCase()} (${label})`, { device: dev, audio });
      return loaded;
    } catch (e) {
      lastErr = e;
      console.warn(`Loading on ${dev} failed`, e);
      if (model) { try { await model.dispose(); } catch {} model = null; }
      state("loading", `Loading on ${dev} failed, trying next backend`, { device: dev });
    }
  }
  state("error", `Could not load the model: ${lastErr?.message || lastErr}`);
  throw lastErr;
}

function rows(tensor) {
  const [n, d] = tensor.dims;
  const data = tensor.data;
  const out = [];
  for (let i = 0; i < n; i++) {
    const v = new Float32Array(d);
    let s = 0;
    for (let j = 0; j < d; j++) { v[j] = data[i * d + j]; s += v[j] * v[j]; }
    s = Math.sqrt(s) || 1;
    for (let j = 0; j < d; j++) v[j] /= s;
    out.push(v);
  }
  return out;
}

let lastModelMs = 0;
async function run(inputs) {
  const tm = performance.now();
  const out = await model(inputs);
  lastModelMs = performance.now() - tm;
  const emb = rows(out.sentence_embedding);
  try { out.sentence_embedding.dispose?.(); } catch {}
  return emb;
}

// texts: array of strings, embedded in small batches (right padding; padded tokens are masked out)
async function embedTexts(texts) {
  const res = [];
  const B = 8;
  for (let i = 0; i < texts.length; i += B) {
    const batch = texts.slice(i, i + B);
    res.push(...(await run(await processor(batch))));
  }
  return res;
}

// One input that may interleave text with an image, audio clip or video.
// If the text already holds a placeholder (<|image|>, <|audio|>, <|video|>) it is used as is;
// otherwise the placeholder is put first, as in the trained prompt format. Empty text embeds the media alone.
async function embedInput(input) {
  const { kind } = input;
  const text = (input.text || "").trim();
  const withPh = (ph) => (text.includes(ph) ? text : `${ph}${text}`);
  if (kind === "text" || !kind) {
    return (await run(await processor([text])))[0];
  }
  if (kind === "image" || kind === "rgba") {
    if (processor.image_processor) processor.image_processor.max_soft_tokens = input.budget || 280;
    const img = kind === "rgba"
      ? new RawImage(new Uint8ClampedArray(input.data), input.width, input.height, 4)
      : await RawImage.fromBlob(input.blob);
    if (!text) return (await run(await processor(null, img)))[0];
    return (await run(await processor([withPh("<|image|>")], [[img]])))[0];
  }
  if (kind === "audio") {
    if (!loaded?.audio) throw new Error("The audio encoder is not loaded.");
    const audio = input.audio instanceof Float32Array ? input.audio : new Float32Array(input.audio);
    if (!text) return (await run(await processor(null, null, audio)))[0];
    return (await run(await processor([withPh("<|audio|>")], null, [audio])))[0];
  }
  if (kind === "video") {
    if (processor.image_processor) processor.image_processor.max_soft_tokens = input.budget || 280;
    const frames = input.frames.map((f) => new RawVideoFrame(new RawImage(new Uint8ClampedArray(f.data), f.width, f.height, 4), f.timestamp));
    const video = new RawVideo(frames, input.duration);
    if (!text) return (await run(await processor(null, null, null, video)))[0];
    return (await run(await processor([withPh("<|video|>")], null, null, [video])))[0];
  }
  throw new Error(`Unknown input kind: ${kind}`);
}

async function handle(msg) {
  const { id, op } = msg;
  try {
    if (op === "load") {
      const info = await loadModel(msg.options || {});
      post({ type: "result", id, result: info });
      return;
    }
    if (!model) throw new Error("Model is not loaded yet.");
    const t0 = performance.now();
    if (op === "embedTexts") {
      const embs = await embedTexts(msg.texts);
      post({ type: "result", id, result: { embeddings: embs, ms: performance.now() - t0 } }, embs.map((e) => e.buffer));
    } else if (op === "embedInput") {
      const emb = await embedInput(msg.input);
      post({ type: "result", id, result: { embedding: emb, ms: performance.now() - t0, modelMs: lastModelMs } }, [emb.buffer]);
    } else if (op === "embedInputs") {
      // several prompts over the same media (one pass each); used by live cards in the trained format
      const embs = [];
      for (const inp of msg.inputs) embs.push(await embedInput(inp));
      post({ type: "result", id, result: { embeddings: embs, ms: performance.now() - t0 } }, embs.map((e) => e.buffer));
    } else {
      throw new Error(`Unknown op ${op}`);
    }
  } catch (e) {
    post({ type: "error", id, message: e?.message || String(e) });
  }
}

self.addEventListener("message", ({ data }) => {
  // serialize all requests: one model, one queue
  queue = queue.then(() => handle(data));
});