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)

  1. Flash WLED on the ESP32; configure the 8×8 matrix (panel 8×8, 64 LEDs).
  2. Join it to the same LAN; note its IP. Set WLED_ENABLED=true WLED_HOST=<wled-ip>.
  3. Mapping ownership: this server applies serpentine/rotation. Do not also enable WLED 2D mapping transforms, or you'll double-transform (R-053).
  4. 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 30s to a 90-day hard ceiling (30 days fully supported, R-060). Stored as duration_ms; the API accepts "30d", {value:30, unit:"d"}, or raw milliseconds.
  • Times: stored UTC (ends_at_utc is absolute); displayed in TZ. A wall time typed without an offset is interpreted in the exhibition timezone (DST-aware via Intl).
  • 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_utc shifts by the paused interval. Skip completes the exhibition immediately. Cancel marks it canceled. All transitions are audited.
  • Restart (R-067/R-068): position is persisted ~1×/s; on boot the scheduler sweeps expired active exhibitions, 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 with DELETE /api/admin/series/:id — the currently playing occurrence finishes, all future occurrences are canceled. Occurrence IDs work with POST /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:

  1. OS: Raspberry Pi OS 64-bit (arm64). 32-bit works but is slower and limits RAM.
  2. Node: install Node ≥ 22 arm64 (NodeSource repo or nvm; the distro nodejs package is usually too old for node:sqlite).
  3. ffmpeg: sudo apt install ffmpeg (on PATH by default).
  4. 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 set P5_BROWSER_PATH=/usr/bin/chromium in your env (or P5_BROWSER_CHANNEL=chromium).
  5. 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.
  6. Storage: SQLite + original media live under MEDIA_DIR — put it on an SSD or a decent SD card; see DB_PATH/MEDIA_DIR.
  7. Run at boot: systemd unit or pm2; set WLED_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 in metadata_json.seed (admin can fetch the sketch with GET /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 with default-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_PATH env → P5_BROWSER_CHANNEL env → msedge → chrome → chromium channels → well-known binaries (chromium, chromium-browser, google-chrome, microsoft-edge on PATH). GET /api/p5/meta reports what it found (sandbox.available). If no browser exists, media + pixel8-JSON submissions still work; sketch endpoints return a clear 422/no_browser instead 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).
Description
No description provided
Readme 160 KiB
Languages
JavaScript 100%