File size: 23,206 Bytes
498391f
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88143fe
498391f
 
88143fe
 
 
 
 
 
 
498391f
88143fe
498391f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88143fe
 
 
 
 
 
 
 
 
 
 
 
498391f
88143fe
 
 
 
498391f
88143fe
 
 
 
 
 
 
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146ba97
 
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498391f
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146ba97
 
2dc8d66
 
146ba97
 
 
2dc8d66
 
146ba97
 
 
 
2dc8d66
 
 
498391f
 
 
 
 
 
 
 
 
 
 
 
2dc8d66
 
 
 
 
498391f
 
2dc8d66
498391f
2dc8d66
 
 
 
498391f
2dc8d66
 
 
146ba97
2dc8d66
 
 
 
 
 
 
 
 
 
498391f
2dc8d66
498391f
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498391f
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498391f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498391f
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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
import { Agate, ort, VERSIONS, webgpuStatus, clearCache, hasCachedModel } from "./agate.js";
import { addPngText, readWatermark } from "./marking.js";
import { prepare as preparePrompt, countVector } from "./prompt.js";
import { ThinkerView } from "./thinker.js";

// Model location: 001 from window.AGATE_MODEL_BASE (js/config.js, default ./models/), 002 / 003 from their model
// repos (agate.js VERSIONS); ?models=<url> serves every version from one local-layout base instead.
const params = new URLSearchParams(location.search);
const BASE = window.AGATE_MODEL_BASE || "./models/";   // 001's files (002 / 003 come from their model repos)
const MODELS_OVERRIDE = params.get("models");           // every version from <url>/{,002/,003/} (local testing)
const FORCE_EP = params.get("ep");            // "wasm" forces the CPU path
const PARITY = params.has("parity");
const GOLDEN = params.has("golden");        // prompt-pipeline golden test (003)
const VARIANT = params.get("variant");      // "full16": experimental fp16-compute generator (001)

const store = {
  get(k) { try { return localStorage.getItem(k); } catch { return null; } },
  set(k, v) { try { localStorage.setItem(k, v); } catch { /* storage blocked */ } },
};
const DEFAULT_VERSION = "003";
let version = [params.get("v"), store.get("agate-version"), DEFAULT_VERSION].find((v) => v && VERSIONS[v]);
let resolution = Number(params.get("res") || store.get("agate-res-003") || 512);
if (!VERSIONS["003"].res.includes(resolution)) resolution = 512;

const EXAMPLES = {
  common: [
    "a green teapot and a red cup on a table",
    "a minimalist logo of a fox head, orange, flat design, white background",
    "a dog sitting to the left of a cat",
    "a red cube on top of a blue sphere",
  ],
  "003": [
    'a shop sign that says "OPEN", three red apples, no people',
    "a portrait of an old fisherman at golden hour, detailed",
    'a coffee cup with the text "Good Morning" on it',
  ],
};

const $ = (id) => document.getElementById(id);
const ui = {
  prompt: $("prompt"), negative: $("negative"), seed: $("seed"), steps: $("steps"), cfg: $("cfg"), go: $("go"), dice: $("dice"),
  fill: $("bar-fill"), status: $("status"), statusR: $("status-r"), canvas: $("canvas"), frame: $("frame"),
  placeholder: $("placeholder"), timings: $("timings"), save: $("save"), crisp: $("crisp"), backend: $("backend"),
  versions: $("versions"), res: $("res"), resRow: $("res-row"), prepared: $("prepared"), aiNote: $("ai-note"),
  showThinker: $("show-thinker"), thinker: $("thinker"), thinkerStep: $("thinker-step"),
};
const thinkerView = new ThinkerView($("plan-canvas"), $("pred-canvas"));

/* Reference image (image-to-image): upload, preview, strength. The file is encoded to an SD-VAE latent on the
   first Generate (and again after a resolution change), see generate() below. */
const referenceInput = $("reference-image");
const referencePreview = $("reference-preview");
const referencePreviewWrap = $("reference-preview-wrap");
const clearReferenceButton = $("clear-reference");
const imageStrength = $("image-strength");
const imageStrengthValue = $("image-strength-v");

let referenceFile = null;
let referenceImageUrl = null;
const referenceLatents = new Map();     // resolution -> encoded latent of the current reference file

function setReference(file) {
  if (referenceImageUrl) { URL.revokeObjectURL(referenceImageUrl); referenceImageUrl = null; }
  referenceLatents.clear();
  referenceFile = file || null;
  if (file) {
    referenceImageUrl = URL.createObjectURL(file);
    referencePreview.src = referenceImageUrl;
  } else {
    referencePreview.removeAttribute("src");
    referenceInput.value = "";
  }
  referencePreviewWrap.hidden = !file;
  imageStrength.disabled = !file;       // the slider only means something with a reference image
}

referenceInput.addEventListener("change", () => {
  const file = referenceInput.files?.[0];

  if (!file) return;

  if (!file.type.startsWith("image/")) {
    referenceInput.value = "";
    setProgress(0, "Please select an image file", "");
    return;
  }

  setReference(file);
  setProgress(0, "Reference image selected", file.name);
});

clearReferenceButton.addEventListener("click", () => {
  setReference(null);
  setProgress(0, agate ? "Ready" : "Idle", "");
});

imageStrength.addEventListener("input", () => {
  imageStrengthValue.textContent = Number(imageStrength.value).toFixed(2);
});

ui.showThinker.checked = store.get("agate-thinker") !== "0";
ui.showThinker.onchange = () => { store.set("agate-thinker", ui.showThinker.checked ? "1" : "0"); if (!ui.showThinker.checked) ui.thinker.hidden = true; };

let agate = null, ep = FORCE_EP || "webgpu", busy = false, stop = false, gpuOk = true, last = null;
window.__agate = { state: "idle", version };           // read by the automated browser test

const res = () => (version === "003" ? resolution : 256);

function renderExamples() {
  const box = $("examples");
  box.querySelectorAll(".chip").forEach((c) => c.remove());
  for (const p of [...(EXAMPLES[version] || []), ...EXAMPLES.common].slice(0, 6)) {
    const b = document.createElement("button");
    b.className = "chip"; b.textContent = p; b.type = "button";
    b.onclick = () => { ui.prompt.value = p; };
    box.appendChild(b);
  }
}

function seg(el, items, current, onPick) {
  el.innerHTML = "";
  for (const it of items) {
    const b = document.createElement("button");
    b.type = "button"; b.setAttribute("role", "radio"); b.setAttribute("aria-checked", String(it.value === current));
    b.dataset.value = it.value;
    b.innerHTML = `${it.label}${it.sub ? `<small>${it.sub}</small>` : ""}`;
    b.onclick = () => { if (!busy && it.value !== current) onPick(it.value); };
    el.appendChild(b);
  }
}

function renderVersion() {
  const spec = VERSIONS[version];
  seg(ui.versions, Object.keys(VERSIONS).map((v) => ({ value: v, label: `Preview ${v}`, sub: `${Math.max(...VERSIONS[v].res)} px${v === DEFAULT_VERSION ? " · newest" : ""}` })),
    version, pickVersion);
  $("ver-note").textContent = spec.note;
  $("mast-version").textContent = `Preview ${version}`;
  $("mast-spec").textContent = `0.26B · text-to-image · ${spec.res.join(" / ")} px`;
  $("ai-model").textContent = `Agate Preview ${version}`;
  $("neg-hint").textContent = version === "003" ? "(optional; “no X” in the prompt is added automatically)" : "(optional negative prompt)";
  ui.resRow.hidden = spec.res.length < 2;
  if (spec.res.length > 1) seg(ui.res, spec.res.map((r) => ({ value: r, label: `${r} × ${r}`, sub: r === 512 ? "native" : "faster" })), resolution, pickRes);
  $("out-label").textContent = `Output · ${res()} × ${res()}`;
  renderExamples();
  document.title = `Agate Preview ${version} — in your browser`;
}

async function pickVersion(v) {
  version = v; store.set("agate-version", v);
  window.__agate.version = v;
  if (agate) { await agate.release(); agate = null; }
  clearOutput();
  renderVersion();
  if (!gpuOk && !FORCE_EP) return;
  ui.go.textContent = "Load model"; ui.go.disabled = false;
  setProgress(0, "Idle", "");
  if (await hasCachedModel(v)) await load();
}

async function pickRes(r) {
  resolution = r; store.set("agate-res-003", String(r));
  renderVersion();
  if (agate?.sessions && agate.mr) {
    busy = true; ui.go.disabled = true;
    setProgress(1, `Preparing the ${r} px generator`, "");
    try { const ms = await agate.setResolution(r); setProgress(0, "Ready", `${r} px · ${(ms / 1000).toFixed(1)} s`); }
    catch (e) { console.error(e); setProgress(0, "Switch failed", String(e.message || e)); }
    busy = false; ui.go.disabled = false;
  }
}

ui.steps.oninput = () => { $("steps-v").textContent = ui.steps.value; $("fast").checked = Number(ui.steps.value) === 25; };
$("fast").onchange = () => { ui.steps.value = $("fast").checked ? 25 : 50; $("steps-v").textContent = ui.steps.value; };
ui.cfg.oninput = () => { $("cfg-v").textContent = Number(ui.cfg.value).toFixed(1); };
ui.dice.onclick = () => { ui.seed.value = Math.floor(Math.random() * 2 ** 31); };
ui.crisp.checked = store.get("agate-crisp") === "1";
const applyCrisp = () => { ui.frame.classList.toggle("crisp", ui.crisp.checked); store.set("agate-crisp", ui.crisp.checked ? "1" : "0"); };
ui.crisp.onchange = applyCrisp; applyCrisp();

const mb = (b) => (b / 1e6).toFixed(0);
function setProgress(frac, left, right = "") {
  ui.fill.style.width = `${Math.max(0, Math.min(1, frac)) * 100}%`;
  ui.status.textContent = left; ui.statusR.textContent = right;
}
function showTimings(rows) {
  ui.timings.innerHTML = rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("");
}
function clearOutput() {
  ui.placeholder.hidden = false; ui.aiNote.hidden = true; ui.thinker.hidden = true; ui.save.hidden = true; ui.prepared.hidden = true;
  showTimings([]); last = null;
  if (ui.save.href?.startsWith("blob:")) URL.revokeObjectURL(ui.save.href);
}

async function load() {
  busy = true; ui.go.disabled = true;
  window.__agate.state = "loading";
  agate = new Agate({ base: BASE, override: MODELS_OVERRIDE, version, ep, variant: VARIANT, optLevel: params.get("opt") || "all" });
  window.__model = agate;                                  // for console diagnostics, e.g. __encoderRoundTrip()
  try {
    const t0 = performance.now();
    const st = await agate.load(({ phase, file, loaded, total }) => {
      if (phase === "download") setProgress(loaded / total, `Downloading ${file}`, `${mb(loaded)} / ${mb(total)} MB`);
      else setProgress(1, `Preparing ${file} on ${ep === "webgpu" ? "WebGPU" : "CPU"}`, `${mb(total)} MB`);
    }, res());
    const loadMs = performance.now() - t0;
    $("dl-size").textContent = `${st.downloadMB.toFixed(0)} MB`;
    ui.backend.textContent = `backend: ${ep === "webgpu" ? "WebGPU" : "WebAssembly (CPU)"}`;
    const src = st.fromCacheMB > st.downloadMB * 0.99 ? "from browser cache" : "downloaded";
    setProgress(0, "Ready", `Preview ${version} · ${st.downloadMB.toFixed(0)} MB ${src} · ${(loadMs / 1000).toFixed(1)} s`);
    window.__agate = { state: "ready", version, load: { ...st, loadMs, ep } };
    ui.go.textContent = "Generate"; ui.go.disabled = false;
  } catch (e) {
    console.error(e);
    setProgress(0, "Load failed", String(e.message || e));
    window.__agate = { state: "error", version, error: String(e.message || e) };
    ui.go.textContent = "Retry load"; ui.go.disabled = false; agate = null;
  }
  busy = false;
}

async function pngWithMetadata() {
  const blob = await new Promise((r) => ui.canvas.toBlob(r, "image/png"));
  const bytes = addPngText(new Uint8Array(await blob.arrayBuffer()), agate.marks.info);
  return new Blob([bytes], { type: "image/png" });
}

// The previews: frames go to thinkerView, which colours them in a Web Worker and drops frames while busy. The
// sampler never waits for any of it (agate.js sampleFast); with Show thinker off nothing is requested at all.
function previewFn() {
  thinkerView.reset();
  let n = 0, t0 = performance.now();
  window.__agate.thinker = { submitted: 0 };
  thinkerView.onDrawn = ({ step, steps, workerMs, dropped, drawn }) => {
    ui.thinker.hidden = false;
    ui.thinkerStep.textContent = `${step} / ${steps}`;
    window.__agate.thinker = { submitted: n, drawn, dropped, lastWorkerMs: workerMs, lastStep: step };
  };
  return ({ step, steps, plan, x1, hw }) => {
    if (thinkerView.submit(plan, x1, hw, { step, steps })) n++;
  };
}

// The reference image as a latent at the model's current resolution, encoded once per image and resolution.
async function referenceLatentFor(model) {
  const key = String(model.resolution);
  let latent = referenceLatents.get(key);
  if (!latent) {
    setProgress(0, "Encoding reference image", "");
    latent = await model.encodeReferenceImage(referenceFile);
    referenceLatents.set(key, latent);
  }
  return latent;
}

async function generate(opts = {}) {
  busy = true; stop = false;
  ui.go.textContent = "Stop"; ui.frame.classList.add("busy");
  window.__agate.state = "generating";
  const steps = opts.steps ?? Number(ui.steps.value);
  const useReference = !!referenceFile && opts.reference !== false;     // the parity test passes reference: false
  const strength = Number(imageStrength.value);
  try {
    const referenceLatent = useReference ? await referenceLatentFor(agate) : null;
    setProgress(0, "Encoding prompt", "");
    const r = await agate.generate({
      prompt: opts.prompt ?? ui.prompt.value.trim(), negative: opts.negative ?? ui.negative.value.trim(),
      seed: Number(ui.seed.value) >>> 0, steps, cfg: opts.cfg ?? Number(ui.cfg.value), noise: opts.noise ?? null,
      referenceLatent, denoise: useReference ? strength : 1,
      watermark: opts.watermark ?? true,
      onStep: (i, n) => setProgress(i / n, `Step ${i} / ${n}`, ""),
      onPreview: (opts.thinker ?? ui.showThinker.checked) ? previewFn() : null,
      previewReady: () => !thinkerView.pending,
      shouldStop: () => stop,
    });
    const ctx = ui.canvas.getContext("2d");
    ui.canvas.width = r.width; ui.canvas.height = r.height;
    ctx.putImageData(new ImageData(r.rgba, r.width, r.height), 0, 0);
    ui.placeholder.hidden = true;
    ui.aiNote.hidden = false;
    const T = r.timings;
    setProgress(1, "Done", `${(T.totalMs / 1000).toFixed(1)} s`);
    showTimings([
      ...(useReference ? [["image-to-image", `strength ${strength.toFixed(2)} · ${T.steps} of ${steps} steps`]] : []),
      ["text encode", `${T.textMs.toFixed(0)} ms (bucket ${T.bucket})`],
      ["per step", `${T.stepMs.toFixed(0)} ms × ${T.steps ?? steps} (first ${T.firstStepMs.toFixed(0)} ms)`],
      ["decode + mark", `${T.decodeMs.toFixed(0)} + ${T.markMs.toFixed(0)} ms`],
      ["total", `${(T.totalMs / 1000).toFixed(2)} s`],
    ]);
    const p = r.prepared;
    const changed = agate.mr && (p.text !== (opts.prompt ?? ui.prompt.value.trim()) || p.negative || p.countsNonzero);
    ui.prepared.hidden = !changed;
    if (changed) ui.prepared.textContent = `Model sees: ${p.text}${p.negative ? ` · avoid: ${p.negative}` : ""}${p.countsNonzero ? ` · count code on ${p.countsNonzero} token(s)` : ""}`;
    if (ui.save.href?.startsWith("blob:")) URL.revokeObjectURL(ui.save.href);
    ui.save.href = URL.createObjectURL(await pngWithMetadata());
    ui.save.download = `agate-${version}-${r.width}px-seed${Number(ui.seed.value) >>> 0}.png`;
    ui.save.hidden = false;
    last = r;
    window.__agate = { ...window.__agate, state: "done", timings: T, prepared: p, size: [r.width, r.height], watermarked: r.watermarked,
      thinkerShown: !ui.thinker.hidden };
    return r;
  } catch (e) {
    if (String(e.message) === "stopped") setProgress(0, "Stopped", "");
    else { console.error(e); setProgress(0, "Error", String(e.message || e)); window.__agate.error = String(e.message || e); }
    window.__agate.state = "done";
  } finally {
    busy = false; ui.go.textContent = "Generate"; ui.frame.classList.remove("busy");
  }
}

ui.go.onclick = async () => {
  if (busy && agate?.sessions) { stop = true; return; }
  if (busy) return;
  if (!agate) return load();
  return generate();
};
ui.prompt.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && agate && !busy) generate(); });

// Parity self-test: ?parity=1 runs the chosen version's fixture (export/parity_full*.py): same initial noise as the
// PyTorch package, watermark off (the fixture holds unmarked pixels); then checks the mark on the marked image.
async function parity() {
  const dir = version === "001" ? "test/" : version === "002" ? "test/002/" : `test/003_${res()}/`;
  const fx = await (await fetch(`${dir}parity.json`)).json();
  const bin = async (f) => new Uint8Array(await (await fetch(`${dir}${f}`)).arrayBuffer());
  const noise = new Float32Array((await bin("parity_noise.bin")).buffer);
  const refZ = new Float32Array((await bin("parity_latent.bin")).buffer);
  const refImg = await bin("parity_image.bin");     // HWC uint8
  ui.prompt.value = fx.prompt; ui.steps.value = fx.steps; ui.cfg.value = fx.cfg; ui.negative.value = "";
  const r = await generate({ prompt: fx.prompt, negative: "", steps: fx.steps, cfg: fx.cfg, noise, watermark: false, reference: false });
  let dz = 0, dzs = 0, di = 0, dis = 0, se = 0;
  for (let k = 0; k < refZ.length; k++) { const d = Math.abs(r.latent[k] - refZ[k]); dz = Math.max(dz, d); dzs += d; }
  for (let p = 0; p < refImg.length / 3; p++) for (let c = 0; c < 3; c++) {
    const d = Math.abs(r.rgba[p * 4 + c] - refImg[p * 3 + c]); di = Math.max(di, d); dis += d; se += d * d;
  }
  const psnr = 10 * Math.log10(255 * 255 / Math.max(1e-9, se / refImg.length));
  const prepOk = fx.prepared === undefined || (r.prepared.text === fx.prepared && r.prepared.negative === fx.negative);
  // the same image marked, read back in the page
  const { embedWatermark } = await import("./marking.js");
  const marked = Uint8ClampedArray.from(r.rgba);
  embedWatermark(marked, r.width, r.height, agate.marks.payload);
  const rb = readWatermark(marked, r.width, r.height, agate.marks.payload);
  const res_ = { version, resolution: res(), latentMaxAbs: dz, latentMeanAbs: dzs / refZ.length, imageMaxAbs: di, imageMeanAbs: dis / refImg.length,
    psnr, preparedMatchesPython: prepOk, watermarkReadback: rb.text, watermarkBitAcc: rb.bitAccuracy, ep, timings: r.timings };
  const el = $("parity"); el.hidden = false;
  el.innerHTML = `<span class="label"><i class="sq red"></i>Parity vs PyTorch package · Preview ${version} · ${res()} px (${ep})</span>
    <p>final latent max |Δ| ${dz.toFixed(4)} (mean ${res_.latentMeanAbs.toFixed(5)}) · image max |Δ| ${di}/255 (mean ${res_.imageMeanAbs.toFixed(3)}, PSNR ${psnr.toFixed(1)} dB)
    · prompt pipeline ${prepOk ? "identical" : "DIFFERENT"} · watermark read back: ${rb.text} (${(rb.bitAccuracy * 100).toFixed(0)}% bits)</p>`;
  window.__agate = { ...window.__agate, parity: res_, state: "parity-done" };
}

// Golden test of the prompt pipeline in this browser (tokenizers.js + prompt.js) against the Python package.
async function golden() {
  const G = await (await fetch("test/prompt_golden.json")).json();
  const tok = agate.tok, fails = [];
  const enc = (s) => agate.tokenize(s).map(Number);
  for (const c of G.cases) {
    const [text, neg] = preparePrompt(c.prompt);
    const ids = enc(text);
    const cv = countVector(tok, text, ids, ids.length), counts = {};
    cv.forEach((v, j) => { if (v) counts[String(j)] = v; });
    const ok = text === c.prepared && neg === c.negative && JSON.stringify(ids) === JSON.stringify(c.ids)
      && JSON.stringify(enc(neg)) === JSON.stringify(c.neg_ids) && JSON.stringify(counts) === JSON.stringify(c.counts);
    if (!ok) fails.push(c.prompt);
  }
  const res_ = { cases: G.cases.length, identical: G.cases.length - fails.length, fails };
  const el = $("parity"); el.hidden = false;
  el.innerHTML += `<span class="label"><i class="sq red"></i>Prompt pipeline golden test</span><p>${res_.identical} / ${res_.cases} prompts identical to the Python package (prompt, negative, token ids, count code)</p>`;
  window.__agate = { ...window.__agate, golden: res_ };
}

// For the automated test: the PNG exactly as "Save PNG" gives it, base64
window.__agateSavedPng = async () => {
  const b = new Uint8Array(await (await pngWithMetadata()).arrayBuffer());
  let s = ""; for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000));
  return btoa(s);
};
window.agateClearCache = clearCache;

// Diagnostic for image-to-image: encode the selected reference image with the SD 1.5 VAE encoder, decode that
// latent straight away with Agate's own decoder (no generator involved) and show it next to the reference crop.
// If the two look alike, the encoder, preprocessing and 0.18215 scale agree with the decoder. Run from the
// console after loading the model and picking an image:  await __encoderRoundTrip()
window.__encoderRoundTrip = async () => {
  if (!agate?.sessions) throw new Error("Load the model first.");
  if (!referenceFile) throw new Error("Select a reference image first.");
  const size = agate.resolution, C = agate.manifest.latent_ch, hw = size / 8;

  const lat = await agate.encodeReferenceImage(referenceFile);
  let min = Infinity, max = -Infinity, sum = 0;
  for (const v of lat) { if (v < min) min = v; if (v > max) max = v; sum += v; }
  console.log(`latent: ${lat.length} values (expected ${C * hw * hw}), range ${min.toFixed(3)} .. ${max.toFixed(3)}, mean ${(sum / lat.length).toFixed(3)}`);

  const dec = agate.sessions.vae;
  const result = await dec.run({ [dec.inputNames[0]]: new ort.Tensor("float32", lat, [1, C, hw, hw]) });
  const out = result[dec.outputNames[0]];
  const [, , H, W] = out.dims;
  console.log("decoder output dims:", out.dims.join(" x "));
  const rgba = new Uint8ClampedArray(H * W * 4);
  for (let p = 0; p < H * W; p++) {
    for (let c = 0; c < 3; c++) rgba[p * 4 + c] = Math.round((Math.max(-1, Math.min(1, out.data[c * H * W + p])) + 1) * 127.5);
    rgba[p * 4 + 3] = 255;
  }

  // the reference, center-cropped exactly as encodeReferenceImage does
  const refCanvas = document.createElement("canvas"); refCanvas.width = size; refCanvas.height = size;
  const bmp = await createImageBitmap(referenceFile);
  const k = Math.max(size / bmp.width, size / bmp.height);
  refCanvas.getContext("2d").drawImage(bmp, (bmp.width - size / k) / 2, (bmp.height - size / k) / 2, size / k, size / k, 0, 0, size, size);
  bmp.close();
  const decCanvas = document.createElement("canvas"); decCanvas.width = W; decCanvas.height = H;
  decCanvas.getContext("2d").putImageData(new ImageData(rgba, W, H), 0, 0);

  const el = $("parity"); el.hidden = false;
  el.innerHTML = `<span class="label"><i class="sq red"></i>Encoder round trip · reference crop (left) vs decoded latent (right)</span>`;
  const row = document.createElement("div");
  row.style.cssText = "display:flex;gap:12px;margin-top:10px;flex-wrap:wrap";
  for (const c of [refCanvas, decCanvas]) { c.style.cssText = "max-width:48%;height:auto"; row.appendChild(c); }
  el.appendChild(row);
  return { values: lat.length, min, max, mean: sum / lat.length, outputDims: [...out.dims] };
};

(async () => {
  renderVersion();
  if (!FORCE_EP) {
    const s = await webgpuStatus();
    if (!s.ok) {
      gpuOk = false; ep = "wasm";
      $("nogpu").hidden = false; $("nogpu-why").textContent = s.why;
      ui.go.disabled = true; ui.go.textContent = "WebGPU required";
      window.__agate = { state: "no-webgpu", why: s.why, version };
      $("use-wasm").onclick = () => { $("nogpu").hidden = true; gpuOk = true; ui.go.textContent = "Load model (CPU)"; ui.go.disabled = false; };
      return;
    }
    ui.backend.textContent = `backend: WebGPU${s.adapter ? " · " + s.adapter : ""}`;
  }
  ui.go.disabled = false;
  ui.go.textContent = "Load model";
  if (params.has("autoload") || PARITY || GOLDEN || await hasCachedModel(version)) {   // second visit: load straight from cache
    await load();
    if (GOLDEN && agate && version === "003") await golden();
    if (PARITY && agate) await parity();
    if (GOLDEN && !PARITY) window.__agate.state = "golden-done";
  }
})();