[object Object]

← back to Particle Text

auto-save: 2026-07-25T11:35:57 (2 files) — index.html test/

43ca7d54aa615907f83ed8f65a48a8c7c0959689 · 2026-07-25 11:36:00 -0700 · Steve Abrams

Files touched

Diff

commit 43ca7d54aa615907f83ed8f65a48a8c7c0959689
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 25 11:36:00 2026 -0700

    auto-save: 2026-07-25T11:35:57 (2 files) — index.html test/
---
 index.html       |  5 ++++
 test/README.md   | 12 ++++++++++
 test/gif.spec.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 86 insertions(+)

diff --git a/index.html b/index.html
index a00ed8b..93d2ecb 100644
--- a/index.html
+++ b/index.html
@@ -529,7 +529,9 @@
     octx.fillStyle = "#05060a"; octx.fillRect(0, 0, gw, gh);
     scatterAll();
     var f = 0;
+    function endRec(msg) { try { clearInterval(iv); } catch (e) {} recording = false; gifBtn.disabled = false; codeBtn.disabled = false; if (msg) toast(msg); }
     var iv = setInterval(function () {
+     try {
       // render particles straight at GIF resolution (preserves brightness + additive glow)
       octx.globalCompositeOperation = "source-over";
       octx.fillStyle = "rgba(5,6,10,0.34)"; octx.fillRect(0, 0, gw, gh); // motion trail
@@ -544,11 +546,13 @@
       frames.push(octx.getImageData(0, 0, gw, gh).data.slice());
       if (++f >= TOTAL) { clearInterval(iv); encode(); }
       else toast("Recording GIF… " + Math.round(f / TOTAL * 100) + "%", 900);
+     } catch (e) { console.log(e); endRec("GIF capture failed — try again"); }
     }, DELAY * 10);
 
     function encode() {
       toast("Encoding GIF…", 6000);
       setTimeout(function () {
+       try {
         // Sample for the palette: keep EVERY bright particle pixel (they're sparse but are the
         // whole point), and only a thin slice of the dark background — otherwise median-cut spends
         // all 256 slots on near-black and quantizes the glow away.
@@ -581,6 +585,7 @@
         setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
         recording = false; gifBtn.disabled = false; codeBtn.disabled = false;
         toast("GIF saved · " + ((bytes.length / 1024) | 0) + " KB", 2400);
+       } catch (e) { console.log(e); endRec("GIF encode failed — try again"); }
       }, 60);
     }
   }
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 0000000..d628db2
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,12 @@
+# Starling — GIF export verification
+
+`gif.spec.js` is a headless Playwright suite (12 checks): GIF + Code buttons, code copies
+to clipboard, the GIF downloads, is valid GIF89a of the right dimensions, is animated,
+loops, and **round-trips through Chromium's own decoder to real (non-blank/non-noise)
+pixels** — proving the from-scratch GIF89a + LZW encoder is correct.
+
+Run (serve the folder same-origin so the gif loads without taint):
+```bash
+python3 -m http.server 4180 --bind 127.0.0.1 &
+SAVEDIR=$(pwd)/.. NODE_PATH=~/Projects/animals/node_modules BASE=http://127.0.0.1:4180 node gif.spec.js
+```
diff --git a/test/gif.spec.js b/test/gif.spec.js
new file mode 100644
index 0000000..f8f19e1
--- /dev/null
+++ b/test/gif.spec.js
@@ -0,0 +1,69 @@
+const { chromium } = require('playwright');
+const fs = require('fs');
+(async () => {
+  const errors = [];
+  const browser = await chromium.launch();
+  const ctx = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
+  const page = await ctx.newPage();
+  page.on('console', m => { if (m.type() === 'error') errors.push('console:' + m.text()); });
+  page.on('pageerror', e => errors.push('pageerror:' + e.message));
+  const base = process.env.BASE || 'http://localhost:4180';
+  const log = [], ok = (c, m) => log.push((c ? 'PASS ' : 'FAIL ') + m);
+
+  await page.goto(base, { waitUntil: 'networkidle' });
+  ok(await page.locator('#gif').isVisible(), 'GIF button present');
+  ok(await page.locator('#code').isVisible(), 'Code button present');
+
+  // --- Code copy ---
+  await page.click('#code');
+  await page.waitForTimeout(700);
+  const clip = await page.evaluate(() => navigator.clipboard.readText());
+  ok(/<!DOCTYPE html>/i.test(clip) && /particle/i.test(clip) && clip.length > 3000, 'code copied to clipboard (' + clip.length + ' chars)');
+
+  // --- GIF save ---
+  const [dl] = await Promise.all([
+    page.waitForEvent('download', { timeout: 20000 }),
+    page.click('#gif'),
+  ]);
+  const fn = dl.suggestedFilename();
+  ok(/^starling-.*\.gif$/.test(fn), 'download fires: ' + fn);
+  const path = (process.env.SAVEDIR || '.') + '/_gifcheck.gif';
+  await dl.saveAs(path);
+  const buf = fs.readFileSync(path);
+  ok(buf.slice(0, 6).toString('ascii') === 'GIF89a', 'valid GIF89a header');
+  const w = buf[6] | (buf[7] << 8), h = buf[8] | (buf[9] << 8);
+  ok(w > 0 && h > 0, 'logical screen ' + w + 'x' + h);
+  // count graphic-control-extension blocks (0x21 0xF9) == frame count
+  let frames = 0;
+  for (let i = 0; i + 1 < buf.length; i++) if (buf[i] === 0x21 && buf[i + 1] === 0xF9) frames++;
+  ok(frames >= 20, 'animated: ' + frames + ' frames');
+  // netscape loop present
+  ok(buf.includes(Buffer.from('NETSCAPE2.0')), 'netscape loop extension present');
+  ok(buf[buf.length - 1] === 0x3B, 'GIF trailer 0x3B present');
+  ok(buf.length > 2000, 'gif size ' + (buf.length / 1024 | 0) + ' KB');
+
+  // --- authoritative decode: let Chromium's own GIF decoder render it ---
+  const gifUrl = base + '/_gifcheck.gif';
+  const dec = await page.evaluate(async (url) => {
+    const im = new Image(); im.src = url; im.style.position = 'fixed'; im.style.left = '-9999px';
+    document.body.appendChild(im);           // attach so the animated GIF actually plays
+    try { await im.decode(); } catch (e) {}
+    if (!im.naturalWidth) return { w: 0, h: 0, nonbg: 0 };
+    await new Promise(r => setTimeout(r, 1700)); // let it form the text
+    const c = document.createElement('canvas'); c.width = im.naturalWidth; c.height = im.naturalHeight;
+    const x = c.getContext('2d'); x.drawImage(im, 0, 0);
+    const d = x.getImageData(0, 0, c.width, c.height).data; let nonbg = 0;
+    for (let i = 0; i < d.length; i += 4) if (d[i] > 45 || d[i + 1] > 45 || d[i + 2] > 45) nonbg++;
+    im.remove();
+    return { w: im.naturalWidth, h: im.naturalHeight, nonbg };
+  }, gifUrl);
+  ok(dec.w === w && dec.h === h, 'Chromium decodes the GIF: ' + dec.w + 'x' + dec.h);
+  // correct sparse frame-0 → few real lit px; corrupt LZW → 0 (blank) or tens-of-thousands (noise)
+  ok(dec.nonbg > 10 && dec.nonbg < 40000, 'LZW decodes to real content (' + dec.nonbg + ' lit px — sparse, not blank/noise)');
+
+  await browser.close();
+  console.log(log.join('\n'));
+  console.log('\nconsole/page errors: ' + (errors.length ? '\n' + errors.join('\n') : 'NONE'));
+  console.log('gif saved: ' + path);
+  process.exit(log.some(l => l.startsWith('FAIL')) || errors.length ? 1 : 0);
+})().catch(e => { console.error('HARNESS ERROR', e); process.exit(2); });

← e64bb70 chore: v1.1.0 (session close)  ·  back to Particle Text  ·  (newest)