pixel8
A network-connected, curated digital art installation: an 8×8 RGB LED matrix (64 pixels) driven by an ESP32 running WLED, controlled by this Node.js server. Visitors submit artwork in a browser; curators approve it; the server renders every format to canonical 8×8 RGB frames, streams them to WLED over DDP/UDP, and broadcasts the same frames to browser previews over WebSocket.
Spec: pixel8_requirements.md · Status: Phases 0–4 implemented and verified (Phase 0 hardware steps pending field installation). Phases 5–6 incremental.
Quick start
Requires Node.js ≥ 22, ffmpeg on PATH (media decoding) and Edge or Chromium on the server (p5.js sketch sandbox). Dependencies (playwright-core, p5) install via npm install; everything else uses built-in modules.
node src/server.js # start server (or: npm start)
# -> http://localhost:8080 (live preview)
# -> http://localhost:8080/editor (8×8 pixel editor)
# -> http://localhost:8080/p5 (p5.js sketch studio)
# -> http://localhost:8080/submit (public submission form)
# -> http://localhost:8080/schedule (public exhibition schedule)
# -> http://localhost:8080/admin (admin console, needs ADMIN_TOKEN)
Configuration is via environment variables; see .env.example:
| Var | Default | Purpose |
|---|---|---|
PORT / HOST |
8080 / 0.0.0.0 |
HTTP server |
DB_PATH / MEDIA_DIR |
./data/pixel8.db |
SQLite + media storage |
WLED_ENABLED |
false |
Set true to transmit DDP |
WLED_HOST / WLED_PORT |
127.0.0.1 / 4048 |
WLED device (DDP/UDP) |
DEFAULT_FPS |
30 |
Playback tick rate |
BRIGHTNESS_CEILING / GAMMA |
1.0 / 1.0 |
Output pipeline (R-040) |
SERPENTINE / ROTATE / FLIP_X / FLIP_Y |
false/0 |
LED mapping (R-053) |
ADMIN_TOKEN |
(empty = admin disabled) | Bearer token for admin API |
FFMPEG_PATH |
ffmpeg |
FFmpeg binary used for decoding |
MAX_UPLOAD_BYTES |
26214400 (25 MB) |
Upload size cap |
MAX_DURATION_SEC / MAX_FRAMES |
60 / 300 |
Media duration & frame caps |
RENDER_CONCURRENCY |
2 |
Concurrent decode workers |
DEFAULT_ITEM_MS |
30000 |
Dwell per queue item (30 s) |
TZ |
UTC |
Exhibition timezone (IANA, e.g. America/New_York); exhibitions are stored in UTC and shown in this timezone |
WLED setup (Phase 0, hardware)
- Flash WLED on the ESP32; configure the 8×8 matrix (panel 8×8, 64 LEDs).
- Join it to the same LAN; note its IP. Set
WLED_ENABLED=true WLED_HOST=<wled-ip>. - Mapping ownership: this server applies serpentine/rotation. Do not also enable WLED 2D mapping transforms, or you'll double-transform (R-053).
- Test patterns (admin, requires
ADMIN_TOKEN):
curl -X POST localhost:8080/api/admin/playback/override \
-H 'Authorization: Bearer <ADMIN_TOKEN>' \
-d '{"pattern":"corners"}' # corners|rows|columns|red|green|blue|white|black|chase
curl -X POST localhost:8080/api/admin/playback/override \
-H 'Authorization: Bearer <ADMIN_TOKEN>' -d '{"source":"chase"}'
curl -X DELETE localhost:8080/api/admin/playback/override \
-H 'Authorization: Bearer <ADMIN_TOKEN>' # restore fallback (black)
Scheduling semantics (Phase 3)
- Durations from
30sto a 90-day hard ceiling (30 days fully supported, R-060). Stored asduration_ms; the API accepts"30d",{value:30, unit:"d"}, or raw milliseconds. - Times: stored UTC (
ends_at_utcis absolute); displayed inTZ. A wall time typed without an offset is interpreted in the exhibition timezone (DST-aware viaIntl). - Priority (R-065): admin override → fixed scheduled exhibition → approved queue/rotation → idle fallback. Overlapping exhibitions: rejected (
resolution:"reject", default) or the higher-priority one shows (resolution:"priority");resolution:"replace"cancels the overlapping exhibitions. - Pause (R-066): freezes the frame; on resume the remaining duration is preserved and
ends_at_utcshifts by the paused interval. Skip completes the exhibition immediately. Cancel marks itcanceled. All transitions are audited. - Restart (R-067/R-068): position is persisted ~1×/s; on boot the scheduler sweeps expired
activeexhibitions, resumes an in-flight exhibition at its persisted position with its original end time, and falls back by priority (override > exhibition > queue > idle). - Recurring exhibitions (R-063/R-072): create series via
POST /api/admin/series(or the dropdown in/admin):freq: daily|weekly|monthly,interval,byweekday(weekly, 0=Sun),until/count(omit both = repeat forever on a rolling 370-day horizon, topped up on restart). Occurrences are DST-safe (a 09:00 daily stays 09:00 local) and materialized as UTC rows. Occurrences inherit priority/loop/hold/pause/skip; pausing an occurrence extends only that occurrence. Cancel a series withDELETE /api/admin/series/:id— the currently playing occurrence finishes, all future occurrences are canceled. Occurrence IDs work withPOST /api/admin/occurrences/:id/pause|resume|skip.
Try it (Phase 3)
# create a 30-second exhibition starting "now" (admin token)
curl -X POST localhost:8080/api/admin/exhibitions \
-H 'Authorization: Bearer <ADMIN_TOKEN>' -H 'content-type: application/json' \
-d '{"artwork_id":"<id>","starts_at":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","duration":"30s"}'
curl localhost:8080/api/schedule # public: title/artist/time/LIVE badge
curl -X POST localhost:8080/api/admin/exhibitions/<ex-id>/pause -H 'Authorization: Bearer <ADMIN_TOKEN>'
curl -X POST localhost:8080/api/admin/exhibitions/<ex-id>/resume -H 'Authorization: Bearer <ADMIN_TOKEN>'
curl localhost:8080/api/status # current frame + artwork metadata + next work
curl -X POST localhost:8080/api/submissions \ # native pixel8 JSON
-H 'content-type: application/json' \
-d '{"title":"Red","pixels":[[255,0,0],...x64]}'
curl -X POST localhost:8080/api/uploads \ # media (PNG/JPG/GIF/MP4/…)
-F 'title=My art' -F 'artist=Me' -F 'fit=contain' -F 'file=@art.png'
# Then open /admin to approve it; approved works can be queued or scheduled.
Supported uploads: PNG, JPG, BMP, WebP (all stills), GIF/animated WebP, MP4, MOV, AVI, WebM. SVG is a planned future addition (needs a sanitizing rasterizer; FFmpeg has none) — .svg is rejected with 415 unsupported_format. Every upload is validated by extension and magic bytes (R-013), hash-deduped (R-019), rendered to 8×8 RGB frames with a chosen fit mode (contain/cover/stretch, R-015), and held as pending until a curator approves it (R-017).
The preview page shows the intended frame output. It is not verification of the physical LED state (R-085) — DDP/UDP is fire-and-forget and cannot confirm receipt (R-055).
Architecture (implemented)
Browser/QR ──HTTPS/LAN──► pixel8 server (node:http + node:sqlite)
├─ frame engine: 64×RGB logical frames (R-030)
├─ renderers: static / procedural / sequence (R-031)
├─ output pipeline: brightness → gamma → mapping (R-040/R-053)
├─ DDP client ──UDP 4048──► ESP32 / WLED ──► 8×8 LEDs
└─ WebSocket /api/live ──► browser previews (R-080)
| Module | Role |
|---|---|
src/frame.js |
Canonical 64-pixel frame model + pixel8 JSON I/O (R-030, R-111) |
src/sources.js |
Renderers: solid, chase, rainbow, frame sequences (R-031) |
src/engine.js |
Monotonic playback clock, fixed-rate ticks, fallback on error (R-035, R-037, R-056) |
src/mapping.js |
Serpentine / rotation / flip + test patterns (R-053, R-054) |
src/ddp.js |
DDP packet builder + UDP client (192 B payload/frame) (R-051) |
src/store.js |
SQLite schema + migrations (Phase 3: playlists, playlist_items, playback_state pause fields) |
src/scheduler.js |
Exhibition scheduler: priority (override > exhibition/occurrence > queue > idle), pause/resume/skip, persisted position, restart recovery (R-065–R-068) |
src/recurrence.js |
Recurrence engine: local-time rules → UTC occurrence rows (DST-safe), horizon materialization (R-063, R-072) |
src/p5-render.js |
p5.js sandboxed renderer: sealed Edge/Chromium worker, CSP lock-down, seeded determinism, per-render isolation (R-120–R-125) |
src/timeutil.js |
UTC storage, DST-aware local→UTC conversion (Intl), duration parsing (30s…30d) (R-061, R-072) |
src/media.js |
FFmpeg/ffprobe decode, fit modes, content sniffing, hash dedupe (R-012–R-019) |
src/worker-pool.js |
Bounded concurrent decode pool (R-036) |
src/multipart.js |
Dependency-free multipart/form-data parser (R-012) |
src/queue.js |
Approved-rotation queue; admin overrides hold the display (R-065) |
src/ws-server.js |
Dependency-free RFC 6455 server, broadcast only |
src/web/pages.js |
Public preview, submission form, schedule page, p5 sketch studio, 8×8 pixel editor, admin console |
src/server.js |
HTTP API, WebSocket fan-out, page serving (R-080–R-086) |
src/config.js |
Environment-based configuration (R-052) |
Deploying on a Raspberry Pi
Everything except the sketch sandbox runs on plain Node — the whole server is comfortable on a Pi 4 (4 GB) or Pi 5. Recommended:
- OS: Raspberry Pi OS 64-bit (arm64). 32-bit works but is slower and limits RAM.
- Node: install Node ≥ 22 arm64 (NodeSource repo or
nvm; the distronodejspackage is usually too old fornode:sqlite). - ffmpeg:
sudo apt install ffmpeg(on PATH by default). - Sketch sandbox: Microsoft Edge has no ARM-Linux build, so install Chromium instead:
sudo apt install chromium. The renderer auto-detects it; to be explicit setP5_BROWSER_PATH=/usr/bin/chromiumin your env (orP5_BROWSER_CHANNEL=chromium). - Performance: playback is pre-rendered 8×8 frames over DDP — ~200 B/frame at 30 fps, trivial for a Pi. The only heavy moments are uploads (ffmpeg) and sketch renders (headless Chromium, once per submission, ~2-5 s + ~150-250 MB RAM briefly). Sketch concurrency is capped at 2 (
RENDER_CONCURRENCY); on a 1 GB Pi lower it to 1. - Storage: SQLite + original media live under
MEDIA_DIR— put it on an SSD or a decent SD card; seeDB_PATH/MEDIA_DIR. - Run at boot:
systemdunit orpm2; setWLED_ENABLED=true WLED_HOST=<wled-ip>once the matrix is on the network.
playwright-core is pure JavaScript (no native binaries), so it runs on arm64/armv7l without changes — only the browser binary itself must exist for your architecture.
Phase 4 — pixel editor & p5.js
/editor— zero-dependency 8×8 drawing grid: curated palette, custom color, eraser, undo, clear; export pixel8 JSON / PNG or re-import previous art; one-click submit to the moderation queue. pixel8 JSON is exactly 64[r,g,b]triplets — the same document the importer validates./p5— p5.js v2.3.4 (vendored, global mode) sketch studio: live preview in a sealed sandbox, seed/frame-count/frame-rate controls, example sketch prefilled, supported-API cheat sheet. Submit → the sketch is pre-rendered once at submission → frames enter the normal pending → approved pipeline. Sketches are excluded from the live display path: playback always reads cached frames from the database, so exhibitions (30 s → 30 d) are fully reproducible.- Determinism (R-124):
millis(),Math.random(),performance.now(),Date.now()are all pinned to a seeded synthetic clock;randomSeed(seed)-free — the renderer IS the seed. Restart=frame 0 by construction; the seed is stored inmetadata_json.seed(admin can fetch the sketch withGET /api/artworks/:id/sketch). - Sandbox (R-121/R-122/R-123): sketches run in a disposable Edge/Chromium process driven by
playwright-core— no library is loaded from the network, the page is served from a locked-down temporary file URL withdefault-src 'none'(no scripts, frames, fonts, images, media, workers, manifest — only inline text), all network requests are aborted at the routing layer, frames/dimensions/frequency are capped (120 frames, 16 KB source, 64 MB V8 heap, 15 s wall clock), workers recycle per submission, and a broken sketch yields a 422 + diagnostics instead of a crashed server. - Source-level dedupe (R-019): identical (seed + frame params + normalized sketch) submissions short-circuit to the existing pending artifact.
- Browser selection (Pi-friendly): the sandbox auto-detects a Chromium-family browser in this order —
P5_BROWSER_PATHenv →P5_BROWSER_CHANNELenv →msedge→chrome→chromiumchannels → well-known binaries (chromium,chromium-browser,google-chrome,microsoft-edgeon PATH).GET /api/p5/metareports what it found (sandbox.available). If no browser exists, media + pixel8-JSON submissions still work; sketch endpoints return a clear422/no_browserinstead of crashing.
Supported API surface is documented in-app at GET /api/p5/meta and in p5-render.js (SUPPORTED_API). Wire format for submissions:
curl -X POST localhost:8080/api/submissions/p5 \
-H 'content-type: application/json' \
-d '{"title":"Sparkle","artist":"Me","seed":42,"frame_count":90,"frame_ms":100,"sketch":"function setup(){createCanvas(8,8)} function draw(){background(10,12,30); ellipse(4,4,3,3);}"}'
API (Phase 4)
| Endpoint | Access | Purpose |
|---|---|---|
GET / |
Public | Live preview page (current + next work, countdown) |
GET /editor |
Public | 8×8 pixel editor (draw → submit) |
GET /p5 |
Public | p5.js sketch studio (write → preview → submit) |
GET /submit |
Public | Submission form (drag & drop) |
GET /schedule |
Public | Exhibition schedule page (chronological, live badge) |
GET /admin |
Public shell | Admin console (needs token to act) |
GET /api/status |
Public | Current frame, artwork metadata, queue/exhibition, next work |
GET /api/schedule |
Public | Upcoming approved exhibitions (title/attribution/time/live) |
GET /api/queue |
Public | Queued artworks (title/duration) |
WS /api/live |
Public | Push: current 8×8 frames + artwork metadata |
POST /api/submissions |
Public | Submit pixel8 JSON artwork → pending |
POST /api/submissions/p5 |
Public | Submit p5.js sketch → sandboxed render → pending |
POST /api/p5/preview |
Public | Preview a sketch without saving (same sandbox, strict limits) |
GET /api/p5/meta |
Public | Supported API surface, limits, restart policy |
GET /api/artworks/:id/sketch |
Admin | Download stored sketch + seed/metadata |
POST /api/uploads |
Public | Multipart media upload → pending (rendered 8×8) |
GET /api/artworks/:id/preview |
Public if approved | First 8×8 frame (else admin) |
GET /api/admin/artworks?status=pending |
Admin | Moderation inbox with previews |
PATCH / DELETE /api/admin/artworks/:id |
Admin | Approve/reject/archive/delete |
POST /api/admin/artworks/:id/play |
Admin | Play immediately (override) |
GET/POST/DELETE /api/admin/queue[/:id] |
Admin | Queue management |
POST /api/admin/queue/skip |
Admin | Advance queue |
GET /api/admin/exhibitions |
Admin | List all exhibitions |
POST /api/admin/exhibitions |
Admin | Create (duration 30s…30d, starts_at, priority, state_policy, resolution: reject|replace|priority) |
PATCH / DELETE /api/admin/exhibitions/:id |
Admin | Reschedule/reprioritize/replace conflicts / cancel |
POST /api/admin/exhibitions/:id/pause|resume|skip |
Admin | Pause (freezes frame, end shifts) / resume / skip |
GET/POST/DELETE /api/admin/series[/:id] |
Admin | Recurring exhibitions: list / create (daily/weekly/monthly) / end series |
POST /api/admin/occurrences/:id/pause|resume|skip · DELETE |
Admin | Occurrence control (per-instance) |
GET/POST/PATCH/DELETE /api/admin/playlists[/:id] |
Admin | Playlist CRUD (approved artworks only) |
Tests & roadmap
npm start # or: node src/server.js
npm test # node:test suite (40 tests, Phases 1–4)
- Phase 1 (done): frame engine, DDP transport, mapping, live preview. ✅
- Phase 2 (done): image/GIF/video decoding (FFmpeg; SVG deferred), submission form, moderation inbox, basic queue. ✅
- Phase 3 (done): durable exhibition records (30 s → 90-day ceiling), priorities, override/pause/resume/skip, playlists, conflict detection (
reject/replace/priority), public schedule, restart-safe recovery, recurring exhibitions (DST-aware, R-063). ✅ - Phase 4 (done): 8×8 pixel editor (JSON round-trip, undo, import/export), p5.js sketch studio with sealed sandbox (Edge/Chromium, CSP-locked, network-blocked, deterministic seeded render), preview + submit endpoints, moderation queue integration, source-level dedupe. ✅
- Phase 5–6: Processing
.pde(container-isolated), public hardening (HTTPS, rate limits, backups).