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>
166 lines
5.5 KiB
JavaScript
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); });
|