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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
10b8052bcc
commit
97a5443601
@@ -0,0 +1,53 @@
|
||||
# Splash animation tools
|
||||
|
||||
Two-step pipeline for getting third-party 20×20 pixel animations onto the device.
|
||||
|
||||
## 1. Scrape
|
||||
|
||||
```bash
|
||||
node scrape_claudepix.js
|
||||
```
|
||||
|
||||
Fetches the manifest from `claudepix.vercel.app/app.js`, then each animation's
|
||||
HTML file, evaluates the embedded JS in a Node VM context (loading the same
|
||||
`creature-engine.js` the site uses), and writes resolved frame data to
|
||||
`tools/claudepix_data/*.json`.
|
||||
|
||||
Each output file looks like:
|
||||
```json
|
||||
{
|
||||
"filename": "idle_breathe.html",
|
||||
"name": "idle breathe",
|
||||
"category": "Idle",
|
||||
"description": "...",
|
||||
"frame_count": 17,
|
||||
"frames": [{ "hold": 500, "grid": [[0,0,...],[0,1,1,...],...] }, ...]
|
||||
}
|
||||
```
|
||||
|
||||
Override URL or output dir with `--base` and `--out`.
|
||||
|
||||
## 2. Convert to C
|
||||
|
||||
```bash
|
||||
node convert_to_c.js
|
||||
```
|
||||
|
||||
Reads `tools/claudepix_data/*.json` and emits a single
|
||||
`firmware/src/splash_animations.h` with:
|
||||
- `splash_<ident>_frames[N][400]` — per-frame cell codes (0 = empty, 1 = body, 2 = eye)
|
||||
- `splash_<ident>_holds[N]` — per-frame hold time in ms
|
||||
- `splash_anims[]` — master table with name, category, frame count, pointers
|
||||
- `SPLASH_ANIM_COUNT`
|
||||
|
||||
The firmware (`splash.cpp`) consumes this header to render and animate.
|
||||
|
||||
## Re-running
|
||||
|
||||
The scraper is idempotent — re-run any time the source library updates. The
|
||||
converter overwrites the header. Rebuild firmware after running both.
|
||||
|
||||
## License note
|
||||
|
||||
The scraper hits a public site without a stated license. Confirm reuse is
|
||||
appropriate for your case before redistributing the output.
|
||||
@@ -0,0 +1,93 @@
|
||||
[
|
||||
{
|
||||
"filename": "idle_breathe.html",
|
||||
"name": "idle breathe",
|
||||
"category": "Idle",
|
||||
"frame_count": 16,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "idle_blink.html",
|
||||
"name": "idle blink",
|
||||
"category": "Idle",
|
||||
"frame_count": 16,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "idle_look_around.html",
|
||||
"name": "idle look around",
|
||||
"category": "Idle",
|
||||
"frame_count": 17,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "expression_wink.html",
|
||||
"name": "expression wink",
|
||||
"category": "Expressions",
|
||||
"frame_count": 12,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "expression_surprise.html",
|
||||
"name": "expression surprise",
|
||||
"category": "Expressions",
|
||||
"frame_count": 12,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "expression_sleep.html",
|
||||
"name": "expression sleep",
|
||||
"category": "Expressions",
|
||||
"frame_count": 24,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "dance_bounce.html",
|
||||
"name": "dance bounce",
|
||||
"category": "Dance",
|
||||
"frame_count": 16,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "dance_sway.html",
|
||||
"name": "dance sway",
|
||||
"category": "Dance",
|
||||
"frame_count": 12,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "work_coding.html",
|
||||
"name": "work coding",
|
||||
"category": "Work",
|
||||
"frame_count": 23,
|
||||
"palette_size": 10
|
||||
},
|
||||
{
|
||||
"filename": "work_think.html",
|
||||
"name": "work think",
|
||||
"category": "Work",
|
||||
"frame_count": 24,
|
||||
"palette_size": 3
|
||||
},
|
||||
{
|
||||
"filename": "dance_bounce_dj.html",
|
||||
"name": "dance bounce dj",
|
||||
"category": "Dance",
|
||||
"frame_count": 16,
|
||||
"palette_size": 10
|
||||
},
|
||||
{
|
||||
"filename": "dance_sway_dj.html",
|
||||
"name": "dance sway dj",
|
||||
"category": "Dance",
|
||||
"frame_count": 12,
|
||||
"palette_size": 10
|
||||
},
|
||||
{
|
||||
"filename": "dance_djmix.html",
|
||||
"name": "dance djmix",
|
||||
"category": "Dance",
|
||||
"frame_count": 16,
|
||||
"palette_size": 10
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Converts scraped JSON animation data to firmware/src/splash_animations.h.
|
||||
*
|
||||
* Per-animation palette (up to 10 entries) is converted to RGB565. Cells in
|
||||
* each frame are palette indices (0..9). Splash module looks up colors via
|
||||
* palette[cell].
|
||||
*
|
||||
* Usage: node convert_to_c.js [--in DIR] [--out FILE]
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k, def) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : def; };
|
||||
|
||||
const IN_DIR = path.resolve(opt('--in', path.join(__dirname, 'claudepix_data')));
|
||||
const OUT_FILE = path.resolve(opt('--out',
|
||||
path.join(__dirname, '..', 'firmware', 'src', 'splash_animations.h')));
|
||||
|
||||
const PALETTE_SIZE = 10;
|
||||
|
||||
function safeIdent(s) {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
function hexToRgb565(hex) {
|
||||
if (!hex || hex === 'transparent') return 0x10A2; // dark bg
|
||||
let h = hex.replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||
const r = parseInt(h.substr(0, 2), 16);
|
||||
const g = parseInt(h.substr(2, 2), 16);
|
||||
const b = parseInt(h.substr(4, 2), 16);
|
||||
return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
|
||||
}
|
||||
|
||||
function paletteToRgb565(palette) {
|
||||
const out = new Array(PALETTE_SIZE).fill(0x10A2);
|
||||
for (let i = 0; i < palette.length && i < PALETTE_SIZE; i++) {
|
||||
out[i] = hexToRgb565(palette[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(IN_DIR)) {
|
||||
console.error(`No scraped data at ${IN_DIR}. Run scrape_claudepix.js first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const indexPath = path.join(IN_DIR, '_index.json');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.error(`Missing ${indexPath}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
console.log(`Converting ${index.length} animations`);
|
||||
|
||||
let out = '';
|
||||
out += '// ============================================================\n';
|
||||
out += '// Splash animations — generated by tools/convert_to_c.js.\n';
|
||||
out += '// Source: https://claudepix.vercel.app (20x20 pixel-art creature\n';
|
||||
out += '// animation library). Frames extracted by tools/scrape_claudepix.js\n';
|
||||
out += '// from per-animation HTML files served by the source site.\n';
|
||||
out += '// Do not edit by hand — re-run the scraper + converter to refresh.\n';
|
||||
out += '// ============================================================\n';
|
||||
out += '// Each animation carries a 10-entry RGB565 palette.\n';
|
||||
out += '// Cell values 0..9 index into palette.\n';
|
||||
out += '#pragma once\n#include <stdint.h>\n\n';
|
||||
|
||||
out += `#define SPLASH_PALETTE_SIZE ${PALETTE_SIZE}\n\n`;
|
||||
|
||||
out += 'typedef struct {\n';
|
||||
out += ' const char *name;\n';
|
||||
out += ' const char *category;\n';
|
||||
out += ' uint16_t frame_count;\n';
|
||||
out += ' const uint16_t *palette;\n';
|
||||
out += ' const uint8_t (*frames)[400];\n';
|
||||
out += ' const uint16_t *holds;\n';
|
||||
out += '} splash_anim_def_t;\n\n';
|
||||
|
||||
const entries = [];
|
||||
|
||||
for (const meta of index) {
|
||||
const ident = safeIdent(meta.filename.replace(/\.html?$/, ''));
|
||||
const dataPath = path.join(IN_DIR, meta.filename.replace(/\.html?$/, '.json'));
|
||||
const data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
|
||||
|
||||
const pal565 = paletteToRgb565(data.palette);
|
||||
out += `static const uint16_t splash_${ident}_palette[${PALETTE_SIZE}] = {`;
|
||||
out += pal565.map(c => `0x${c.toString(16).toUpperCase().padStart(4, '0')}`).join(',');
|
||||
out += '};\n';
|
||||
|
||||
out += `static const uint8_t splash_${ident}_frames[${data.frames.length}][400] = {\n`;
|
||||
for (const f of data.frames) {
|
||||
const flat = [];
|
||||
for (let r = 0; r < 20; r++)
|
||||
for (let c = 0; c < 20; c++)
|
||||
flat.push(f.grid[r][c]);
|
||||
out += ' {' + flat.join(',') + '},\n';
|
||||
}
|
||||
out += '};\n';
|
||||
|
||||
out += `static const uint16_t splash_${ident}_holds[${data.frames.length}] = {`;
|
||||
out += data.frames.map(f => f.hold).join(',');
|
||||
out += '};\n\n';
|
||||
|
||||
entries.push({ ident, name: data.name, category: data.category, count: data.frames.length });
|
||||
}
|
||||
|
||||
out += `#define SPLASH_ANIM_COUNT ${entries.length}\n`;
|
||||
out += 'static const splash_anim_def_t splash_anims[SPLASH_ANIM_COUNT] = {\n';
|
||||
for (const e of entries) {
|
||||
out += ` {"${e.name}", "${e.category}", ${e.count}, splash_${e.ident}_palette, splash_${e.ident}_frames, splash_${e.ident}_holds},\n`;
|
||||
}
|
||||
out += '};\n';
|
||||
|
||||
fs.writeFileSync(OUT_FILE, out);
|
||||
console.log(`Wrote ${OUT_FILE} (${entries.length} animations, ${(out.length / 1024).toFixed(1)} KB)`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
// Convert a PNG with alpha channel to an LVGL RGB565A8 C array.
|
||||
// Layout: w*h RGB565 pixels (little-endian) followed by w*h alpha bytes.
|
||||
// Usage: node png_to_lvgl.js <input.png> <symbol_name> [W_MACRO] [H_MACRO] [--tint=RRGGBB]
|
||||
// Default tint=FFFFFF — Lucide PNGs are black-on-transparent, so without a tint
|
||||
// the icon would render as invisible black; white is the typical choice for UI overlays.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PNG } = require('pngjs');
|
||||
|
||||
function rgb565(r, g, b) {
|
||||
return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
|
||||
}
|
||||
|
||||
function convert(pngPath, symbol, wMacro, hMacro, tint) {
|
||||
const buf = fs.readFileSync(pngPath);
|
||||
const png = PNG.sync.read(buf);
|
||||
const w = png.width, h = png.height;
|
||||
const total = w * h;
|
||||
|
||||
const colorBytes = Buffer.alloc(total * 2);
|
||||
const alphaBytes = Buffer.alloc(total);
|
||||
|
||||
const tr = tint ? (tint >> 16) & 0xFF : null;
|
||||
const tg = tint ? (tint >> 8) & 0xFF : null;
|
||||
const tb = tint ? tint & 0xFF : null;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const r = tint !== null ? tr : png.data[i * 4 + 0];
|
||||
const g = tint !== null ? tg : png.data[i * 4 + 1];
|
||||
const b = tint !== null ? tb : png.data[i * 4 + 2];
|
||||
const a = png.data[i * 4 + 3];
|
||||
const c = rgb565(r, g, b);
|
||||
colorBytes[i * 2 + 0] = c & 0xFF;
|
||||
colorBytes[i * 2 + 1] = (c >> 8) & 0xFF;
|
||||
alphaBytes[i] = a;
|
||||
}
|
||||
|
||||
const all = Buffer.concat([colorBytes, alphaBytes]);
|
||||
|
||||
let out = `#define ${wMacro} ${w}\n#define ${hMacro} ${h}\n`;
|
||||
out += `// RGB565A8: ${total} RGB565 pixels (little-endian) followed by ${total} alpha bytes\n`;
|
||||
out += `static const uint8_t ${symbol}[${all.length}] = {\n `;
|
||||
const lines = [];
|
||||
for (let i = 0; i < all.length; i += 16) {
|
||||
const row = [];
|
||||
for (let j = 0; j < 16 && i + j < all.length; j++) {
|
||||
row.push('0x' + all[i + j].toString(16).padStart(2, '0').toUpperCase());
|
||||
}
|
||||
lines.push(row.join(', '));
|
||||
}
|
||||
out += lines.join(',\n ');
|
||||
out += '\n};\n';
|
||||
return out;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
let tint = 0xFFFFFF;
|
||||
const positional = [];
|
||||
for (const a of args) {
|
||||
if (a.startsWith('--tint=')) tint = parseInt(a.slice(7), 16);
|
||||
else if (a === '--no-tint') tint = null;
|
||||
else positional.push(a);
|
||||
}
|
||||
const [pngPath, symbol, wMacro, hMacro] = positional;
|
||||
if (!pngPath || !symbol) {
|
||||
console.error('Usage: node png_to_lvgl.js <input.png> <symbol_name> [W_MACRO] [H_MACRO] [--tint=RRGGBB | --no-tint]');
|
||||
process.exit(1);
|
||||
}
|
||||
const base = symbol.toUpperCase().replace(/_DATA$/, '');
|
||||
process.stdout.write(convert(pngPath, symbol, wMacro || `${base}_W`, hMacro || `${base}_H`, tint));
|
||||
}
|
||||
|
||||
module.exports = { convert, rgb565 };
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/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); });
|
||||
Reference in New Issue
Block a user