File size: 17,722 Bytes
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146ba97
 
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146ba97
 
2dc8d66
 
146ba97
 
 
2dc8d66
 
146ba97
 
 
 
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146ba97
2dc8d66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { Agate, 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"));
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" });
  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++;
  };
}

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);
  try {
    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,
      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([
      ["text encode", `${T.textMs.toFixed(0)} ms (bucket ${T.bucket})`],
      ["per step", `${T.stepMs.toFixed(0)} ms × ${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 });
  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;

(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";
  }
})();