Files
clawdmeter/tools/scrape_claudepix.js
Hermann Björgvin HaraldssonandClaude Opus 4.7 97a5443601 Migrate to Waveshare ESP32-S3-Touch-AMOLED-2.16 with auto-rotation, splash, and battery
Full hardware swap from Panlee SC01 Plus (480×320 IPS) to Waveshare 2.16"
square AMOLED (480×480, CO5300 + CST9220 + AXP2101 + QMI8658). Library
stack moves to Arduino_GFX, SensorLib, XPowersLib on the pioarduino
platform 55.03.38-1 (Arduino Core 3.x).

UI:
- 4 screens (splash, usage, controller, bluetooth) with 3-button physical
  navigation: GPIO 0 = prev, AXP PKEY = cycle, GPIO 18 = next.
- IMU-driven 90° auto-rotation. CO5300 can't rotate via MADCTL, so flush
  callback does CPU strip-rotation in PARTIAL render mode. Rotation
  transitions use AMOLED brightness flash (instant black → redraw → ramp).
- Battery indicator (Lucide icons) in upper-right, RGB565A8 alpha so it
  blends over the splash animations.
- USB plugged/unplugged auto-switches between Usage and Controller
  screens (suppressed while on splash).
- Fonts and icons re-scaled ~1.9× for the higher-DPI panel; 20px margins
  to clear rounded corners.

Splash:
- 13 × 20×20 pixel-art creature animations sourced from
  claudepix.vercel.app via tools/scrape_claudepix.js (scraper handles
  both PRESET creature-engine and standalone FRAMES+PAL formats).
  tools/convert_to_c.js emits firmware/src/splash_animations.h.
  Attribution preserved in README and the generated header.

Tooling:
- tools/png_to_lvgl.js converts alpha PNGs to LVGL RGB565A8 (planar
  layout, with --tint to colorize Lucide black-on-transparent sources).
- tools/scrape_claudepix.js, tools/convert_to_c.js for the splash
  pipeline.

Docs:
- New worktree CLAUDE.md with hardware pin map, file inventory, build
  commands, and the 8 critical gotchas (CO5300 rotation, OPI PSRAM,
  pioarduino requirement, LVGL 9 font patching, centralized touch read,
  even-aligned flush regions, touch swap/mirror values, RGB565A8 layout).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:04:04 +00:00

166 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Scrapes animations from claudepix.vercel.app.
* Handles two on-site formats:
* 1) PRESET via creature-engine.js (idle_*, expression_*, work_*, dance_bounce, dance_sway)
* 2) Standalone with window.FRAMES + window.PAL (dance_*_dj, dance_djmix)
*
* Output: tools/claudepix_data/<name>.json with shape:
* { filename, name, category, description, palette: ['#RRGGBB',...],
* frame_count, frames: [{ hold, grid: number[20][20] }] }
*
* Usage: node scrape_claudepix.js [--base URL] [--out DIR]
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const args = process.argv.slice(2);
const opt = (k, def) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : def; };
const BASE = opt('--base', 'https://claudepix.vercel.app');
const OUT_DIR = path.resolve(opt('--out', path.join(__dirname, 'claudepix_data')));
async function fetchText(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} fetching ${url}`);
return res.text();
}
// Build a sandbox that stubs the DOM so animations that touch it don't crash.
function makeSandbox() {
const fakeStyle = new Proxy({}, { get: () => '', set: () => true });
const fakeEl = {
style: fakeStyle,
appendChild: () => fakeEl,
addEventListener: () => {},
innerHTML: '',
children: [],
};
const sandbox = {
window: {},
document: {
getElementById: () => fakeEl,
createElement: () => ({ ...fakeEl, style: new Proxy({}, { get: () => '', set: () => true }) }),
},
requestAnimationFrame: () => 0,
cancelAnimationFrame: () => {},
performance: { now: () => 0 },
console,
};
sandbox.window.addEventListener = () => {};
return sandbox;
}
// Default creature-engine palette (CSS colors used by mount())
const ENGINE_PALETTE = ['transparent', '#CD7F6A', '#0f0f0f'];
// Derive category from filename prefix (matches site logic)
function deriveCategory(filename) {
const base = filename.replace(/\.html?$/i, '');
const prefix = base.split('_')[0];
if (!base.includes('_')) return 'Idle';
const aliases = { idle: 'Idle', expression: 'Expressions', dance: 'Dance', work: 'Work', code: 'Coding', coding: 'Coding' };
return aliases[prefix] || (prefix.charAt(0).toUpperCase() + prefix.slice(1));
}
function displayName(filename) {
return filename.replace(/\.html?$/i, '').replace(/_/g, ' ');
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
console.log(`Fetching manifest from ${BASE}/app.js`);
const appJs = await fetchText(`${BASE}/app.js`);
const manifestMatch = appJs.match(/const\s+MANIFEST\s*=\s*(\[[^\]]+\])/);
if (!manifestMatch) throw new Error('MANIFEST not found in app.js');
const manifest = eval(manifestMatch[1]);
console.log(`Found ${manifest.length} animations`);
console.log(`Fetching engine`);
const engineJs = await fetchText(`${BASE}/animations/creature-engine.js`);
const results = [];
for (const filename of manifest) {
process.stdout.write(` ${filename} ... `);
let html;
try { html = await fetchText(`${BASE}/animations/${filename}`); }
catch (e) { console.log(`FETCH ERR: ${e.message}`); continue; }
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)]
.map(m => m[1]).filter(s => s.trim().length > 50);
if (scripts.length === 0) { console.log('SKIP (no scripts)'); continue; }
const animScript = scripts[scripts.length - 1];
const sandbox = makeSandbox();
vm.createContext(sandbox);
const isStandalone = animScript.includes('STANDALONE') || animScript.includes('window.FRAMES');
try {
if (!isStandalone) {
vm.runInContext(engineJs, sandbox);
sandbox.window.PixelEngine.mount = () => ({});
}
vm.runInContext(animScript, sandbox);
} catch (e) {
console.log(`ERR: ${e.message}`);
continue;
}
let resolved, palette, name, category, description;
if (sandbox.window.PRESET) {
const preset = sandbox.window.PRESET;
const CREATURE = sandbox.window.PixelEngine.CREATURE;
resolved = preset.frames.map(f => ({
hold: f.hold,
grid: f.frame ? f.frame : CREATURE,
}));
palette = ENGINE_PALETTE;
name = preset.name;
category = preset.category;
description = preset.description || '';
} else if (sandbox.window.FRAMES) {
resolved = sandbox.window.FRAMES.map(f => ({ hold: f.hold, grid: f.frame }));
palette = sandbox.window.PAL || ENGINE_PALETTE;
// Standalone files don't expose PRESET metadata — derive from filename
name = displayName(filename);
category = deriveCategory(filename);
description = '';
} else {
console.log('SKIP (no PRESET or FRAMES)');
continue;
}
const out = {
filename,
name,
category,
description,
palette,
frame_count: resolved.length,
frames: resolved,
};
const outPath = path.join(OUT_DIR, filename.replace(/\.html?$/, '.json'));
fs.writeFileSync(outPath, JSON.stringify(out, null, 2));
console.log(`${resolved.length} frames, ${palette.length}-color palette`);
results.push(out);
}
fs.writeFileSync(
path.join(OUT_DIR, '_index.json'),
JSON.stringify(results.map(r => ({
filename: r.filename, name: r.name, category: r.category,
frame_count: r.frame_count, palette_size: r.palette.length,
})), null, 2)
);
console.log(`\nDone. ${results.length}/${manifest.length} animations saved.`);
}
main().catch(e => { console.error(e); process.exit(1); });