Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

oven: track all server modules + fix papers git-add -A nuke

Adds all oven server modules to the repo so they survive redeploys.
Key fix: papers-builder.mjs changed from `git add -A` (which staged
deletions of the entire repo) to `git add papers/ system/public/papers.aesthetic.computer/`
so it only stages paper output files.

Also includes infra configs (Caddyfile, systemd unit) for reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+12199
+49
oven/.env.example
··· 1 + # Oven Service Environment Variables 2 + # Copy to /aesthetic-computer-vault/oven/.env and fill in values 3 + 4 + # DigitalOcean Spaces - Source ZIPs 5 + ART_SPACES_KEY=your_key_here 6 + ART_SPACES_SECRET=your_secret_here 7 + ART_SPACES_ENDPOINT=https://sfo3.digitaloceanspaces.com 8 + ART_SPACES_BUCKET=art-aesthetic-computer 9 + 10 + # DigitalOcean Spaces - Processed Videos 11 + AT_BLOBS_SPACES_KEY=your_key_here 12 + AT_BLOBS_SPACES_SECRET=your_secret_here 13 + AT_BLOBS_SPACES_ENDPOINT=https://sfo3.digitaloceanspaces.com 14 + AT_BLOBS_SPACES_BUCKET=at-blobs-aesthetic-computer 15 + 16 + # Optional: Custom CDN domain (requires Cloudflare CNAME setup) 17 + # AT_BLOBS_CDN=at-blobs.aesthetic.computer 18 + 19 + # Webhook callback authentication 20 + CALLBACK_SECRET=generate_random_secret_here 21 + 22 + # OS base-image admin key (required for /os-base-build POST/cancel) 23 + # Prefer file-based secret in production: 24 + # OS_BUILD_ADMIN_KEY_FILE=/opt/oven/secrets/os-build-admin-key.txt 25 + OS_BUILD_ADMIN_KEY=change_me_long_random_secret 26 + # OS_BUILD_ADMIN_KEY_FILE=/opt/oven/secrets/os-build-admin-key.txt 27 + 28 + # OS base-image build runtime 29 + OS_BASE_IMAGE_SIZE_GB=4 30 + OS_BASE_BUILD_SCRIPT=/opt/oven/fedac/scripts/make-kiosk-piece-usb.sh 31 + OS_BASE_BUILD_CWD=/opt/oven 32 + # OS_BASE_WORK_BASE=/tmp 33 + # OS_BASE_KEEP_ARTIFACTS=0 34 + 35 + # OS base-image artifact destination (DigitalOcean Spaces) 36 + OS_SPACES_KEY=your_key_here 37 + OS_SPACES_SECRET=your_secret_here 38 + OS_SPACES_ENDPOINT=https://sfo3.digitaloceanspaces.com 39 + OS_SPACES_BUCKET=assets-aesthetic-computer 40 + OS_SPACES_CDN_BASE=https://assets-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com 41 + OS_SPACES_PREFIX=os 42 + 43 + # Service configuration 44 + PORT=3002 45 + NODE_ENV=development 46 + 47 + # MongoDB (optional - for persistent bake history) 48 + # MONGODB_CONNECTION_STRING=mongodb://... 49 + # MONGODB_NAME=aesthetic-computer
+17
oven/.gitignore
··· 1 + # Ignore node modules 2 + node_modules/ 3 + 4 + # Ignore environment files (use vault) 5 + .env 6 + 7 + # Ignore logs 8 + *.log 9 + npm-debug.log* 10 + 11 + # Ignore secrets / keys (use vault) 12 + os-build-admin-key.txt 13 + secrets/ 14 + 15 + # Ignore temp files 16 + .DS_Store 17 + *.tmp
+100
oven/LOCAL-DEV-SETUP.md
··· 1 + # Oven Local Development Setup 2 + 3 + ## Quick Start 4 + 5 + ```fish 6 + # Start the oven service 7 + ac-oven 8 + ``` 9 + 10 + This will: 11 + - Navigate to ~/aesthetic-computer/oven 12 + - Clean up any stuck processes 13 + - Kill port 3002 14 + - Start the oven server with auto-reload on https://localhost:3002 15 + 16 + ## Architecture 17 + 18 + ### Production Flow 19 + 1. **track-media.mjs** (Netlify) → Creates tape record, POSTs to `https://oven.aesthetic.computer/bake` 20 + 2. **oven** → Downloads ZIP, processes video, uploads to Spaces 21 + 3. **oven** → Calls webhook `POST /api/tape-mp4-complete` on Netlify 22 + 4. **tape-mp4-complete.mjs** (Netlify) → Downloads MP4/thumbnail, syncs to ATProto 23 + 24 + ### Local Dev Flow 25 + 1. **track-media.mjs** (Netlify dev) → Creates tape record, POSTs to `https://localhost:3002/bake` 26 + 2. **oven** (local) → Downloads ZIP, processes video, uploads to Spaces 27 + 3. **oven** (local) → Calls webhook `POST localhost:8888/api/tape-mp4-complete` 28 + 4. **tape-mp4-complete.mjs** (Netlify dev) → Downloads MP4/thumbnail, syncs to ATProto 29 + 30 + ## Key URLs 31 + 32 + - **Oven dashboard**: https://localhost:3002/ 33 + - **Health check**: https://localhost:3002/health 34 + - **Status API**: https://localhost:3002/status 35 + - **Bake endpoint**: https://localhost:3002/bake (POST) 36 + - **WebSocket**: wss://localhost:3002/ws 37 + 38 + ## Environment Detection 39 + 40 + The oven service is automatically selected based on environment: 41 + 42 + ```javascript 43 + const isDev = process.env.CONTEXT === 'dev' || process.env.NODE_ENV === 'development'; 44 + const ovenUrl = isDev ? 'https://localhost:3002' : 'https://oven.aesthetic.computer'; 45 + ``` 46 + 47 + ## Testing Locally 48 + 49 + 1. Start the oven service: 50 + ```fish 51 + ac-oven 52 + ``` 53 + 54 + 2. Start Netlify dev (in another terminal): 55 + ```fish 56 + ac-site 57 + ``` 58 + 59 + 3. Upload a tape through the site - it will automatically use the local oven 60 + 61 + 4. Monitor the oven dashboard at https://localhost:3002/ to see live processing status 62 + 63 + ## Emacs Tab Configuration 64 + 65 + Add to your Emacs aesthetic-backend configuration: 66 + 67 + ```elisp 68 + ;; Example tab configuration for oven 69 + (add-to-list 'aesthetic-tabs 70 + '("oven" . (lambda () 71 + (aesthetic-run-in-tab "oven" "ac-oven")))) 72 + ``` 73 + 74 + This allows you to switch to the oven tab using your Emacs aesthetic interface. 75 + 76 + ## Files Modified 77 + 78 + - `/oven/*` - New oven service 79 + - `system/netlify/functions/track-media.mjs` - Updated to call oven instead of inline processing 80 + - `system/netlify/functions/tape-mp4-complete.mjs` - Updated webhook to handle oven callbacks 81 + - `.devcontainer/config.fish` - Added `ac-oven` function 82 + 83 + ## Code-Based File Naming 84 + 85 + Both guest and user tapes now use **code** for file identification: 86 + 87 + - Guest ZIP: `art-aesthetic-computer/tapes/{code}.zip` 88 + - User ZIP: `user-aesthetic-computer/tapes/{code}.zip` (future) 89 + - Output MP4: `at-blobs-aesthetic-computer/tapes/{code}.mp4` 90 + - Output thumbnail: `at-blobs-aesthetic-computer/tapes/{code}-thumb.jpg` 91 + 92 + This provides consistency across the database and storage layer. 93 + 94 + ## Next Steps 95 + 96 + 1. Test full local flow (upload → oven → webhook → atproto) 97 + 2. Add oven tab to Emacs configuration 98 + 3. Deploy oven to DigitalOcean droplet 99 + 4. Set up production environment variables 100 + 5. Configure Caddy for HTTPS on oven.aesthetic.computer
+397
oven/README.md
··· 1 + # Oven - Video Processing Service for Aesthetic Computer Tapes 2 + 3 + ## Overview 4 + `oven.aesthetic.computer` is a dedicated DigitalOcean Droplet service for processing tape recordings into MP4 videos. This solves the Netlify Function 250MB size limit issue caused by bundling ffmpeg. 5 + 6 + ## Quick Deployment 7 + 8 + ### Deploy to Production 9 + 10 + ```bash 11 + cd /workspaces/aesthetic-computer/oven 12 + fish deploy.fish 13 + ``` 14 + 15 + This automated script will: 16 + 1. ✅ Create DigitalOcean droplet (2GB RAM, 2 vCPU, $18/month) 17 + 2. ✅ Install Node.js 20, ffmpeg, and Caddy 18 + 3. ✅ Deploy oven service code 19 + 4. ✅ Configure HTTPS with automatic certificates 20 + 5. ✅ Set up systemd service for auto-restart 21 + 6. ✅ Configure DNS (oven.aesthetic.computer) 22 + 23 + ### Post-Deployment Steps 24 + 25 + 1. **Update Netlify Environment:** 26 + ```bash 27 + # Add to system/.env (for development) 28 + OVEN_URL=https://oven.aesthetic.computer 29 + 30 + # Add to Netlify dashboard (for production) 31 + # Site settings → Environment variables 32 + OVEN_URL=https://oven.aesthetic.computer 33 + ``` 34 + 35 + 2. **Test the Service:** 36 + ```bash 37 + # Health check 38 + curl https://oven.aesthetic.computer/health 39 + # Expected: {"status":"healthy"} 40 + 41 + # View dashboard 42 + open https://oven.aesthetic.computer 43 + ``` 44 + 45 + 3. **Monitor Logs:** 46 + ```bash 47 + ssh -i ~/.ssh/oven-deploy-key root@DROPLET_IP 48 + tail -f /var/log/oven/oven.log 49 + ``` 50 + 51 + ## Architecture 52 + 53 + ### Current Problem 54 + - Netlify Functions have a 250MB size limit 55 + - `ffmpeg-static` (50MB) + `ffprobe-static` + dependencies exceed this limit 56 + - Functions `track-media` and `tape-convert-background` fail to deploy 57 + 58 + ### Solution 59 + External video processing service on a dedicated server: 60 + 61 + ``` 62 + User uploads tape 63 + 64 + aesthetic.computer (Netlify) 65 + ↓ POST job to oven 66 + oven.aesthetic.computer (DigitalOcean Droplet) 67 + ↓ Download ZIP to /tmp/ 68 + ↓ Process video with ffmpeg 69 + ↓ Upload MP4 + thumbnail to at-blobs-aesthetic-computer/tapes/ 70 + ↓ POST callback with Spaces URLs 71 + ↓ Clean up /tmp/ 72 + aesthetic.computer receives callback 73 + ↓ Download MP4 from Spaces 74 + ↓ Upload blob to ATProto 75 + ↓ Update MongoDB 76 + ``` 77 + 78 + ## Service Components 79 + 80 + ### 1. Oven Server (Express.js) 81 + - **Location**: `/oven/server.mjs` 82 + - **Port**: 3000 (behind Caddy reverse proxy on 443) 83 + - **Endpoints**: 84 + - `POST /bake` - Accept tape conversion job 85 + - `GET /health` - Health check 86 + - `GET /status/:jobId` - Check job status (optional) 87 + 88 + ### 2. Processing Pipeline 89 + Reuse existing code from `system/backend/tape-to-mp4.mjs`: 90 + 1. Download ZIP from `art-aesthetic-computer` to `/tmp/tape-${slug}/` 91 + 2. Extract frames and audio 92 + 3. Read timing.json 93 + 4. Generate thumbnail with Sharp 94 + 5. Probe audio duration with ffprobe 95 + 6. Convert to MP4 with ffmpeg 96 + 7. Upload MP4 to `at-blobs-aesthetic-computer/tapes/${slug}.mp4` 97 + 8. Upload thumbnail to `at-blobs-aesthetic-computer/tapes/${slug}-thumb.jpg` 98 + 9. POST callback with Spaces URLs 99 + 10. Clean up `/tmp/tape-${slug}/` directory 100 + 101 + **Storage Strategy**: 102 + - **Source ZIPs**: `art-aesthetic-computer/${slug}.zip` (already there) 103 + - **Processed MP4s**: `at-blobs-aesthetic-computer/tapes/${slug}.mp4` 104 + - **Thumbnails**: `at-blobs-aesthetic-computer/tapes/${slug}-thumb.jpg` 105 + - **Temp processing**: `/tmp/tape-${slug}/` (deleted after upload) 106 + - **Organization**: All ATProto-related blobs in dedicated Space 107 + 108 + ### 3. Netlify Integration 109 + Update `system/netlify/functions/track-media.mjs`: 110 + - Remove inline MP4 conversion 111 + - Remove ffmpeg-static and ffprobe-static dependencies 112 + - POST to `https://oven.aesthetic.computer/bake` with: 113 + ```json 114 + { 115 + "mongoId": "...", 116 + "slug": "...", 117 + "zipUrl": "https://...", 118 + "metadata": {...}, 119 + "callbackUrl": "https://aesthetic.computer/api/tape-bake-complete", 120 + "callbackSecret": "shared-secret" 121 + } 122 + ``` 123 + 124 + ### 4. Callback Webhook 125 + New Netlify function: `system/netlify/functions/tape-bake-complete.mjs`: 126 + - Receives JSON callback with Spaces URLs 127 + - Verifies shared secret for authentication 128 + - Downloads MP4 from `at-blobs-aesthetic-computer/tapes/${slug}.mp4` 129 + - Uploads MP4 blob to ATProto PDS 130 + - Updates MongoDB with completion status, rkey, and MP4 URL 131 + - Returns 200 on success 132 + 133 + **Callback Payload**: 134 + ```json 135 + { 136 + "mongoId": "...", 137 + "slug": "...", 138 + "mp4Url": "https://at-blobs-aesthetic-computer.sfo3.digitaloceanspaces.com/tapes/${slug}.mp4", 139 + "thumbnailUrl": "https://at-blobs-aesthetic-computer.sfo3.digitaloceanspaces.com/tapes/${slug}-thumb.jpg", 140 + "secret": "shared-secret" 141 + } 142 + ``` 143 + 144 + **Data Flow**: 145 + - Oven uploads to permanent Spaces storage 146 + - Webhook downloads from Spaces, uploads to ATProto 147 + - MP4 remains in Spaces for backup/future use 148 + - ATProto gets blob, MongoDB gets both URLs and rkey 149 + 150 + ## Deployment Strategy 151 + 152 + ### Following Existing Patterns 153 + Model deployment after `/at` and `/grab`: 154 + 155 + 1. **Vault Configuration**: `/aesthetic-computer-vault/oven/` 156 + - `.env` - Environment variables 157 + - `deploy.env` - DigitalOcean deployment config 158 + 159 + 2. **Deployment Script**: `/oven/deploy.fish` 160 + - Loads vault credentials 161 + - Creates/updates DigitalOcean Droplet 162 + - Installs ffmpeg, Node.js, dependencies 163 + - Sets up systemd service 164 + - Configures Caddy for HTTPS 165 + 166 + 3. **DNS Configuration**: Automatic via Cloudflare API 167 + - Similar to `/grab/scripts/deploy-with-dns.fish` 168 + - Creates A record: `oven.aesthetic.computer` → Droplet IP 169 + - Proxied through Cloudflare for DDoS protection 170 + 171 + ### Infrastructure Requirements 172 + 173 + #### DigitalOcean Droplet 174 + - **Size**: Basic Droplet ($6-12/month) 175 + - 1-2 GB RAM (sufficient for 8-second tapes) 176 + - 1 vCPU 177 + - 25-50 GB SSD 178 + - **Image**: Ubuntu 24.04 LTS 179 + - **Region**: SFO3 (same as Spaces) 180 + - **Firewall**: 181 + - Allow 80, 443 (HTTP/HTTPS) 182 + - Allow 22 (SSH for deployment) 183 + 184 + #### Software Stack 185 + - **Node.js**: v20+ (via nvm) 186 + - **ffmpeg**: Latest via apt 187 + - **ffprobe**: Included with ffmpeg 188 + - **Caddy**: Automatic HTTPS 189 + - **PM2** or **systemd**: Process management 190 + 191 + ### Environment Variables (Vault) 192 + 193 + #### `/aesthetic-computer-vault/oven/.env` 194 + ```bash 195 + # DigitalOcean Spaces - Source ZIPs 196 + ART_SPACES_KEY=... 197 + ART_SPACES_SECRET=... 198 + ART_SPACES_ENDPOINT=https://sfo3.digitaloceanspaces.com 199 + ART_SPACES_BUCKET=art-aesthetic-computer 200 + 201 + # DigitalOcean Spaces - Processed Videos 202 + AT_BLOBS_SPACES_KEY=... 203 + AT_BLOBS_SPACES_SECRET=... 204 + AT_BLOBS_SPACES_ENDPOINT=https://sfo3.digitaloceanspaces.com 205 + AT_BLOBS_SPACES_BUCKET=at-blobs-aesthetic-computer 206 + 207 + # Optional: Custom CDN domain (requires manual Cloudflare CNAME setup) 208 + # AT_BLOBS_CDN=at-blobs.aesthetic.computer 209 + # CNAME: at-blobs.aesthetic.computer → at-blobs-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com 210 + # Enable Cloudflare proxy for CDN caching and DDoS protection 211 + 212 + # Webhook callback 213 + CALLBACK_SECRET=... # Shared secret for webhook authentication 214 + 215 + # Service config 216 + PORT=3000 217 + NODE_ENV=production 218 + TEMP_DIR=/tmp # Where to store working files during processing 219 + ``` 220 + 221 + #### `/aesthetic-computer-vault/oven/deploy.env` 222 + ```bash 223 + # DigitalOcean API 224 + DO_TOKEN=... 225 + 226 + # Droplet configuration 227 + DROPLET_NAME=oven-aesthetic-computer 228 + DROPLET_SIZE=s-1vcpu-1gb 229 + DROPLET_IMAGE=ubuntu-24-04-x64 230 + DROPLET_REGION=sfo3 231 + 232 + # Cloudflare DNS 233 + CLOUDFLARE_EMAIL=... 234 + CLOUDFLARE_API_TOKEN=... 235 + CLOUDFLARE_ZONE_ID=a23b54e8877a833a1cf8db7765bce3ca 236 + ``` 237 + 238 + ## File Structure 239 + 240 + ``` 241 + /oven/ 242 + ├── README.md # This file (architecture & deployment docs) 243 + ├── package.json # Dependencies 244 + ├── server.mjs # Express server 245 + ├── baker.mjs # Tape processing logic (from tape-to-mp4.mjs) 246 + ├── deploy.fish # Main deployment script 247 + ├── setup.sh # Server provisioning script 248 + ├── oven.service # Systemd service file 249 + ├── Caddyfile # Caddy reverse proxy config 250 + └── scripts/ 251 + └── setup-droplet.sh # Initial droplet setup 252 + 253 + /system/netlify/functions/ 254 + ├── track-media.mjs # Updated: POST to oven instead of inline 255 + └── tape-bake-complete.mjs # New: Webhook callback handler 256 + 257 + /aesthetic-computer-vault/oven/ 258 + ├── .env # Service environment variables 259 + └── deploy.env # Deployment configuration 260 + 261 + DigitalOcean Spaces: 262 + ├── art-aesthetic-computer/ 263 + │ └── ${slug}.zip # Source tape recordings 264 + └── at-blobs-aesthetic-computer/ 265 + └── tapes/ 266 + ├── ${slug}.mp4 # Processed videos 267 + └── ${slug}-thumb.jpg # Thumbnails 268 + ``` 269 + 270 + ## Implementation Steps 271 + 272 + ### Phase 1: Oven Service Setup 273 + 1. ✅ Create `/oven` directory structure 274 + 2. Create `server.mjs` with Express endpoints 275 + 3. Extract processing logic to `baker.mjs` 276 + 4. Add health check and status endpoints 277 + 5. Test locally with Docker 278 + 279 + ### Phase 2: Deployment Automation 280 + 1. Create vault configuration files 281 + 2. Write `deploy.fish` following `/grab` pattern 282 + 3. Create `setup.sh` for server provisioning 283 + 4. Configure Caddy for HTTPS 284 + 5. Set up systemd service 285 + 286 + ### Phase 3: Netlify Integration 287 + 1. Create `tape-bake-complete.mjs` webhook 288 + 2. Update `track-media.mjs` to POST to oven 289 + 3. Remove ffmpeg-static and ffprobe-static deps 290 + 4. Add oven URL to environment variables 291 + 5. Test end-to-end flow 292 + 293 + ### Phase 4: Testing & Monitoring 294 + 1. Test anonymous tape uploads 295 + 2. Test authenticated tape uploads 296 + 3. Monitor oven server logs 297 + 4. Set up health check alerts 298 + 5. Document troubleshooting 299 + 300 + ## Security Considerations 301 + 302 + ### Authentication 303 + - Webhook callback uses shared secret 304 + - Validate callback signature 305 + - Only accept jobs from known origins 306 + 307 + ### Rate Limiting 308 + - Limit concurrent jobs (start with 2-3) 309 + - Queue additional requests 310 + - Reject jobs over size/duration limits 311 + 312 + ### Firewall 313 + - Restrict SSH to known IPs 314 + - Only expose 80/443 for web traffic 315 + - Use Cloudflare proxy for DDoS protection 316 + 317 + ## Cost Estimation 318 + 319 + ### Monthly Costs 320 + - **Droplet**: $6-12/month (Basic/Regular) 321 + - **Bandwidth**: Included (1TB transfer) 322 + - **Spaces traffic**: Minimal (already using Spaces) 323 + - **DNS**: Free (existing Cloudflare account) 324 + 325 + **Total**: ~$6-12/month 326 + 327 + ### Scaling Considerations 328 + - Single droplet handles ~10-20 concurrent conversions 329 + - Can add load balancer + multiple droplets if needed 330 + - Current traffic: <100 tapes/day = easily handled 331 + 332 + ## Monitoring & Maintenance 333 + 334 + ### Health Checks 335 + - `/health` endpoint for uptime monitoring 336 + - Cloudflare health checks 337 + - Alert on 5xx errors or timeouts 338 + 339 + ### Logs 340 + - PM2/systemd logs for debugging 341 + - Rotate logs daily 342 + - Monitor disk space usage 343 + 344 + ### Updates 345 + - Automatic security updates (unattended-upgrades) 346 + - Manual ffmpeg updates as needed 347 + - Node.js updates via nvm 348 + 349 + ## Rollback Plan 350 + 351 + If oven service fails: 352 + 1. Revert Netlify functions to inline processing 353 + 2. Use smaller droplet sizes temporarily 354 + 3. Fall back to background function (if <250MB somehow) 355 + 356 + ## Future Enhancements 357 + 358 + ### Potential Additions 359 + - Job queue with Redis (for high traffic) 360 + - Multiple worker droplets with load balancer 361 + - Separate thumbnail generation endpoint 362 + - Progress callbacks (for UI feedback) 363 + - Video preview generation 364 + - Format conversion (WebM, different resolutions) 365 + 366 + ## References 367 + 368 + ### Similar Services in Repo 369 + - `/at` - ATProto PDS server (DigitalOcean + Cloudflare DNS) 370 + - `/grab` - Screenshot worker (Cloudflare Worker + DNS) 371 + - `/session-server` - WebSocket server example 372 + 373 + ### External Documentation 374 + - [DigitalOcean API](https://docs.digitalocean.com/reference/api/) 375 + - [Cloudflare API](https://developers.cloudflare.com/api/) 376 + - [ffmpeg Documentation](https://ffmpeg.org/documentation.html) 377 + - [Express.js](https://expressjs.com/) 378 + 379 + ## Timeline Estimate 380 + 381 + - **Phase 1**: 4-6 hours (service implementation) 382 + - **Phase 2**: 2-3 hours (deployment automation) 383 + - **Phase 3**: 2-3 hours (Netlify integration) 384 + - **Phase 4**: 1-2 hours (testing) 385 + 386 + **Total**: ~9-14 hours of development 387 + 388 + ## Success Criteria 389 + 390 + ✅ Anonymous tape uploads work in production 391 + ✅ Authenticated tape uploads work in production 392 + ✅ MP4 conversion completes in <30 seconds for 8-second tapes 393 + ✅ ATProto sync happens automatically after conversion 394 + ✅ Netlify functions deploy successfully (<250MB) 395 + ✅ Service has 99% uptime 396 + ✅ Automatic deployment via `deploy.fish` 397 + ✅ DNS automatically configured on deployment
+268
oven/REPORT.md
··· 1 + # Oven Architecture Report 2 + 3 + **Generated:** 2026-02-13 4 + **Server:** oven.aesthetic.computer (137.184.237.166) 5 + **Uptime:** 44 days (OS), ~12 min since last oven restart 6 + 7 + --- 8 + 9 + ## 1. Machine Specs 10 + 11 + | Resource | Value | 12 + |----------|-------| 13 + | **CPU** | 2 vCPUs (Intel, DO-Regular) | 14 + | **RAM** | 1.97 GB total, ~635 MB used, ~1.3 GB available | 15 + | **Swap** | None configured | 16 + | **Disk** | 58 GB, 6.7 GB used (12%) | 17 + | **OS** | Ubuntu 24.04.3 LTS (kernel 6.8.0-90) | 18 + | **Node** | v20.20.0 | 19 + | **Chrome** | 143.0.7499.40 (headless, Puppeteer-managed) | 20 + | **ffmpeg** | 6.1.1 (system package, WebP + H.264 support) | 21 + 22 + ### Current Memory Breakdown (at rest with 1 active grab) 23 + - **Node (server.mjs):** ~175 MB (8.6% of RAM) 24 + - **Chrome main process:** ~202 MB (10%) 25 + - **Chrome GPU process:** ~157 MB (7.7%) 26 + - **Chrome network service:** ~125 MB (6.2%) 27 + - **Chrome renderer(s):** ~65-100 MB each (3-5%) 28 + - **Caddy:** ~32 MB 29 + - **Total Chrome footprint:** ~600-700 MB 30 + - **Peak memory observed in logs:** **1.4 GB** (during heavy grab batches) 31 + 32 + **Verdict:** With 2 GB total and no swap, the machine is memory-constrained. A single Chrome instance + Node already consumes ~850 MB at rest. During heavy workloads, peak memory hits 1.4 GB, leaving very little headroom. This is the primary bottleneck. 33 + 34 + --- 35 + 36 + ## 2. Architecture Overview 37 + 38 + ``` 39 + Internet → Caddy (port 443/80, gzip, TLS) → Express (port 3002) → Puppeteer (Chrome) 40 + → ffmpeg (WebP/MP4) 41 + → terser (JS minification) 42 + → DO Spaces (S3 storage) 43 + → MongoDB (metadata) 44 + ``` 45 + 46 + ### Process Model 47 + - **Single Node process** (`server.mjs`) — no clustering, no workers 48 + - **Single Chrome browser** — shared instance, reused across all grab/icon/preview requests 49 + - **Serial grab queue** — `grabRunning` boolean, one grab at a time, 100ms delay between jobs 50 + - systemd manages the oven service with `Restart=always`, `RestartSec=10` 51 + 52 + ### Key Modules 53 + | Module | Purpose | Size | 54 + |--------|---------|------| 55 + | `server.mjs` | Express routes + dashboard HTML | 104 KB | 56 + | `grabber.mjs` | Screenshot/WebP/icon capture via Puppeteer | 127 KB | 57 + | `baker.mjs` | Tape (MP4) baking pipeline | 24 KB | 58 + | `bundler.mjs` | KidLisp/JS piece HTML bundle generation | 44 KB | 59 + 60 + --- 61 + 62 + ## 3. API Endpoints (41 routes) 63 + 64 + ### Core Operations 65 + | Endpoint | Method | Purpose | 66 + |----------|--------|---------| 67 + | `/` | GET | Dashboard (real-time WebSocket updates) | 68 + | `/health` | GET | Health check | 69 + | `/status` | GET | Server status + recent bakes | 70 + | `/grab-status` | GET | Active grabs + queue state | 71 + 72 + ### Tape Baking (MP4) 73 + | Endpoint | Method | Purpose | 74 + |----------|--------|---------| 75 + | `/bake` | POST | Start tape bake (WebP frames → MP4) | 76 + | `/bake-complete` | POST | Callback when bake finishes | 77 + | `/bake-status` | POST | Check bake progress | 78 + 79 + ### Screenshots & WebP Captures (Grabber) 80 + | Endpoint | Method | Purpose | 81 + |----------|--------|---------| 82 + | `/grab` | POST | Trigger grab (screenshot/animation) | 83 + | `/grab/:format/:width/:height/:piece` | GET | Direct grab with params | 84 + | `/grab-ipfs` | POST | Grab + IPFS upload | 85 + | `/grab-cleanup` | POST | Clean stale grabs | 86 + | `/grab-clear` | POST | Clear all active grabs | 87 + | `/icon/:size/:piece.png` | GET | Piece icon (cached → DO Spaces) | 88 + | `/icon/:size/:piece.webp` | GET | Piece icon as WebP | 89 + | `/preview/:size/:piece.png` | GET | Piece preview screenshot | 90 + 91 + ### OG Images 92 + | Endpoint | Method | Purpose | 93 + |----------|--------|---------| 94 + | `/kidlisp-og.png` | GET | KidLisp OG image (for social sharing) | 95 + | `/kidlisp-og` | GET | KidLisp OG page (HTML) | 96 + | `/kidlisp-og/status` | GET | OG cache status | 97 + | `/kidlisp-og/preview` | GET | OG preview page | 98 + | `/notepat-og.png` | GET | Notepat OG image | 99 + | `/kidlisp-backdrop.webp` | GET | KidLisp backdrop animation | 100 + | `/kidlisp-backdrop` | GET | KidLisp backdrop page | 101 + 102 + ### App Screenshots 103 + | Endpoint | Method | Purpose | 104 + |----------|--------|---------| 105 + | `/app-screenshots` | GET | App screenshot dashboard | 106 + | `/app-screenshots/:preset/:piece.png` | GET | Screenshot by preset | 107 + | `/app-screenshots/download/:piece` | GET | Download all presets as ZIP | 108 + | `/api/app-screenshots/:piece` | GET | JSON metadata for screenshots | 109 + 110 + ### Bundle (HTML offline bundles) 111 + | Endpoint | Method | Purpose | 112 + |----------|--------|---------| 113 + | `/bundle-html` | GET | Generate HTML bundle (SSE streaming) | 114 + | `/bundle-prewarm` | POST | Prewarm bundle cache | 115 + | `/bundle-status` | GET | Bundle cache status | 116 + 117 + ### Misc 118 + | Endpoint | Method | Purpose | 119 + |----------|--------|---------| 120 + | `/api/frozen` | GET | List frozen pieces | 121 + | `/api/frozen/:piece` | DELETE | Unfreeze a piece | 122 + | `/keeps/latest` | GET | Latest keep thumbnail | 123 + | `/keeps/latest/:piece` | GET | Latest keep for specific piece | 124 + | `/keeps/all` | GET | All latest IPFS uploads | 125 + 126 + --- 127 + 128 + ## 4. Current Issues 129 + 130 + ### 4.1 Terser Not Found (FIXED in latest deploy) 131 + The error log shows **92 minification failures** with `Cannot find package 'terser'`. This was from a previous deploy where `npm install` wasn't run after `terser` was added to `package.json`. The latest deploy (today) resolved this — bundler is working and prewarm succeeds. 132 + 133 + ### 4.2 Repeated Service Crashes 134 + The systemd journal shows **25 instances** of `Main process exited, code=exited, status=1/FAILURE`. These are likely from: 135 + - Deploys that didn't run `npm install` before restarting 136 + - OOM situations (no swap, peak memory hit 1.4 GB on a 2 GB machine) 137 + - Chrome connection drops during heavy workloads 138 + 139 + ### 4.3 Serial Grab Queue (Primary Performance Bottleneck) 140 + The grabber processes **one grab at a time** using a simple boolean lock: 141 + ```javascript 142 + let grabRunning = false; // Only one grab runs at a time 143 + ``` 144 + Currently there are **19 items in the queue** (1 capturing, 18 queued). Each grab takes roughly 30-40 seconds (load page + wait for ready signal + capture 16 frames + ffmpeg encode + upload to Spaces). That means the current queue will take **~10-13 minutes** to clear. 145 + 146 + ### 4.4 No Swap Space 147 + With 2 GB RAM and Chrome eating 600-700 MB at rest, there's no safety net. If a grab hits a memory-heavy piece (or multiple Chrome renderer processes spawn), the OOM killer can terminate the process. 148 + 149 + ### 4.5 Low File Descriptor Limit 150 + `ulimit -n` is 1024 (default). Chrome alone can use hundreds of FDs. Under heavy load this could cause `EMFILE` errors. 151 + 152 + ### 4.6 Stale PM2 Process 153 + There's a PM2 daemon running (`PM2 v6.0.14`) from before the systemd migration. It's consuming 17 MB of RAM doing nothing. 154 + 155 + --- 156 + 157 + ## 5. Recommendations for Faster Parallel WebP Recording 158 + 159 + ### Priority 1: Upgrade the Droplet (Immediate Impact) 160 + 161 + | Current | Recommended | Cost | 162 + |---------|-------------|------| 163 + | 2 vCPU / 2 GB | **4 vCPU / 8 GB** | ~$48/mo (vs ~$18/mo now) | 164 + 165 + With 8 GB RAM you can comfortably run **3-4 concurrent Chrome tabs** for parallel captures. 4 vCPUs means ffmpeg encoding can happen in parallel without blocking grabs. 166 + 167 + ### Priority 2: Add Swap (Quick Win, Free) 168 + ```bash 169 + fallocate -l 2G /swapfile 170 + chmod 600 /swapfile 171 + mkswap /swapfile 172 + swapon /swapfile 173 + echo '/swapfile none swap sw 0 0' >> /etc/fstab 174 + ``` 175 + This prevents OOM kills during peak usage. Even slow swap is better than crashing. 176 + 177 + ### Priority 3: Parallel Grab Workers (Architecture Change) 178 + 179 + Replace the serial `grabRunning` boolean with a **concurrency pool**: 180 + 181 + ``` 182 + Current: [Queue] → [Single Worker] → [Upload] 183 + 184 + Proposed: [Queue] → [Worker 1] → [Upload] 185 + → [Worker 2] → [Upload] 186 + → [Worker 3] → [Upload] 187 + ``` 188 + 189 + **Implementation approach:** 190 + 1. Replace the single shared browser with a **browser page pool** — launch N pages (tabs) in the same Chrome instance 191 + 2. Replace `grabRunning` boolean with a semaphore/counter: `let grabsRunning = 0; const MAX_CONCURRENT_GRABS = 3;` 192 + 3. Each worker gets its own page from the pool, captures frames, encodes, uploads, then returns the page 193 + 4. Chrome tabs share memory more efficiently than separate browser instances (~65 MB per tab vs ~300+ MB per browser) 194 + 195 + **Key changes in `grabber.mjs`:** 196 + - `processGrabQueue()` — loop while `grabsRunning < MAX_CONCURRENT_GRABS && queue.length > 0` 197 + - Page pool: pre-create N pages at startup, hand them out via `acquirePage()` / `releasePage()` 198 + - ffmpeg calls already happen in child processes, so they parallelize naturally 199 + 200 + **Expected improvement:** With 3 concurrent workers on a 4-CPU/8-GB droplet: 201 + - Current: 19 queued items × ~35s each = **~11 minutes** 202 + - Parallel: 19 items / 3 workers × ~35s = **~3.7 minutes** (3x speedup) 203 + 204 + ### Priority 4: Optimize Individual Grab Speed 205 + 206 + - **Reduce `acPieceReady` timeout** from 30s to 10s — pieces that don't signal ready in 10s probably won't at 30s either 207 + - **Skip Google Analytics** in capture mode — add `?noanalytics=true` param or block GA URLs in Chrome's request interception (eliminates `ERR_ABORTED` noise in logs) 208 + - **Pre-render frame capture** — instead of 16 sequential `page.screenshot()` calls with delays, consider a client-side approach where the piece renders frames to an offscreen canvas and bundles them 209 + 210 + ### Priority 5: Separate Concerns (Long-term) 211 + 212 + The oven server handles too many responsibilities in a single process: 213 + - Screenshot/WebP capture (CPU + memory intensive) 214 + - OG image generation (CPU intensive) 215 + - Bundle HTML generation (CPU intensive during minification) 216 + - Tape baking (CPU intensive) 217 + - Dashboard serving 218 + - Icon/preview caching 219 + 220 + Consider splitting into: 221 + 1. **API gateway** (Express, lightweight) — routes, dashboard, status 222 + 2. **Capture workers** (Chrome + ffmpeg) — the heavy lifting, can be scaled independently 223 + 3. **Bundle worker** — terser minification, isolated from capture workload 224 + 225 + This could be done with Node worker threads, separate processes, or even separate droplets behind a load balancer. 226 + 227 + ### Quick Wins (Do Now) 228 + 229 + 1. **Kill stale PM2:** `pm2 kill` — frees 17 MB 230 + 2. **Add swap:** 2 GB swapfile — prevents OOM crashes 231 + 3. **Increase file limits:** Add `LimitNOFILE=65536` to oven.service 232 + 4. **Clean up logs:** `journalctl --vacuum-time=7d` 233 + 234 + --- 235 + 236 + ## 6. Storage & CDN 237 + 238 + | Storage | Bucket | Content | 239 + |---------|--------|---------| 240 + | DO Spaces (art) | `art-aesthetic-computer` | Source ZIPs, grab WebPs, icons | 241 + | DO Spaces (blobs) | `at-blobs-aesthetic-computer` | Processed tapes (MP4), thumbnails | 242 + | CDN | `art-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com` | Public CDN for grabs/icons | 243 + | CDN | `at-blobs.aesthetic.computer` | Public CDN for tapes | 244 + 245 + - ac-source on oven: **640 files** in `/opt/oven/ac-source/` 246 + - Total oven directory: **168 MB** (including node_modules) 247 + 248 + --- 249 + 250 + ## 7. Bundle Cache Status 251 + 252 + - **Cache state:** Warm (189 core files minified) 253 + - **Git version:** `64512591a` 254 + - **ac-source synced:** 640 files 255 + - **Post-push hook:** Installed (`.git/hooks/pre-push` → `sync-source.sh`) 256 + - **Prewarm:** Triggered on every `deploy.sh` restart 257 + 258 + --- 259 + 260 + ## 8. Summary 261 + 262 + The oven is a **capable but resource-constrained** single-process server trying to do everything at once on a 2 vCPU / 2 GB droplet. The serial grab queue is the biggest performance bottleneck — with 18+ items queued, individual WebP recordings wait 10+ minutes. 263 + 264 + **Fastest path to improvement:** 265 + 1. Add 2 GB swap (5 min, prevents crashes) 266 + 2. Upgrade to 4 vCPU / 8 GB ($30/mo more) 267 + 3. Implement parallel grab workers (code change in `grabber.mjs`) 268 + 4. Expected result: **3-4x faster WebP recording throughput**
+833
oven/baker.mjs
··· 1 + // Baker - Core tape processing logic 2 + // Adapted from system/backend/tape-to-mp4.mjs 3 + 4 + import { spawn } from 'child_process'; 5 + import { promises as fs } from 'fs'; 6 + import { tmpdir } from 'os'; 7 + import https from 'https'; 8 + import http from 'http'; 9 + import { join } from 'path'; 10 + import { randomBytes } from 'crypto'; 11 + import AdmZip from 'adm-zip'; 12 + import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; 13 + import { MongoClient } from 'mongodb'; 14 + 15 + // MongoDB connection 16 + let mongoClient; 17 + let db; 18 + 19 + async function connectMongo() { 20 + if (!mongoClient) { 21 + const mongoUri = process.env.MONGODB_CONNECTION_STRING; 22 + const dbName = process.env.MONGODB_NAME; 23 + 24 + if (!mongoUri || !dbName) { 25 + console.warn('⚠️ MongoDB not configured, bake history will not persist'); 26 + return null; 27 + } 28 + 29 + try { 30 + mongoClient = await MongoClient.connect(mongoUri); 31 + db = mongoClient.db(dbName); 32 + console.log('✅ Connected to MongoDB for bake history'); 33 + } catch (error) { 34 + console.error('❌ Failed to connect to MongoDB:', error.message); 35 + return null; 36 + } 37 + } 38 + return db; 39 + } 40 + 41 + // Initialize MongoDB on startup and set up change stream watcher 42 + connectMongo().then((database) => { 43 + if (database) { 44 + watchForNewTapes(); 45 + } 46 + }); 47 + 48 + /** 49 + * Watch MongoDB for new tape inserts to show "incoming" bakes 50 + */ 51 + async function watchForNewTapes() { 52 + try { 53 + const collection = db.collection('tapes'); 54 + const changeStream = collection.watch([ 55 + { $match: { operationType: 'insert' } } 56 + ]); 57 + 58 + console.log('👀 Watching MongoDB for new tapes...'); 59 + 60 + changeStream.on('change', (change) => { 61 + const tape = change.fullDocument; 62 + 63 + // Only track tapes that don't have MP4s yet (need processing) 64 + if (!tape.mp4Url && !tape.mp4Status && tape.code) { 65 + console.log(`📥 Incoming tape detected: ${tape.code}`); 66 + 67 + incomingBakes.set(tape.code, { 68 + code: tape.code, 69 + slug: tape.slug, 70 + mongoId: tape._id.toString(), 71 + detectedAt: Date.now(), 72 + status: 'incoming', 73 + details: 'Waiting for processing to start' 74 + }); 75 + 76 + notifySubscribers(); 77 + 78 + // Auto-remove from incoming after 60 seconds if not picked up 79 + setTimeout(() => { 80 + if (incomingBakes.has(tape.code) && !activeBakes.has(tape.code)) { 81 + console.log(`⏱️ Removing stale incoming bake: ${tape.code}`); 82 + incomingBakes.delete(tape.code); 83 + notifySubscribers(); 84 + } 85 + }, 60000); 86 + } 87 + }); 88 + 89 + changeStream.on('error', (error) => { 90 + console.error('❌ Change stream error:', error); 91 + // Attempt to reconnect after 5 seconds 92 + setTimeout(watchForNewTapes, 5000); 93 + }); 94 + 95 + } catch (error) { 96 + console.error('❌ Failed to set up change stream:', error); 97 + } 98 + } 99 + 100 + // Initialize ffmpeg and ffprobe paths 101 + let ffmpegPath = 'ffmpeg'; 102 + let ffprobePath = 'ffprobe'; 103 + 104 + // Initialize S3 clients 105 + const artSpacesClient = new S3Client({ 106 + endpoint: process.env.ART_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com', 107 + region: 'us-east-1', // Required but ignored by DigitalOcean 108 + credentials: { 109 + accessKeyId: process.env.ART_SPACES_KEY, 110 + secretAccessKey: process.env.ART_SPACES_SECRET, 111 + }, 112 + }); 113 + 114 + const atBlobsSpacesClient = new S3Client({ 115 + endpoint: process.env.AT_BLOBS_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com', 116 + region: 'us-east-1', 117 + credentials: { 118 + accessKeyId: process.env.AT_BLOBS_SPACES_KEY, 119 + secretAccessKey: process.env.AT_BLOBS_SPACES_SECRET, 120 + }, 121 + }); 122 + 123 + const ART_BUCKET = process.env.ART_SPACES_BUCKET || 'art-aesthetic-computer'; 124 + const AT_BLOBS_BUCKET = process.env.AT_BLOBS_SPACES_BUCKET || 'at-blobs-aesthetic-computer'; 125 + const AT_BLOBS_CDN = process.env.AT_BLOBS_CDN || null; // Optional custom CDN domain 126 + const CALLBACK_SECRET = process.env.CALLBACK_SECRET; 127 + 128 + // In-memory status tracking 129 + const recentBakes = []; // Store last 20 completed bakes 130 + const activeBakes = new Map(); // Currently processing bakes 131 + const incomingBakes = new Map(); // Tapes waiting to be processed (from MongoDB watch) 132 + 133 + // WebSocket subscribers (defined here, used throughout) 134 + const subscribers = new Set(); 135 + 136 + async function loadRecentBakes() { 137 + const database = await connectMongo(); 138 + if (!database) return; 139 + 140 + try { 141 + const ovenBakes = database.collection('oven-bakes'); 142 + const tapes = database.collection('tapes'); 143 + const handles = database.collection('@handles'); 144 + 145 + const bakes = await ovenBakes 146 + .find({}) 147 + .sort({ completedAt: -1 }) 148 + .limit(20) 149 + .toArray(); 150 + 151 + // Enrich each bake with user handle for ATProto links 152 + for (const bake of bakes) { 153 + // Get tape to find user ID 154 + const tape = await tapes.findOne({ code: bake.code }); 155 + if (tape && tape.user) { 156 + // Get handle for user 157 + const handleDoc = await handles.findOne({ _id: tape.user }); 158 + if (handleDoc) { 159 + bake.userHandle = handleDoc.handle; 160 + } 161 + } 162 + } 163 + 164 + recentBakes.length = 0; // Clear existing 165 + recentBakes.push(...bakes); 166 + console.log(`📚 Loaded ${bakes.length} recent bakes from MongoDB`); 167 + } catch (error) { 168 + console.error('❌ Failed to load recent bakes:', error.message); 169 + } 170 + } 171 + 172 + /** 173 + * Clean up stale active bakes by checking against completed bakes in MongoDB 174 + */ 175 + export async function cleanupStaleBakes() { 176 + const database = await connectMongo(); 177 + if (!database) return; 178 + 179 + try { 180 + const collection = database.collection('oven-bakes'); 181 + 182 + // Check each active bake to see if it's actually completed 183 + for (const [code, bake] of activeBakes.entries()) { 184 + const completed = await collection.findOne({ code: code }); 185 + if (completed) { 186 + console.log(`🧹 Removing stale active bake: ${code} (found in completed)`); 187 + activeBakes.delete(code); 188 + 189 + // Add to recent if not already there 190 + if (!recentBakes.find(b => b.code === code)) { 191 + recentBakes.unshift({ 192 + ...bake, 193 + ...completed, 194 + success: completed.success, 195 + completedAt: completed.completedAt?.getTime() || Date.now(), 196 + duration: completed.completedAt?.getTime() - bake.startTime 197 + }); 198 + if (recentBakes.length > 20) recentBakes.pop(); 199 + } 200 + } 201 + } 202 + } catch (error) { 203 + console.error('❌ Failed to cleanup stale bakes:', error.message); 204 + } 205 + } 206 + 207 + /** 208 + * Load pending (unprocessed) tapes on startup to restore queue 209 + */ 210 + async function loadPendingTapes() { 211 + const database = await connectMongo(); 212 + if (!database) return; 213 + 214 + try { 215 + const collection = database.collection('tapes'); 216 + 217 + // Find tapes that need processing: 218 + // - Have no mp4Url (not yet processed) 219 + // - Were created in the last hour (not ancient) 220 + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); 221 + 222 + const pendingTapes = await collection.find({ 223 + mp4Url: { $exists: false }, 224 + createdAt: { $gte: oneHourAgo } 225 + }).sort({ createdAt: 1 }).limit(10).toArray(); 226 + 227 + if (pendingTapes.length > 0) { 228 + console.log(`📥 Restoring ${pendingTapes.length} pending tapes to queue...`); 229 + for (const tape of pendingTapes) { 230 + if (tape.code && !incomingBakes.has(tape.code)) { 231 + incomingBakes.set(tape.code, { 232 + code: tape.code, 233 + slug: tape.slug, 234 + mongoId: tape._id.toString(), 235 + detectedAt: new Date(tape.createdAt).getTime(), 236 + status: 'incoming', 237 + details: 'Restored after server restart' 238 + }); 239 + } 240 + } 241 + } 242 + } catch (error) { 243 + console.error('❌ Failed to load pending tapes:', error.message); 244 + } 245 + } 246 + 247 + // Load recent bakes and pending queue on startup 248 + loadRecentBakes(); 249 + loadPendingTapes(); 250 + 251 + function addRecentBake(bake) { 252 + recentBakes.unshift(bake); 253 + if (recentBakes.length > 20) recentBakes.pop(); 254 + 255 + // Note: MongoDB persistence is handled by oven-complete webhook 256 + } 257 + 258 + function startBake(code, data) { 259 + // Remove from incoming if present 260 + if (incomingBakes.has(code)) { 261 + console.log(`📤 Moving ${code} from incoming to active`); 262 + incomingBakes.delete(code); 263 + } 264 + 265 + activeBakes.set(code, { 266 + code, 267 + startTime: Date.now(), 268 + status: 'downloading', 269 + ...data 270 + }); 271 + } 272 + 273 + function updateBakeStatus(code, status, details = {}) { 274 + const bake = activeBakes.get(code); 275 + if (bake) { 276 + bake.status = status; 277 + bake.lastUpdate = Date.now(); 278 + Object.assign(bake, details); 279 + } 280 + } 281 + 282 + function completeBake(code, success, result = {}) { 283 + const bake = activeBakes.get(code); 284 + if (bake) { 285 + activeBakes.delete(code); 286 + addRecentBake({ 287 + ...bake, 288 + success, 289 + completedAt: Date.now(), 290 + duration: Date.now() - bake.startTime, 291 + ...result 292 + }); 293 + } 294 + } 295 + 296 + /** 297 + * Health check handler 298 + */ 299 + export async function healthHandler(req, res) { 300 + res.json({ 301 + status: 'ok', 302 + service: 'oven', 303 + timestamp: new Date().toISOString() 304 + }); 305 + } 306 + 307 + /** 308 + * Main bake handler 309 + */ 310 + export async function bakeHandler(req, res) { 311 + const { mongoId, slug, code, zipUrl, callbackUrl, callbackSecret, metadata } = req.body; 312 + 313 + console.log(`📥 Bake request received:`, { 314 + mongoId, 315 + slug, 316 + code, 317 + zipUrl, 318 + callbackUrl, 319 + hasSecret: !!callbackSecret, 320 + secretPreview: callbackSecret ? callbackSecret.substring(0, 10) + '...' : 'none', 321 + expectedSecretPreview: CALLBACK_SECRET ? CALLBACK_SECRET.substring(0, 10) + '...' : 'none' 322 + }); 323 + 324 + // Validate request 325 + if (!mongoId || !slug || !code || !zipUrl || !callbackUrl) { 326 + console.error('❌ Missing required fields'); 327 + return res.status(400).json({ 328 + error: 'Missing required fields: mongoId, slug, code, zipUrl, callbackUrl' 329 + }); 330 + } 331 + 332 + if (callbackSecret !== CALLBACK_SECRET) { 333 + console.error('❌ Invalid callback secret'); 334 + console.error(` Received: ${callbackSecret}`); 335 + console.error(` Expected: ${CALLBACK_SECRET}`); 336 + return res.status(401).json({ error: 'Invalid callback secret' }); 337 + } 338 + 339 + console.log(`🔥 Starting bake for tape: ${slug} (${code})`); 340 + 341 + // Start tracking (keyed by code) 342 + startBake(code, { mongoId, slug, code, zipUrl, callbackUrl }); 343 + notifySubscribers(); 344 + 345 + // Respond immediately - processing happens in background 346 + res.json({ 347 + status: 'accepted', 348 + slug, 349 + code, 350 + message: 'Baking started' 351 + }); 352 + 353 + // Process asynchronously 354 + processTape({ mongoId, slug, code, zipUrl, callbackUrl, metadata }) 355 + .catch(err => { 356 + console.error(`❌ Bake failed for ${slug}:`, err); 357 + }); 358 + } 359 + 360 + /** 361 + * Process tape: download, convert, upload, callback 362 + */ 363 + async function processTape({ mongoId, slug, code, zipUrl, callbackUrl, metadata }) { 364 + const workDir = join(tmpdir(), `tape-${code}-${Date.now()}`); 365 + 366 + try { 367 + updateBakeStatus(code, 'downloading'); 368 + notifySubscribers(); 369 + console.log(`📥 Downloading ZIP for ${code} (${slug})...`); 370 + const zipBuffer = await downloadZip(zipUrl); 371 + 372 + updateBakeStatus(code, 'extracting'); 373 + notifySubscribers(); 374 + console.log(`📦 Extracting to ${workDir}...`); 375 + await fs.mkdir(workDir, { recursive: true }); 376 + await extractZip(zipBuffer, workDir); 377 + 378 + updateBakeStatus(code, 'processing'); 379 + notifySubscribers(); 380 + console.log(`📊 Reading timing data...`); 381 + const timing = await readTiming(workDir); 382 + 383 + console.log(`📸 Generating thumbnail...`); 384 + const thumbnailBuffer = await generateThumbnail(workDir); 385 + 386 + console.log(`🎬 Converting to MP4...`); 387 + const mp4Buffer = await framesToMp4(workDir, timing); 388 + 389 + updateBakeStatus(code, 'uploading'); 390 + notifySubscribers(); 391 + console.log(`☁️ Uploading to Spaces...`); 392 + const mp4Url = await uploadToSpaces(mp4Buffer, `tapes/${code}.mp4`); 393 + const thumbnailUrl = await uploadToSpaces(thumbnailBuffer, `tapes/${code}-thumb.jpg`, 'image/jpeg'); 394 + 395 + console.log(`📞 Calling back to ${callbackUrl}...`); 396 + await postCallback({ mongoId, slug, code, mp4Url, thumbnailUrl, callbackUrl }); 397 + 398 + console.log(`🧹 Cleaning up ${workDir}...`); 399 + await fs.rm(workDir, { recursive: true, force: true }); 400 + 401 + console.log(`✅ Bake complete for ${code} (${slug})`); 402 + // Completion will be handled by webhook notification 403 + 404 + } catch (error) { 405 + console.error(`❌ Error processing ${code} (${slug}):`, error); 406 + 407 + // Notify callback of error 408 + try { 409 + await postCallback({ 410 + mongoId, 411 + slug, 412 + code, 413 + callbackUrl, 414 + error: error.message 415 + }); 416 + } catch (callbackError) { 417 + console.warn(`⚠️ Callback notification failed:`, callbackError.message); 418 + } 419 + 420 + // Error completion will be handled by webhook notification 421 + 422 + throw error; 423 + } finally { 424 + // Ensure cleanup 425 + try { 426 + await fs.rm(workDir, { recursive: true, force: true }); 427 + } catch (cleanupError) { 428 + console.warn(`⚠️ Cleanup failed for ${workDir}:`, cleanupError.message); 429 + } 430 + } 431 + } 432 + 433 + /** 434 + * Download ZIP from URL (supports both http and https with self-signed certs) 435 + */ 436 + async function downloadZip(zipUrl) { 437 + return new Promise((resolve, reject) => { 438 + const url = new URL(zipUrl); 439 + const isHttps = url.protocol === 'https:'; 440 + const client = isHttps ? https : http; 441 + 442 + const options = { 443 + hostname: url.hostname, 444 + port: url.port || (isHttps ? 443 : 80), 445 + path: url.pathname + url.search, 446 + method: 'GET', 447 + rejectUnauthorized: false, // Accept self-signed certs in dev 448 + }; 449 + 450 + // Set timeout for both connection and idle 451 + const timeoutMs = 120000; // 2 minutes 452 + let timeoutId; 453 + 454 + const req = client.request(options, (res) => { 455 + // Clear connection timeout, set idle timeout 456 + clearTimeout(timeoutId); 457 + timeoutId = setTimeout(() => { 458 + req.destroy(); 459 + reject(new Error('Download timeout: no data received for 2 minutes')); 460 + }, timeoutMs); 461 + 462 + // Follow redirects 463 + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 464 + console.log(` Following redirect to: ${res.headers.location}`); 465 + clearTimeout(timeoutId); 466 + downloadZip(res.headers.location).then(resolve).catch(reject); 467 + return; 468 + } 469 + 470 + if (res.statusCode !== 200) { 471 + clearTimeout(timeoutId); 472 + reject(new Error(`Failed to download ZIP: ${res.statusCode} ${res.statusMessage}`)); 473 + return; 474 + } 475 + 476 + const chunks = []; 477 + res.on('data', (chunk) => { 478 + chunks.push(chunk); 479 + // Reset timeout on each data chunk 480 + clearTimeout(timeoutId); 481 + timeoutId = setTimeout(() => { 482 + req.destroy(); 483 + reject(new Error('Download timeout: no data received for 2 minutes')); 484 + }, timeoutMs); 485 + }); 486 + res.on('end', () => { 487 + clearTimeout(timeoutId); 488 + resolve(Buffer.concat(chunks)); 489 + }); 490 + }); 491 + 492 + // Initial connection timeout 493 + timeoutId = setTimeout(() => { 494 + req.destroy(); 495 + reject(new Error('Connection timeout: failed to connect within 2 minutes')); 496 + }, timeoutMs); 497 + 498 + req.on('error', (err) => { 499 + clearTimeout(timeoutId); 500 + reject(err); 501 + }); 502 + req.end(); 503 + }); 504 + } 505 + 506 + /** 507 + * Extract ZIP to directory 508 + */ 509 + async function extractZip(zipBuffer, workDir) { 510 + const zip = new AdmZip(zipBuffer); 511 + zip.extractAllTo(workDir, true); 512 + } 513 + 514 + /** 515 + * Read timing.json 516 + */ 517 + async function readTiming(workDir) { 518 + const timingPath = join(workDir, 'timing.json'); 519 + try { 520 + const timingData = await fs.readFile(timingPath, 'utf-8'); 521 + const timing = JSON.parse(timingData); 522 + if (Array.isArray(timing) && timing.length > 0) { 523 + console.log(` Found timing data for ${timing.length} frames`); 524 + return timing; 525 + } 526 + return null; 527 + } catch (error) { 528 + console.log(` No timing.json found, using default frame rate`); 529 + return null; 530 + } 531 + } 532 + 533 + /** 534 + * Generate thumbnail from midpoint frame 535 + */ 536 + async function generateThumbnail(workDir) { 537 + try { 538 + const files = await fs.readdir(workDir); 539 + const frameFiles = files.filter(f => f.startsWith('frame-') && f.endsWith('.png')).sort(); 540 + 541 + if (frameFiles.length === 0) { 542 + throw new Error('No frames found for thumbnail'); 543 + } 544 + 545 + const midIndex = Math.floor(frameFiles.length / 2); 546 + const midFrame = frameFiles[midIndex]; 547 + const framePath = join(workDir, midFrame); 548 + 549 + console.log(` Using frame ${midIndex + 1}/${frameFiles.length}: ${midFrame}`); 550 + 551 + const sharp = (await import('sharp')).default; 552 + 553 + const image = sharp(framePath); 554 + const metadata = await image.metadata(); 555 + 556 + // Scale 3x with nearest neighbor 557 + const scaled3x = await image 558 + .resize(metadata.width * 3, metadata.height * 3, { 559 + kernel: 'nearest' 560 + }) 561 + .toBuffer(); 562 + 563 + // Fit to 512x512 564 + const thumbnail = await sharp(scaled3x) 565 + .resize(512, 512, { 566 + fit: 'contain', 567 + background: { r: 0, g: 0, b: 0, alpha: 0 } 568 + }) 569 + .jpeg({ quality: 90 }) 570 + .toBuffer(); 571 + 572 + const sizeKB = (thumbnail.length / 1024).toFixed(2); 573 + console.log(` Thumbnail: ${sizeKB} KB`); 574 + 575 + return thumbnail; 576 + } catch (error) { 577 + console.error(` Thumbnail generation failed:`, error.message); 578 + return null; 579 + } 580 + } 581 + 582 + /** 583 + * Convert frames to MP4 using ffmpeg 584 + */ 585 + async function framesToMp4(workDir, timing) { 586 + const outputPath = join(workDir, 'output.mp4'); 587 + const soundtrackPath = join(workDir, 'soundtrack.wav'); 588 + 589 + // Check for audio 590 + const hasSoundtrack = await fs.access(soundtrackPath).then(() => true).catch(() => false); 591 + 592 + let frameRate = 60; // default 593 + 594 + if (hasSoundtrack && timing && Array.isArray(timing) && timing.length > 0) { 595 + // Probe audio duration 596 + const audioDuration = await new Promise((resolve, reject) => { 597 + const ffprobe = spawn(ffprobePath, [ 598 + '-v', 'error', 599 + '-show_entries', 'format=duration', 600 + '-of', 'default=noprint_wrappers=1:nokey=1', 601 + soundtrackPath 602 + ]); 603 + 604 + let output = ''; 605 + ffprobe.stdout.on('data', (data) => output += data.toString()); 606 + ffprobe.on('error', (err) => reject(err)); 607 + ffprobe.on('close', (code) => { 608 + if (code === 0) { 609 + resolve(parseFloat(output.trim())); 610 + } else { 611 + reject(new Error('ffprobe failed')); 612 + } 613 + }); 614 + }); 615 + 616 + frameRate = Math.round(timing.length / audioDuration); 617 + console.log(` ${timing.length} frames, ${audioDuration.toFixed(2)}s audio → ${frameRate}fps`); 618 + } else if (timing && Array.isArray(timing) && timing.length > 0) { 619 + const totalDuration = timing.reduce((sum, frame) => sum + frame.duration, 0); 620 + const avgFrameDuration = totalDuration / timing.length; 621 + frameRate = Math.round(1000 / avgFrameDuration); 622 + console.log(` ${timing.length} frames, calculated ${frameRate}fps from timing`); 623 + } 624 + 625 + const ffmpegArgs = [ 626 + '-r', frameRate.toString(), 627 + '-i', join(workDir, 'frame-%05d.png'), 628 + ]; 629 + 630 + if (hasSoundtrack) { 631 + console.log(` Including soundtrack.wav`); 632 + ffmpegArgs.push('-i', soundtrackPath); 633 + } 634 + 635 + ffmpegArgs.push( 636 + '-vf', 'scale=iw*3:ih*3:flags=neighbor,scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=neighbor', 637 + '-c:v', 'libx264', 638 + '-pix_fmt', 'yuv420p', 639 + ); 640 + 641 + if (hasSoundtrack) { 642 + ffmpegArgs.push('-c:a', 'aac', '-b:a', '128k'); 643 + } 644 + 645 + ffmpegArgs.push( 646 + '-movflags', '+faststart', 647 + '-y', 648 + outputPath 649 + ); 650 + 651 + console.log(` Running ffmpeg at ${frameRate}fps`); 652 + 653 + return new Promise((resolve, reject) => { 654 + const ffmpeg = spawn(ffmpegPath, ffmpegArgs); 655 + 656 + let stderr = ''; 657 + ffmpeg.stderr.on('data', (data) => { 658 + stderr += data.toString(); 659 + }); 660 + 661 + ffmpeg.on('error', (error) => { 662 + reject(new Error(`ffmpeg spawn error: ${error.message}`)); 663 + }); 664 + 665 + ffmpeg.on('close', async (code) => { 666 + if (code !== 0) { 667 + reject(new Error(`ffmpeg exited with code ${code}\n${stderr}`)); 668 + return; 669 + } 670 + 671 + try { 672 + const mp4Buffer = await fs.readFile(outputPath); 673 + const sizeKB = (mp4Buffer.length / 1024).toFixed(2); 674 + console.log(` MP4 created: ${sizeKB} KB`); 675 + resolve(mp4Buffer); 676 + } catch (error) { 677 + reject(new Error(`Failed to read MP4: ${error.message}`)); 678 + } 679 + }); 680 + }); 681 + } 682 + 683 + /** 684 + * Upload buffer to Spaces 685 + */ 686 + async function uploadToSpaces(buffer, key, contentType = 'video/mp4') { 687 + if (!buffer) { 688 + throw new Error('Cannot upload null buffer'); 689 + } 690 + 691 + const command = new PutObjectCommand({ 692 + Bucket: AT_BLOBS_BUCKET, 693 + Key: key, 694 + Body: buffer, 695 + ContentType: contentType, 696 + ACL: 'public-read', 697 + }); 698 + 699 + await atBlobsSpacesClient.send(command); 700 + 701 + // Always use direct Spaces URL for webhook callbacks 702 + // The CDN URL (at-blobs.aesthetic.computer) might have auth restrictions 703 + const endpoint = process.env.AT_BLOBS_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com'; 704 + const region = endpoint.match(/https:\/\/([^.]+)\./)?.[1] || 'sfo3'; 705 + const url = `https://${AT_BLOBS_BUCKET}.${region}.digitaloceanspaces.com/${key}`; 706 + 707 + console.log(` Uploaded: ${url}`); 708 + return url; 709 + } 710 + 711 + /** 712 + * POST callback to Netlify 713 + */ 714 + async function postCallback({ mongoId, slug, code, mp4Url, thumbnailUrl, callbackUrl, error }) { 715 + const payload = { 716 + mongoId, 717 + slug, 718 + code, 719 + secret: CALLBACK_SECRET, 720 + }; 721 + 722 + if (error) { 723 + payload.error = error; 724 + } else { 725 + payload.mp4Url = mp4Url; 726 + payload.thumbnailUrl = thumbnailUrl; 727 + } 728 + 729 + return new Promise((resolve, reject) => { 730 + const url = new URL(callbackUrl); 731 + const isHttps = url.protocol === 'https:'; 732 + const client = isHttps ? https : http; 733 + const body = JSON.stringify(payload); 734 + 735 + const options = { 736 + hostname: url.hostname, 737 + port: url.port || (isHttps ? 443 : 80), 738 + path: url.pathname + url.search, 739 + method: 'POST', 740 + headers: { 741 + 'Content-Type': 'application/json', 742 + 'Content-Length': Buffer.byteLength(body), 743 + }, 744 + rejectUnauthorized: false, // Accept self-signed certs in dev 745 + }; 746 + 747 + const req = client.request(options, (res) => { 748 + if (res.statusCode < 200 || res.statusCode >= 300) { 749 + reject(new Error(`Callback failed: ${res.statusCode} ${res.statusMessage}`)); 750 + return; 751 + } 752 + 753 + console.log(` Callback successful`); 754 + resolve(); 755 + }); 756 + 757 + req.on('error', reject); 758 + req.write(body); 759 + req.end(); 760 + }); 761 + } 762 + 763 + export function subscribeToUpdates(callback) { 764 + subscribers.add(callback); 765 + return () => subscribers.delete(callback); 766 + } 767 + 768 + function notifySubscribers() { 769 + subscribers.forEach(cb => cb()); 770 + } 771 + 772 + export function getActiveBakes() { 773 + return activeBakes; 774 + } 775 + 776 + export function getIncomingBakes() { 777 + return incomingBakes; 778 + } 779 + 780 + export function getRecentBakes() { 781 + return recentBakes; 782 + } 783 + 784 + export async function statusHandler(req, res) { 785 + // Clean up any stale active bakes before returning status 786 + await cleanupStaleBakes(); 787 + 788 + res.json({ 789 + incoming: Array.from(incomingBakes.values()), 790 + active: Array.from(activeBakes.values()), 791 + recent: recentBakes 792 + }); 793 + } 794 + 795 + /** 796 + * Bake completion notification handler 797 + * Called by oven-complete webhook to notify oven that processing finished 798 + */ 799 + export function bakeCompleteHandler(req, res) { 800 + const { slug, code, success, mp4Url, thumbnailUrl, error, atprotoRkey } = req.body; 801 + 802 + if (!code) { 803 + return res.status(400).json({ error: 'Missing code' }); 804 + } 805 + 806 + console.log(`🎬 Bake completion notification: ${slug} (${code}) - ${success ? 'success' : 'failed'}${atprotoRkey ? ' 🦋' : ''}`); 807 + 808 + // Move from active to recent (keyed by code) 809 + completeBake(code, success, { slug, code, mp4Url, thumbnailUrl, error, atprotoRkey }); 810 + notifySubscribers(); 811 + 812 + res.json({ status: 'ok' }); 813 + } 814 + 815 + /** 816 + * Bake status update handler 817 + * Called by oven-complete webhook for incremental progress updates 818 + */ 819 + export function bakeStatusHandler(req, res) { 820 + const { code, status, details } = req.body; 821 + 822 + if (!code) { 823 + return res.status(400).json({ error: 'Missing code' }); 824 + } 825 + 826 + console.log(`📊 Status update: ${code} - ${status}${details ? ': ' + details : ''}`); 827 + 828 + // Update the bake status 829 + updateBakeStatus(code, status, details); 830 + notifySubscribers(); 831 + 832 + res.json({ status: 'ok' }); 833 + }
+1507
oven/bundler.mjs
··· 1 + // bundler.mjs - HTML bundle generation for Aesthetic Computer pieces 2 + // Ported from system/netlify/functions/bundle-html.js (CJS → ESM) 3 + // 4 + // Generates self-contained HTML bundles on-demand via Express routes. 5 + // Supports KidLisp pieces ($code) and JavaScript pieces (piece=name). 6 + // 7 + // Core system files (platform code, fonts) are cached in-memory per git 8 + // commit and pre-warmed on deploy so that per-piece bundling is fast. 9 + 10 + import { promises as fs } from "fs"; 11 + import fsSync from "fs"; 12 + import path from "path"; 13 + import { gzipSync, gunzipSync, brotliCompressSync, constants as zlibConstants } from "zlib"; 14 + import { execSync } from "child_process"; 15 + import { MongoClient } from "mongodb"; 16 + import sharp from "sharp"; 17 + import { fileURLToPath } from "url"; 18 + 19 + // ─── Configuration ────────────────────────────────────────────────── 20 + 21 + // Source directory for aesthetic.computer platform files. 22 + // In production this is rsynced by deploy.sh to /opt/oven/ac-source/ 23 + // In dev, override with AC_SOURCE_DIR env var. 24 + const AC_SOURCE_DIR = process.env.AC_SOURCE_DIR 25 + || path.resolve(process.cwd(), "ac-source"); 26 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 27 + const BOX_ART_FONT_STACK = "'KidLispComic', 'Comic Relief', 'Comic Sans MS', 'Comic Sans', cursive"; 28 + const BOX_ART_COMIC_FONT_FILE = path.join(__dirname, "fonts", "ComicRelief-Bold.ttf"); 29 + let comicReliefBoldBase64 = ""; 30 + try { 31 + comicReliefBoldBase64 = fsSync.readFileSync(BOX_ART_COMIC_FONT_FILE).toString("base64"); 32 + } catch { 33 + // Falls back to Comic Sans stack when oven font assets are unavailable. 34 + } 35 + 36 + function getGitCommit() { 37 + if (process.env.OVEN_VERSION && process.env.OVEN_VERSION !== "unknown") 38 + return process.env.OVEN_VERSION; 39 + try { 40 + const hash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); 41 + const isDirty = execSync("git status --porcelain", { encoding: "utf8" }).trim().length > 0; 42 + return isDirty ? `${hash} (dirty)` : hash; 43 + } catch { 44 + return "unknown"; 45 + } 46 + } 47 + 48 + let GIT_COMMIT = getGitCommit(); 49 + 50 + // Re-read git commit (called after deploy rsync updates source). 51 + export function refreshGitCommit() { 52 + GIT_COMMIT = getGitCommit(); 53 + return GIT_COMMIT; 54 + } 55 + 56 + // ─── In-memory cache ──────────────────────────────────────────────── 57 + 58 + let coreBundleCache = null; 59 + let coreBundleCacheCommit = null; 60 + let skipMinification = false; 61 + 62 + // ─── Brotli WASM decoder (loaded once at startup) ────────────────── 63 + 64 + let brotliWasmGzBase64 = null; // gzip'd + base64'd WASM binary for inline embedding 65 + 66 + function loadBrotliWasmDecoder() { 67 + try { 68 + const wasmPath = path.resolve(process.cwd(), "node_modules/brotli-dec-wasm/pkg/brotli_dec_wasm_bg.wasm"); 69 + const wasmBytes = fsSync.readFileSync(wasmPath); 70 + const gzipped = gzipSync(wasmBytes, { level: 9 }); 71 + brotliWasmGzBase64 = gzipped.toString("base64"); 72 + console.log(`[bundler] brotli WASM decoder loaded (${Math.round(brotliWasmGzBase64.length / 1024)} KB gzip+base64)`); 73 + } catch (err) { 74 + console.warn(`[bundler] brotli WASM decoder not available: ${err.message}`); 75 + } 76 + } 77 + 78 + // Load on import 79 + loadBrotliWasmDecoder(); 80 + 81 + // ─── Constants ────────────────────────────────────────────────────── 82 + 83 + const ESSENTIAL_FILES = [ 84 + "boot.mjs", "bios.mjs", 85 + "lib/loop.mjs", "lib/disk.mjs", "lib/parse.mjs", 86 + "lib/kidlisp.mjs", 87 + "lib/graph.mjs", "lib/geo.mjs", "lib/2d.mjs", "lib/pen.mjs", 88 + "lib/num.mjs", "lib/gl.mjs", "lib/webgl-blit.mjs", 89 + "lib/helpers.mjs", "lib/logs.mjs", "lib/store.mjs", 90 + "lib/platform.mjs", "lib/pack-mode.mjs", 91 + "lib/keyboard.mjs", "lib/gamepad.mjs", "lib/motion.mjs", 92 + "lib/speech.mjs", "lib/help.mjs", "lib/midi.mjs", "lib/usb.mjs", 93 + "lib/headers.mjs", "lib/glaze.mjs", "lib/ui.mjs", 94 + "disks/common/tape-player.mjs", 95 + "lib/sound/sound-whitelist.mjs", 96 + "lib/speaker-bundled.mjs", 97 + "dep/gl-matrix/common.mjs", "dep/gl-matrix/vec2.mjs", 98 + "dep/gl-matrix/vec3.mjs", "dep/gl-matrix/vec4.mjs", 99 + "dep/gl-matrix/mat3.mjs", "dep/gl-matrix/mat4.mjs", 100 + "dep/gl-matrix/quat.mjs", 101 + "lib/glazes/uniforms.js", 102 + ]; 103 + 104 + const SKIP_FILES = [ 105 + "dep/wasmboy/", // GameBoy emulator (~424 KB) — only needed if piece uses GB features 106 + "disks/prompt.mjs", // 377 KB — stubbed below (not needed in PACK mode) 107 + "disks/chat.mjs", // 184 KB — stubbed below (chat UI, not functional in PACK_MODE) 108 + ]; 109 + 110 + // Lightweight stubs injected into VFS to satisfy static import chains 111 + // without bundling the full 560+ KB of source. 112 + // lib/disk.mjs dynamically imports "../disks/chat.mjs" (dead code: chatEnabled = false) 113 + const VFS_STUBS = { 114 + "disks/prompt.mjs": ``, 115 + "disks/chat.mjs": `export const CHAT_FONTS={};export function boot(){}export function paint(){}export function act(){}export function sim(){}export function receive(){}`, 116 + }; 117 + 118 + // ─── Helpers ──────────────────────────────────────────────────────── 119 + 120 + // Parse BDF font file and extract glyphs for the given code point set. 121 + // Returns a map of hex-padded keys (e.g. "0041") to glyph data objects. 122 + function parseBDFGlyphs(bdfText, codePointSet) { 123 + const lines = bdfText.split(/\r?\n/); 124 + const map = {}; 125 + let fontAscent = 8, fontDescent = 0; 126 + let inChar = false, inBitmap = false; 127 + let charCode = -1, bbx = null, dwidth = null, bitmapLines = []; 128 + 129 + for (const line of lines) { 130 + if (line.startsWith("FONT_ASCENT")) { 131 + fontAscent = parseInt(line.split(" ")[1]); 132 + } else if (line.startsWith("FONT_DESCENT")) { 133 + fontDescent = parseInt(line.split(" ")[1]); 134 + } else if (line.startsWith("STARTCHAR")) { 135 + inChar = true; inBitmap = false; 136 + charCode = -1; bbx = null; dwidth = null; bitmapLines = []; 137 + } else if (inChar && line.startsWith("ENCODING")) { 138 + charCode = parseInt(line.split(" ")[1]); 139 + } else if (inChar && line.startsWith("DWIDTH")) { 140 + const p = line.split(" "); 141 + dwidth = { x: parseInt(p[1]), y: parseInt(p[2]) }; 142 + } else if (inChar && line.startsWith("BBX")) { 143 + const p = line.split(" "); 144 + bbx = { width: parseInt(p[1]), height: parseInt(p[2]), xOffset: parseInt(p[3]), yOffset: parseInt(p[4]) }; 145 + } else if (inChar && line.trim() === "BITMAP") { 146 + inBitmap = true; 147 + } else if (inChar && line.startsWith("ENDCHAR")) { 148 + if (codePointSet.has(charCode) && bbx && bbx.width > 0 && bbx.height > 0) { 149 + // Parse bitmap into point commands 150 + const points = []; 151 + for (let r = 0; r < bbx.height && r < bitmapLines.length; r++) { 152 + let hex = bitmapLines[r].trim(); 153 + if (hex.length % 2 !== 0) hex += "0"; 154 + const bytes = Buffer.from(hex, "hex"); 155 + for (let c = 0; c < bbx.width; c++) { 156 + const bi = Math.floor(c / 8); 157 + if (bi < bytes.length && (bytes[bi] >> (7 - (c % 8))) & 1) { 158 + points.push({ name: "point", args: [c, r] }); 159 + } 160 + } 161 + } 162 + // Optimize consecutive points into line commands 163 + const commands = optimizeBDFPoints(points); 164 + const hexKey = charCode.toString(16).toUpperCase().padStart(4, "0"); 165 + map[hexKey] = { 166 + resolution: [bbx.width, bbx.height], 167 + offset: [bbx.xOffset, bbx.yOffset], 168 + baselineOffset: [bbx.xOffset, fontAscent - bbx.height - bbx.yOffset], 169 + advance: dwidth ? dwidth.x : 4, 170 + bbx: { width: bbx.width, height: bbx.height, xOffset: bbx.xOffset, yOffset: bbx.yOffset }, 171 + dwidth, fontMetrics: { ascent: fontAscent, descent: fontDescent }, 172 + commands, 173 + }; 174 + } 175 + inChar = false; inBitmap = false; 176 + } else if (inChar && inBitmap && bbx && bitmapLines.length < bbx.height) { 177 + bitmapLines.push(line.trim()); 178 + } 179 + } 180 + return map; 181 + } 182 + 183 + // Merge adjacent horizontal/vertical points into line commands for smaller JSON. 184 + function optimizeBDFPoints(points) { 185 + if (points.length === 0) return []; 186 + const unique = new Map(); 187 + for (const p of points) { 188 + const k = `${p.args[0]},${p.args[1]}`; 189 + if (!unique.has(k)) unique.set(k, { x: p.args[0], y: p.args[1] }); 190 + } 191 + const pts = Array.from(unique.values()).sort((a, b) => a.y !== b.y ? a.y - b.y : a.x - b.x); 192 + const used = new Set(); 193 + const cmds = []; 194 + for (let i = 0; i < pts.length; i++) { 195 + if (used.has(i)) continue; 196 + const p = pts[i]; 197 + // Try horizontal run 198 + const hRun = [i]; 199 + for (let j = i + 1; j < pts.length; j++) { 200 + if (used.has(j) || pts[j].y !== p.y) break; 201 + if (pts[j].x === p.x + hRun.length) hRun.push(j); else break; 202 + } 203 + if (hRun.length >= 2) { 204 + cmds.push({ name: "line", args: [p.x, p.y, pts[hRun[hRun.length - 1]].x, p.y] }); 205 + hRun.forEach(idx => used.add(idx)); 206 + continue; 207 + } 208 + // Try vertical run 209 + const vRun = [i]; 210 + for (let j = i + 1; j < pts.length; j++) { 211 + if (used.has(j)) continue; 212 + if (pts[j].x === p.x && pts[j].y === p.y + vRun.length) vRun.push(j); 213 + } 214 + if (vRun.length >= 2) { 215 + cmds.push({ name: "line", args: [p.x, p.y, p.x, pts[vRun[vRun.length - 1]].y] }); 216 + vRun.forEach(idx => used.add(idx)); 217 + continue; 218 + } 219 + cmds.push({ name: "point", args: [p.x, p.y] }); 220 + used.add(i); 221 + } 222 + return cmds; 223 + } 224 + 225 + function timestamp(date = new Date()) { 226 + const pad = (n, digits = 2) => n.toString().padStart(digits, "0"); 227 + return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}.${date.getHours()}.${date.getMinutes()}.${date.getSeconds()}.${pad(date.getMilliseconds(), 3)}`; 228 + } 229 + 230 + // CSS color names recognized by KidLisp (must match kidlisp.mjs KIDLISP_COLORS) 231 + const KIDLISP_COLORS = new Set([ 232 + "red", "green", "blue", "yellow", "orange", "purple", "pink", "cyan", "magenta", 233 + "black", "white", "gray", "grey", "brown", "lime", "navy", "teal", "olive", 234 + "maroon", "aqua", "fuchsia", "silver", "gold", "coral", "salmon", "khaki", 235 + "indigo", "violet", "turquoise", "tomato", "crimson", "lavender", "beige", 236 + "plum", "orchid", "tan", "chocolate", "sienna", "peru", "wheat", 237 + ]); 238 + 239 + // Extract the first color word from KidLisp source for the page background. 240 + // KidLisp shorthand puts a bare color name as the first token, e.g. "purple, ink, line" 241 + function extractFirstColor(source) { 242 + if (!source) return null; 243 + // Get the first token (split on whitespace or comma) 244 + const firstToken = source.trim().split(/[\s,()]+/)[0]?.toLowerCase(); 245 + if (firstToken && KIDLISP_COLORS.has(firstToken)) return firstToken; 246 + // Also check inside (wipe "color") or (wipe color) 247 + const wipeMatch = source.match(/\(\s*wipe\s+["']?([a-z]+)["']?\s*\)/i); 248 + if (wipeMatch && KIDLISP_COLORS.has(wipeMatch[1].toLowerCase())) return wipeMatch[1].toLowerCase(); 249 + return null; 250 + } 251 + 252 + function extractPaintingCodes(source) { 253 + const codes = []; 254 + const regex = /#([a-zA-Z0-9]{3})\b/g; 255 + let match; 256 + while ((match = regex.exec(source)) !== null) { 257 + if (!codes.includes(match[1])) codes.push(match[1]); 258 + } 259 + return codes; 260 + } 261 + 262 + async function resolvePaintingCode(code) { 263 + try { 264 + const response = await fetch(`https://aesthetic.computer/api/painting-code?code=${code}`); 265 + if (!response.ok) return null; 266 + const data = await response.json(); 267 + return { code, handle: data.handle || "anon", slug: data.slug }; 268 + } catch { 269 + return null; 270 + } 271 + } 272 + 273 + async function fetchPaintingImage(handle, slug) { 274 + const handlePath = handle === "anon" ? "" : `@${handle}/`; 275 + const url = `https://aesthetic.computer/media/${handlePath}painting/${slug}.png`; 276 + try { 277 + const response = await fetch(url); 278 + if (!response.ok) return null; 279 + const buffer = await response.arrayBuffer(); 280 + return Buffer.from(buffer).toString("base64"); 281 + } catch { 282 + return null; 283 + } 284 + } 285 + 286 + // ─── Author info (MongoDB) ────────────────────────────────────────── 287 + 288 + let mongoClient = null; 289 + 290 + async function getDb() { 291 + const uri = process.env.MONGODB_CONNECTION_STRING; 292 + const dbName = process.env.MONGODB_NAME; 293 + if (!uri || !dbName) return null; 294 + if (!mongoClient) { 295 + mongoClient = await MongoClient.connect(uri); 296 + } 297 + return mongoClient.db(dbName); 298 + } 299 + 300 + async function fetchAuthorInfo(userId) { 301 + if (!userId) return { handle: null, userCode: null }; 302 + let acHandle = null; 303 + let userCode = null; 304 + 305 + try { 306 + const response = await fetch( 307 + `https://aesthetic.computer/handle?for=${encodeURIComponent(userId)}` 308 + ); 309 + if (response.ok) { 310 + const data = await response.json(); 311 + if (data.handle) acHandle = data.handle; 312 + } 313 + } catch { /* ignore */ } 314 + 315 + try { 316 + const db = await getDb(); 317 + if (db) { 318 + const user = await db.collection("users").findOne( 319 + { _id: userId }, { projection: { code: 1 } } 320 + ); 321 + if (user?.code) userCode = user.code; 322 + } 323 + } catch { /* ignore */ } 324 + 325 + return { handle: acHandle, userCode }; 326 + } 327 + 328 + function normalizeHandle(handle) { 329 + if (typeof handle !== "string") return null; 330 + const cleaned = handle.trim().replace(/^@+/, ""); 331 + return cleaned || null; 332 + } 333 + 334 + // ─── KidLisp source fetching ──────────────────────────────────────── 335 + 336 + async function fetchKidLispFromAPI(pieceName) { 337 + const cleanName = pieceName.replace("$", ""); 338 + const response = await fetch( 339 + `https://aesthetic.computer/api/store-kidlisp?code=${cleanName}` 340 + ); 341 + const data = await response.json(); 342 + if (data.error || !data.source) { 343 + throw new Error(`Piece '$${cleanName}' not found`); 344 + } 345 + return { 346 + source: data.source, 347 + userId: data.user || null, 348 + authorHandle: normalizeHandle(data.handle), 349 + }; 350 + } 351 + 352 + function extractKidLispRefs(source) { 353 + const refs = []; 354 + const regex = /\$[a-z0-9_-]+/gi; 355 + for (const match of source.matchAll(regex)) { 356 + const ref = match[0].toLowerCase(); 357 + if (!refs.includes(ref)) refs.push(ref); 358 + } 359 + return refs; 360 + } 361 + 362 + async function getKidLispSourceWithDeps(pieceName) { 363 + const allSources = {}; 364 + const toProcess = [pieceName]; 365 + const processed = new Set(); 366 + let mainPieceUserId = null; 367 + let mainPieceAuthorHandle = null; 368 + 369 + while (toProcess.length > 0) { 370 + const current = toProcess.shift(); 371 + const cleanName = current.replace("$", ""); 372 + if (processed.has(cleanName)) continue; 373 + processed.add(cleanName); 374 + 375 + const { source, userId, authorHandle } = await fetchKidLispFromAPI(cleanName); 376 + allSources[cleanName] = source; 377 + 378 + if (cleanName === pieceName.replace("$", "")) { 379 + if (userId) mainPieceUserId = userId; 380 + if (authorHandle) mainPieceAuthorHandle = authorHandle; 381 + } 382 + 383 + const refs = extractKidLispRefs(source); 384 + for (const ref of refs) { 385 + const refName = ref.replace("$", ""); 386 + if (!processed.has(refName)) toProcess.push(refName); 387 + } 388 + } 389 + 390 + let authorHandle = mainPieceAuthorHandle || "anon"; 391 + let userCode = null; 392 + if (mainPieceUserId) { 393 + const info = await fetchAuthorInfo(mainPieceUserId); 394 + if (!mainPieceAuthorHandle && info.handle) { 395 + authorHandle = normalizeHandle(info.handle) || authorHandle; 396 + } 397 + if (info.userCode) userCode = info.userCode; 398 + } 399 + 400 + return { sources: allSources, authorHandle, userCode }; 401 + } 402 + 403 + // ─── Import rewriting ─────────────────────────────────────────────── 404 + 405 + function resolvePath(base, relative) { 406 + if (!relative.startsWith(".")) return relative; 407 + let dir = path.dirname(base); 408 + const parts = dir === "." ? [] : dir.split("/").filter((p) => p); 409 + const relParts = relative.split("/"); 410 + for (const part of relParts) { 411 + if (part === "..") parts.pop(); 412 + else if (part !== "." && part !== "") parts.push(part); 413 + } 414 + return parts.join("/"); 415 + } 416 + 417 + function rewriteImports(code, filepath) { 418 + code = code.replace( 419 + /from\s*['"]aesthetic\.computer\/disks\/([^'"]+)['"]/g, 420 + (match, p) => "from 'ac/disks/" + p + "'" 421 + ); 422 + code = code.replace( 423 + /import\s*\((['"]aesthetic\.computer\/disks\/([^'"]+)['")])\)/g, 424 + (match, fullPath, p) => "import('ac/disks/" + p + "')" 425 + ); 426 + code = code.replace( 427 + /from\s*['"](\.\.\/[^'"]+|\.\/[^'"]+)(\?[^'"]+)?['"]/g, 428 + (match, p) => { 429 + const resolved = resolvePath(filepath, p); 430 + return 'from"' + resolved + '"'; 431 + } 432 + ); 433 + code = code.replace( 434 + /import\s*\((['"](\.\.\/[^'"]+|\.\/[^'"]+)(\?[^'"]+)?['")])\)/g, 435 + (match, fullPath, p) => { 436 + const resolved = resolvePath(filepath, p); 437 + return 'import("' + resolved + '")'; 438 + } 439 + ); 440 + code = code.replace( 441 + /import\s*\(\`(\.\.\/[^\`]+|\.\/[^\`]+)\`\)/g, 442 + (match, p) => { 443 + const clean = p.split("?")[0]; 444 + const resolved = resolvePath(filepath, clean); 445 + return 'import("' + resolved + '")'; 446 + } 447 + ); 448 + code = code.replace( 449 + /\(\s*['"](\.\.?\/[^'"]+\.m?js)(\?[^'"]+)?['"]\s*\)/g, 450 + (match, p) => { 451 + const resolved = resolvePath(filepath, p); 452 + return '("' + resolved + '")'; 453 + } 454 + ); 455 + 456 + // Rewrite new URL("relative-path", import.meta.url) patterns. 457 + // In pack mode, import.meta.url is a blob: URL that can't resolve relative paths. 458 + // Resolve the relative path to a VFS-absolute path so the fetch intercept can serve it. 459 + code = code.replace( 460 + /new\s+URL\(\s*['"](\.\.?\/[^'"]+)['"]\s*,\s*import\.meta\.url\s*\)/g, 461 + (match, p) => { 462 + const resolved = resolvePath(filepath, p); 463 + return 'new URL("/' + resolved + '", location.href)'; 464 + } 465 + ); 466 + 467 + return code; 468 + } 469 + 470 + // ─── Minification ─────────────────────────────────────────────────── 471 + 472 + async function minifyJS(content, relativePath) { 473 + const ext = path.extname(relativePath); 474 + if (ext !== ".mjs" && ext !== ".js") return content; 475 + 476 + let processed = rewriteImports(content, relativePath); 477 + if (skipMinification) return processed; 478 + 479 + try { 480 + const { minify } = await import("terser"); 481 + const result = await minify(processed, { 482 + compress: { 483 + dead_code: true, 484 + drop_debugger: true, 485 + unused: true, 486 + passes: 2, 487 + pure_getters: "strict", 488 + }, 489 + mangle: true, 490 + module: true, 491 + format: { comments: false, ascii_only: false, ecma: 2020 }, 492 + }); 493 + return result.code || processed; 494 + } catch (err) { 495 + console.error(`[bundler] minify failed ${relativePath}:`, err.message); 496 + return processed; 497 + } 498 + } 499 + 500 + // ─── Dependency discovery ─────────────────────────────────────────── 501 + 502 + async function discoverDependencies(acDir, essentialFiles, skipFiles) { 503 + const discovered = new Set(essentialFiles); 504 + const toProcess = [...essentialFiles]; 505 + 506 + while (toProcess.length > 0) { 507 + const file = toProcess.shift(); 508 + const fullPath = path.join(acDir, file); 509 + if (!fsSync.existsSync(fullPath)) continue; 510 + 511 + try { 512 + const content = await fs.readFile(fullPath, "utf8"); 513 + const importRegex = /from\s+["'](\.\.[^"']+|\.\/[^"']+)["']/g; 514 + const dynamicRegex = /import\s*\(\s*["'](\.\.[^"']+|\.\/[^"']+)["']\s*\)/g; 515 + 516 + let match; 517 + while ((match = importRegex.exec(content)) !== null) { 518 + const resolved = resolvePath(file, match[1]); 519 + if (skipFiles.some((s) => resolved.includes(s))) continue; 520 + if (!discovered.has(resolved)) { discovered.add(resolved); toProcess.push(resolved); } 521 + } 522 + while ((match = dynamicRegex.exec(content)) !== null) { 523 + const resolved = resolvePath(file, match[1]); 524 + if (skipFiles.some((s) => resolved.includes(s))) continue; 525 + if (!discovered.has(resolved)) { discovered.add(resolved); toProcess.push(resolved); } 526 + } 527 + } catch { /* ignore */ } 528 + } 529 + 530 + return Array.from(discovered); 531 + } 532 + 533 + // ─── Core bundle (cached per git commit) ──────────────────────────── 534 + 535 + async function getCoreBundle(onProgress = () => {}, forceRefresh = false) { 536 + const acDir = AC_SOURCE_DIR; 537 + 538 + if (!forceRefresh && coreBundleCache && coreBundleCacheCommit === GIT_COMMIT) { 539 + console.log(`[bundler] cache hit for ${GIT_COMMIT}`); 540 + onProgress({ stage: "cache-hit", message: "Using cached core files..." }); 541 + return coreBundleCache; 542 + } 543 + 544 + console.log(`[bundler] building core bundle for ${GIT_COMMIT}...`); 545 + const coreFiles = {}; 546 + 547 + onProgress({ stage: "discover", message: "Discovering dependencies..." }); 548 + const allFiles = await discoverDependencies(acDir, ESSENTIAL_FILES, SKIP_FILES); 549 + 550 + onProgress({ stage: "minify", message: `Minifying ${allFiles.length} files...` }); 551 + 552 + // Parallel minification in batches 553 + let minified = 0; 554 + const BATCH = 10; 555 + for (let i = 0; i < allFiles.length; i += BATCH) { 556 + const batch = allFiles.slice(i, i + BATCH); 557 + const results = await Promise.all( 558 + batch.map(async (file) => { 559 + const full = path.join(acDir, file); 560 + try { 561 + if (!fsSync.existsSync(full)) return null; 562 + let content = await fs.readFile(full, "utf8"); 563 + content = await minifyJS(content, file); 564 + return { file, content }; 565 + } catch { return null; } 566 + }) 567 + ); 568 + for (const r of results) { 569 + if (r) { 570 + coreFiles[r.file] = { content: r.content, binary: false, type: path.extname(r.file).slice(1) }; 571 + minified++; 572 + } 573 + } 574 + onProgress({ stage: "minify", message: `Minified ${minified}/${allFiles.length} files...` }); 575 + } 576 + 577 + // nanoid 578 + const nanoidPath = "dep/nanoid/index.js"; 579 + const nanoidFull = path.join(acDir, nanoidPath); 580 + if (fsSync.existsSync(nanoidFull)) { 581 + let c = await fs.readFile(nanoidFull, "utf8"); 582 + c = await minifyJS(c, nanoidPath); 583 + coreFiles[nanoidPath] = { content: c, binary: false, type: "js" }; 584 + } 585 + 586 + onProgress({ stage: "fonts", message: "Loading fonts..." }); 587 + 588 + // Font glyphs 589 + const font1Dir = path.join(acDir, "disks/drawings/font_1"); 590 + for (const category of ["lowercase", "uppercase", "numbers", "symbols"]) { 591 + const catDir = path.join(font1Dir, category); 592 + try { 593 + if (fsSync.existsSync(catDir)) { 594 + for (const f of fsSync.readdirSync(catDir).filter((n) => n.endsWith(".json"))) { 595 + const content = await fs.readFile(path.join(catDir, f), "utf8"); 596 + coreFiles[`disks/drawings/font_1/${category}/${f}`] = { content, binary: false, type: "json" }; 597 + } 598 + } 599 + } catch { /* skip */ } 600 + } 601 + 602 + // BDF glyph maps — parse directly from BDF font files for comprehensive coverage 603 + onProgress({ stage: "glyphs", message: "Parsing BDF font files..." }); 604 + const bdfGlyphs = {}; 605 + const prodTypeDir = path.resolve(process.cwd(), "assets-type"); 606 + const devTypeDir = path.resolve(acDir, "../assets/type"); 607 + const assetsTypeDir = fsSync.existsSync(prodTypeDir) ? prodTypeDir : devTypeDir; 608 + 609 + // Character ranges to embed: ASCII printable + Latin-1 Supplement + common symbols 610 + const EMBED_RANGES = [ 611 + [0x0020, 0x007E], // ASCII printable (95 chars) 612 + [0x00A0, 0x00FF], // Latin-1 Supplement (96 chars) 613 + [0x0100, 0x017F], // Latin Extended-A (128 chars) 614 + [0x2000, 0x206F], // General Punctuation (112 chars) 615 + [0x2190, 0x21FF], // Arrows (112 chars) 616 + [0x2500, 0x257F], // Box Drawing (128 chars) 617 + [0x25A0, 0x25FF], // Geometric Shapes (96 chars) 618 + [0x2600, 0x26FF], // Misc Symbols (256 chars) 619 + ]; 620 + const embedSet = new Set(); 621 + for (const [lo, hi] of EMBED_RANGES) { 622 + for (let cp = lo; cp <= hi; cp++) embedSet.add(cp); 623 + } 624 + 625 + for (const { fontName, bdfFile, key } of [ 626 + { fontName: "MatrixChunky8", bdfFile: "MatrixChunky8.bdf", key: "MatrixChunky8" }, 627 + { fontName: "unifont-16.0.03", bdfFile: "unifont-16.0.03.bdf", key: "unifont" }, 628 + ]) { 629 + const bdfPath = path.join(assetsTypeDir, bdfFile); 630 + const bdfGzPath = bdfPath + ".gz"; 631 + let bdfText = null; 632 + try { 633 + if (fsSync.existsSync(bdfPath)) { 634 + bdfText = fsSync.readFileSync(bdfPath, "utf8"); 635 + } else if (fsSync.existsSync(bdfGzPath)) { 636 + bdfText = gunzipSync(fsSync.readFileSync(bdfGzPath)).toString("utf8"); 637 + } 638 + } catch (err) { 639 + console.error(`[bundler] failed to read BDF ${bdfFile}:`, err.message); 640 + } 641 + if (!bdfText) { 642 + // Fallback: read pre-cached JSON files 643 + const fontDir = path.join(assetsTypeDir, fontName); 644 + if (fsSync.existsSync(fontDir)) { 645 + const map = {}; 646 + try { 647 + for (const f of fsSync.readdirSync(fontDir).filter((n) => n.endsWith(".json"))) { 648 + const hex = f.replace(".json", "").toUpperCase(); 649 + const paddedHex = hex.length < 4 ? hex.padStart(4, "0") : hex; 650 + map[paddedHex] = JSON.parse(await fs.readFile(path.join(fontDir, f), "utf8")); 651 + } 652 + } catch { /* skip */ } 653 + bdfGlyphs[key] = map; 654 + console.log(`[bundler] fallback: loaded ${Object.keys(map).length} ${key} glyphs from cache`); 655 + } 656 + continue; 657 + } 658 + 659 + const map = parseBDFGlyphs(bdfText, embedSet); 660 + bdfGlyphs[key] = map; 661 + console.log(`[bundler] parsed ${Object.keys(map).length} ${key} glyphs from BDF`); 662 + } 663 + coreFiles.__bdfGlyphs = bdfGlyphs; 664 + 665 + coreBundleCache = coreFiles; 666 + coreBundleCacheCommit = GIT_COMMIT; 667 + console.log(`[bundler] cached ${Object.keys(coreFiles).length} core files`); 668 + return coreFiles; 669 + } 670 + 671 + // ─── KidLisp bundle ───────────────────────────────────────────────── 672 + 673 + export async function createBundle(pieceName, onProgress = () => {}, nocompress = false, density = null, brotli = false, noboxart = false, keeplabel = false) { 674 + const PIECE_NAME_NO_DOLLAR = pieceName.replace(/^\$/, ""); 675 + const PIECE_NAME = "$" + PIECE_NAME_NO_DOLLAR; 676 + 677 + onProgress({ stage: "fetch", message: `Fetching $${PIECE_NAME_NO_DOLLAR}...` }); 678 + 679 + const { sources: kidlispSources, authorHandle, userCode } = await getKidLispSourceWithDeps(PIECE_NAME_NO_DOLLAR); 680 + const mainSource = kidlispSources[PIECE_NAME_NO_DOLLAR]; 681 + const depCount = Object.keys(kidlispSources).length - 1; 682 + 683 + onProgress({ stage: "deps", message: `Found ${depCount} dependenc${depCount === 1 ? "y" : "ies"}` }); 684 + 685 + const packTime = Date.now(); 686 + const packDate = new Date().toLocaleString("en-US", { 687 + timeZone: "America/Los_Angeles", year: "numeric", month: "long", day: "numeric", 688 + hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true, 689 + }); 690 + const bundleTimestamp = timestamp(); 691 + 692 + const coreFiles = await getCoreBundle(onProgress); 693 + const files = { ...coreFiles }; 694 + 695 + // Inject lightweight stubs for skipped files 696 + for (const [stubPath, stubContent] of Object.entries(VFS_STUBS)) { 697 + files[stubPath] = { content: stubContent, binary: false, type: "mjs" }; 698 + } 699 + 700 + // Paintings 701 + const allKidlispSource = Object.values(kidlispSources).join("\n"); 702 + const paintingCodes = extractPaintingCodes(allKidlispSource); 703 + const paintingData = {}; 704 + 705 + if (paintingCodes.length > 0) { 706 + onProgress({ stage: "paintings", message: `Embedding ${paintingCodes.length} painting${paintingCodes.length === 1 ? "" : "s"}...` }); 707 + } 708 + 709 + for (const code of paintingCodes) { 710 + const resolved = await resolvePaintingCode(code); 711 + if (resolved) { 712 + paintingData[code] = resolved; 713 + const img = await fetchPaintingImage(resolved.handle, resolved.slug); 714 + if (img) files[`paintings/${code}.png`] = { content: img, binary: true, type: "png" }; 715 + } 716 + } 717 + 718 + for (const [name, source] of Object.entries(kidlispSources)) { 719 + files[`disks/${name}.lisp`] = { content: source, binary: false, type: "lisp" }; 720 + } 721 + 722 + onProgress({ stage: "generate", message: "Generating HTML bundle..." }); 723 + 724 + const filename = `$${PIECE_NAME_NO_DOLLAR}-${authorHandle}-${bundleTimestamp}.lisp.html`; 725 + 726 + const bgColor = extractFirstColor(mainSource); 727 + 728 + // Extract BDF glyph maps (not part of VFS) 729 + const bdfGlyphs = files.__bdfGlyphs || {}; 730 + delete files.__bdfGlyphs; 731 + 732 + const boxArtPNG = noboxart ? null : await generateBoxArtPNG(PIECE_NAME, authorHandle, bgColor, packDate).catch(() => null); 733 + 734 + const htmlContent = generateHTMLBundle({ 735 + PIECE_NAME, PIECE_NAME_NO_DOLLAR, mainSource, kidlispSources, 736 + files, paintingData, authorHandle, packDate, packTime, 737 + gitVersion: GIT_COMMIT, filename, density, bgColor, bdfGlyphs, boxArtPNG, keeplabel, 738 + }); 739 + 740 + const method = nocompress ? "none" : brotli ? "brotli" : "gzip"; 741 + onProgress({ stage: "compress", message: nocompress ? "Skipping compression..." : `Compressing (${method})...` }); 742 + 743 + if (nocompress) { 744 + return { html: htmlContent, filename, sizeKB: Math.round(htmlContent.length / 1024), mainSource, authorHandle, userCode, packDate, depCount }; 745 + } 746 + 747 + const htmlBuf = Buffer.from(htmlContent, "utf-8"); 748 + let finalHtml; 749 + 750 + if (brotli && brotliWasmGzBase64) { 751 + const compressed = brotliCompressSync(htmlBuf, { 752 + params: { [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, 753 + }); 754 + finalHtml = generateSelfExtractingBrotliHTML(PIECE_NAME, compressed.toString("base64"), bgColor, boxArtPNG); 755 + } else { 756 + const compressed = gzipSync(htmlBuf, { level: 9 }); 757 + finalHtml = generateSelfExtractingHTML(PIECE_NAME, compressed.toString("base64"), bgColor, boxArtPNG); 758 + } 759 + 760 + return { html: finalHtml, filename, sizeKB: Math.round(finalHtml.length / 1024), mainSource, authorHandle, userCode, packDate, depCount }; 761 + } 762 + 763 + // ─── JS piece bundle ──────────────────────────────────────────────── 764 + 765 + export async function createJSPieceBundle(pieceName, onProgress = () => {}, nocompress = false, density = null, brotli = false, noboxart = false, keeplabel = false) { 766 + const acDir = AC_SOURCE_DIR; 767 + onProgress({ stage: "init", message: `Bundling ${pieceName}...` }); 768 + 769 + const packTime = Date.now(); 770 + const packDate = new Date().toLocaleString("en-US", { 771 + timeZone: "America/Los_Angeles", year: "numeric", month: "long", day: "numeric", 772 + hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true, 773 + }); 774 + const bundleTimestamp = timestamp(); 775 + 776 + const coreFiles = await getCoreBundle(onProgress); 777 + const files = { ...coreFiles }; 778 + 779 + // Inject lightweight stubs for skipped files 780 + for (const [stubPath, stubContent] of Object.entries(VFS_STUBS)) { 781 + files[stubPath] = { content: stubContent, binary: false, type: "mjs" }; 782 + } 783 + 784 + const piecePath = `disks/${pieceName}.mjs`; 785 + const pieceFullPath = path.join(acDir, piecePath); 786 + if (!fsSync.existsSync(pieceFullPath)) { 787 + throw new Error(`Piece '${pieceName}' not found at ${piecePath}`); 788 + } 789 + 790 + onProgress({ stage: "piece", message: `Loading ${pieceName}.mjs...` }); 791 + 792 + const pieceContent = await fs.readFile(pieceFullPath, "utf8"); 793 + files[piecePath] = { content: rewriteImports(pieceContent, piecePath), binary: false, type: "mjs" }; 794 + 795 + const pieceDepFiles = await discoverDependencies(acDir, [piecePath], SKIP_FILES); 796 + onProgress({ stage: "deps", message: `Found ${pieceDepFiles.length} dependencies...` }); 797 + 798 + for (const depFile of pieceDepFiles) { 799 + if (files[depFile]) continue; 800 + const depFullPath = path.join(acDir, depFile); 801 + try { 802 + if (!fsSync.existsSync(depFullPath)) continue; 803 + let content = await fs.readFile(depFullPath, "utf8"); 804 + content = depFile.startsWith("disks/") ? rewriteImports(content, depFile) : await minifyJS(content, depFile); 805 + files[depFile] = { content, binary: false, type: path.extname(depFile).slice(1) }; 806 + } catch { /* skip */ } 807 + } 808 + 809 + onProgress({ stage: "generate", message: "Generating HTML bundle..." }); 810 + 811 + // Extract BDF glyph maps (not part of VFS) 812 + const bdfGlyphs = files.__bdfGlyphs || {}; 813 + delete files.__bdfGlyphs; 814 + 815 + const boxArtPNG = noboxart ? null : await generateBoxArtPNG(pieceName, null, null, packDate).catch(() => null); 816 + 817 + const htmlContent = generateJSPieceHTMLBundle({ pieceName, files, packDate, packTime, gitVersion: GIT_COMMIT, bdfGlyphs, boxArtPNG, keeplabel }); 818 + const filename = `${pieceName}-${bundleTimestamp}.html`; 819 + 820 + const method = nocompress ? "none" : brotli ? "brotli" : "gzip"; 821 + onProgress({ stage: "compress", message: nocompress ? "Skipping compression..." : `Compressing (${method})...` }); 822 + 823 + if (nocompress) return { html: htmlContent, filename, sizeKB: Math.round(htmlContent.length / 1024) }; 824 + 825 + const htmlBuf = Buffer.from(htmlContent, "utf-8"); 826 + let finalHtml; 827 + 828 + if (brotli && brotliWasmGzBase64) { 829 + const compressed = brotliCompressSync(htmlBuf, { 830 + params: { [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, 831 + }); 832 + finalHtml = generateSelfExtractingBrotliHTML(pieceName, compressed.toString("base64"), null, boxArtPNG); 833 + } else { 834 + const compressed = gzipSync(htmlBuf, { level: 9 }); 835 + finalHtml = generateSelfExtractingHTML(pieceName, compressed.toString("base64"), null, boxArtPNG); 836 + } 837 + 838 + return { html: finalHtml, filename, sizeKB: Math.round(finalHtml.length / 1024) }; 839 + } 840 + 841 + // ─── M4D (Max for Live) ───────────────────────────────────────────── 842 + 843 + const M4L_HEADER_INSTRUMENT = Buffer.from( 844 + "ampf\x04\x00\x00\x00iiiimeta\x04\x00\x00\x00\x00\x00\x00\x00ptch", "binary" 845 + ); 846 + 847 + function generateM4DPatcher(pieceName, dataUri, width = 400, height = 200) { 848 + return { 849 + patcher: { 850 + fileversion: 1, 851 + appversion: { major: 9, minor: 0, revision: 7, architecture: "x64", modernui: 1 }, 852 + classnamespace: "box", 853 + rect: [134, 174, 800, 600], 854 + openrect: [0, 0, width, height], 855 + openinpresentation: 1, 856 + gridsize: [15, 15], 857 + enablehscroll: 0, enablevscroll: 0, 858 + devicewidth: width, 859 + description: `Aesthetic Computer ${pieceName} (offline)`, 860 + boxes: [ 861 + { box: { disablefind: 0, id: "obj-jweb", latency: 0, maxclass: "jweb~", numinlets: 1, numoutlets: 3, outlettype: ["signal","signal",""], patching_rect: [10,50,width,height], presentation: 1, presentation_rect: [0,0,width+1,height+1], rendermode: 1, url: dataUri } }, 862 + { box: { id: "obj-plugout", maxclass: "newobj", numinlets: 2, numoutlets: 0, patching_rect: [10,280,75,22], text: "plugout~ 1 2" } }, 863 + { box: { id: "obj-thisdevice", maxclass: "newobj", numinlets: 1, numoutlets: 3, outlettype: ["bang","int","int"], patching_rect: [350,50,85,22], text: "live.thisdevice" } }, 864 + { box: { id: "obj-print", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [350,80,150,22], text: `print [AC-${pieceName.toUpperCase()}]` } }, 865 + { box: { id: "obj-route", maxclass: "newobj", numinlets: 1, numoutlets: 2, outlettype: ["",""], patching_rect: [350,140,60,22], text: "route ready" } }, 866 + { box: { id: "obj-activate", maxclass: "message", numinlets: 2, numoutlets: 1, outlettype: [""], patching_rect: [350,170,60,22], text: "activate 1" } }, 867 + { box: { id: "obj-jweb-print", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [350,110,90,22], text: "print [AC-JWEB]" } }, 868 + { box: { id: "obj-route-logs", maxclass: "newobj", numinlets: 1, numoutlets: 4, outlettype: ["","","",""], patching_rect: [470,140,120,22], text: "route log error warn" } }, 869 + { box: { id: "obj-udpsend", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [470,210,160,22], text: "udpsend 127.0.0.1 7777" } }, 870 + { box: { id: "obj-prepend-log", maxclass: "newobj", numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [470,170,55,22], text: "prepend log" } }, 871 + { box: { id: "obj-prepend-error", maxclass: "newobj", numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [530,170,65,22], text: "prepend error" } }, 872 + { box: { id: "obj-prepend-warn", maxclass: "newobj", numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [600,170,60,22], text: "prepend warn" } }, 873 + ], 874 + lines: [ 875 + { patchline: { destination: ["obj-plugout",0], source: ["obj-jweb",0] } }, 876 + { patchline: { destination: ["obj-plugout",1], source: ["obj-jweb",1] } }, 877 + { patchline: { destination: ["obj-print",0], source: ["obj-thisdevice",0] } }, 878 + { patchline: { destination: ["obj-jweb-print",0], source: ["obj-jweb",2] } }, 879 + { patchline: { destination: ["obj-route",0], source: ["obj-jweb",2] } }, 880 + { patchline: { destination: ["obj-activate",0], source: ["obj-route",0] } }, 881 + { patchline: { destination: ["obj-jweb",0], source: ["obj-activate",0] } }, 882 + { patchline: { destination: ["obj-route-logs",0], source: ["obj-jweb",2] } }, 883 + { patchline: { destination: ["obj-prepend-log",0], source: ["obj-route-logs",0] } }, 884 + { patchline: { destination: ["obj-prepend-error",0], source: ["obj-route-logs",1] } }, 885 + { patchline: { destination: ["obj-prepend-warn",0], source: ["obj-route-logs",2] } }, 886 + { patchline: { destination: ["obj-udpsend",0], source: ["obj-prepend-log",0] } }, 887 + { patchline: { destination: ["obj-udpsend",0], source: ["obj-prepend-error",0] } }, 888 + { patchline: { destination: ["obj-udpsend",0], source: ["obj-prepend-warn",0] } }, 889 + ], 890 + dependency_cache: [], latency: 0, is_mpe: 0, 891 + external_mpe_tuning_enabled: 0, minimum_live_version: "", 892 + minimum_max_version: "", platform_compatibility: 0, autosave: 0, 893 + }, 894 + }; 895 + } 896 + 897 + function packAMXD(patcher) { 898 + const json = Buffer.from(JSON.stringify(patcher)); 899 + const len = Buffer.alloc(4); 900 + len.writeUInt32LE(json.length, 0); 901 + return Buffer.concat([M4L_HEADER_INSTRUMENT, len, json]); 902 + } 903 + 904 + export async function createM4DBundle(pieceName, isJSPiece, onProgress = () => {}, density = null) { 905 + onProgress({ stage: "fetch", message: `Building M4L device for ${pieceName}...` }); 906 + 907 + const bundleResult = isJSPiece 908 + ? await createJSPieceBundle(pieceName, onProgress, false, density) 909 + : await createBundle(pieceName, onProgress, false, density); 910 + 911 + onProgress({ stage: "generate", message: "Embedding bundle in M4L device..." }); 912 + 913 + const dataUri = `data:text/html;base64,${Buffer.from(bundleResult.html).toString("base64")}`; 914 + const patcher = generateM4DPatcher(pieceName, dataUri); 915 + 916 + onProgress({ stage: "compress", message: "Packing .amxd binary..." }); 917 + 918 + const binary = packAMXD(patcher); 919 + const filename = `AC ${pieceName} (offline).amxd`; 920 + 921 + return { binary, filename, sizeKB: Math.round(binary.length / 1024) }; 922 + } 923 + 924 + // ─── Self-extracting HTML wrapper ─────────────────────────────────── 925 + 926 + function renderBoxArt(title, boxArtPNG, inlineStyle = "") { 927 + if (!boxArtPNG) return ""; 928 + const styleAttr = inlineStyle ? ` style="${inlineStyle}"` : ""; 929 + // Render the image in normal DOM (visible to macOS Finder preview / og parsers), 930 + // then immediately hide it with a synchronous inline script — no flash. 931 + return `<img id="ac-box-art" src="data:image/png;base64,${boxArtPNG}" alt="${title}"${styleAttr}><script>document.getElementById('ac-box-art').style.display='none'<\/script>`; 932 + } 933 + 934 + function generateSelfExtractingHTML(title, gzipBase64, bgColor = null, boxArtPNG = null) { 935 + const bgRule = `background:${bgColor || "black"};`; 936 + const boxArtTag = renderBoxArt( 937 + title, 938 + boxArtPNG, 939 + "position:fixed;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;" 940 + ); 941 + return `<!DOCTYPE html> 942 + <html lang="en"> 943 + <head> 944 + <meta charset="utf-8"> 945 + <title>${title} · Aesthetic Computer</title> 946 + <style>html,body{margin:0;padding:0;width:100%;height:100%;${bgRule}overflow:hidden}canvas{background:black}</style> 947 + </head> 948 + <body> 949 + ${boxArtTag} 950 + <script> 951 + // Use blob: URL instead of data: URL for CSP compatibility (objkt sandboxing) 952 + const b64='${gzipBase64}'; 953 + const bin=atob(b64); 954 + const bytes=new Uint8Array(bin.length); 955 + for(let i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i); 956 + const blob=new Blob([bytes],{type:'application/gzip'}); 957 + const url=URL.createObjectURL(blob); 958 + fetch(url) 959 + .then(r=>r.blob()) 960 + .then(b=>b.stream().pipeThrough(new DecompressionStream('gzip'))) 961 + .then(s=>new Response(s).text()) 962 + .then(h=>{URL.revokeObjectURL(url);document.open();document.write(h);document.close();}) 963 + .catch(e=>{document.body.style.cssText='color:#fff;background:#000;padding:20px;font-family:monospace';document.body.textContent='Bundle error: '+e.message;}); 964 + </script> 965 + </body> 966 + </html>`; 967 + } 968 + 969 + function generateSelfExtractingBrotliHTML(title, brotliBase64, bgColor = null, boxArtPNG = null) { 970 + if (!brotliWasmGzBase64) throw new Error("Brotli WASM decoder not loaded"); 971 + const bgRule = `background:${bgColor || "black"};`; 972 + const boxArtTag = renderBoxArt( 973 + title, 974 + boxArtPNG, 975 + "position:fixed;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;" 976 + ); 977 + // The inline script: 978 + // 1. Decompresses the WASM decoder binary using browser's native gzip DecompressionStream 979 + // 2. Instantiates the brotli-dec-wasm WASM module with minimal glue 980 + // 3. Decompresses the brotli payload 981 + // 4. Writes the resulting HTML 982 + return `<!DOCTYPE html> 983 + <html lang="en"> 984 + <head> 985 + <meta charset="utf-8"> 986 + <title>${title} · Aesthetic Computer</title> 987 + <style>html,body{margin:0;padding:0;width:100%;height:100%;${bgRule}overflow:hidden}canvas{background:black}</style> 988 + </head> 989 + <body> 990 + ${boxArtTag} 991 + <script>(async()=>{ 992 + const d=s=>Uint8Array.from(atob(s),c=>c.charCodeAt(0)); 993 + const gz=async b=>new Uint8Array(await new Response(new Blob([b]).stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer()); 994 + const wb=await gz(d("${brotliWasmGzBase64}")); 995 + let w,m=null,V=0,dv=null; 996 + const gM=()=>{if(!m||m.byteLength===0)m=new Uint8Array(w.memory.buffer);return m;}; 997 + const gD=()=>{if(!dv||dv.buffer!==w.memory.buffer)dv=new DataView(w.memory.buffer);return dv;}; 998 + const td=new TextDecoder('utf-8',{ignoreBOM:true,fatal:true});td.decode(); 999 + const te=new TextEncoder(); 1000 + const gs=(p,l)=>td.decode(gM().subarray(p>>>0,(p>>>0)+l)); 1001 + const imp={wbg:{}}; 1002 + imp.wbg.__wbg_Error_52673b7de5a0ca89=(a,b)=>Error(gs(a,b)); 1003 + imp.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e=(a,b)=>{throw new Error(gs(a,b));}; 1004 + imp.wbg.__wbg_error_7534b8e9a36f1ab4=(a,b)=>{try{console.error(gs(a,b));}finally{w.__wbindgen_free(a,b,1);}}; 1005 + imp.wbg.__wbg_new_8a6f238a6ece86ea=()=>new Error(); 1006 + imp.wbg.__wbg_stack_0ed75d68575b0f3c=(a,b)=>{const s=b.stack;const buf=te.encode(s);const p=w.__wbindgen_malloc(buf.length,1)>>>0;gM().subarray(p,p+buf.length).set(buf);gD().setInt32(a+4,buf.length,true);gD().setInt32(a,p,true);}; 1007 + imp.wbg.__wbindgen_init_externref_table=()=>{const t=w.__wbindgen_externrefs;const o=t.grow(4);t.set(0,undefined);t.set(o,undefined);t.set(o+1,null);t.set(o+2,true);t.set(o+3,false);}; 1008 + const{instance:inst}=await WebAssembly.instantiate(wb,imp); 1009 + w=inst.exports;m=null;dv=null;w.__wbindgen_start(); 1010 + const br=d("${brotliBase64}"); 1011 + const p0=w.__wbindgen_malloc(br.length,1)>>>0;gM().set(br,p0);V=br.length; 1012 + const r=w.decompress(p0,V); 1013 + if(r[3]){const v=w.__wbindgen_externrefs.get(r[2]);w.__externref_table_dealloc(r[2]);throw v;} 1014 + const out=gM().subarray(r[0]>>>0,(r[0]>>>0)+r[1]).slice(); 1015 + w.__wbindgen_free(r[0],r[1],1); 1016 + const html=new TextDecoder().decode(out); 1017 + document.open();document.write(html);document.close(); 1018 + })().catch(e=>{document.body.style.cssText='color:#fff;background:#000;padding:20px;font-family:monospace';document.body.textContent='Bundle error: '+e.message;});<\/script> 1019 + </body> 1020 + </html>`; 1021 + } 1022 + 1023 + // ─── Box art generation ────────────────────────────────────────────── 1024 + 1025 + function escapeXml(str) { 1026 + return String(str) 1027 + .replace(/&/g, "&amp;") 1028 + .replace(/</g, "&lt;") 1029 + .replace(/>/g, "&gt;") 1030 + .replace(/"/g, "&quot;") 1031 + .replace(/'/g, "&apos;"); 1032 + } 1033 + 1034 + function boxArtAccentColor(bgColor) { 1035 + const palette = { 1036 + red: "#ff6f61", 1037 + green: "#46d89a", 1038 + blue: "#56b4ff", 1039 + yellow: "#ffd166", 1040 + orange: "#ff9f43", 1041 + cyan: "#44f6ff", 1042 + black: "#a7b4c8", 1043 + white: "#7b8798", 1044 + gray: "#7fa0b5", 1045 + grey: "#7fa0b5", 1046 + brown: "#d19a66", 1047 + navy: "#6aa2ff", 1048 + teal: "#4fe5d2", 1049 + }; 1050 + const key = String(bgColor || "").toLowerCase(); 1051 + if (key === "purple" || key === "magenta" || key === "pink" || key === "violet" || key === "indigo") 1052 + return "#ffb454"; 1053 + return palette[key] || "#67e8f9"; 1054 + } 1055 + 1056 + function boxArtCodeColor(char, index) { 1057 + if (char === "$") return "#ffd166"; 1058 + if (/\d/.test(char)) { 1059 + const digitPalette = ["#ffadad", "#ffd6a5", "#fdffb6", "#caffbf", "#9bf6ff", "#a0c4ff"]; 1060 + return digitPalette[(Number(char) + index) % digitPalette.length]; 1061 + } 1062 + const codePalette = ["#56e8ff", "#ffd166", "#ff8fab", "#8affc1", "#9fb6ff", "#ffb703"]; 1063 + return codePalette[index % codePalette.length]; 1064 + } 1065 + 1066 + function boxArtCodeTspans(display) { 1067 + return Array.from(display).map((char, index) => 1068 + `<tspan fill="${boxArtCodeColor(char, index)}">${escapeXml(char)}</tspan>` 1069 + ).join(""); 1070 + } 1071 + 1072 + async function generateBoxArtPNG(pieceName, authorHandle, bgColor, packDate) { 1073 + const W = 800, H = 1000; // portrait 4:5 1074 + const display = String(pieceName || "").trim(); 1075 + const len = Math.max(1, Array.from(display).length); 1076 + const namePx = len <= 4 ? 190 : len <= 6 ? 162 : len <= 8 ? 140 : len <= 12 ? 118 : len <= 18 ? 94 : 76; 1077 + const accent = boxArtAccentColor(bgColor); 1078 + const codeTspans = boxArtCodeTspans(display); 1079 + const nameY = H / 2; 1080 + // Comic Relief Bold is installed as a system font on the oven server. 1081 + // sharp/librsvg can only use system fonts, not @font-face data URIs. 1082 + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"> 1083 + <defs> 1084 + <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> 1085 + <stop offset="0%" stop-color="#07131f"/> 1086 + <stop offset="55%" stop-color="#0f2532"/> 1087 + <stop offset="100%" stop-color="#18313f"/> 1088 + </linearGradient> 1089 + <radialGradient id="glow-a" cx="18%" cy="20%" r="65%"> 1090 + <stop offset="0%" stop-color="${accent}" stop-opacity="0.34"/> 1091 + <stop offset="100%" stop-color="${accent}" stop-opacity="0"/> 1092 + </radialGradient> 1093 + <radialGradient id="glow-b" cx="86%" cy="82%" r="72%"> 1094 + <stop offset="0%" stop-color="#ff9f43" stop-opacity="0.25"/> 1095 + <stop offset="100%" stop-color="#ff9f43" stop-opacity="0"/> 1096 + </radialGradient> 1097 + </defs> 1098 + <style> 1099 + .kidlisp-comic { font-family: 'Comic Relief', 'Comic Sans MS', cursive; font-weight: 700; } 1100 + </style> 1101 + <rect width="${W}" height="${H}" fill="url(#bg)"/> 1102 + <rect width="${W}" height="${H}" fill="url(#glow-a)"/> 1103 + <rect width="${W}" height="${H}" fill="url(#glow-b)"/> 1104 + <text x="${W / 2}" y="${nameY + 9}" class="kidlisp-comic" 1105 + font-size="${namePx}" fill="rgba(0,0,0,0.45)" 1106 + text-anchor="middle" dominant-baseline="middle">${escapeXml(display)}</text> 1107 + <text x="${W / 2}" y="${nameY}" class="kidlisp-comic" 1108 + font-size="${namePx}" text-anchor="middle" dominant-baseline="middle">${codeTspans}</text> 1109 + ${packDate ? `<text x="${W / 2}" y="${H - 42}" class="kidlisp-comic" 1110 + font-size="24" letter-spacing="2" fill="rgba(231,242,255,0.5)" 1111 + text-anchor="middle" dominant-baseline="middle">${escapeXml(packDate)}</text>` : ""} 1112 + </svg>`; 1113 + 1114 + const buf = await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer(); 1115 + return buf.toString("base64"); 1116 + } 1117 + 1118 + // ─── HTML templates ───────────────────────────────────────────────── 1119 + // These are large template literals — kept as-is from the Netlify version. 1120 + 1121 + function generateHTMLBundle(opts) { 1122 + const { 1123 + PIECE_NAME, PIECE_NAME_NO_DOLLAR, mainSource, kidlispSources, 1124 + files, paintingData, authorHandle, packDate, packTime, gitVersion, filename, density, bgColor, bdfGlyphs, boxArtPNG, keeplabel, 1125 + } = opts; 1126 + 1127 + const bgRule = `background: ${bgColor || "black"}; `; 1128 + const boxArtImg = renderBoxArt(PIECE_NAME, boxArtPNG); 1129 + 1130 + return `<!DOCTYPE html> 1131 + <html lang="en"> 1132 + <head> 1133 + <meta charset="utf-8"> 1134 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 1135 + <title>${PIECE_NAME} · Aesthetic Computer</title> 1136 + <style> 1137 + html, body { margin: 0; padding: 0; width: 100%; height: 100%; ${bgRule}overflow: hidden; } 1138 + canvas { display: block; image-rendering: pixelated; background: black; } 1139 + #ac-box-art { position: fixed; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; pointer-events: none; } 1140 + </style> 1141 + </head> 1142 + <body> 1143 + ${boxArtImg} 1144 + <script> 1145 + // Phase 1: Setup VFS, blob URLs, import map, and fetch interception. 1146 + // This MUST run in a regular <script> (not type="module") so the import map 1147 + // is in the DOM BEFORE any <script type="module"> executes. 1148 + window.acPACK_MODE = true; 1149 + window.acUseWebGLComposite = false; // Disable WebGL composite in packed bundles to prevent black screen on IPFS/sandboxed contexts 1150 + ${keeplabel ? `window.acKEEP_LABEL = true;` : ""} 1151 + window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS = true; 1152 + window.__acKidlispConsoleEnabled = false; 1153 + window.acKEEP_MODE = true; 1154 + window.acSTARTING_PIECE = "${PIECE_NAME}"; 1155 + window.acPACK_PIECE = "${PIECE_NAME}"; 1156 + window.acPACK_DATE = "${packDate}"; 1157 + window.acPACK_GIT = "${gitVersion}"; 1158 + window.acKIDLISP_SOURCE = ${JSON.stringify(mainSource)}; 1159 + ${density ? `window.acPACK_DENSITY = ${density};` : `// Smart density: match device.html logic for keep bundles 1160 + (function() { 1161 + var sw = window.screen.width * (window.devicePixelRatio || 1); 1162 + var sh = window.screen.height * (window.devicePixelRatio || 1); 1163 + var maxDim = Math.max(sw, sh); 1164 + if (maxDim >= 3840) { window.acPACK_DENSITY = 8; } 1165 + else if (Math.abs(sw - sh) < 100 && maxDim >= 1000 && maxDim <= 1200) { window.acPACK_DENSITY = 4; } 1166 + else { window.acPACK_DENSITY = 3; } 1167 + })();`} 1168 + window.acPACK_COLOPHON = { 1169 + piece: { name: '${PIECE_NAME_NO_DOLLAR}', sourceCode: ${JSON.stringify(mainSource)}, isKidLisp: true }, 1170 + build: { author: '${authorHandle}', packTime: ${packTime}, gitCommit: '${gitVersion}', gitIsDirty: false, fileCount: ${Object.keys(files).length}, filename: '${filename}' } 1171 + }; 1172 + window.acPAINTING_CODE_MAP = ${JSON.stringify(paintingData)}; 1173 + window.VFS = ${JSON.stringify(files).replace(/<\/script>/g, "<\\/script>")}; 1174 + window.acBUNDLED_GLYPHS = ${JSON.stringify(bdfGlyphs)}; 1175 + window.acOBJKT_MATRIX_CHUNKY_GLYPHS = window.acBUNDLED_GLYPHS.MatrixChunky8 || {}; 1176 + window.acEMBEDDED_PAINTING_BITMAPS = {}; 1177 + window.acPAINTING_BITMAPS_READY = false; 1178 + function decodePaintingToBitmap(code, base64Data) { 1179 + return new Promise(function(resolve, reject) { 1180 + var img = new Image(); 1181 + img.onload = function() { 1182 + var canvas = document.createElement('canvas'); 1183 + canvas.width = img.width; canvas.height = img.height; 1184 + var ctx = canvas.getContext('2d'); 1185 + ctx.drawImage(img, 0, 0); 1186 + var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 1187 + resolve({ width: imageData.width, height: imageData.height, pixels: imageData.data }); 1188 + }; 1189 + img.onerror = reject; 1190 + img.src = 'data:image/png;base64,' + base64Data; 1191 + }); 1192 + } 1193 + window.acDecodePaintingsPromise = (function() { 1194 + var promises = []; 1195 + var map = window.acPAINTING_CODE_MAP || {}; 1196 + var codes = Object.keys(map); 1197 + for (var i = 0; i < codes.length; i++) { 1198 + var code = codes[i]; 1199 + var vfsPath = 'paintings/' + code + '.png'; 1200 + if (window.VFS && window.VFS[vfsPath]) { 1201 + promises.push(decodePaintingToBitmap(code, window.VFS[vfsPath].content).then(function(c) { return function(bitmap) { 1202 + window.acEMBEDDED_PAINTING_BITMAPS['#' + c] = bitmap; 1203 + window.acEMBEDDED_PAINTING_BITMAPS[c] = bitmap; 1204 + }; }(code)).catch(function() {})); 1205 + } 1206 + } 1207 + return Promise.all(promises).then(function() { window.acPAINTING_BITMAPS_READY = true; }); 1208 + })(); 1209 + window.EMBEDDED_KIDLISP_SOURCE = ${JSON.stringify(mainSource)}; 1210 + window.EMBEDDED_KIDLISP_PIECE = '${PIECE_NAME_NO_DOLLAR}'; 1211 + window.objktKidlispCodes = ${JSON.stringify(kidlispSources)}; 1212 + window.acPREFILL_CODE_CACHE = ${JSON.stringify(kidlispSources)}; 1213 + var originalAppendChild = Element.prototype.appendChild; 1214 + Element.prototype.appendChild = function(child) { 1215 + if (child.tagName === 'LINK' && child.rel === 'stylesheet' && child.href && child.href.includes('.css')) return child; 1216 + return originalAppendChild.call(this, child); 1217 + }; 1218 + var originalBodyAppend = HTMLBodyElement.prototype.append; 1219 + HTMLBodyElement.prototype.append = function() { 1220 + var args = []; for (var i = 0; i < arguments.length; i++) { var n = arguments[i]; if (!(n.tagName === 'LINK' && n.rel === 'stylesheet')) args.push(n); } 1221 + return originalBodyAppend.apply(this, args); 1222 + }; 1223 + window.VFS_BLOB_URLS = {}; 1224 + window.modulePaths = []; 1225 + Object.entries(window.VFS).forEach(function(entry) { 1226 + var path = entry[0], file = entry[1]; 1227 + if (path.endsWith('.mjs') || path.endsWith('.js')) { 1228 + var blob = new Blob([file.content], { type: 'application/javascript' }); 1229 + window.VFS_BLOB_URLS[path] = URL.createObjectURL(blob); 1230 + window.modulePaths.push(path); 1231 + } 1232 + }); 1233 + var importMapEntries = {}; 1234 + for (var i = 0; i < window.modulePaths.length; i++) { 1235 + var fp = window.modulePaths[i]; 1236 + if (window.VFS_BLOB_URLS[fp]) { 1237 + importMapEntries[fp] = window.VFS_BLOB_URLS[fp]; 1238 + importMapEntries['/' + fp] = window.VFS_BLOB_URLS[fp]; 1239 + importMapEntries['./' + fp] = window.VFS_BLOB_URLS[fp]; 1240 + } 1241 + } 1242 + var importMapScript = document.createElement('script'); 1243 + importMapScript.type = 'importmap'; 1244 + importMapScript.textContent = JSON.stringify({ imports: importMapEntries }); 1245 + document.head.appendChild(importMapScript); 1246 + var originalFetch = window.fetch; 1247 + window.fetch = function(url, options) { 1248 + var urlStr = typeof url === 'string' ? url : url.toString(); 1249 + if (urlStr.includes('/api/')) { 1250 + if (urlStr.includes('/api/bdf-glyph') && window.acBUNDLED_GLYPHS) { 1251 + var fontM = urlStr.match(/[?&]font=([^&]+)/); 1252 + var charsM = urlStr.match(/[?&]chars?=([^&]+)/); 1253 + var fontKey = fontM ? decodeURIComponent(fontM[1]) : 'unifont'; 1254 + if (fontKey === 'unifont-16.0.03') fontKey = 'unifont'; 1255 + var fontMap = window.acBUNDLED_GLYPHS[fontKey] || {}; 1256 + var glyphs = {}; 1257 + if (charsM) { for (var c of charsM[1].split(',')) { var hex = c.trim().toUpperCase(); if (fontMap[hex]) glyphs[c.trim()] = fontMap[hex]; } } 1258 + return Promise.resolve(new Response(JSON.stringify({ glyphs: glyphs }), { status: 200, headers: { 'Content-Type': 'application/json' } })); 1259 + } 1260 + if (urlStr.includes('/api/painting-code')) { 1261 + var m = urlStr.match(/[?&]code=([^&]+)/); 1262 + if (m) { 1263 + var info = window.acPAINTING_CODE_MAP[m[1]]; 1264 + if (info) return Promise.resolve(new Response(JSON.stringify({ code: info.code, handle: info.handle, slug: info.slug }), { status: 200, headers: { 'Content-Type': 'application/json' } })); 1265 + } 1266 + return Promise.resolve(new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })); 1267 + } 1268 + return Promise.resolve(new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })); 1269 + } 1270 + var vfsPath = decodeURIComponent(urlStr).replace(/^https?:\\/\\/[^\\/]+\\//g, '').replace(/^aesthetic\\.computer\\//g, '').replace(/#.*$/g, '').replace(/\\?.*$/g, ''); 1271 + vfsPath = vfsPath.replace(/^\\.\\.\\/+/g, '').replace(/^\\.\\//g, '').replace(/^\\//g, '').replace(/^aesthetic\\.computer\\//g, ''); 1272 + if (urlStr.includes('/media/') && urlStr.includes('/painting/')) { 1273 + var paintKeys = Object.keys(window.acPAINTING_CODE_MAP || {}); 1274 + for (var pi = 0; pi < paintKeys.length; pi++) { 1275 + var pcode = paintKeys[pi], pinfo = window.acPAINTING_CODE_MAP[pcode]; 1276 + if (urlStr.includes(pinfo.slug)) { 1277 + var pvfs = 'paintings/' + pcode + '.png'; 1278 + if (window.VFS[pvfs]) { 1279 + var f = window.VFS[pvfs]; var bs = atob(f.content); var bytes = new Uint8Array(bs.length); 1280 + for (var j = 0; j < bs.length; j++) bytes[j] = bs.charCodeAt(j); 1281 + return Promise.resolve(new Response(bytes, { status: 200, headers: { 'Content-Type': 'image/png' } })); 1282 + } 1283 + } 1284 + } 1285 + } 1286 + if (window.VFS[vfsPath]) { 1287 + var file = window.VFS[vfsPath]; var content; var ct = 'text/plain'; 1288 + if (file.binary) { 1289 + var bs2 = atob(file.content); var bytes2 = new Uint8Array(bs2.length); 1290 + for (var j2 = 0; j2 < bs2.length; j2++) bytes2[j2] = bs2.charCodeAt(j2); 1291 + content = bytes2; 1292 + if (file.type === 'png') ct = 'image/png'; else if (file.type === 'jpg' || file.type === 'jpeg') ct = 'image/jpeg'; 1293 + } else { 1294 + content = file.content; 1295 + if (file.type === 'mjs' || file.type === 'js') ct = 'application/javascript'; else if (file.type === 'json') ct = 'application/json'; 1296 + } 1297 + return Promise.resolve(new Response(content, { status: 200, headers: { 'Content-Type': ct } })); 1298 + } 1299 + if (vfsPath.includes('disks/drawings/font_') || vfsPath.endsWith('.mjs') || vfsPath.includes('cursors/') || vfsPath.endsWith('.svg') || vfsPath.endsWith('.css') || urlStr.includes('/type/webfonts/')) { 1300 + return Promise.resolve(new Response('', { status: 200, headers: { 'Content-Type': 'text/css' } })); 1301 + } 1302 + return originalFetch.call(this, url, options); 1303 + }; 1304 + // Telemetry 1305 + (function initTelemetry() { 1306 + var origin = window.location ? window.location.origin : ''; 1307 + if (origin.indexOf('aesthetic.computer') < 0 && origin.indexOf('localhost') < 0) return; 1308 + var bootStart = performance.now(); 1309 + var sid = Math.random().toString(36).slice(2) + Date.now().toString(36); 1310 + var tUrl = 'https://aesthetic.computer/api/bundle-telemetry'; 1311 + function send(type, data) { 1312 + try { 1313 + var body = JSON.stringify({ type: type, data: Object.assign({ sessionId: sid, piece: window.acPACK_PIECE || 'unknown', density: window.acPACK_DENSITY || 2, screenWidth: innerWidth, screenHeight: innerHeight, devicePixelRatio: devicePixelRatio || 1, userAgent: navigator.userAgent }, data) }); 1314 + if (navigator.sendBeacon && navigator.sendBeacon(tUrl, body)) return; 1315 + fetch(tUrl, { method: 'POST', body: body, headers: {'Content-Type':'application/json'}, keepalive: true }).catch(function(){}); 1316 + } catch(e) {} 1317 + } 1318 + addEventListener('load', function() { send('boot', { bootTime: Math.round(performance.now() - bootStart), vfsFileCount: Object.keys(window.VFS || {}).length, blobUrlCount: Object.keys(window.VFS_BLOB_URLS || {}).length }); }); 1319 + var lastFT = performance.now(), fc = 0, sc = 0, ps = [], ms = 60; 1320 + function mf() { 1321 + fc++; var now = performance.now(); 1322 + if (now - lastFT >= 1000) { var fps = fc; fc = 0; lastFT = now; if (sc < ms) { ps.push({t:Math.round(now-bootStart),fps:fps}); sc++; if (sc%10===0) send('perf',{samples:ps.slice(-10)}); } } 1323 + requestAnimationFrame(mf); 1324 + } 1325 + requestAnimationFrame(mf); 1326 + addEventListener('error', function(e) { send('error', { message: e.message, filename: e.filename, lineno: e.lineno, colno: e.colno }); }); 1327 + })(); 1328 + </script> 1329 + <script type="module"> 1330 + // Phase 2: Boot the app. Import map is already in the DOM from Phase 1. 1331 + // Top-level await is valid in module scripts. 1332 + if (window.acDecodePaintingsPromise) await window.acDecodePaintingsPromise; 1333 + try { 1334 + await import(window.VFS_BLOB_URLS['boot.mjs']); 1335 + } catch (err) { 1336 + document.body.style.cssText='color:#fff;background:#000;padding:20px;font-family:monospace'; 1337 + document.body.textContent='Boot failed: '+err.message; 1338 + } 1339 + </script> 1340 + </body> 1341 + </html>`; 1342 + } 1343 + 1344 + function generateJSPieceHTMLBundle(opts) { 1345 + const { pieceName, files, packDate, packTime, gitVersion, bdfGlyphs, boxArtPNG, keeplabel } = opts; 1346 + 1347 + const boxArtImg = renderBoxArt(pieceName, boxArtPNG); 1348 + 1349 + return `<!DOCTYPE html> 1350 + <html lang="en"> 1351 + <head> 1352 + <meta charset="utf-8"> 1353 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 1354 + <title>${pieceName} · Aesthetic Computer</title> 1355 + <style> 1356 + body { margin: 0; padding: 0; overflow: hidden; } 1357 + canvas { display: block; image-rendering: pixelated; } 1358 + #ac-box-art { position: fixed; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; pointer-events: none; } 1359 + </style> 1360 + </head> 1361 + <body> 1362 + ${boxArtImg} 1363 + <script> 1364 + // Phase 1: Setup VFS, blob URLs, import map, and fetch interception. 1365 + // This MUST run in a regular <script> (not type="module") so the import map 1366 + // is in the DOM BEFORE any <script type="module"> executes. 1367 + window.acPACK_MODE = true; 1368 + ${keeplabel ? `window.acKEEP_LABEL = true;` : ""} 1369 + window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS = true; 1370 + window.__acKidlispConsoleEnabled = false; 1371 + window.acSTARTING_PIECE = "${pieceName}"; 1372 + window.acPACK_PIECE = "${pieceName}"; 1373 + window.acPACK_DATE = "${packDate}"; 1374 + window.acPACK_GIT = "${gitVersion}"; 1375 + window.acPACK_COLOPHON = { 1376 + piece: { name: '${pieceName}', isKidLisp: false }, 1377 + build: { author: '@jeffrey', packTime: ${packTime}, gitCommit: '${gitVersion}', gitIsDirty: false, fileCount: ${Object.keys(files).length} } 1378 + }; 1379 + window.VFS = ${JSON.stringify(files).replace(/<\/script>/g, "<\\/script>")}; 1380 + window.acBUNDLED_GLYPHS = ${JSON.stringify(bdfGlyphs)}; 1381 + window.acOBJKT_MATRIX_CHUNKY_GLYPHS = window.acBUNDLED_GLYPHS.MatrixChunky8 || {}; 1382 + var originalAppendChild = Element.prototype.appendChild; 1383 + Element.prototype.appendChild = function(child) { 1384 + if (child.tagName === 'LINK' && child.rel === 'stylesheet' && child.href && child.href.includes('.css')) return child; 1385 + return originalAppendChild.call(this, child); 1386 + }; 1387 + var originalBodyAppend = HTMLBodyElement.prototype.append; 1388 + HTMLBodyElement.prototype.append = function() { 1389 + var args = []; for (var i = 0; i < arguments.length; i++) { var n = arguments[i]; if (!(n.tagName === 'LINK' && n.rel === 'stylesheet')) args.push(n); } 1390 + return originalBodyAppend.apply(this, args); 1391 + }; 1392 + window.VFS_BLOB_URLS = {}; 1393 + window.modulePaths = []; 1394 + Object.entries(window.VFS).forEach(function(entry) { 1395 + var path = entry[0], file = entry[1]; 1396 + if (path.endsWith('.mjs') || path.endsWith('.js')) { 1397 + var blob = new Blob([file.content], { type: 'application/javascript' }); 1398 + window.VFS_BLOB_URLS[path] = URL.createObjectURL(blob); 1399 + window.modulePaths.push(path); 1400 + } 1401 + }); 1402 + var entries = {}; 1403 + for (var i = 0; i < window.modulePaths.length; i++) { 1404 + var fp = window.modulePaths[i]; 1405 + if (window.VFS_BLOB_URLS[fp]) { 1406 + entries[fp] = window.VFS_BLOB_URLS[fp]; 1407 + entries['/' + fp] = window.VFS_BLOB_URLS[fp]; 1408 + entries['aesthetic.computer/' + fp] = window.VFS_BLOB_URLS[fp]; 1409 + entries['/aesthetic.computer/' + fp] = window.VFS_BLOB_URLS[fp]; 1410 + entries['./aesthetic.computer/' + fp] = window.VFS_BLOB_URLS[fp]; 1411 + entries['https://aesthetic.computer/' + fp] = window.VFS_BLOB_URLS[fp]; 1412 + entries['./' + fp] = window.VFS_BLOB_URLS[fp]; 1413 + entries['../' + fp] = window.VFS_BLOB_URLS[fp]; 1414 + entries['../../' + fp] = window.VFS_BLOB_URLS[fp]; 1415 + } 1416 + } 1417 + var s = document.createElement('script'); 1418 + s.type = 'importmap'; 1419 + s.textContent = JSON.stringify({ imports: entries }); 1420 + document.head.appendChild(s); 1421 + var originalFetch = window.fetch; 1422 + window.fetch = function(url, options) { 1423 + var urlStr = typeof url === 'string' ? url : url.toString(); 1424 + if (urlStr.includes('/api/')) { 1425 + if (urlStr.includes('/api/bdf-glyph') && window.acBUNDLED_GLYPHS) { 1426 + var fontM = urlStr.match(/[?&]font=([^&]+)/); 1427 + var charsM = urlStr.match(/[?&]chars?=([^&]+)/); 1428 + var fontKey = fontM ? decodeURIComponent(fontM[1]) : 'unifont'; 1429 + if (fontKey === 'unifont-16.0.03') fontKey = 'unifont'; 1430 + var fontMap = window.acBUNDLED_GLYPHS[fontKey] || {}; 1431 + var glyphs = {}; 1432 + if (charsM) { for (var c of charsM[1].split(',')) { var hex = c.trim().toUpperCase(); if (fontMap[hex]) glyphs[c.trim()] = fontMap[hex]; } } 1433 + return Promise.resolve(new Response(JSON.stringify({ glyphs: glyphs }), { status: 200, headers: { 'Content-Type': 'application/json' } })); 1434 + } 1435 + return Promise.resolve(new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })); 1436 + } 1437 + var vfsPath = decodeURIComponent(urlStr).replace(/^https?:\\/\\/[^\\/]+\\//g, '').replace(/^aesthetic\\.computer\\//g, '').replace(/#.*$/g, '').replace(/\\?.*$/g, ''); 1438 + vfsPath = vfsPath.replace(/^\\.\\.\\/+/g, '').replace(/^\\.\\//g, '').replace(/^\\//g, '').replace(/^aesthetic\\.computer\\//g, ''); 1439 + if (window.VFS[vfsPath]) { 1440 + var file = window.VFS[vfsPath]; var content; var ct = 'text/plain'; 1441 + if (file.binary) { 1442 + var bs = atob(file.content); var bytes = new Uint8Array(bs.length); 1443 + for (var j = 0; j < bs.length; j++) bytes[j] = bs.charCodeAt(j); 1444 + content = bytes; 1445 + if (file.type === 'png') ct = 'image/png'; else if (file.type === 'jpg' || file.type === 'jpeg') ct = 'image/jpeg'; 1446 + } else { 1447 + content = file.content; 1448 + if (file.type === 'mjs' || file.type === 'js') ct = 'application/javascript'; else if (file.type === 'json') ct = 'application/json'; 1449 + } 1450 + return Promise.resolve(new Response(content, { status: 200, headers: { 'Content-Type': ct } })); 1451 + } 1452 + if (vfsPath.includes('disks/drawings/font_') || vfsPath.includes('cursors/') || vfsPath.endsWith('.svg') || vfsPath.endsWith('.css') || urlStr.includes('/type/webfonts/')) { 1453 + return Promise.resolve(new Response('', { status: 200, headers: { 'Content-Type': 'text/css' } })); 1454 + } 1455 + if (vfsPath.endsWith('.mjs')) console.warn('[VFS] Missing .mjs file:', vfsPath); 1456 + return originalFetch.call(this, url, options); 1457 + }; 1458 + </script> 1459 + <script type="module"> 1460 + // Phase 2: Boot the app. The import map is already in the DOM from Phase 1. 1461 + import(window.VFS_BLOB_URLS['boot.mjs']).catch(err => { document.body.style.cssText='color:#fff;background:#000;padding:20px;font-family:monospace'; document.body.textContent='Boot failed: '+err.message; }); 1462 + </script> 1463 + </body> 1464 + </html>`; 1465 + } 1466 + 1467 + // ─── Device mode (simple iframe wrapper) ──────────────────────────── 1468 + 1469 + export function generateDeviceHTML(pieceCode, density) { 1470 + const densityParam = density ? `?density=${density}` : ""; 1471 + const pieceUrl = `https://aesthetic.computer/${pieceCode}${densityParam}`; 1472 + return `<!DOCTYPE html> 1473 + <html lang="en"> 1474 + <head> 1475 + <meta charset="utf-8"> 1476 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 1477 + <title>${pieceCode} · Aesthetic Computer (Device)</title> 1478 + <style>*{margin:0;padding:0;box-sizing:border-box}html,body{width:100%;height:100%;overflow:hidden;background:black}iframe{width:100%;height:100%;border:none}</style> 1479 + </head> 1480 + <body><iframe src="${pieceUrl}" allow="autoplay; fullscreen"></iframe></body> 1481 + </html>`; 1482 + } 1483 + 1484 + // ─── Public API for server.mjs ────────────────────────────────────── 1485 + 1486 + export async function prewarmCache() { 1487 + const start = Date.now(); 1488 + refreshGitCommit(); 1489 + console.log(`[bundler] prewarming core bundle (commit ${GIT_COMMIT})...`); 1490 + await getCoreBundle((p) => console.log(`[bundler] ${p.stage}: ${p.message}`), true); 1491 + const elapsed = Date.now() - start; 1492 + console.log(`[bundler] prewarm complete in ${elapsed}ms`); 1493 + return { commit: GIT_COMMIT, elapsed, fileCount: Object.keys(coreBundleCache).length }; 1494 + } 1495 + 1496 + export function getCacheStatus() { 1497 + return { 1498 + cached: !!coreBundleCache, 1499 + commit: coreBundleCacheCommit, 1500 + fileCount: coreBundleCache ? Object.keys(coreBundleCache).length : 0, 1501 + acSourceDir: AC_SOURCE_DIR, 1502 + acSourceExists: fsSync.existsSync(AC_SOURCE_DIR), 1503 + }; 1504 + } 1505 + 1506 + export { skipMinification }; 1507 + export function setSkipMinification(val) { skipMinification = val; }
+284
oven/deploy.fish
··· 1 + #!/usr/bin/env fish 2 + # Oven Deployment Script 3 + #!/usr/bin/env fish 4 + # Oven Deployment Script 5 + # Provisions and deploys the Oven video processing service to DigitalOcean 6 + 7 + # Colors for output 8 + set RED '\033[0;31m' 9 + set GREEN '\033[0;32m' 10 + set YELLOW '\033[1;33m' 11 + set NC '\033[0m' # No Color 12 + 13 + # Paths 14 + set SCRIPT_DIR (dirname (status --current-filename)) 15 + set VAULT_DIR "$SCRIPT_DIR/../aesthetic-computer-vault/oven" 16 + set DEPLOY_ENV "$VAULT_DIR/deploy.env" 17 + set SERVICE_ENV "$VAULT_DIR/.env" 18 + 19 + # Check for required files 20 + if not test -f $DEPLOY_ENV 21 + echo -e "$RED❌ Deployment config not found: $DEPLOY_ENV$NC" 22 + exit 1 23 + end 24 + 25 + if not test -f $SERVICE_ENV 26 + echo -e "$RED❌ Service config not found: $SERVICE_ENV$NC" 27 + exit 1 28 + end 29 + 30 + # Load deployment configuration (parse bash-style env file) 31 + echo -e "$GREEN🔧 Loading deployment configuration...$NC" 32 + for line in (cat $DEPLOY_ENV | grep -v '^#' | grep -v '^$' | grep '=') 33 + set -l parts (string split '=' $line) 34 + if test (count $parts) -ge 2 35 + set -gx $parts[1] (string join '=' $parts[2..-1]) 36 + end 37 + end 38 + 39 + set -e 40 + 41 + # Colors for output 42 + set RED '\033[0;31m' 43 + set GREEN '\033[0;32m' 44 + set YELLOW '\033[1;33m' 45 + set NC '\033[0m' # No Color 46 + 47 + # Paths 48 + set SCRIPT_DIR (dirname (status --current-filename)) 49 + set VAULT_DIR "$SCRIPT_DIR/../aesthetic-computer-vault/oven" 50 + set DEPLOY_ENV "$VAULT_DIR/deploy.env" 51 + set SERVICE_ENV "$VAULT_DIR/.env" 52 + 53 + # Check for required files 54 + if not test -f $DEPLOY_ENV 55 + echo -e "$RED❌ Deployment config not found: $DEPLOY_ENV$NC" 56 + exit 1 57 + end 58 + 59 + if not test -f $SERVICE_ENV 60 + echo -e "$RED❌ Service config not found: $SERVICE_ENV$NC" 61 + exit 1 62 + end 63 + 64 + # Load deployment configuration 65 + echo -e "$GREEN🔧 Loading deployment configuration...$NC" 66 + source $DEPLOY_ENV 67 + 68 + # Check for required tools 69 + if not command -v doctl &> /dev/null 70 + echo -e "$RED❌ doctl not found. Install with: wget https://github.com/digitalocean/doctl/releases/download/v1.109.0/doctl-1.109.0-linux-amd64.tar.gz$NC" 71 + exit 1 72 + end 73 + 74 + if not command -v node &> /dev/null 75 + echo -e "$RED❌ node not found$NC" 76 + exit 1 77 + end 78 + 79 + # Authenticate with DigitalOcean 80 + echo -e "$GREEN🔑 Authenticating with DigitalOcean...$NC" 81 + doctl auth init --access-token $DO_TOKEN 82 + 83 + # Check if droplet already exists 84 + echo -e "$GREEN🔍 Checking for existing droplet...$NC" 85 + set EXISTING_DROPLET (doctl compute droplet list --format Name,PublicIPv4 | grep "^$DROPLET_NAME" | awk '{print $2}') 86 + 87 + if test -n "$EXISTING_DROPLET" 88 + echo -e "$YELLOW⚠️ Droplet $DROPLET_NAME already exists at $EXISTING_DROPLET$NC" 89 + echo -e "$YELLOW Would you like to redeploy to the existing droplet? (y/n)$NC" 90 + read -l CONFIRM 91 + 92 + if test "$CONFIRM" != "y" 93 + echo -e "$RED❌ Deployment cancelled$NC" 94 + exit 1 95 + end 96 + 97 + set DROPLET_IP $EXISTING_DROPLET 98 + else 99 + # Create SSH key 100 + echo -e "$GREEN🔑 Creating SSH key...$NC" 101 + set SSH_KEY_FILE "$HOME/.ssh/$SSH_KEY_NAME" 102 + 103 + if not test -f $SSH_KEY_FILE 104 + ssh-keygen -t ed25519 -f $SSH_KEY_FILE -N "" -C "oven@aesthetic.computer" 105 + end 106 + 107 + # Upload SSH key to DigitalOcean 108 + set SSH_PUBLIC_KEY (cat "$SSH_KEY_FILE.pub") 109 + doctl compute ssh-key import $SSH_KEY_NAME --public-key-file "$SSH_KEY_FILE.pub" 2>/dev/null; or true 110 + 111 + # Create droplet 112 + echo -e "$GREEN🚀 Creating droplet: $DROPLET_NAME...$NC" 113 + echo -e "$YELLOW Size: $DROPLET_SIZE$NC" 114 + echo -e "$YELLOW Region: $DROPLET_REGION$NC" 115 + echo -e "$YELLOW Image: $DROPLET_IMAGE$NC" 116 + 117 + doctl compute droplet create $DROPLET_NAME \ 118 + --size $DROPLET_SIZE \ 119 + --image $DROPLET_IMAGE \ 120 + --region $DROPLET_REGION \ 121 + --ssh-keys (doctl compute ssh-key list --format ID --no-header) \ 122 + --wait 123 + 124 + # Get droplet IP 125 + echo -e "$GREEN⏳ Waiting for droplet to be ready...$NC" 126 + sleep 30 127 + 128 + set DROPLET_IP (doctl compute droplet list --format Name,PublicIPv4 | grep "^$DROPLET_NAME" | awk '{print $2}') 129 + 130 + if test -z "$DROPLET_IP" 131 + echo -e "$RED❌ Failed to get droplet IP$NC" 132 + exit 1 133 + end 134 + 135 + echo -e "$GREEN✅ Droplet created: $DROPLET_IP$NC" 136 + 137 + # Configure firewall 138 + echo -e "$GREEN🔒 Configuring firewall...$NC" 139 + doctl compute firewall create \ 140 + --name "oven-firewall" \ 141 + --inbound-rules "protocol:tcp,ports:22,sources:addresses:0.0.0.0/0,sources:addresses:::/0 protocol:tcp,ports:80,sources:addresses:0.0.0.0/0,sources:addresses:::/0 protocol:tcp,ports:443,sources:addresses:0.0.0.0/0,sources:addresses:::/0" \ 142 + --outbound-rules "protocol:tcp,ports:all,destinations:addresses:0.0.0.0/0,destinations:addresses:::/0 protocol:udp,ports:all,destinations:addresses:0.0.0.0/0,destinations:addresses:::/0" \ 143 + --droplet-ids (doctl compute droplet list --format ID --no-header | grep -A1 $DROPLET_NAME | tail -1) 2>/dev/null; or true 144 + end 145 + 146 + # Wait for SSH to be ready 147 + echo -e "$GREEN⏳ Waiting for SSH to be ready...$NC" 148 + set MAX_ATTEMPTS 30 149 + set ATTEMPT 1 150 + 151 + while test $ATTEMPT -le $MAX_ATTEMPTS 152 + if ssh -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@$DROPLET_IP "echo SSH ready" &>/dev/null 153 + break 154 + end 155 + echo -e "$YELLOW Attempt $ATTEMPT/$MAX_ATTEMPTS...$NC" 156 + sleep 10 157 + set ATTEMPT (math $ATTEMPT + 1) 158 + end 159 + 160 + if test $ATTEMPT -gt $MAX_ATTEMPTS 161 + echo -e "$RED❌ SSH connection timeout$NC" 162 + exit 1 163 + end 164 + 165 + echo -e "$GREEN✅ SSH connection established$NC" 166 + 167 + # Create setup script 168 + echo -e "$GREEN📝 Creating server setup script...$NC" 169 + set SETUP_SCRIPT "/tmp/oven-setup.sh" 170 + 171 + echo '#!/bin/bash 172 + set -e 173 + 174 + echo "🔧 Setting up Oven server..." 175 + 176 + # Update system 177 + echo "📦 Updating system packages..." 178 + apt-get update 179 + apt-get upgrade -y 180 + 181 + # Install Node.js 20 182 + echo "📦 Installing Node.js 20..." 183 + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - 184 + apt-get install -y nodejs 185 + 186 + # Install ffmpeg 187 + echo "📦 Installing ffmpeg..." 188 + apt-get install -y ffmpeg 189 + 190 + # Install Caddy 191 + echo "📦 Installing Caddy..." 192 + apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl 193 + curl -1sLf '"'"'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'"'"' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 194 + curl -1sLf '"'"'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt'"'"' | tee /etc/apt/sources.list.d/caddy-stable.list 195 + apt-get update 196 + apt-get install -y caddy 197 + 198 + # Create oven user 199 + echo "👤 Creating oven user..." 200 + useradd -m -s /bin/bash oven || true 201 + 202 + # Create directories 203 + echo "📁 Creating directories..." 204 + mkdir -p /opt/oven 205 + mkdir -p /var/log/oven 206 + chown -R oven:oven /opt/oven /var/log/oven 207 + 208 + echo "✅ Server setup complete"' > $SETUP_SCRIPT 209 + 210 + # Upload and run setup script 211 + echo -e "$GREEN📤 Uploading setup script...$NC" 212 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SETUP_SCRIPT root@$DROPLET_IP:/tmp/ 213 + ssh -i "$HOME/.ssh/$SSH_KEY_NAME" root@$DROPLET_IP "chmod +x /tmp/oven-setup.sh && /tmp/oven-setup.sh" 214 + 215 + # Upload service files 216 + echo -e "$GREEN📤 Uploading service files...$NC" 217 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/package.json root@$DROPLET_IP:/opt/oven/ 218 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/package-lock.json root@$DROPLET_IP:/opt/oven/ 219 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/server.mjs root@$DROPLET_IP:/opt/oven/ 220 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/baker.mjs root@$DROPLET_IP:/opt/oven/ 221 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/grabber.mjs root@$DROPLET_IP:/opt/oven/ 222 + scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SERVICE_ENV root@$DROPLET_IP:/opt/oven/.env 223 + 224 + # Update .env for production 225 + echo -e "$GREEN🔧 Configuring production environment...$NC" 226 + ssh -i "$HOME/.ssh/$SSH_KEY_NAME" root@$DROPLET_IP "cd /opt/oven && sed -i 's/NODE_ENV=development/NODE_ENV=production/g' .env && sed -i 's/PORT=3002/PORT=3000/g' .env" 227 + 228 + # Install dependencies 229 + echo -e "$GREEN📦 Installing Node.js dependencies...$NC" 230 + ssh -i "$HOME/.ssh/$SSH_KEY_NAME" root@$DROPLET_IP "cd /opt/oven && npm install --production" 231 + 232 + # Create systemd service 233 + echo -e "$GREEN🔧 Creating systemd service...$NC" 234 + ssh -i "$HOME/.ssh/$SSH_KEY_NAME" root@$DROPLET_IP "printf '[Unit]\nDescription=Oven Video Processing Service\nAfter=network.target\n\n[Service]\nType=simple\nUser=oven\nWorkingDirectory=/opt/oven\nEnvironment=NODE_ENV=production\nExecStart=/usr/bin/node /opt/oven/server.mjs\nRestart=always\nRestartSec=10\nStandardOutput=append:/var/log/oven/oven.log\nStandardError=append:/var/log/oven/oven-error.log\n\n[Install]\nWantedBy=multi-user.target\n' > /etc/systemd/system/oven.service" 235 + 236 + # Configure Caddy 237 + echo -e "$GREEN🔧 Configuring Caddy...$NC" 238 + ssh -i "$HOME/.ssh/$SSH_KEY_NAME" root@$DROPLET_IP "printf '{\n email me@jas.life\n}\n\n$OVEN_HOSTNAME {\n reverse_proxy localhost:3000\n encode gzip\n \n log {\n output file /var/log/caddy/oven.log\n }\n}\n' > /etc/caddy/Caddyfile" 239 + 240 + # Start services 241 + echo -e "$GREEN🚀 Starting services...$NC" 242 + ssh -i "$HOME/.ssh/$SSH_KEY_NAME" root@$DROPLET_IP "systemctl daemon-reload && systemctl enable oven && systemctl restart oven && systemctl restart caddy" 243 + 244 + # Wait for service to start 245 + echo -e "$GREEN⏳ Waiting for service to start...$NC" 246 + sleep 5 247 + 248 + # Check service status 249 + echo -e "$GREEN🔍 Checking service status...$NC" 250 + ssh -i "$HOME/.ssh/$SSH_KEY_NAME" root@$DROPLET_IP "systemctl status oven --no-pager | head -20" 251 + 252 + # Configure DNS 253 + echo -e "$GREEN🌐 Configuring DNS...$NC" 254 + echo -e "$YELLOW Creating A record: $OVEN_HOSTNAME -> $DROPLET_IP$NC" 255 + 256 + # Use Cloudflare API to create/update DNS record 257 + curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records" \ 258 + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 259 + -H "Content-Type: application/json" \ 260 + --data "{\"type\":\"A\",\"name\":\"oven\",\"content\":\"$DROPLET_IP\",\"ttl\":1,\"proxied\":false}" \ 261 + 2>/dev/null || \ 262 + curl -X PUT "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records" \ 263 + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 264 + -H "Content-Type: application/json" \ 265 + --data "{\"type\":\"A\",\"name\":\"oven\",\"content\":\"$DROPLET_IP\",\"ttl\":1,\"proxied\":false}" 266 + 267 + echo "" 268 + echo -e "$GREEN✅ Deployment complete!$NC" 269 + echo "" 270 + echo -e "$YELLOW📋 Service Information:$NC" 271 + echo -e " URL: https://$OVEN_HOSTNAME" 272 + echo -e " IP: $DROPLET_IP" 273 + echo -e " SSH: ssh -i ~/.ssh/$SSH_KEY_NAME root@$DROPLET_IP" 274 + echo "" 275 + echo -e "$YELLOW🔧 Useful commands:$NC" 276 + echo -e " Check status: systemctl status oven" 277 + echo -e " View logs: tail -f /var/log/oven/oven.log" 278 + echo -e " Restart: systemctl restart oven" 279 + echo "" 280 + echo -e "$YELLOW⚠️ Next steps:$NC" 281 + echo -e " 1. Update Netlify env var: OVEN_URL=https://$OVEN_HOSTNAME" 282 + echo -e " 2. Test health: curl https://$OVEN_HOSTNAME/health" 283 + echo -e " 3. Test dashboard: https://$OVEN_HOSTNAME" 284 + echo ""
+250
oven/deploy.sh
··· 1 + #!/bin/bash 2 + # Fast oven deploy with verbose output 3 + # Usage: ./deploy.sh [--no-restart] 4 + 5 + set -e 6 + 7 + OVEN_HOST="137.184.237.166" 8 + SSH_KEY="${SSH_KEY:-$(dirname "$0")/../aesthetic-computer-vault/oven/ssh/oven-deploy-key}" 9 + REMOTE_DIR="/opt/oven" 10 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 11 + AC_SOURCE="$SCRIPT_DIR/../system/public/aesthetic.computer" 12 + FEDAC_SOURCE="$SCRIPT_DIR/../fedac" 13 + VAULT_OS_KEY="$SCRIPT_DIR/../aesthetic-computer-vault/oven/os-build-admin-key.txt" 14 + 15 + echo "🚀 Deploying oven..." 16 + echo " Host: $OVEN_HOST" 17 + echo " Key: $SSH_KEY" 18 + 19 + # Get current git version for OVEN_VERSION env var 20 + GIT_VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") 21 + echo " Version: $GIT_VERSION" 22 + 23 + # Time the rsync 24 + START_TIME=$(date +%s%3N) 25 + 26 + echo "" 27 + echo "📦 Syncing oven files..." 28 + rsync -avz --progress --delete \ 29 + --exclude='node_modules' \ 30 + --exclude='.git' \ 31 + --exclude='*.log' \ 32 + --exclude='ac-source' \ 33 + -e "ssh -i $SSH_KEY -o StrictHostKeyChecking=no" \ 34 + "$SCRIPT_DIR/" \ 35 + "root@$OVEN_HOST:$REMOTE_DIR/" 36 + 37 + END_SYNC=$(date +%s%3N) 38 + SYNC_TIME=$((END_SYNC - START_TIME)) 39 + echo "" 40 + echo "✅ Oven sync complete in ${SYNC_TIME}ms" 41 + 42 + # Sync aesthetic.computer source files needed for bundle generation 43 + echo "" 44 + echo "📦 Syncing ac-source files for bundler..." 45 + rsync -avz --progress --delete \ 46 + --include='*/' \ 47 + --include='*.mjs' \ 48 + --include='*.js' \ 49 + --include='*.json' \ 50 + --include='*.lisp' \ 51 + --exclude='*' \ 52 + -e "ssh -i $SSH_KEY -o StrictHostKeyChecking=no" \ 53 + "$AC_SOURCE/" \ 54 + "root@$OVEN_HOST:$REMOTE_DIR/ac-source/" 55 + 56 + END_AC_SYNC=$(date +%s%3N) 57 + AC_SYNC_TIME=$((END_AC_SYNC - END_SYNC)) 58 + echo "" 59 + echo "✅ ac-source sync complete in ${AC_SYNC_TIME}ms" 60 + 61 + # Sync fedac scripts/overlays used by background OS base-image build jobs 62 + echo "" 63 + echo "📦 Syncing fedac OS build pipeline..." 64 + rsync -avz --progress --delete \ 65 + --exclude='.git' \ 66 + --exclude='*.img' \ 67 + --exclude='*.iso' \ 68 + --exclude='*.qcow2' \ 69 + --exclude='*.log' \ 70 + --exclude='native/build/' \ 71 + -e "ssh -i $SSH_KEY -o StrictHostKeyChecking=no" \ 72 + "$FEDAC_SOURCE/" \ 73 + "root@$OVEN_HOST:$REMOTE_DIR/fedac/" 74 + 75 + END_FEDAC_SYNC=$(date +%s%3N) 76 + FEDAC_SYNC_TIME=$((END_FEDAC_SYNC - END_AC_SYNC)) 77 + echo "" 78 + echo "✅ fedac sync complete in ${FEDAC_SYNC_TIME}ms" 79 + 80 + # Install kernel build tools needed for native OTA builds (idempotent) 81 + echo "" 82 + echo "🔧 Installing native kernel build tools..." 83 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" \ 84 + "apt-get install -y -q gcc make flex bison libelf-dev libssl-dev bc cpio lz4 musl-tools python3 pahole libdrm-dev libasound2-dev flite1-dev pkg-config 2>&1 | tail -5 || true" 85 + echo "✅ Kernel build tools ready" 86 + 87 + # Install TeX Live for papers PDF builds (idempotent) 88 + echo "" 89 + echo "📄 Installing TeX Live for papers builds..." 90 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" \ 91 + "apt-get install -y -q texlive-xetex texlive-fonts-extra texlive-latex-extra texlive-bibtex-extra fonts-droid-fallback 2>&1 | tail -5 || true" 92 + echo "✅ TeX Live ready" 93 + 94 + # Optional vault-managed admin key for /os-base-build endpoints 95 + echo "" 96 + if [ -f "$VAULT_OS_KEY" ]; then 97 + echo "🔐 Syncing OS build admin key from vault..." 98 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" " 99 + mkdir -p $REMOTE_DIR/secrets 100 + chmod 700 $REMOTE_DIR/secrets 101 + if id -u oven >/dev/null 2>&1; then 102 + chown oven:oven $REMOTE_DIR/secrets 103 + fi 104 + " 105 + rsync -avz --progress \ 106 + -e "ssh -i $SSH_KEY -o StrictHostKeyChecking=no" \ 107 + "$VAULT_OS_KEY" \ 108 + "root@$OVEN_HOST:$REMOTE_DIR/secrets/os-build-admin-key.txt" 109 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" " 110 + chmod 600 $REMOTE_DIR/secrets/os-build-admin-key.txt 111 + if id -u oven >/dev/null 2>&1; then 112 + chown oven:oven $REMOTE_DIR/secrets/os-build-admin-key.txt 113 + fi 114 + if grep -q '^OS_BUILD_ADMIN_KEY_FILE=' $REMOTE_DIR/.env 2>/dev/null; then 115 + sed -i 's|^OS_BUILD_ADMIN_KEY_FILE=.*|OS_BUILD_ADMIN_KEY_FILE=$REMOTE_DIR/secrets/os-build-admin-key.txt|' $REMOTE_DIR/.env 116 + else 117 + echo 'OS_BUILD_ADMIN_KEY_FILE=$REMOTE_DIR/secrets/os-build-admin-key.txt' >> $REMOTE_DIR/.env 118 + fi 119 + " 120 + echo "✅ OS build admin key synced" 121 + else 122 + echo "⚠️ No vault key at $VAULT_OS_KEY (skipping OS_BUILD_ADMIN_KEY_FILE provisioning)" 123 + fi 124 + 125 + END_SECRET_SYNC=$(date +%s%3N) 126 + SECRET_SYNC_TIME=$((END_SECRET_SYNC - END_FEDAC_SYNC)) 127 + echo "✅ Secret sync stage complete in ${SECRET_SYNC_TIME}ms" 128 + 129 + # Sync BDF font files + glyph caches for bundle font embedding 130 + echo "" 131 + echo "📦 Syncing font assets (BDF + glyph caches)..." 132 + rsync -avz --progress \ 133 + --include='*/' \ 134 + --include='*.bdf' \ 135 + --include='*.bdf.gz' \ 136 + --include='*.json' \ 137 + --exclude='*' \ 138 + -e "ssh -i $SSH_KEY -o StrictHostKeyChecking=no" \ 139 + "$SCRIPT_DIR/../system/public/assets/type/" \ 140 + "root@$OVEN_HOST:$REMOTE_DIR/assets-type/" 141 + 142 + END_FONT_SYNC=$(date +%s%3N) 143 + FONT_SYNC_TIME=$((END_FONT_SYNC - END_SECRET_SYNC)) 144 + echo "" 145 + echo "✅ Font glyph sync complete in ${FONT_SYNC_TIME}ms" 146 + 147 + # Ensure OS cache path is writable by the oven service user. 148 + echo "" 149 + echo "🧹 Ensuring OS cache directory + permissions..." 150 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" " 151 + mkdir -p $REMOTE_DIR/cache 152 + if id -u oven >/dev/null 2>&1; then 153 + chown -R oven:oven $REMOTE_DIR 154 + fi 155 + if grep -q '^OS_CACHE_DIR=' $REMOTE_DIR/.env 2>/dev/null; then 156 + sed -i 's|^OS_CACHE_DIR=.*|OS_CACHE_DIR=$REMOTE_DIR/cache|' $REMOTE_DIR/.env 157 + else 158 + echo 'OS_CACHE_DIR=$REMOTE_DIR/cache' >> $REMOTE_DIR/.env 159 + fi 160 + " 161 + echo "✅ OS cache path ready: $REMOTE_DIR/cache" 162 + 163 + # Set up native git repo for auto-polling OTA builds 164 + echo "" 165 + echo "📦 Setting up native git repo for OTA auto-builds..." 166 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" " 167 + NATIVE_GIT_DIR=/opt/oven/native-git 168 + if [ ! -d \$NATIVE_GIT_DIR/.git ]; then 169 + echo ' Cloning repo (first time)...' 170 + git clone --branch main --single-branch https://github.com/whistlegraph/aesthetic-computer.git \$NATIVE_GIT_DIR 171 + else 172 + echo ' Git repo exists, fetching latest...' 173 + cd \$NATIVE_GIT_DIR && git fetch origin main --quiet && git merge origin/main --ff-only --quiet || true 174 + fi 175 + if id -u oven >/dev/null 2>&1; then 176 + chown -R oven:oven \$NATIVE_GIT_DIR 177 + su - oven -s /bin/bash -c 'git config --global --add safe.directory /opt/oven/native-git' 2>/dev/null || true 178 + fi 179 + echo ' Done.' 180 + " 181 + echo "✅ Native git repo ready" 182 + 183 + # Configure git push credentials for papers PDF auto-push 184 + echo "" 185 + echo "🔑 Configuring git push credentials for papers auto-build..." 186 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" " 187 + NATIVE_GIT_DIR=/opt/oven/native-git 188 + # Read OVEN_GH_TOKEN from .env if it exists 189 + OVEN_GH_TOKEN=\$(grep '^OVEN_GH_TOKEN=' $REMOTE_DIR/.env 2>/dev/null | cut -d= -f2-) 190 + if [ -n \"\$OVEN_GH_TOKEN\" ]; then 191 + cd \$NATIVE_GIT_DIR 192 + # Set push URL with token authentication (fetch stays anonymous HTTPS) 193 + git remote set-url --push origin https://x-access-token:\${OVEN_GH_TOKEN}@github.com/whistlegraph/aesthetic-computer.git 194 + echo ' Push URL configured with token auth' 195 + else 196 + echo ' WARNING: OVEN_GH_TOKEN not found in $REMOTE_DIR/.env — papers auto-push will fail' 197 + echo ' Add OVEN_GH_TOKEN=ghp_... to aesthetic-computer-vault/oven/.env and re-deploy' 198 + fi 199 + " 200 + echo "✅ Git push credentials configured" 201 + 202 + # Restart unless --no-restart flag 203 + if [ "$1" != "--no-restart" ]; then 204 + echo "" 205 + echo "🔄 Restarting oven service..." 206 + 207 + # Update OVEN_VERSION in both .env and systemd override, then restart 208 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" " 209 + cd $REMOTE_DIR 210 + # Update version in .env 211 + if grep -q '^OVEN_VERSION=' .env 2>/dev/null; then 212 + sed -i 's/^OVEN_VERSION=.*/OVEN_VERSION=$GIT_VERSION/' .env 213 + else 214 + echo 'OVEN_VERSION=$GIT_VERSION' >> .env 215 + fi 216 + # Update systemd override (takes precedence over .env) 217 + sed -i 's/^Environment=OVEN_VERSION=.*/Environment=OVEN_VERSION=$GIT_VERSION/' /etc/systemd/system/oven.service.d/override.conf 218 + systemctl daemon-reload 219 + systemctl restart oven 220 + sleep 2 221 + systemctl status oven --no-pager | head -5 222 + " 223 + 224 + END_RESTART=$(date +%s%3N) 225 + RESTART_TIME=$((END_RESTART - END_FONT_SYNC)) 226 + 227 + echo "" 228 + echo "✅ Restart complete in ${RESTART_TIME}ms" 229 + 230 + # Prewarm the bundle cache after restart 231 + echo "" 232 + echo "🔥 Prewarming bundle cache..." 233 + PREWARM_RESULT=$(ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" \ 234 + "curl -s -X POST http://localhost:3002/bundle-prewarm --max-time 120" 2>/dev/null || echo '{"error":"prewarm timeout"}') 235 + echo " $PREWARM_RESULT" 236 + 237 + END_PREWARM=$(date +%s%3N) 238 + PREWARM_TIME=$((END_PREWARM - END_RESTART)) 239 + TOTAL_TIME=$((END_PREWARM - START_TIME)) 240 + 241 + echo "" 242 + echo "✅ Prewarm complete in ${PREWARM_TIME}ms" 243 + echo "🏁 Total deploy time: ${TOTAL_TIME}ms" 244 + else 245 + echo "" 246 + echo "⏭️ Skipped restart (--no-restart)" 247 + fi 248 + 249 + echo "" 250 + echo "🔥 Done! https://oven.aesthetic.computer"
+150
oven/generate-og-samples.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * KidLisp.com OG Image Generator 4 + * 5 + * Generates Open Graph preview images for KidLisp.com social sharing. 6 + * Uses top trending pieces from the TV API, deduplicates by source similarity, 7 + * and creates various layout options. 8 + * 9 + * Usage: 10 + * node generate-og-samples.mjs # Generate all layouts to /scratch 11 + * node generate-og-samples.mjs mosaic # Generate specific layout 12 + * node generate-og-samples.mjs --production # Upload to CDN (skip local save) 13 + * 14 + * Layouts: 15 + * - featured: Single top piece with branding overlay 16 + * - mosaic: 4x4 grid of top 16 unique pieces with KidLisp.com text 17 + * - filmstrip: 5 frames from the same piece (animation sequence) 18 + * - code-split: Code preview + visual split screen 19 + * 20 + * Source Deduplication: 21 + * Pieces with >90% similar source code (by trigram Jaccard) are filtered out 22 + * to ensure visual variety in the mosaic. 23 + * 24 + * Automation: 25 + * This script is designed to run daily via cron on the oven backend. 26 + * Images are uploaded to DigitalOcean Spaces CDN with 24hr cache. 27 + */ 28 + 29 + import 'dotenv/config'; 30 + import { promises as fs } from 'fs'; 31 + import { join } from 'path'; 32 + 33 + // Import the OG generation functions 34 + import { generateKidlispOGImage, closeAll } from './grabber.mjs'; 35 + 36 + const SCRATCH_DIR = '/workspaces/aesthetic-computer/scratch'; 37 + const LAYOUTS = ['featured', 'mosaic', 'filmstrip', 'code-split']; 38 + 39 + async function main() { 40 + const args = process.argv.slice(2); 41 + const productionMode = args.includes('--production'); 42 + const specificLayout = args.find(a => LAYOUTS.includes(a)); 43 + const layoutsToGenerate = specificLayout ? [specificLayout] : LAYOUTS; 44 + 45 + // Parse --handle=@jeffrey or --handle jeffrey 46 + let handle = null; 47 + const handleArg = args.find(a => a.startsWith('--handle')); 48 + if (handleArg) { 49 + if (handleArg.includes('=')) { 50 + handle = handleArg.split('=')[1]; 51 + } else { 52 + const idx = args.indexOf(handleArg); 53 + if (idx < args.length - 1) { 54 + handle = args[idx + 1]; 55 + } 56 + } 57 + } 58 + 59 + console.log('🖼️ KidLisp OG Image Generator'); 60 + console.log('================================'); 61 + console.log(`📋 Layouts: ${layoutsToGenerate.join(', ')}`); 62 + console.log(`📦 Mode: ${productionMode ? 'Production (CDN upload)' : 'Development (local save)'}`); 63 + if (handle) console.log(`👤 Handle filter: ${handle}`); 64 + console.log(''); 65 + 66 + // Ensure scratch directory exists (for dev mode) 67 + if (!productionMode) { 68 + await fs.mkdir(SCRATCH_DIR, { recursive: true }); 69 + } 70 + 71 + const results = []; 72 + 73 + for (const layout of layoutsToGenerate) { 74 + console.log(`\n📸 Generating ${layout} layout...`); 75 + 76 + try { 77 + const result = await generateKidlispOGImage(layout, true, { handle }); // force=true to skip cache 78 + 79 + if (result.buffer) { 80 + // Save locally in dev mode 81 + if (!productionMode) { 82 + const filename = `kidlisp-og-${layout}.png`; 83 + const filepath = join(SCRATCH_DIR, filename); 84 + await fs.writeFile(filepath, result.buffer); 85 + console.log(` 💾 Saved: ${filepath}`); 86 + } 87 + 88 + console.log(` 📊 Size: ${(result.buffer.length / 1024).toFixed(1)} KB`); 89 + console.log(` 🌐 CDN: ${result.url}`); 90 + 91 + if (result.featuredPiece) { 92 + console.log(` 🎯 Featured: $${result.featuredPiece.code} (${result.featuredPiece.hits} hits)`); 93 + } 94 + 95 + results.push({ layout, success: true, url: result.url, size: result.buffer.length }); 96 + } 97 + 98 + } catch (error) { 99 + console.error(` ❌ Failed: ${error.message}`); 100 + results.push({ layout, success: false, error: error.message }); 101 + } 102 + } 103 + 104 + // Cleanup all connections 105 + console.log('\n🧹 Cleaning up...'); 106 + await closeAll(); 107 + 108 + // Summary 109 + console.log('\n✨ Generation Complete!'); 110 + console.log('========================'); 111 + 112 + const successful = results.filter(r => r.success); 113 + const failed = results.filter(r => !r.success); 114 + 115 + if (successful.length > 0) { 116 + console.log(`\n✅ Successful (${successful.length}):`); 117 + for (const r of successful) { 118 + console.log(` ${r.layout}: ${(r.size / 1024).toFixed(1)} KB → ${r.url}`); 119 + } 120 + } 121 + 122 + if (failed.length > 0) { 123 + console.log(`\n❌ Failed (${failed.length}):`); 124 + for (const r of failed) { 125 + console.log(` ${r.layout}: ${r.error}`); 126 + } 127 + } 128 + 129 + // List local files in dev mode 130 + if (!productionMode) { 131 + console.log('\n📂 Local files in /scratch:'); 132 + try { 133 + const files = await fs.readdir(SCRATCH_DIR); 134 + const ogFiles = files.filter(f => f.startsWith('kidlisp-og-')); 135 + for (const f of ogFiles) { 136 + const stat = await fs.stat(join(SCRATCH_DIR, f)); 137 + console.log(` ${f} (${(stat.size / 1024).toFixed(1)} KB)`); 138 + } 139 + } catch (e) { 140 + // Ignore readdir errors 141 + } 142 + } 143 + 144 + process.exit(failed.length > 0 ? 1 : 0); 145 + } 146 + 147 + main().catch(err => { 148 + console.error('Fatal error:', err); 149 + process.exit(1); 150 + });
+4700
oven/grabber.mjs
··· 1 + // Grabber - KidLisp piece screenshot/GIF capture using Puppeteer 2 + // Captures frames from running KidLisp pieces for thumbnails 3 + 4 + import { spawn, execSync } from 'child_process'; 5 + import { promises as fs } from 'fs'; 6 + import { tmpdir } from 'os'; 7 + import { join } from 'path'; 8 + import { randomBytes, createHash } from 'crypto'; 9 + import puppeteer from 'puppeteer'; 10 + import { MongoClient } from 'mongodb'; 11 + import { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; 12 + import sharp from 'sharp'; 13 + import { readFileSync } from 'fs'; 14 + import { dirname } from 'path'; 15 + import { fileURLToPath } from 'url'; 16 + 17 + // Load Comic Relief font for OG image generation 18 + const __dirname = dirname(fileURLToPath(import.meta.url)); 19 + let comicReliefBoldBase64 = ''; 20 + try { 21 + const fontPath = join(__dirname, 'fonts', 'ComicRelief-Bold.ttf'); 22 + comicReliefBoldBase64 = readFileSync(fontPath).toString('base64'); 23 + console.log('✅ Loaded Comic Relief Bold font for OG images'); 24 + } catch (err) { 25 + console.warn('⚠️ Comic Relief font not found, OG images will use fallback font'); 26 + } 27 + 28 + // Git version for cache invalidation (set by env or detected) 29 + let GIT_VERSION = process.env.OVEN_VERSION || 'unknown'; 30 + if (GIT_VERSION === 'unknown') { 31 + try { 32 + GIT_VERSION = execSync('git rev-parse --short HEAD', { encoding: 'utf8', cwd: '/workspaces/aesthetic-computer' }).trim(); 33 + } catch { 34 + // Not in a git repo 35 + } 36 + } 37 + 38 + // DigitalOcean Spaces (S3-compatible) for caching icons/previews 39 + const spacesClient = new S3Client({ 40 + endpoint: process.env.ART_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com', 41 + region: 'sfo3', 42 + credentials: { 43 + accessKeyId: process.env.ART_SPACES_KEY || '', 44 + secretAccessKey: process.env.ART_SPACES_SECRET || '', 45 + }, 46 + }); 47 + const SPACES_BUCKET = process.env.ART_SPACES_BUCKET || 'art-aesthetic-computer'; 48 + const SPACES_CDN_BASE = process.env.ART_CDN_BASE || 'https://art.aesthetic.computer'; 49 + const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days 50 + 51 + // Stable cache version — bump manually when rendering pipeline changes meaningfully. 52 + // Do NOT tie to GIT_VERSION, which changes every deploy and invalidates all cached icons. 53 + const CACHE_RENDER_VERSION = 'v5'; 54 + 55 + // MongoDB connection 56 + let mongoClient; 57 + let db; 58 + 59 + async function connectMongo() { 60 + if (!mongoClient) { 61 + const mongoUri = process.env.MONGODB_CONNECTION_STRING; 62 + const dbName = process.env.MONGODB_NAME; 63 + 64 + if (!mongoUri || !dbName) { 65 + console.warn('⚠️ MongoDB not configured, grab history will not persist'); 66 + return null; 67 + } 68 + 69 + try { 70 + mongoClient = await MongoClient.connect(mongoUri); 71 + db = mongoClient.db(dbName); 72 + console.log('✅ Connected to MongoDB for grab history'); 73 + } catch (error) { 74 + console.error('❌ Failed to connect to MongoDB:', error.message); 75 + return null; 76 + } 77 + } 78 + return db; 79 + } 80 + 81 + // Pinata IPFS upload configuration 82 + const PINATA_API_URL = 'https://api.pinata.cloud'; 83 + 84 + // IPFS gateway for serving content 85 + const IPFS_GATEWAY = 'https://ipfs.aesthetic.computer'; 86 + 87 + // App Store Screenshot Presets (Google Play requirements) 88 + // All dimensions meet the 16:9 or 9:16 aspect ratio requirement 89 + // Phone screenshots need 1080px minimum for promotion eligibility 90 + export const APP_SCREENSHOT_PRESETS = { 91 + // Phone screenshots (9:16 portrait, meets 1080px promotion requirement) 92 + 'phone-portrait': { width: 1080, height: 1920, label: 'Phone Portrait', category: 'phone' }, 93 + 'phone-landscape': { width: 1920, height: 1080, label: 'Phone Landscape', category: 'phone' }, 94 + 95 + // 7-inch tablet (9:16 portrait, 320-3840px range) 96 + 'tablet7-portrait': { width: 1200, height: 1920, label: '7" Tablet Portrait', category: 'tablet7' }, 97 + 'tablet7-landscape': { width: 1920, height: 1200, label: '7" Tablet Landscape', category: 'tablet7' }, 98 + 99 + // 10-inch tablet (9:16 portrait, 1080-7680px range for 10" tablets) 100 + 'tablet10-portrait': { width: 1600, height: 2560, label: '10" Tablet Portrait', category: 'tablet10' }, 101 + 'tablet10-landscape': { width: 2560, height: 1600, label: '10" Tablet Landscape', category: 'tablet10' }, 102 + }; 103 + 104 + // Reusable browser instance 105 + let browser = null; 106 + let browserLaunchPromise = null; 107 + 108 + // Grab queue with concurrent worker pool 109 + const grabQueue = []; 110 + let grabsRunning = 0; 111 + const MAX_CONCURRENT_GRABS = 8; 112 + const RESERVED_KEEP_SLOTS = 1; // Reserve 1 slot for keep (NFT mint) grabs 113 + 114 + // Per-grab progress tracking (keyed by grabId) 115 + const grabProgressMap = new Map(); 116 + 117 + function createEmptyProgress(grabId, piece, format) { 118 + return { 119 + grabId, piece, format, 120 + stage: null, stageDetail: null, 121 + framesCaptured: 0, framesTotal: 0, percent: 0, 122 + previewFrame: null, previewWidth: 0, previewHeight: 0, 123 + }; 124 + } 125 + 126 + // Callback for notifying subscribers of progress updates 127 + let progressNotifyCallback = null; 128 + 129 + /** 130 + * Set a callback to be notified when progress/status updates occur 131 + */ 132 + export function setNotifyCallback(callback) { 133 + progressNotifyCallback = callback; 134 + } 135 + 136 + /** 137 + * Notify all subscribers of status change 138 + */ 139 + function notifySubscribers() { 140 + if (progressNotifyCallback) { 141 + progressNotifyCallback(); 142 + } 143 + } 144 + 145 + /** 146 + * Update progress state for a specific grab 147 + */ 148 + export function updateProgress(grabId, updates) { 149 + let progress = grabProgressMap.get(grabId); 150 + if (!progress) { 151 + progress = createEmptyProgress(grabId, updates.piece, updates.format); 152 + grabProgressMap.set(grabId, progress); 153 + } 154 + Object.assign(progress, updates); 155 + notifySubscribers(); 156 + } 157 + 158 + /** 159 + * Capture a low-res preview screenshot from a Puppeteer page 160 + * @param {Page} page - Puppeteer page 161 + * @param {number} width - Preview width (default 64) 162 + * @param {number} height - Preview height (default 64) 163 + * @returns {Promise<string|null>} Base64 encoded JPEG or null on error 164 + */ 165 + async function capturePreviewFrame(page, width = 64, height = 64) { 166 + try { 167 + // Use page.screenshot() which handles deviceScaleFactor correctly, 168 + // then resize with sharp. The old CDP clip.scale approach was wrong — 169 + // it captured a top-left crop instead of a scaled-down full-frame preview. 170 + const buffer = await Promise.race([ 171 + page.screenshot({ type: 'jpeg', quality: 20 }), 172 + new Promise((_, reject) => setTimeout(() => reject(new Error('Preview timeout')), 500)) 173 + ]); 174 + if (!buffer) return null; 175 + const resized = await sharp(buffer) 176 + .resize(width, height, { fit: 'fill' }) 177 + .jpeg({ quality: 20 }) 178 + .toBuffer(); 179 + return resized.toString('base64'); 180 + } catch (err) { 181 + return null; // Don't fail on preview errors 182 + } 183 + } 184 + 185 + /** 186 + * Update progress with a preview frame 187 + * @param {Page} page - Puppeteer page 188 + * @param {object} updates - Other progress updates 189 + */ 190 + async function updateProgressWithPreview(grabId, page, updates) { 191 + const previewFrame = await capturePreviewFrame(page, 80, 80); 192 + updateProgress(grabId, { 193 + ...updates, 194 + previewFrame, 195 + previewWidth: 80, 196 + previewHeight: 80, 197 + }); 198 + } 199 + 200 + /** 201 + * Get current progress state (backward-compat: returns first active grab's progress) 202 + */ 203 + export function getCurrentProgress() { 204 + for (const progress of grabProgressMap.values()) { 205 + if (progress.stage) return { ...progress }; 206 + } 207 + return createEmptyProgress(null, null, null); 208 + } 209 + 210 + /** 211 + * Get all active grab progress entries 212 + */ 213 + export function getAllProgress() { 214 + const result = {}; 215 + for (const [grabId, progress] of grabProgressMap.entries()) { 216 + result[grabId] = { ...progress }; 217 + } 218 + return result; 219 + } 220 + 221 + /** 222 + * Get concurrency status 223 + */ 224 + export function getConcurrencyStatus() { 225 + return { active: grabsRunning, max: MAX_CONCURRENT_GRABS, queueDepth: grabQueue.length }; 226 + } 227 + 228 + /** 229 + * Clear progress for a completed grab 230 + */ 231 + function clearProgress(grabId) { 232 + grabProgressMap.delete(grabId); 233 + } 234 + 235 + /** 236 + * Get queue status with estimated wait times 237 + */ 238 + export function getQueueStatus() { 239 + return grabQueue.map((item, index) => ({ 240 + position: index + 1, 241 + piece: item.metadata?.piece || 'unknown', 242 + format: item.metadata?.format || '?', 243 + addedAt: item.metadata?.addedAt || Date.now(), 244 + estimatedWait: estimateWaitTime(index + 1), 245 + })); 246 + } 247 + 248 + // Track recent grab durations for ETA estimation 249 + const recentDurations = []; 250 + const MAX_DURATION_SAMPLES = 10; 251 + const DEFAULT_GRAB_DURATION_MS = 30000; // 30 seconds default estimate 252 + 253 + /** 254 + * Record a grab duration for ETA estimation 255 + */ 256 + export function recordGrabDuration(durationMs) { 257 + recentDurations.push(durationMs); 258 + if (recentDurations.length > MAX_DURATION_SAMPLES) { 259 + recentDurations.shift(); 260 + } 261 + } 262 + 263 + /** 264 + * Estimate wait time based on queue position and average duration 265 + */ 266 + export function estimateWaitTime(queuePosition) { 267 + const avgDuration = recentDurations.length > 0 268 + ? recentDurations.reduce((a, b) => a + b, 0) / recentDurations.length 269 + : DEFAULT_GRAB_DURATION_MS; 270 + const batchesAhead = Math.ceil(queuePosition / MAX_CONCURRENT_GRABS); 271 + return Math.round(avgDuration * batchesAhead); 272 + } 273 + 274 + async function enqueueGrab(fn, metadata = {}) { 275 + return new Promise((resolve, reject) => { 276 + // Deduplicate: silently resolve if the same captureKey is already waiting in queue 277 + if (metadata.captureKey) { 278 + const alreadyQueued = grabQueue.some( 279 + item => item.metadata?.captureKey === metadata.captureKey 280 + ); 281 + if (alreadyQueued) { 282 + console.log(`♻️ Dedup: ${metadata.piece} already queued, skipping`); 283 + resolve({ success: true, cached: true, deduplicated: true, piece: metadata.piece }); 284 + return; 285 + } 286 + } 287 + 288 + const queueItem = { fn, resolve, reject, metadata: { ...metadata, addedAt: Date.now() } }; 289 + 290 + // Priority queue: 'keep' (NFT minting) jumps to front, others go to back 291 + const isKeep = metadata.source === 'keep'; 292 + if (isKeep) { 293 + // Find first non-keep item and insert before it (maintains FIFO among keeps) 294 + const firstNonKeepIndex = grabQueue.findIndex(item => item.metadata?.source !== 'keep'); 295 + if (firstNonKeepIndex === -1) { 296 + grabQueue.push(queueItem); // All items are keeps or queue is empty 297 + } else { 298 + grabQueue.splice(firstNonKeepIndex, 0, queueItem); // Insert before first non-keep 299 + } 300 + console.log(`🎯 Priority grab queued: ${metadata.piece} (keep #${metadata.keepId || '?'}) - position ${firstNonKeepIndex === -1 ? grabQueue.length : firstNonKeepIndex + 1}`); 301 + } else { 302 + grabQueue.push(queueItem); 303 + } 304 + 305 + // Notify subscribers of queue change 306 + if (progressNotifyCallback) progressNotifyCallback(); 307 + processGrabQueue(); 308 + }); 309 + } 310 + 311 + async function processGrabQueue() { 312 + while (grabQueue.length > 0) { 313 + const nextItem = grabQueue[0]; 314 + const isKeepGrab = nextItem.metadata?.source === 'keep'; 315 + 316 + // Keep grabs can use all slots; non-keep grabs must leave RESERVED_KEEP_SLOTS free 317 + const maxForThis = isKeepGrab ? MAX_CONCURRENT_GRABS : MAX_CONCURRENT_GRABS - RESERVED_KEEP_SLOTS; 318 + if (grabsRunning >= maxForThis) { 319 + // If slots are full for this type, check if a keep is waiting further back 320 + if (!isKeepGrab) { 321 + const keepIdx = grabQueue.findIndex(item => item.metadata?.source === 'keep'); 322 + if (keepIdx > 0 && grabsRunning < MAX_CONCURRENT_GRABS) { 323 + // Pull the keep grab forward and run it in the reserved slot 324 + const keepItem = grabQueue.splice(keepIdx, 1)[0]; 325 + grabQueue.unshift(keepItem); 326 + continue; 327 + } 328 + } 329 + break; 330 + } 331 + 332 + grabsRunning++; 333 + const { fn, resolve, reject, metadata } = grabQueue.shift(); 334 + 335 + const priorityLabel = metadata?.source === 'keep' ? ' [PRIORITY]' : ''; 336 + console.log(`📋 Processing queue item: ${metadata?.piece || 'unknown'}${priorityLabel} (${grabsRunning}/${MAX_CONCURRENT_GRABS} active, ${grabQueue.length} queued)`); 337 + 338 + // Fire-and-forget async — each grab runs independently with timeout protection 339 + (async () => { 340 + // Max grab duration: 90 seconds (enough for 30s load + 30s capture/encode + 30s buffer) 341 + const GRAB_TIMEOUT_MS = 90000; 342 + const startTime = Date.now(); 343 + 344 + const timeoutId = setTimeout(() => { 345 + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); 346 + const error = new Error(`Grab exceeded ${GRAB_TIMEOUT_MS/1000}s timeout`); 347 + console.error(`⏱️ Grab timeout: ${metadata?.piece || 'unknown'} after ${elapsed}s`); 348 + 349 + // Mark any active grabs for this piece as timed out 350 + const pieceName = metadata?.piece?.replace(/^\$/, ''); 351 + if (pieceName) { 352 + for (const [grabId, grab] of activeGrabs.entries()) { 353 + if (grab.piece === metadata.piece && grab.status === 'capturing') { 354 + console.log(` Marking ${grabId} as timed-out`); 355 + grab.status = 'timeout'; 356 + grab.error = `Exceeded ${GRAB_TIMEOUT_MS/1000}s timeout`; 357 + grab.completedAt = Date.now(); 358 + 359 + // Move to recent and remove from active 360 + recentGrabs.unshift({ 361 + ...grab, 362 + duration: Date.now() - grab.startTime, 363 + }); 364 + activeGrabs.delete(grabId); 365 + } 366 + } 367 + } 368 + 369 + // Reset browser on timeout to prevent cascading failures 370 + console.log('🔄 Resetting browser after timeout...'); 371 + browser = null; 372 + 373 + reject(error); 374 + }, GRAB_TIMEOUT_MS); 375 + 376 + try { 377 + const result = await fn(); 378 + clearTimeout(timeoutId); 379 + resolve(result); 380 + } catch (error) { 381 + clearTimeout(timeoutId); 382 + console.error(`❌ Queue item failed: ${metadata?.piece || 'unknown'} - ${error.message}`); 383 + 384 + // If it's a browser connection error, try to reset the browser 385 + if (error.message.includes('Connection closed') || 386 + error.message.includes('disconnected') || 387 + error.message.includes('Target closed')) { 388 + console.log('🔄 Browser connection lost, resetting browser...'); 389 + browser = null; 390 + } 391 + 392 + reject(error); 393 + } finally { 394 + grabsRunning--; 395 + const delay = browser === null ? 500 : 100; 396 + if (grabQueue.length > 0) { 397 + console.log(`📋 Slot freed (${grabsRunning}/${MAX_CONCURRENT_GRABS} active, ${grabQueue.length} queued)`); 398 + setTimeout(processGrabQueue, delay); 399 + } 400 + } 401 + })(); 402 + } 403 + } 404 + 405 + // In-memory tracking (similar to baker.mjs) 406 + const activeGrabs = new Map(); 407 + const recentGrabs = []; 408 + 409 + // Track in-progress grabs by captureKey for deduplication 410 + const inProgressByKey = new Map(); // captureKey -> { grabId, piece, format, startTime, queuePosition } 411 + 412 + /** 413 + * Check if a grab is currently in progress or queued for this captureKey 414 + * @param {string} captureKey - The capture key to check 415 + * @returns {{ inProgress: boolean, grabId?: string, piece?: string, queuePosition?: number, estimatedWait?: number }} 416 + */ 417 + export function getInProgressGrab(captureKey) { 418 + // Check activeGrabs for matching captureKey 419 + for (const [grabId, grab] of activeGrabs.entries()) { 420 + if (grab.captureKey === captureKey) { 421 + // Find queue position if queued 422 + const queueIndex = grabQueue.findIndex(q => q.metadata?.piece === grab.piece); 423 + const queuePosition = queueIndex >= 0 ? queueIndex + 1 : 0; 424 + return { 425 + inProgress: true, 426 + grabId, 427 + piece: grab.piece, 428 + status: grab.status, 429 + queuePosition, 430 + estimatedWait: queuePosition > 0 ? estimateWaitTime(queuePosition) : 5000, 431 + }; 432 + } 433 + } 434 + return { inProgress: false }; 435 + } 436 + 437 + /** 438 + * Generate a "baking" placeholder image showing queue status 439 + * @param {object} options - { width, height, format, piece, queuePosition, estimatedWait } 440 + * @returns {Promise<Buffer>} - WebP/PNG image buffer 441 + */ 442 + export async function generateBakingPlaceholder(options = {}) { 443 + const { 444 + width = 200, 445 + height = 200, 446 + format = 'webp', 447 + piece = '?', 448 + queuePosition = 0, 449 + estimatedWait = 30000, 450 + } = options; 451 + 452 + // Simple gradient background with centered text 453 + const bgColor = { r: 30, g: 30, b: 40 }; // Dark blue-gray 454 + const accentColor = { r: 255, g: 180, b: 50 }; // Warm yellow/orange 455 + 456 + // Status text 457 + const statusText = queuePosition > 0 458 + ? `#${queuePosition} in queue` 459 + : 'baking...'; 460 + const etaSeconds = Math.ceil(estimatedWait / 1000); 461 + const etaText = etaSeconds > 60 462 + ? `~${Math.ceil(etaSeconds / 60)}m` 463 + : `~${etaSeconds}s`; 464 + 465 + // Create SVG with styling 466 + const cleanPiece = piece.replace(/^\$/, '').slice(0, 12); 467 + const svg = ` 468 + <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> 469 + <defs> 470 + <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> 471 + <stop offset="0%" style="stop-color:rgb(${bgColor.r},${bgColor.g},${bgColor.b})"/> 472 + <stop offset="100%" style="stop-color:rgb(${bgColor.r + 20},${bgColor.g + 20},${bgColor.b + 30})"/> 473 + </linearGradient> 474 + </defs> 475 + <rect width="100%" height="100%" fill="url(#bg)"/> 476 + <text x="50%" y="40%" text-anchor="middle" fill="rgb(${accentColor.r},${accentColor.g},${accentColor.b})" 477 + font-family="monospace" font-size="${Math.max(12, width / 10)}px" font-weight="bold"> 478 + 🔥 479 + </text> 480 + <text x="50%" y="55%" text-anchor="middle" fill="white" 481 + font-family="monospace" font-size="${Math.max(10, width / 14)}px"> 482 + ${statusText} 483 + </text> 484 + <text x="50%" y="70%" text-anchor="middle" fill="rgba(255,255,255,0.6)" 485 + font-family="monospace" font-size="${Math.max(8, width / 18)}px"> 486 + ${cleanPiece} 487 + </text> 488 + <text x="50%" y="85%" text-anchor="middle" fill="rgba(${accentColor.r},${accentColor.g},${accentColor.b},0.8)" 489 + font-family="monospace" font-size="${Math.max(8, width / 20)}px"> 490 + ${etaText} 491 + </text> 492 + </svg> 493 + `; 494 + 495 + // Convert SVG to image 496 + let image = sharp(Buffer.from(svg)).resize(width, height); 497 + 498 + if (format === 'webp') { 499 + return image.webp({ quality: 80 }).toBuffer(); 500 + } else if (format === 'gif') { 501 + // GIF doesn't support as easily, output PNG 502 + return image.png().toBuffer(); 503 + } else { 504 + return image.png().toBuffer(); 505 + } 506 + } 507 + 508 + // Track frozen pieces (pieces that failed due to identical frames) 509 + const frozenPieces = new Map(); // piece -> { piece, attempts, lastAttempt, firstDetected, error } 510 + 511 + // Track most recent IPFS uploads per piece (for live collection thumbnail) 512 + const latestIPFSUploads = new Map(); // piece -> { ipfsCid, ipfsUri, timestamp, ... } 513 + let latestKeepThumbnail = null; // Most recent across all pieces 514 + const KEEPS_SECRET_ID = process.env.KEEPS_SECRET_ID || 'tezos-kidlisp'; 515 + const KEEP_THUMBNAIL_FALLBACK_TTL_MS = 30 * 1000; 516 + let latestKeepFallbackCheckedAt = 0; 517 + 518 + function extractIpfsCidFromUri(uri) { 519 + if (typeof uri !== 'string') return null; 520 + const trimmed = uri.trim(); 521 + if (!trimmed) return null; 522 + 523 + if (trimmed.startsWith('ipfs://')) { 524 + const noScheme = trimmed.slice('ipfs://'.length).replace(/^ipfs\//, ''); 525 + return noScheme.split(/[/?#]/)[0] || null; 526 + } 527 + 528 + const gatewayMatch = trimmed.match(/\/ipfs\/([^/?#]+)/i); 529 + if (gatewayMatch?.[1]) return gatewayMatch[1]; 530 + 531 + return null; 532 + } 533 + 534 + async function loadLatestKeepThumbnailFromKidlisp() { 535 + const database = await connectMongo(); 536 + if (!database) return null; 537 + 538 + try { 539 + const secrets = database.collection('secrets'); 540 + const kidlisp = database.collection('kidlisp'); 541 + 542 + const secretDoc = await secrets.findOne( 543 + { _id: KEEPS_SECRET_ID }, 544 + { projection: { currentKeepsContract: 1, keepsContract: 1 } } 545 + ); 546 + const activeContract = secretDoc?.currentKeepsContract || secretDoc?.keepsContract?.mainnet || null; 547 + 548 + let pieceDoc = null; 549 + let thumbnailUri = null; 550 + let eventTime = null; 551 + 552 + if (activeContract) { 553 + const contractPath = `tezos.contracts.${activeContract}`; 554 + pieceDoc = await kidlisp.find( 555 + { 556 + [`${contractPath}.minted`]: true, 557 + [`${contractPath}.thumbnailUri`]: { $type: 'string' }, 558 + }, 559 + { 560 + projection: { 561 + code: 1, 562 + tezos: 1, 563 + when: 1, 564 + }, 565 + } 566 + ).sort({ 567 + [`${contractPath}.mintedAt`]: -1, 568 + when: -1, 569 + }).limit(1).next(); 570 + 571 + if (pieceDoc) { 572 + const contractKeep = pieceDoc?.tezos?.contracts?.[activeContract]; 573 + thumbnailUri = contractKeep?.thumbnailUri || null; 574 + eventTime = contractKeep?.mintedAt || contractKeep?.lastConfirmAt || pieceDoc?.when || null; 575 + } 576 + } 577 + 578 + if (!pieceDoc) { 579 + pieceDoc = await kidlisp.find( 580 + { 'kept.thumbnailUri': { $type: 'string' } }, 581 + { projection: { code: 1, kept: 1, when: 1 } } 582 + ).sort({ 583 + 'kept.keptAt': -1, 584 + when: -1, 585 + }).limit(1).next(); 586 + 587 + if (pieceDoc) { 588 + thumbnailUri = pieceDoc?.kept?.thumbnailUri || null; 589 + eventTime = pieceDoc?.kept?.keptAt || pieceDoc?.when || null; 590 + } 591 + } 592 + 593 + const ipfsCid = extractIpfsCidFromUri(thumbnailUri); 594 + if (!ipfsCid || !pieceDoc?.code) return null; 595 + 596 + const timestamp = Number.isFinite(new Date(eventTime).getTime()) 597 + ? new Date(eventTime).getTime() 598 + : Date.now(); 599 + const uploadInfo = { 600 + ipfsCid, 601 + ipfsUri: `ipfs://${ipfsCid}`, 602 + piece: pieceDoc.code, 603 + format: 'webp', 604 + mimeType: 'image/webp', 605 + timestamp, 606 + source: 'kidlisp-fallback', 607 + contractAddress: activeContract || null, 608 + }; 609 + 610 + latestIPFSUploads.set(pieceDoc.code, uploadInfo); 611 + latestKeepThumbnail = uploadInfo; 612 + console.log(`📸 Loaded latest keep thumbnail fallback: $${pieceDoc.code} (${uploadInfo.ipfsUri})`); 613 + return uploadInfo; 614 + } catch (error) { 615 + console.error('❌ Failed to load latest keep thumbnail fallback:', error.message); 616 + return null; 617 + } 618 + } 619 + 620 + export async function ensureLatestKeepThumbnail() { 621 + if (latestKeepThumbnail) return latestKeepThumbnail; 622 + 623 + const now = Date.now(); 624 + if (now - latestKeepFallbackCheckedAt < KEEP_THUMBNAIL_FALLBACK_TTL_MS) { 625 + return latestKeepThumbnail; 626 + } 627 + 628 + latestKeepFallbackCheckedAt = now; 629 + await loadLatestKeepThumbnailFromKidlisp(); 630 + return latestKeepThumbnail; 631 + } 632 + 633 + // Stale grab timeout: grabs older than this are considered stuck and can be cleaned up 634 + const STALE_GRAB_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes 635 + 636 + /** 637 + * Clean up stale grabs that have been active for too long (likely stuck) 638 + * @returns {{ cleaned: number, remaining: number }} 639 + */ 640 + export function cleanupStaleGrabs() { 641 + const now = Date.now(); 642 + let cleaned = 0; 643 + 644 + for (const [grabId, grab] of activeGrabs.entries()) { 645 + const age = now - grab.startTime; 646 + if (age > STALE_GRAB_TIMEOUT_MS) { 647 + console.log(`🧹 Cleaning up stale grab: ${grabId} (age: ${Math.round(age / 1000)}s)`); 648 + serverLog('cleanup', '🧹', `Cleaned stale grab: ${grab.piece} (stuck for ${Math.round(age / 1000)}s)`); 649 + 650 + // Mark as failed and move to recent 651 + grab.status = 'stale-cleaned'; 652 + grab.error = 'Grab timed out and was cleaned up'; 653 + grab.completedAt = now; 654 + 655 + activeGrabs.delete(grabId); 656 + recentGrabs.unshift(grab); 657 + if (recentGrabs.length > 20) recentGrabs.pop(); 658 + saveGrab(grab); 659 + cleaned++; 660 + } 661 + } 662 + 663 + if (cleaned > 0) { 664 + notifySubscribers(); 665 + console.log(`🧹 Cleaned ${cleaned} stale grabs`); 666 + } 667 + 668 + return { cleaned, remaining: activeGrabs.size }; 669 + } 670 + 671 + /** 672 + * Clear all active grabs (emergency reset) 673 + * @returns {{ cleared: number }} 674 + */ 675 + export function clearAllActiveGrabs() { 676 + const count = activeGrabs.size; 677 + const now = Date.now(); 678 + 679 + for (const [grabId, grab] of activeGrabs.entries()) { 680 + console.log(`🗑️ Force clearing grab: ${grabId}`); 681 + grab.status = 'force-cleared'; 682 + grab.error = 'Manually cleared by admin'; 683 + grab.completedAt = now; 684 + 685 + recentGrabs.unshift(grab); 686 + if (recentGrabs.length > 20) recentGrabs.pop(); 687 + saveGrab(grab); 688 + } 689 + 690 + activeGrabs.clear(); 691 + 692 + // Also clear the queue and progress 693 + const queueCount = grabQueue.length; 694 + grabQueue.length = 0; 695 + grabsRunning = 0; 696 + grabProgressMap.clear(); 697 + 698 + notifySubscribers(); 699 + serverLog('cleanup', '🗑️', `Force cleared ${count} active grabs and ${queueCount} queued items`); 700 + 701 + return { cleared: count, queueCleared: queueCount }; 702 + } 703 + 704 + /** 705 + * Record a frozen piece (failed due to identical frames) 706 + * @param {string} piece - The piece name (e.g. '$woww') 707 + * @param {string} error - The error message 708 + * @param {string} previewUrl - Optional CDN URL of the frozen preview 709 + */ 710 + function recordFrozenPiece(piece, error, previewUrl = null) { 711 + const existing = frozenPieces.get(piece); 712 + const now = Date.now(); 713 + 714 + if (existing) { 715 + existing.attempts++; 716 + existing.lastAttempt = now; 717 + existing.error = error; 718 + if (previewUrl) existing.previewUrl = previewUrl; 719 + console.log(`🥶 Frozen piece updated: ${piece} (${existing.attempts} attempts)`); 720 + } else { 721 + frozenPieces.set(piece, { 722 + piece, 723 + attempts: 1, 724 + firstDetected: now, 725 + lastAttempt: now, 726 + error, 727 + previewUrl, 728 + }); 729 + console.log(`🥶 New frozen piece recorded: ${piece}`); 730 + } 731 + 732 + // Persist to MongoDB 733 + saveFrozenPiece(piece); 734 + } 735 + 736 + /** 737 + * Save frozen piece to MongoDB 738 + */ 739 + async function saveFrozenPiece(piece) { 740 + const database = await connectMongo(); 741 + if (!database) return; 742 + 743 + const frozen = frozenPieces.get(piece); 744 + if (!frozen) return; 745 + 746 + try { 747 + const collection = database.collection('oven-frozen-pieces'); 748 + await collection.updateOne( 749 + { piece }, 750 + { $set: frozen, $setOnInsert: { createdAt: new Date() } }, 751 + { upsert: true } 752 + ); 753 + } catch (error) { 754 + console.error('❌ Failed to save frozen piece to MongoDB:', error.message); 755 + } 756 + } 757 + 758 + /** 759 + * Load frozen pieces from MongoDB on startup 760 + */ 761 + async function loadFrozenPieces() { 762 + const database = await connectMongo(); 763 + if (!database) return; 764 + 765 + try { 766 + const collection = database.collection('oven-frozen-pieces'); 767 + const pieces = await collection.find({}).sort({ lastAttempt: -1 }).limit(100).toArray(); 768 + 769 + for (const p of pieces) { 770 + frozenPieces.set(p.piece, { 771 + piece: p.piece, 772 + attempts: p.attempts || 1, 773 + firstDetected: p.firstDetected || p.createdAt?.getTime() || Date.now(), 774 + lastAttempt: p.lastAttempt || Date.now(), 775 + error: p.error || 'Frozen animation', 776 + }); 777 + } 778 + 779 + console.log(`📂 Loaded ${pieces.length} frozen pieces from MongoDB`); 780 + } catch (error) { 781 + console.error('❌ Failed to load frozen pieces from MongoDB:', error.message); 782 + } 783 + } 784 + 785 + /** 786 + * Get list of all frozen pieces 787 + * @returns {Array} Array of frozen piece objects 788 + */ 789 + export function getFrozenPieces() { 790 + return Array.from(frozenPieces.values()).sort((a, b) => b.lastAttempt - a.lastAttempt); 791 + } 792 + 793 + /** 794 + * Clear a piece from the frozen list (e.g., after fixing it) 795 + * @param {string} piece - The piece name to clear 796 + */ 797 + export async function clearFrozenPiece(piece) { 798 + frozenPieces.delete(piece); 799 + 800 + const database = await connectMongo(); 801 + if (database) { 802 + try { 803 + await database.collection('oven-frozen-pieces').deleteOne({ piece }); 804 + console.log(`✅ Cleared frozen piece: ${piece}`); 805 + } catch (error) { 806 + console.error('❌ Failed to clear frozen piece from MongoDB:', error.message); 807 + } 808 + } 809 + 810 + return { success: true, piece }; 811 + } 812 + 813 + // Run stale cleanup every 2 minutes 814 + setInterval(() => { 815 + if (activeGrabs.size > 0) { 816 + cleanupStaleGrabs(); 817 + } 818 + }, 2 * 60 * 1000); 819 + 820 + /** 821 + * Load recent grabs from MongoDB on startup 822 + */ 823 + async function loadRecentGrabs() { 824 + const database = await connectMongo(); 825 + if (!database) return; 826 + 827 + try { 828 + const collection = database.collection('oven-grabs'); 829 + const grabs = await collection.find({}).sort({ completedAt: -1 }).limit(20).toArray(); 830 + 831 + recentGrabs.length = 0; 832 + recentGrabs.push(...grabs.map(g => ({ 833 + ...g, 834 + completedAt: g.completedAt?.getTime?.() || g.completedAt, 835 + startTime: g.startTime?.getTime?.() || g.startTime, 836 + }))); 837 + 838 + // Also restore latestIPFSUploads from grabs that have IPFS data 839 + for (const grab of grabs) { 840 + if (grab.ipfsCid && grab.piece) { 841 + const existing = latestIPFSUploads.get(grab.piece); 842 + const grabTime = grab.completedAt?.getTime?.() || grab.completedAt || 0; 843 + if (!existing || grabTime > existing.timestamp) { 844 + latestIPFSUploads.set(grab.piece, { 845 + ipfsCid: grab.ipfsCid, 846 + ipfsUri: grab.ipfsUri, 847 + timestamp: grabTime, 848 + piece: grab.piece, 849 + format: grab.format, 850 + }); 851 + } 852 + } 853 + } 854 + 855 + // Set latestKeepThumbnail to most recent IPFS upload 856 + if (latestIPFSUploads.size > 0) { 857 + let mostRecent = null; 858 + for (const upload of latestIPFSUploads.values()) { 859 + if (!mostRecent || upload.timestamp > mostRecent.timestamp) { 860 + mostRecent = upload; 861 + } 862 + } 863 + latestKeepThumbnail = mostRecent; 864 + } 865 + 866 + if (!latestKeepThumbnail) { 867 + await ensureLatestKeepThumbnail(); 868 + } 869 + 870 + console.log(`📂 Loaded ${recentGrabs.length} recent grabs from MongoDB (${latestIPFSUploads.size} with IPFS)`); 871 + } catch (error) { 872 + console.error('❌ Failed to load recent grabs:', error.message); 873 + } 874 + } 875 + 876 + /** 877 + * Save a grab to MongoDB 878 + */ 879 + async function saveGrab(grab) { 880 + const database = await connectMongo(); 881 + if (!database) return; 882 + 883 + try { 884 + const collection = database.collection('oven-grabs'); 885 + await collection.insertOne({ 886 + ...grab, 887 + completedAt: new Date(grab.completedAt), 888 + startTime: new Date(grab.startTime), 889 + }); 890 + } catch (error) { 891 + console.error('❌ Failed to save grab to MongoDB:', error.message); 892 + } 893 + } 894 + 895 + /** 896 + * Update a grab in MongoDB (e.g., after IPFS upload) 897 + */ 898 + async function updateGrabInMongo(grabId, updates) { 899 + const database = await connectMongo(); 900 + if (!database) return; 901 + 902 + try { 903 + const collection = database.collection('oven-grabs'); 904 + await collection.updateOne( 905 + { grabId }, 906 + { $set: updates } 907 + ); 908 + } catch (error) { 909 + console.error('❌ Failed to update grab in MongoDB:', error.message); 910 + } 911 + } 912 + 913 + /** 914 + * Check if all frames are identical (frozen animation) 915 + * Compares frame hashes to detect if the capture is static 916 + * @param {Buffer[]} frames - Array of PNG frame buffers 917 + * @returns {Promise<boolean>} True if all frames are the same (frozen) 918 + */ 919 + async function areFramesIdentical(frames) { 920 + if (frames.length <= 1) return false; // Single frame can't be "frozen" 921 + 922 + try { 923 + // Hash each frame and compare 924 + const hashes = []; 925 + for (const frame of frames) { 926 + const hash = createHash('md5').update(frame).digest('hex'); 927 + hashes.push(hash); 928 + } 929 + 930 + // Check if all hashes are the same 931 + const firstHash = hashes[0]; 932 + const allSame = hashes.every(h => h === firstHash); 933 + 934 + if (allSame) { 935 + console.log(` ⚠️ All ${frames.length} frames are identical (frozen)`); 936 + } else { 937 + // Count unique frames 938 + const uniqueHashes = new Set(hashes); 939 + console.log(` ✅ Found ${uniqueHashes.size} unique frames out of ${frames.length}`); 940 + } 941 + 942 + return allSame; 943 + } catch (error) { 944 + console.error('⚠️ Failed to check frame identity:', error.message); 945 + return false; // Assume not frozen if check fails 946 + } 947 + } 948 + 949 + /** 950 + * Check if frames have uniform/solid color content (a "dud" animation) 951 + * This catches pieces that are just solid wipe colors with no actual visual content 952 + * @param {Buffer[]} frames - Array of PNG frame buffers 953 + * @returns {Promise<{ isUniform: boolean, color?: string, reason?: string }>} 954 + */ 955 + async function isUniformColorContent(frames) { 956 + if (frames.length === 0) return { isUniform: false }; 957 + 958 + try { 959 + const sharp = (await import('sharp')).default; 960 + 961 + // Sample multiple frames across the animation 962 + const sampleIndices = [ 963 + 0, 964 + Math.floor(frames.length / 4), 965 + Math.floor(frames.length / 2), 966 + Math.floor(frames.length * 3 / 4), 967 + frames.length - 1 968 + ].filter((v, i, arr) => arr.indexOf(v) === i && v < frames.length); 969 + 970 + // Grid sample points (like give page's validateWebpImage) 971 + const gridSize = 5; 972 + 973 + for (const frameIdx of sampleIndices) { 974 + const frame = frames[frameIdx]; 975 + 976 + // Sample at a consistent size for analysis 977 + const sampleSize = 64; 978 + const { data, info } = await sharp(frame) 979 + .resize(sampleSize, sampleSize, { fit: 'fill' }) 980 + .raw() 981 + .toBuffer({ resolveWithObject: true }); 982 + 983 + const channels = info.channels; 984 + 985 + // Generate sample points in a grid pattern 986 + const samplePoints = []; 987 + const gap = Math.floor(sampleSize / gridSize); 988 + for (let gx = 0; gx < gridSize; gx++) { 989 + for (let gy = 0; gy < gridSize; gy++) { 990 + samplePoints.push({ 991 + x: Math.min(gap / 2 + gx * gap, sampleSize - 1), 992 + y: Math.min(gap / 2 + gy * gap, sampleSize - 1) 993 + }); 994 + } 995 + } 996 + 997 + // Sample pixel colors 998 + let firstColor = null; 999 + let maxColorDiff = 0; 1000 + 1001 + for (const pt of samplePoints) { 1002 + const idx = (Math.floor(pt.y) * sampleSize + Math.floor(pt.x)) * channels; 1003 + const r = data[idx]; 1004 + const g = data[idx + 1]; 1005 + const b = data[idx + 2]; 1006 + 1007 + if (firstColor === null) { 1008 + firstColor = { r, g, b }; 1009 + } else { 1010 + const colorDiff = Math.abs(r - firstColor.r) + Math.abs(g - firstColor.g) + Math.abs(b - firstColor.b); 1011 + maxColorDiff = Math.max(maxColorDiff, colorDiff); 1012 + } 1013 + } 1014 + 1015 + // If this frame has variance, the animation is not uniform 1016 + if (maxColorDiff >= 30) { 1017 + return { isUniform: false }; 1018 + } 1019 + } 1020 + 1021 + // All sampled frames have uniform color - this is a dud 1022 + // Get the color from the first frame for reporting 1023 + const { data } = await sharp(frames[0]) 1024 + .resize(1, 1, { fit: 'fill' }) 1025 + .raw() 1026 + .toBuffer({ resolveWithObject: true }); 1027 + 1028 + const hex = `#${data[0].toString(16).padStart(2, '0')}${data[1].toString(16).padStart(2, '0')}${data[2].toString(16).padStart(2, '0')}`; 1029 + 1030 + console.log(` ⚠️ Uniform color content detected: ${hex}`); 1031 + return { 1032 + isUniform: true, 1033 + color: hex, 1034 + reason: `UNIFORM_COLOR:${hex}` 1035 + }; 1036 + 1037 + } catch (error) { 1038 + console.error('⚠️ Failed to check uniform color:', error.message); 1039 + return { isUniform: false }; // Assume not uniform if check fails 1040 + } 1041 + } 1042 + 1043 + /** 1044 + * Check if a frame is blank (all black, all transparent, or nearly uniform) 1045 + * Uses sharp to analyze the image efficiently 1046 + */ 1047 + async function isBlankFrame(buffer) { 1048 + try { 1049 + const sharp = (await import('sharp')).default; 1050 + 1051 + // Get image stats - sample a smaller version for speed 1052 + const { channels, width, height } = await sharp(buffer) 1053 + .resize(32, 32, { fit: 'fill' }) // Small sample for speed 1054 + .raw() 1055 + .toBuffer({ resolveWithObject: true }) 1056 + .then(({ data, info }) => { 1057 + // Analyze pixel data 1058 + let totalR = 0, totalG = 0, totalB = 0, totalA = 0; 1059 + let nonTransparentPixels = 0; 1060 + let hasColor = false; 1061 + 1062 + const pixelCount = info.width * info.height; 1063 + const channels = info.channels; 1064 + 1065 + for (let i = 0; i < data.length; i += channels) { 1066 + const r = data[i]; 1067 + const g = data[i + 1]; 1068 + const b = data[i + 2]; 1069 + const a = channels === 4 ? data[i + 3] : 255; 1070 + 1071 + if (a > 10) { // Not fully transparent 1072 + nonTransparentPixels++; 1073 + totalR += r; 1074 + totalG += g; 1075 + totalB += b; 1076 + 1077 + // Check if there's any meaningful color (not just black) 1078 + if (r > 5 || g > 5 || b > 5) { 1079 + hasColor = true; 1080 + } 1081 + } 1082 + totalA += a; 1083 + } 1084 + 1085 + // Blank if: all transparent OR all black (no color) 1086 + const avgAlpha = totalA / pixelCount; 1087 + const isAllTransparent = avgAlpha < 10; 1088 + const isAllBlack = nonTransparentPixels > 0 && !hasColor; 1089 + 1090 + return { 1091 + isBlank: isAllTransparent || isAllBlack, 1092 + avgAlpha, 1093 + nonTransparentPixels, 1094 + hasColor, 1095 + width: info.width, 1096 + height: info.height, 1097 + channels: info.channels 1098 + }; 1099 + }); 1100 + 1101 + return channels.isBlank; 1102 + } catch (error) { 1103 + console.error('⚠️ Failed to check if frame is blank:', error.message); 1104 + return false; // Assume not blank if check fails 1105 + } 1106 + } 1107 + 1108 + // Load recent grabs and frozen pieces on startup 1109 + loadRecentGrabs(); 1110 + loadFrozenPieces(); 1111 + 1112 + function normalizeCacheKey(cacheKey) { 1113 + if (cacheKey === null || cacheKey === undefined) return ''; 1114 + const normalized = String(cacheKey) 1115 + .trim() 1116 + .replace(/[^a-zA-Z0-9_-]/g, '-') 1117 + .replace(/-+/g, '-') 1118 + .slice(0, 48); 1119 + return normalized; 1120 + } 1121 + 1122 + /** 1123 + * Generate a unique capture key for deduplication 1124 + * Format: {piece}_{width}x{height}_{format}_{animated}_{renderVersion}[_key] 1125 + */ 1126 + function getCaptureKey(piece, width, height, format, animated = false, cacheKey = '') { 1127 + const cleanPiece = piece.replace(/^\$/, ''); // Normalize piece name 1128 + const animFlag = animated ? 'anim' : 'still'; 1129 + const normalizedKey = normalizeCacheKey(cacheKey); 1130 + const keySuffix = normalizedKey ? `_${normalizedKey}` : ''; 1131 + return `${cleanPiece}_${width}x${height}_${format}_${animFlag}_${CACHE_RENDER_VERSION}${keySuffix}`; 1132 + } 1133 + 1134 + /** 1135 + * Check if a capture already exists with the same key 1136 + * @returns {{ exists: boolean, cdnUrl?: string, grab?: object }} 1137 + */ 1138 + async function checkExistingCapture(captureKey) { 1139 + const database = await connectMongo(); 1140 + if (!database) return { exists: false }; 1141 + 1142 + try { 1143 + // Check oven-grabs collection for matching capture 1144 + const grab = await database.collection('oven-grabs').findOne({ captureKey }); 1145 + if (grab && grab.cdnUrl) { 1146 + console.log(`✅ Found existing capture: ${captureKey}`); 1147 + return { exists: true, cdnUrl: grab.cdnUrl, grab }; 1148 + } 1149 + 1150 + // Also check oven-cache for icons/previews 1151 + const cache = await database.collection('oven-cache').findOne({ 1152 + key: { $regex: captureKey.replace(/_/g, '.*') } 1153 + }); 1154 + if (cache && cache.cdnUrl && cache.expiresAt > new Date()) { 1155 + console.log(`✅ Found cached capture: ${captureKey}`); 1156 + return { exists: true, cdnUrl: cache.cdnUrl }; 1157 + } 1158 + 1159 + return { exists: false }; 1160 + } catch (error) { 1161 + console.error('❌ Failed to check existing capture:', error.message); 1162 + return { exists: false }; 1163 + } 1164 + } 1165 + 1166 + /** 1167 + * Check if a cached image exists in Spaces and is still valid 1168 + * @returns {string|null} CDN URL if valid cache exists, null otherwise 1169 + */ 1170 + async function checkSpacesCache(cacheKey) { 1171 + if (!process.env.ART_SPACES_KEY) return null; 1172 + 1173 + const database = await connectMongo(); 1174 + if (!database) return null; 1175 + 1176 + try { 1177 + const cache = database.collection('oven-cache'); 1178 + const entry = await cache.findOne({ key: cacheKey }); 1179 + 1180 + if (entry && entry.expiresAt > new Date()) { 1181 + // Cache hit - return CDN URL 1182 + return entry.cdnUrl; 1183 + } 1184 + 1185 + return null; // Cache miss or expired 1186 + } catch (error) { 1187 + console.error('❌ Cache check failed:', error.message); 1188 + return null; 1189 + } 1190 + } 1191 + 1192 + /** 1193 + * Upload image to Spaces and save cache entry 1194 + * @returns {string} CDN URL 1195 + */ 1196 + async function uploadToSpaces(buffer, cacheKey, contentType = 'image/png') { 1197 + if (!process.env.ART_SPACES_KEY) { 1198 + throw new Error('Spaces not configured'); 1199 + } 1200 + 1201 + const spacesKey = `oven/${cacheKey}`; 1202 + 1203 + // Upload to Spaces 1204 + await spacesClient.send(new PutObjectCommand({ 1205 + Bucket: SPACES_BUCKET, 1206 + Key: spacesKey, 1207 + Body: buffer, 1208 + ContentType: contentType, 1209 + ACL: 'public-read', 1210 + CacheControl: 'public, max-age=604800', // 7 day browser cache 1211 + })); 1212 + 1213 + const cdnUrl = `${SPACES_CDN_BASE}/${spacesKey}`; 1214 + 1215 + // Save cache entry to MongoDB 1216 + const database = await connectMongo(); 1217 + if (database) { 1218 + try { 1219 + const cache = database.collection('oven-cache'); 1220 + await cache.updateOne( 1221 + { key: cacheKey }, 1222 + { 1223 + $set: { 1224 + key: cacheKey, 1225 + spacesKey, 1226 + cdnUrl, 1227 + contentType, 1228 + size: buffer.length, 1229 + generatedAt: new Date(), 1230 + expiresAt: new Date(Date.now() + CACHE_TTL_MS), 1231 + } 1232 + }, 1233 + { upsert: true } 1234 + ); 1235 + } catch (error) { 1236 + console.error('❌ Failed to save cache entry:', error.message); 1237 + } 1238 + } 1239 + 1240 + console.log(`📦 Cached to Spaces: ${cdnUrl}`); 1241 + return cdnUrl; 1242 + } 1243 + 1244 + /** 1245 + * Get cached image or generate and cache 1246 + * Uses git version in cache key for automatic invalidation on code changes 1247 + * @param {string} ext - File extension (default: 'png') 1248 + * @param {boolean} skipCache - Skip cache lookup (default: false) 1249 + * @returns {{ cdnUrl: string, fromCache: boolean, buffer?: Buffer }} 1250 + */ 1251 + export async function getCachedOrGenerate(type, piece, width, height, generateFn, ext = 'png', skipCache = false) { 1252 + // Use stable render version — only changes when rendering pipeline is updated 1253 + const cacheKey = `${type}/${piece}-${width}x${height}-${CACHE_RENDER_VERSION}.${ext}`; 1254 + const mimeType = ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/png'; 1255 + 1256 + // Check cache first (unless skipCache is true) 1257 + if (!skipCache) { 1258 + const cachedUrl = await checkSpacesCache(cacheKey); 1259 + if (cachedUrl) { 1260 + console.log(`✅ Cache hit: ${cacheKey}`); 1261 + serverLog('info', '💾', `Cache hit: ${piece} (${width}×${height})`); 1262 + return { cdnUrl: cachedUrl, fromCache: true }; 1263 + } 1264 + } else { 1265 + console.log(`⚡ Force regenerate: ${cacheKey}`); 1266 + serverLog('capture', '⚡', `Force regenerate: ${piece} (${width}×${height})`); 1267 + } 1268 + 1269 + // Generate fresh 1270 + console.log(`🔄 Cache miss: ${cacheKey}, generating...`); 1271 + serverLog('capture', '🔄', `Cache miss: ${piece} - generating ${width}×${height}...`); 1272 + const buffer = await generateFn(); 1273 + 1274 + // Upload to Spaces (async, don't block response) 1275 + if (process.env.ART_SPACES_KEY) { 1276 + uploadToSpaces(buffer, cacheKey, mimeType).catch(e => { 1277 + console.error('❌ Failed to cache to Spaces:', e.message); 1278 + }); 1279 + } 1280 + 1281 + return { cdnUrl: null, fromCache: false, buffer }; 1282 + } 1283 + 1284 + // Activity log callback (will be set by server.mjs) 1285 + let logCallback = null; 1286 + export function setLogCallback(cb) { 1287 + logCallback = cb; 1288 + } 1289 + function serverLog(type, icon, msg) { 1290 + if (logCallback) logCallback(type, icon, msg); 1291 + } 1292 + 1293 + /** 1294 + * Get or launch the shared browser instance 1295 + */ 1296 + async function getBrowser() { 1297 + // Check if existing browser is still usable 1298 + if (browser) { 1299 + try { 1300 + if (browser.isConnected()) { 1301 + return browser; 1302 + } else { 1303 + console.log('⚠️ Browser disconnected, will relaunch...'); 1304 + browser = null; 1305 + } 1306 + } catch (e) { 1307 + console.log('⚠️ Browser check failed, will relaunch:', e.message); 1308 + browser = null; 1309 + } 1310 + } 1311 + 1312 + // Prevent multiple simultaneous launches 1313 + if (browserLaunchPromise) { 1314 + return browserLaunchPromise; 1315 + } 1316 + 1317 + browserLaunchPromise = (async () => { 1318 + console.log('🌐 Launching Puppeteer browser...'); 1319 + 1320 + // Use system Chromium if available (works better on ARM64) 1321 + const fs = await import('fs'); 1322 + let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH; 1323 + if (!executablePath && fs.existsSync('/usr/sbin/chromium-browser')) { 1324 + executablePath = '/usr/sbin/chromium-browser'; 1325 + } 1326 + if (executablePath) { 1327 + console.log(`📍 Using browser at: ${executablePath}`); 1328 + } 1329 + 1330 + browser = await puppeteer.launch({ 1331 + headless: 'new', 1332 + executablePath, 1333 + protocolTimeout: 120000, // 120s timeout for CDP protocol calls (increased) 1334 + args: [ 1335 + '--no-sandbox', 1336 + '--disable-setuid-sandbox', 1337 + '--disable-dev-shm-usage', 1338 + // Enable WebGL for KidLisp pieces 1339 + '--enable-webgl', 1340 + '--use-gl=swiftshader', 1341 + '--enable-unsafe-webgl', 1342 + '--window-size=800,800', 1343 + // Stability flags 1344 + '--disable-gpu-sandbox', 1345 + '--disable-background-timer-throttling', 1346 + '--disable-backgrounding-occluded-windows', 1347 + '--disable-renderer-backgrounding', 1348 + ], 1349 + }); 1350 + 1351 + // Set up disconnect handler for automatic cleanup 1352 + browser.on('disconnected', () => { 1353 + console.log('⚠️ Browser disconnected unexpectedly'); 1354 + browser = null; 1355 + }); 1356 + 1357 + console.log('✅ Browser ready'); 1358 + return browser; 1359 + })(); 1360 + 1361 + const result = await browserLaunchPromise; 1362 + browserLaunchPromise = null; 1363 + return result; 1364 + } 1365 + 1366 + /** 1367 + * Pre-warm the browser by navigating to a simple AC piece. 1368 + * This populates the service worker cache so subsequent piece loads are 3-5x faster. 1369 + * Should be called once after oven startup. 1370 + */ 1371 + export async function prewarmGrabBrowser() { 1372 + const BASE_URL = process.env.AC_BASE_URL || 'https://aesthetic.computer'; 1373 + const b = await getBrowser(); 1374 + 1375 + // Phase 1: Load the AC runtime + service worker 1376 + let page; 1377 + try { 1378 + page = await b.newPage(); 1379 + // Set viewport before navigating so AC boots at a known size (not default 800x600) 1380 + await page.setViewport({ width: 128, height: 128, deviceScaleFactor: 1 }); 1381 + const warmupUrl = `${BASE_URL}/prompt?density=1&nogap=true`; 1382 + console.log(`🔥 Pre-warming browser: ${warmupUrl}`); 1383 + await page.goto(warmupUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }); 1384 + await populateGlyphCache(page); 1385 + await new Promise(r => setTimeout(r, 3000)); 1386 + console.log('✅ Browser pre-warm (runtime) complete'); 1387 + } catch (e) { 1388 + console.warn(`⚠️ Browser pre-warm (runtime) failed (non-fatal): ${e.message}`); 1389 + } finally { 1390 + if (page) await page.close().catch(() => {}); 1391 + } 1392 + 1393 + // Phase 2: Load a blank KidLisp piece to cache kidlisp.mjs (~600KB) 1394 + // This makes subsequent KidLisp keep grabs much faster. 1395 + let klPage; 1396 + try { 1397 + klPage = await b.newPage(); 1398 + // Set viewport before navigating so AC boots at a known size (not default 800x600) 1399 + await klPage.setViewport({ width: 128, height: 128, deviceScaleFactor: 1 }); 1400 + const klUrl = `${BASE_URL}/black?density=1&nolabel=true&nogap=true`; 1401 + console.log(`🔥 Pre-warming KidLisp: ${klUrl}`); 1402 + await klPage.goto(klUrl, { waitUntil: 'domcontentloaded', timeout: 25000 }); 1403 + // Wait for kidlisp.mjs to fully parse and the piece to boot 1404 + await klPage.waitForFunction( 1405 + () => document.querySelector('canvas'), 1406 + { timeout: 15000 } 1407 + ).catch(() => {}); 1408 + console.log('✅ Browser pre-warm (KidLisp) complete'); 1409 + } catch (e) { 1410 + console.warn(`⚠️ Browser pre-warm (KidLisp) failed (non-fatal): ${e.message}`); 1411 + } finally { 1412 + if (klPage) await klPage.close().catch(() => {}); 1413 + } 1414 + } 1415 + 1416 + // Blocked URL patterns for Puppeteer pages (prevent self-referential loops and noise) 1417 + const BLOCKED_URL_PATTERNS = [ 1418 + 'oven.aesthetic.computer', // Prevent oven requesting its own icons/grabs 1419 + 'google-analytics.com', // No analytics in headless captures 1420 + 'googletagmanager.com', 1421 + 'www.google-analytics.com', 1422 + ]; 1423 + 1424 + // Non-essential API endpoints that can be silently dropped during captures 1425 + // These generate network traffic but aren't needed for rendering thumbnails 1426 + const DROPPABLE_API_PATTERNS = [ 1427 + '/api/boot-log', // Telemetry logging — not needed in captures 1428 + '/api/mood/moods-of-the-day', // UI decoration — not needed for thumbnails 1429 + ]; 1430 + 1431 + // Local asset directories for serving font glyphs without hitting aesthetic.computer 1432 + const LOCAL_FONT_DRAWINGS = join(__dirname, 'ac-source', 'disks', 'drawings'); 1433 + const LOCAL_BDF_CACHE = join(__dirname, 'assets-type'); 1434 + 1435 + /** 1436 + * Try to serve a font_1 glyph JSON from local filesystem. 1437 + * Returns the file contents as a Buffer, or null if not found. 1438 + */ 1439 + function tryLocalFontGlyph(url) { 1440 + // Match: /disks/drawings/font_1/{category}/{filename}.json 1441 + const match = url.match(/\/disks\/drawings\/(font_1\/.+\.json)/); 1442 + if (!match) return null; 1443 + try { 1444 + const localPath = join(LOCAL_FONT_DRAWINGS, decodeURIComponent(match[1])); 1445 + return readFileSync(localPath); 1446 + } catch { 1447 + return null; 1448 + } 1449 + } 1450 + 1451 + /** 1452 + * Try to serve a BDF glyph batch from local pre-cached JSONs. 1453 + * The API URL looks like: /api/bdf-glyph?chars=0066,006F&font=6x10 1454 + * Returns a JSON response body, or null if any glyph is missing locally. 1455 + */ 1456 + function tryLocalBDFGlyphs(url) { 1457 + try { 1458 + const parsed = new URL(url); 1459 + const font = parsed.searchParams.get('font'); 1460 + const chars = parsed.searchParams.get('chars'); 1461 + if (!font || !chars) return null; 1462 + 1463 + const codePoints = chars.split(',').filter(Boolean); 1464 + const fontDir = join(LOCAL_BDF_CACHE, font); 1465 + const glyphs = {}; 1466 + 1467 + for (const cp of codePoints) { 1468 + try { 1469 + const data = readFileSync(join(fontDir, `${cp}.json`), 'utf-8'); 1470 + glyphs[cp] = JSON.parse(data); 1471 + } catch { 1472 + // If any glyph is missing locally, fall through to network 1473 + return null; 1474 + } 1475 + } 1476 + 1477 + return JSON.stringify({ glyphs }); 1478 + } catch { 1479 + return null; 1480 + } 1481 + } 1482 + 1483 + /** 1484 + * Load all font_1 glyph JSONs from local filesystem into memory. 1485 + * Used by populateGlyphCache() to pre-populate IndexedDB before piece capture. 1486 + * This bypasses Puppeteer's broken concurrent XHR handling for web worker 1487 + * requests by making the glyphs available via IndexedDB cache instead. 1488 + */ 1489 + let _cachedFontGlyphs = null; // { char: glyphData, ... } — loaded once per process 1490 + 1491 + function loadFontGlyphsSync() { 1492 + if (_cachedFontGlyphs) return _cachedFontGlyphs; 1493 + 1494 + const fontDir = join(LOCAL_FONT_DRAWINGS, 'font_1'); 1495 + let font1Data; 1496 + try { 1497 + // Dynamic import doesn't work synchronously — use readFileSync + parse 1498 + const fontsPath = join(__dirname, 'ac-source', 'disks', 'common', 'fonts.mjs'); 1499 + const fontsSrc = readFileSync(fontsPath, 'utf-8'); 1500 + // Extract font_1 object from the module source (it's a simple object literal) 1501 + const font1Match = fontsSrc.match(/export\s+const\s+font_1\s*=\s*(\{[\s\S]*?\n\});/); 1502 + if (!font1Match) { 1503 + console.warn('⚠️ Could not parse font_1 from fonts.mjs'); 1504 + return null; 1505 + } 1506 + // Evaluate the object (safe: it's our own source file with simple key-value pairs) 1507 + font1Data = new Function('return ' + font1Match[1])(); 1508 + } catch (err) { 1509 + console.warn(`⚠️ Could not load fonts.mjs: ${err.message}`); 1510 + return null; 1511 + } 1512 + 1513 + const metaKeys = new Set(['glyphHeight', 'glyphWidth', 'proportional', 'bdfFallback']); 1514 + const glyphs = {}; 1515 + let loaded = 0, failed = 0; 1516 + for (const [char, location] of Object.entries(font1Data)) { 1517 + if (metaKeys.has(char)) continue; 1518 + try { 1519 + const filePath = join(fontDir, `${location}.json`); 1520 + const data = readFileSync(filePath, 'utf-8'); 1521 + glyphs[char] = JSON.parse(data); 1522 + loaded++; 1523 + } catch { 1524 + failed++; 1525 + } 1526 + } 1527 + 1528 + console.log(` 🔤 Loaded ${loaded} font_1 glyphs from disk (${failed} failed)`); 1529 + _cachedFontGlyphs = loaded > 0 ? glyphs : null; 1530 + return _cachedFontGlyphs; 1531 + } 1532 + 1533 + // Pre-load glyph data (call before page.goto). 1534 + async function injectFontGlyphs(page) { 1535 + loadFontGlyphsSync(); // Warm the cache 1536 + } 1537 + 1538 + // Populate the page's IndexedDB glyph cache after navigation. 1539 + // Must be called AFTER page.goto() so IndexedDB is on the correct origin. 1540 + // The disk.mjs web worker shares IndexedDB with the main page, so when 1541 + // type.mjs calls preWarmGlyphCache(), it finds all 97 glyphs already cached 1542 + // and skips the XHR requests entirely (which hang under Puppeteer interception). 1543 + async function populateGlyphCache(page) { 1544 + const glyphs = loadFontGlyphsSync(); 1545 + if (!glyphs) { 1546 + console.warn('⚠️ No local font_1 glyphs available — captures may show ????'); 1547 + return; 1548 + } 1549 + 1550 + try { 1551 + const result = await page.evaluate(async (glyphData) => { 1552 + return new Promise((resolve, reject) => { 1553 + const DB_NAME = 'ac-glyph-cache'; 1554 + const DB_VERSION = 2; 1555 + const STORE_NAME = 'glyphs'; 1556 + const META_STORE_NAME = 'glyph-meta'; 1557 + 1558 + const req = indexedDB.open(DB_NAME, DB_VERSION); 1559 + req.onupgradeneeded = (e) => { 1560 + const db = e.target.result; 1561 + if (!db.objectStoreNames.contains(STORE_NAME)) { 1562 + db.createObjectStore(STORE_NAME); 1563 + } 1564 + if (!db.objectStoreNames.contains(META_STORE_NAME)) { 1565 + db.createObjectStore(META_STORE_NAME); 1566 + } 1567 + }; 1568 + req.onerror = () => reject(new Error('IDB open failed')); 1569 + req.onsuccess = (e) => { 1570 + const db = e.target.result; 1571 + const tx = db.transaction(STORE_NAME, 'readwrite'); 1572 + const store = tx.objectStore(STORE_NAME); 1573 + let count = 0; 1574 + for (const [char, data] of Object.entries(glyphData)) { 1575 + store.put(data, `font_1:${char}`); 1576 + count++; 1577 + } 1578 + tx.oncomplete = () => { 1579 + db.close(); 1580 + resolve(count); 1581 + }; 1582 + tx.onerror = () => { 1583 + db.close(); 1584 + reject(new Error('IDB transaction failed')); 1585 + }; 1586 + }; 1587 + }); 1588 + }, glyphs); 1589 + console.log(` 🔤 Populated IndexedDB glyph cache: ${result} entries`); 1590 + } catch (err) { 1591 + console.warn(` ⚠️ Failed to populate glyph cache: ${err.message}`); 1592 + } 1593 + } 1594 + 1595 + /** 1596 + * Set up request interception on a Puppeteer page. 1597 + * - Blocks self-referential requests and analytics 1598 + * - Drops non-essential API calls (boot-log, mood) 1599 + * - Serves font glyph JSONs from local filesystem (avoids 429 rate limits) 1600 + */ 1601 + async function interceptSelfRequests(page) { 1602 + await page.setRequestInterception(true); 1603 + // Track interception stats for diagnostics 1604 + const stats = { fontLocal: 0, fontMiss: 0, bdfLocal: 0, bdfMiss: 0, blocked: 0, dropped: 0 }; 1605 + page._interceptStats = stats; 1606 + 1607 + page.on('request', async (request) => { 1608 + const url = request.url(); 1609 + try { 1610 + // Block analytics and self-referential requests 1611 + if (BLOCKED_URL_PATTERNS.some(pattern => url.includes(pattern))) { 1612 + stats.blocked++; 1613 + await request.abort('blockedbyclient'); 1614 + return; 1615 + } 1616 + 1617 + // Drop non-essential API calls with a 200 OK (no-op) 1618 + if (DROPPABLE_API_PATTERNS.some(pattern => url.includes(pattern))) { 1619 + stats.dropped++; 1620 + await request.respond({ status: 200, contentType: 'application/json', body: '{}' }); 1621 + return; 1622 + } 1623 + 1624 + // Font_1 glyph JSONs — if IndexedDB was pre-populated (populateGlyphCache), 1625 + // these requests should never happen. If they do, let them pass through 1626 + // to aesthetic.computer. Do NOT use request.respond() — it hangs for 1627 + // worker XHRs (Puppeteer CDP limitation). 1628 + if (url.includes('/disks/drawings/font_1/') && url.endsWith('.json')) { 1629 + stats.fontLocal++; 1630 + await request.continue(); 1631 + return; 1632 + } 1633 + 1634 + // Serve BDF glyph batch responses from local cache 1635 + if (url.includes('/api/bdf-glyph')) { 1636 + const localResponse = tryLocalBDFGlyphs(url); 1637 + if (localResponse) { 1638 + stats.bdfLocal++; 1639 + await request.respond({ 1640 + status: 200, 1641 + contentType: 'application/json', 1642 + body: localResponse, 1643 + }); 1644 + return; 1645 + } 1646 + stats.bdfMiss++; 1647 + console.log(` [LOCAL MISS] bdf-glyph: ${url.slice(url.indexOf('?'))}`); 1648 + } 1649 + 1650 + await request.continue(); 1651 + } catch (err) { 1652 + // Request may already be handled (page navigated, etc.) 1653 + if (!err.message?.includes('Request is already handled')) { 1654 + console.log(` [INTERCEPT ERR] ${err.message} for ${url.slice(0, 80)}`); 1655 + } 1656 + } 1657 + }); 1658 + } 1659 + 1660 + /** 1661 + * Capture frames from a KidLisp piece 1662 + * @param {string} piece - Piece code (e.g., '$roz' or 'roz') 1663 + * @param {object} options - Capture options 1664 + * @returns {Promise<Buffer[]>} Array of PNG frame buffers 1665 + */ 1666 + async function captureFrames(piece, options = {}) { 1667 + const { 1668 + width = 512, 1669 + height = 512, 1670 + duration = 12000, 1671 + fps = 7.5, 1672 + density = 1, 1673 + viewportScale = null, // Override deviceScaleFactor (null = use density) 1674 + baseUrl = 'https://aesthetic.computer', 1675 + frames: explicitFrames, // Allow explicit frame count override 1676 + grabId = null, // For per-grab progress tracking 1677 + } = options; 1678 + 1679 + // Calculate frames from duration and fps, or use explicit count 1680 + const frames = explicitFrames ?? Math.ceil((duration / 1000) * fps); 1681 + 1682 + // viewportScale: how much to scale the browser viewport 1683 + // - null/undefined: use density (legacy behavior - captures at density*width x density*height) 1684 + // - 1: capture at exact width x height (for app screenshots where density is just for pixel size) 1685 + const effectiveViewportScale = viewportScale ?? density; 1686 + 1687 + // Use piece name as-is (caller decides if $ prefix is needed for KidLisp) 1688 + // tv=true (non-interactive), nolabel=true (no HUD label), nogap=true (no border) 1689 + // noboot=true skips the boot canvas animation to prevent zoom/scale glitches in captures 1690 + const url = `${baseUrl}/${piece}?density=${density}&tv=true&nolabel=true&nogap=true&spoofaudio=true&noboot=true`; 1691 + 1692 + console.log(`📸 Capturing ${frames} frames from ${url} (${fps} fps, ${duration}ms)`); 1693 + console.log(` Size: ${width}x${height}`); 1694 + 1695 + const browser = await getBrowser(); 1696 + const page = await browser.newPage(); 1697 + await interceptSelfRequests(page); 1698 + await injectFontGlyphs(page); 1699 + 1700 + // Log page console messages for debugging (skip blocked-request noise) 1701 + page.on('console', msg => { 1702 + const type = msg.type(); 1703 + const text = msg.text(); 1704 + if (text.includes('ERR_BLOCKED_BY_CLIENT')) return; 1705 + if (text.includes('Failed to load resource: net::ERR_')) return; 1706 + if (type === 'error' || text.includes('KidLisp') || text.includes('$') || text.includes('acPieceReady') || text.includes('BOOT') || text.includes('font') || text.includes('glyph') || text.includes('Typeface') || text.includes('🔤')) { 1707 + console.log(` [PAGE ${type}] ${text}`); 1708 + } 1709 + }); 1710 + 1711 + page.on('pageerror', error => { 1712 + if (error.message?.includes('ERR_BLOCKED_BY_CLIENT')) return; 1713 + console.log(` [PAGE ERROR] ${error.message}`); 1714 + }); 1715 + 1716 + // Log failed requests to identify 404s (skip intentionally blocked requests) 1717 + page.on('requestfailed', request => { 1718 + const url = request.url(); 1719 + if (BLOCKED_URL_PATTERNS.some(p => url.includes(p))) return; 1720 + console.log(` [REQUEST FAILED] ${url} - ${request.failure()?.errorText}`); 1721 + }); 1722 + 1723 + page.on('response', response => { 1724 + if (response.status() >= 400) { 1725 + console.log(` [HTTP ${response.status()}] ${response.url()}`); 1726 + } 1727 + }); 1728 + 1729 + try { 1730 + // Set viewport - effectiveViewportScale controls the actual capture resolution 1731 + // For app screenshots: viewportScale=1 captures at exact width x height 1732 + // For legacy grabs: viewportScale=density captures at density*width x density*height 1733 + await page.setViewport({ width, height, deviceScaleFactor: effectiveViewportScale }); 1734 + 1735 + // Navigate to piece 1736 + console.log(` Loading piece...`); 1737 + await page.goto(url, { 1738 + waitUntil: 'domcontentloaded', // Changed from networkidle2 - $code pieces continue network activity 1739 + timeout: 30000 1740 + }); 1741 + 1742 + // Pre-populate IndexedDB glyph cache on this page so the disk worker 1743 + // finds font_1 glyphs locally instead of making XHR requests that hang 1744 + // under Puppeteer's CDP request interception. 1745 + await populateGlyphCache(page); 1746 + 1747 + // Black background — matches HTML bundle style, prevents white borders in thumbnails 1748 + await page.evaluate(() => { 1749 + document.documentElement.style.background = 'black'; 1750 + document.body.style.background = 'black'; 1751 + }); 1752 + 1753 + // Wait for canvas to be ready 1754 + await page.waitForSelector('canvas', { timeout: 10000 }); 1755 + console.log(' ✓ Canvas found'); 1756 + 1757 + // Wait for the aesthetic-computer wrapper (created by bios.mjs after boot) 1758 + const wrapperFound = await page.waitForSelector('#aesthetic-computer', { timeout: 10000 }).then(() => true).catch(() => { 1759 + console.log(' ⚠️ #aesthetic-computer wrapper not found, continuing anyway...'); 1760 + return false; 1761 + }); 1762 + if (wrapperFound) console.log(' ✓ Wrapper found'); 1763 + 1764 + // Wait for piece to signal it's ready via window.acPieceReady 1765 + // This is set by disk.mjs after the first paint completes 1766 + console.log(' ⏳ Waiting for piece ready signal (window.acPieceReady)...'); 1767 + 1768 + await updateProgressWithPreview(grabId, page, { 1769 + stage: 'loading', 1770 + stageDetail: 'Waiting for piece...', 1771 + percent: 5, 1772 + }); 1773 + 1774 + const pieceWaitStart = Date.now(); 1775 + // 30s gives cold-start loads (service worker cache miss) time to finish. 1776 + // Warm loads typically signal ready in 2-8s. 1777 + const maxPieceWait = 30000; // 30 seconds max for piece to load 1778 + let pieceReady = false; 1779 + let lastPreviewTime = 0; 1780 + // Track boot-hidden state for early exit (piece loaded but acPieceReady never set) 1781 + let bootHiddenSince = 0; 1782 + // When noboot=true, boot canvas is absent from the start — the boot-canvas-hidden 1783 + // heuristic would fire immediately (after 3s) even though the piece hasn't loaded. 1784 + // Detect this so we can use a longer heuristic timeout for noboot captures. 1785 + const isNoboot = url.includes('noboot=true'); 1786 + 1787 + while (!pieceReady && (Date.now() - pieceWaitStart) < maxPieceWait) { 1788 + const status = await page.evaluate(() => { 1789 + const ready = window.acPieceReady === true; 1790 + const readyTime = window.acPieceReadyTime; 1791 + const bootCanvas = document.getElementById('boot-canvas'); 1792 + const bootHidden = !bootCanvas || 1793 + window.getComputedStyle(bootCanvas).display === 'none' || 1794 + window.getComputedStyle(bootCanvas).opacity === '0'; 1795 + // Check if piece paint is running by looking for the main canvas content 1796 + const mainCanvas = document.querySelector('#aesthetic-computer canvas'); 1797 + const canvasActive = mainCanvas && mainCanvas.width > 0 && mainCanvas.height > 0; 1798 + return { ready, readyTime, bootHidden, canvasActive }; 1799 + }); 1800 + 1801 + pieceReady = status.ready; 1802 + 1803 + // Track when boot canvas hides (piece has loaded even if acPieceReady didn't fire) 1804 + if (status.bootHidden && !bootHiddenSince) { 1805 + bootHiddenSince = Date.now(); 1806 + } 1807 + 1808 + // Early exit: if boot canvas has been hidden for a while and canvas is active, 1809 + // treat as ready (workaround for acPieceReady not firing in headless Chrome). 1810 + // With noboot=true, the boot canvas is absent from the start, so use a longer 1811 + // timeout (15s) to let KidLisp pieces fully initialize + load resources. 1812 + const heuristicTimeout = isNoboot ? 15000 : 3000; 1813 + if (!pieceReady && bootHiddenSince && status.canvasActive) { 1814 + const hiddenFor = Date.now() - bootHiddenSince; 1815 + if (hiddenFor > heuristicTimeout) { 1816 + console.log(` ✅ Piece detected as ready via boot-canvas-hidden heuristic (${hiddenFor}ms${isNoboot ? ', noboot mode' : ''})`); 1817 + pieceReady = true; 1818 + break; 1819 + } 1820 + } 1821 + 1822 + if (!pieceReady) { 1823 + const elapsed = Date.now() - pieceWaitStart; 1824 + const phase = status.bootHidden ? 'Loading piece' : 'Booting'; 1825 + 1826 + // Stream preview every 200ms for smooth updates 1827 + if (Date.now() - lastPreviewTime > 200) { 1828 + await updateProgressWithPreview(grabId, page, { 1829 + stage: 'loading', 1830 + stageDetail: `${phase}... ${Math.round(elapsed/1000)}s / ${maxPieceWait/1000}s`, 1831 + percent: Math.min(25, 5 + (elapsed / maxPieceWait) * 20), 1832 + }); 1833 + lastPreviewTime = Date.now(); 1834 + } 1835 + 1836 + await new Promise(r => setTimeout(r, 100)); 1837 + } 1838 + } 1839 + 1840 + if (pieceReady) { 1841 + if (!bootHiddenSince) { 1842 + console.log(` ✅ Piece ready after ${Date.now() - pieceWaitStart}ms`); 1843 + } 1844 + } else { 1845 + console.log(` ⚠️ Piece ready signal not received after ${maxPieceWait}ms`); 1846 + // Force hide boot canvas if still visible 1847 + await page.evaluate(() => { 1848 + const bootCanvas = document.getElementById('boot-canvas'); 1849 + if (bootCanvas) bootCanvas.style.display = 'none'; 1850 + }); 1851 + } 1852 + 1853 + // Wait for any pending async image loads (paste/stamp #code references) 1854 + // Images fetched via prefetchPicture() are async — acPieceReady fires before 1855 + // they finish loading. Poll window.acPendingImages() until all resolve. 1856 + const maxImageWait = 10000; // 10s max for image fetches 1857 + const imageWaitStart = Date.now(); 1858 + let pendingImages = 0; 1859 + try { 1860 + pendingImages = await page.evaluate(() => 1861 + typeof window.acPendingImages === 'function' ? window.acPendingImages() : 0 1862 + ); 1863 + } catch {} 1864 + 1865 + if (pendingImages > 0) { 1866 + console.log(` ⏳ Waiting for ${pendingImages} pending image(s) to load...`); 1867 + await updateProgressWithPreview(grabId, page, { 1868 + stage: 'loading', 1869 + stageDetail: `Loading ${pendingImages} image(s)...`, 1870 + percent: 26, 1871 + }); 1872 + 1873 + while ((Date.now() - imageWaitStart) < maxImageWait) { 1874 + await new Promise(r => setTimeout(r, 200)); 1875 + try { 1876 + pendingImages = await page.evaluate(() => 1877 + typeof window.acPendingImages === 'function' ? window.acPendingImages() : 0 1878 + ); 1879 + } catch { break; } 1880 + if (pendingImages === 0) break; 1881 + } 1882 + 1883 + if (pendingImages === 0) { 1884 + console.log(` ✅ All images loaded after ${Date.now() - imageWaitStart}ms`); 1885 + } else { 1886 + console.log(` ⚠️ ${pendingImages} image(s) still pending after ${maxImageWait}ms timeout`); 1887 + } 1888 + } 1889 + 1890 + // Wait for fonts to finish loading (tf.load() is async and not awaited) 1891 + // Without this, write() renders ???? because glyphs aren't populated yet 1892 + const maxFontWait = 10000; 1893 + const fontWaitStart = Date.now(); 1894 + let fontsReady = false; 1895 + try { 1896 + fontsReady = await page.evaluate(() => window.acFontsReady === true); 1897 + } catch {} 1898 + 1899 + if (!fontsReady) { 1900 + console.log(' ⏳ Waiting for fonts to load (window.acFontsReady)...'); 1901 + while (!fontsReady && (Date.now() - fontWaitStart) < maxFontWait) { 1902 + await new Promise(r => setTimeout(r, 200)); 1903 + try { 1904 + const state = await page.evaluate(() => window.acFontsReady); 1905 + if (state === true) { fontsReady = true; break; } 1906 + if (state === 'error') { 1907 + console.log(' ❌ tf.load() threw an error — fonts failed to load'); 1908 + break; 1909 + } 1910 + } catch { break; } 1911 + } 1912 + if (fontsReady) { 1913 + console.log(` ✅ Fonts ready after ${Date.now() - fontWaitStart}ms`); 1914 + } else { 1915 + const finalState = await page.evaluate(() => window.acFontsReady).catch(() => 'eval-error'); 1916 + console.log(` ⚠️ Fonts not ready after ${Date.now() - fontWaitStart}ms (acFontsReady=${finalState})`); 1917 + } 1918 + } else { 1919 + console.log(' ✅ Fonts already loaded'); 1920 + } 1921 + 1922 + // Log interception stats 1923 + const interceptStats = page._interceptStats; 1924 + if (interceptStats) { 1925 + console.log(` 📊 Intercept stats: fontLocal=${interceptStats.fontLocal} fontMiss=${interceptStats.fontMiss} bdfLocal=${interceptStats.bdfLocal} bdfMiss=${interceptStats.bdfMiss} blocked=${interceptStats.blocked} dropped=${interceptStats.dropped}`); 1926 + } 1927 + 1928 + // Diagnostic: inspect typeface glyph state in the page 1929 + try { 1930 + const fontDiag = await page.evaluate(() => { 1931 + const diag = { 1932 + acFontsReady: window.acFontsReady, 1933 + acPieceReady: window.acPieceReady, 1934 + }; 1935 + try { 1936 + if (window.__acTypeface) { 1937 + const tf = window.__acTypeface; 1938 + diag.tfName = tf.name; 1939 + diag.glyphKeys = Object.keys(tf.glyphs || {}).length; 1940 + diag.hasF = !!tf.glyphs?.['f']; 1941 + diag.hasO = !!tf.glyphs?.['o']; 1942 + } else { 1943 + diag.noTfRef = true; 1944 + } 1945 + // Check glyph cache stats 1946 + if (window.acGlyphCache?.stats) { 1947 + diag.cache = window.acGlyphCache.stats(); 1948 + } 1949 + } catch (e) { 1950 + diag.tfError = e.message; 1951 + } 1952 + return diag; 1953 + }); 1954 + console.log(` 🔤 Font diagnostics:`, JSON.stringify(fontDiag)); 1955 + } catch (e) { 1956 + console.log(` 🔤 Font diagnostics failed: ${e.message}`); 1957 + } 1958 + 1959 + // Settle time: let the piece run a bit more after ready signal 1960 + // For stills, wait longer to let animations stabilize 1961 + // For animations, just a small buffer 1962 + // KidLisp ($code) pieces need extra time for generative rendering to complete 1963 + const isStill = frames === 1; 1964 + const isKidLisp = piece.startsWith('$'); 1965 + const settleTime = isStill ? (isKidLisp ? 5000 : 500) : 200; 1966 + console.log(` ${isStill ? '⏳ Settling for still capture' : '⏳ Brief settle'}... (${settleTime}ms)`); 1967 + 1968 + // Send preview before settling 1969 + await updateProgressWithPreview(grabId, page, { 1970 + stage: 'settling', 1971 + stageDetail: `Settling... ${settleTime}ms`, 1972 + percent: 28, 1973 + }); 1974 + 1975 + await new Promise(r => setTimeout(r, settleTime)); 1976 + 1977 + // Capture frames at intervals 1978 + const frameInterval = duration / frames; 1979 + const capturedFrames = []; 1980 + 1981 + // Update progress with preview: entering capture stage 1982 + await updateProgressWithPreview(grabId, page, { 1983 + stage: 'capturing', 1984 + stageDetail: `Starting frame capture...`, 1985 + framesCaptured: 0, 1986 + framesTotal: frames, 1987 + percent: 30, 1988 + }); 1989 + 1990 + console.log(` Capturing frames...`); 1991 + // Create a single CDP session for the entire capture loop to avoid the 1992 + // overhead and potential compositor interference of creating/destroying a 1993 + // session per frame (90+ times for a 12-second animation). 1994 + const captureClient = await page.createCDPSession(); 1995 + try { 1996 + for (let i = 0; i < frames; i++) { 1997 + console.log(` [Frame ${i+1}] Starting capture...`); 1998 + 1999 + // Only capture a live preview every 10 frames — calling page.screenshot() 2000 + // before every single frame added significant overhead and could cause 2001 + // the WebGL compositor to miss frames in the CDP capture. 2002 + const capturePercent = 30 + ((i / frames) * 50); // 30% to 80% during capture 2003 + if (i % 10 === 0) { 2004 + await updateProgressWithPreview(grabId, page, { 2005 + framesCaptured: i, 2006 + stageDetail: `Frame ${i + 1}/${frames}`, 2007 + percent: Math.round(capturePercent), 2008 + }); 2009 + } else { 2010 + updateProgress(grabId, { 2011 + framesCaptured: i, 2012 + stageDetail: `Frame ${i + 1}/${frames}`, 2013 + percent: Math.round(capturePercent), 2014 + }); 2015 + } 2016 + 2017 + // Use the shared CDP session for screenshot - more reliable than 2018 + // page.screenshot for WebGL, and avoids session churn per frame. 2019 + let frameBuffer; 2020 + try { 2021 + const screenshotPromise = captureClient.send('Page.captureScreenshot', { 2022 + format: 'png', 2023 + clip: { x: 0, y: 0, width, height, scale: 1 }, 2024 + captureBeyondViewport: false 2025 + }); 2026 + 2027 + const timeoutPromise = new Promise((_, reject) => 2028 + setTimeout(() => reject(new Error('CDP Screenshot timeout')), 10000) 2029 + ); 2030 + 2031 + const result = await Promise.race([screenshotPromise, timeoutPromise]); 2032 + frameBuffer = result.data; 2033 + console.log(` [Frame ${i+1}] Screenshot captured (${frameBuffer.length} bytes)`); 2034 + } catch (err) { 2035 + console.log(` ❌ Frame capture failed: ${err.message}`); 2036 + frameBuffer = null; 2037 + } 2038 + 2039 + if (frameBuffer) { 2040 + const buffer = Buffer.from(frameBuffer, 'base64'); 2041 + // Check if frame is blank (all black or all transparent) 2042 + const isBlank = await isBlankFrame(buffer); 2043 + if (isBlank) { 2044 + console.log(` ⚠️ Frame ${i + 1} is blank, skipping`); 2045 + } else { 2046 + capturedFrames.push(buffer); 2047 + } 2048 + } 2049 + 2050 + // Wait for next frame 2051 + if (i < frames - 1) { 2052 + await new Promise(r => setTimeout(r, frameInterval)); 2053 + } 2054 + } 2055 + } finally { 2056 + await captureClient.detach().catch(() => {}); 2057 + } 2058 + 2059 + console.log(` ✅ Captured ${capturedFrames.length} non-blank frames`); 2060 + return capturedFrames; 2061 + 2062 + } finally { 2063 + await page.close(); 2064 + } 2065 + } 2066 + 2067 + /** 2068 + * Capture a single frame (for PNG thumbnail) 2069 + */ 2070 + async function captureFrame(piece, options = {}) { 2071 + const frames = await captureFrames(piece, { ...options, frames: 1, duration: 0 }); 2072 + return frames[0] || null; 2073 + } 2074 + 2075 + /** 2076 + * Convert frames to GIF using ffmpeg 2077 + * @param {Buffer[]} frames - Array of PNG frame buffers 2078 + * @param {object} options - GIF options 2079 + * @returns {Promise<Buffer>} GIF buffer 2080 + */ 2081 + async function framesToGif(frames, options = {}) { 2082 + const { 2083 + fps = 15, 2084 + width = 400, 2085 + height = 400, 2086 + loop = 0, // 0 = infinite loop 2087 + } = options; 2088 + 2089 + if (frames.length === 0) { 2090 + throw new Error('No frames to convert'); 2091 + } 2092 + 2093 + // Create temp directory 2094 + const workDir = join(tmpdir(), `grab-${randomBytes(8).toString('hex')}`); 2095 + await fs.mkdir(workDir, { recursive: true }); 2096 + 2097 + try { 2098 + // Write frames to disk 2099 + console.log(` Writing ${frames.length} frames to temp directory...`); 2100 + for (let i = 0; i < frames.length; i++) { 2101 + const framePath = join(workDir, `frame-${String(i).padStart(5, '0')}.png`); 2102 + await fs.writeFile(framePath, frames[i]); 2103 + } 2104 + 2105 + // Generate GIF using ffmpeg 2106 + const outputPath = join(workDir, 'output.gif'); 2107 + 2108 + console.log(` Generating GIF with ffmpeg...`); 2109 + await new Promise((resolve, reject) => { 2110 + const ffmpeg = spawn('ffmpeg', [ 2111 + '-y', 2112 + '-framerate', String(fps), 2113 + '-i', join(workDir, 'frame-%05d.png'), 2114 + '-vf', `scale=${width}:${height}:flags=neighbor,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=single[p];[s1][p]paletteuse=dither=none`, 2115 + '-loop', String(loop), 2116 + outputPath 2117 + ]); 2118 + 2119 + let stderr = ''; 2120 + ffmpeg.stderr.on('data', (data) => { 2121 + stderr += data.toString(); 2122 + }); 2123 + 2124 + ffmpeg.on('close', (code) => { 2125 + if (code === 0) { 2126 + resolve(); 2127 + } else { 2128 + reject(new Error(`ffmpeg exited with code ${code}: ${stderr}`)); 2129 + } 2130 + }); 2131 + 2132 + ffmpeg.on('error', reject); 2133 + }); 2134 + 2135 + // Read the GIF 2136 + const gifBuffer = await fs.readFile(outputPath); 2137 + const sizeKB = (gifBuffer.length / 1024).toFixed(2); 2138 + console.log(` ✅ GIF generated: ${sizeKB} KB`); 2139 + 2140 + return gifBuffer; 2141 + 2142 + } finally { 2143 + // Cleanup 2144 + await fs.rm(workDir, { recursive: true, force: true }); 2145 + } 2146 + } 2147 + 2148 + /** 2149 + * Generate a scaled PNG thumbnail from a frame 2150 + */ 2151 + async function frameToThumbnail(frameBuffer, options = {}) { 2152 + const { 2153 + width = 400, 2154 + height = 400, 2155 + } = options; 2156 + 2157 + const sharp = (await import('sharp')).default; 2158 + 2159 + const thumbnail = await sharp(frameBuffer) 2160 + .resize(width, height, { 2161 + fit: 'contain', 2162 + background: { r: 0, g: 0, b: 0, alpha: 1 }, 2163 + kernel: 'nearest', // Pixel-perfect scaling 2164 + }) 2165 + .png() 2166 + .toBuffer(); 2167 + 2168 + return thumbnail; 2169 + } 2170 + 2171 + /** 2172 + * Convert frames to animated WebP using ffmpeg 2173 + * @param {Buffer[]} frames - Array of PNG frame buffers 2174 + * @param {object} options - WebP options 2175 + * @returns {Promise<Buffer>} WebP buffer 2176 + */ 2177 + async function framesToWebp(frames, options = {}) { 2178 + const { 2179 + playbackFps = 15, // Playback speed (faster than capture) 2180 + width = 512, 2181 + height = 512, 2182 + loop = 0, // 0 = infinite loop 2183 + quality = 90, 2184 + } = options; 2185 + 2186 + if (frames.length === 0) { 2187 + throw new Error('No frames to convert'); 2188 + } 2189 + 2190 + // Create temp directory 2191 + const workDir = join(tmpdir(), `grab-${randomBytes(8).toString('hex')}`); 2192 + await fs.mkdir(workDir, { recursive: true }); 2193 + 2194 + try { 2195 + // Write frames to disk (flattened to black background) 2196 + console.log(` Writing ${frames.length} frames to temp directory...`); 2197 + for (let i = 0; i < frames.length; i++) { 2198 + const framePath = join(workDir, `frame-${String(i).padStart(5, '0')}.png`); 2199 + const flattened = await sharp(frames[i]) 2200 + .flatten({ background: { r: 0, g: 0, b: 0 } }) 2201 + .png() 2202 + .toBuffer(); 2203 + await fs.writeFile(framePath, flattened); 2204 + } 2205 + 2206 + // Generate animated WebP using ffmpeg 2207 + const outputPath = join(workDir, 'output.webp'); 2208 + 2209 + console.log(` Generating animated WebP with ffmpeg (${playbackFps} fps playback)...`); 2210 + await new Promise((resolve, reject) => { 2211 + const ffmpeg = spawn('ffmpeg', [ 2212 + '-y', 2213 + '-framerate', String(playbackFps), // Playback framerate 2214 + '-i', join(workDir, 'frame-%05d.png'), 2215 + '-vf', `scale=${width}:${height}:flags=neighbor`, // Pixel-perfect scaling 2216 + '-c:v', 'libwebp', 2217 + '-lossless', '0', 2218 + '-compression_level', '6', // Higher = better compression (0-6) 2219 + '-qmin', '50', // Minimum quality 2220 + '-qmax', String(quality), // Maximum quality 2221 + '-quality', String(quality), 2222 + '-loop', String(loop), 2223 + '-preset', 'drawing', // Better for graphics/art (was 'picture') 2224 + '-pix_fmt', 'yuv420p', // Standard pixel format (smaller than yuva420p) 2225 + '-an', 2226 + outputPath 2227 + ]); 2228 + 2229 + let stderr = ''; 2230 + ffmpeg.stderr.on('data', (data) => { 2231 + stderr += data.toString(); 2232 + }); 2233 + 2234 + ffmpeg.on('close', (code) => { 2235 + if (code === 0) { 2236 + resolve(); 2237 + } else { 2238 + reject(new Error(`ffmpeg exited with code ${code}: ${stderr}`)); 2239 + } 2240 + }); 2241 + 2242 + ffmpeg.on('error', reject); 2243 + }); 2244 + 2245 + // Read the WebP 2246 + const webpBuffer = await fs.readFile(outputPath); 2247 + const sizeKB = (webpBuffer.length / 1024).toFixed(2); 2248 + console.log(` ✅ WebP generated: ${sizeKB} KB`); 2249 + 2250 + return webpBuffer; 2251 + 2252 + } finally { 2253 + // Cleanup 2254 + await fs.rm(workDir, { recursive: true, force: true }); 2255 + } 2256 + } 2257 + 2258 + // Blacklisted domains/patterns that should never be processed 2259 + const BLACKLISTED_PIECES = [ 2260 + /\.com$/i, 2261 + /\.net$/i, 2262 + /\.org$/i, 2263 + /\.io$/i, 2264 + /\.dev$/i, 2265 + /\.app$/i, 2266 + /\.xyz$/i, 2267 + /\.co$/i, 2268 + /^https?:\/\//i, 2269 + /sundarakarma/i, 2270 + ]; 2271 + 2272 + // Keep support for small icons/previews (wallet, prompt list, etc.). 2273 + const MIN_GRAB_DIMENSION = 32; 2274 + const MAX_GRAB_DIMENSION = 1000; 2275 + 2276 + /** 2277 + * Check if a piece name is blacklisted (external domain or invalid) 2278 + */ 2279 + function isPieceBlacklisted(piece) { 2280 + if (!piece || typeof piece !== 'string') return true; 2281 + return BLACKLISTED_PIECES.some(pattern => pattern.test(piece)); 2282 + } 2283 + 2284 + /** 2285 + * Full grab workflow: capture piece and generate thumbnail/GIF/WebP 2286 + */ 2287 + export async function grabPiece(piece, options = {}) { 2288 + // Reject blacklisted pieces (external domains, URLs, etc.) 2289 + if (isPieceBlacklisted(piece)) { 2290 + console.log(`🚫 Rejecting blacklisted piece: ${piece}`); 2291 + return { 2292 + success: false, 2293 + error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported`, 2294 + piece, 2295 + }; 2296 + } 2297 + 2298 + const { 2299 + format = 'webp', // 'webp', 'gif', or 'png' 2300 + width = 512, 2301 + height = 512, 2302 + duration = 12000, 2303 + fps = 7.5, // Capture fps 2304 + playbackFps = 15, // Playback fps (2x speed) 2305 + density = 1, 2306 + viewportScale = null, // Override deviceScaleFactor (null = use density) 2307 + quality = 90, 2308 + baseUrl = 'https://aesthetic.computer', 2309 + skipCache = false, // Force regeneration 2310 + cacheKey = '', // Optional extra cache discriminator (e.g. source hash) 2311 + source = 'manual', // 'keep', 'manual', 'api', etc. 2312 + keepId = null, // Tezos keep token ID if source is 'keep' 2313 + author = null, // Author handle (e.g. "@jeffrey") 2314 + pieceCreatedAt = null, // When the piece was originally created 2315 + requestOrigin = null, // Referer/origin of the request 2316 + } = options; 2317 + 2318 + // For captureKey: use actual output size (viewportScale=1 means exact size, otherwise density-scaled) 2319 + const effectiveScale = viewportScale ?? density; 2320 + const outputWidth = width * effectiveScale; 2321 + const outputHeight = height * effectiveScale; 2322 + 2323 + const animated = format !== 'png'; 2324 + const captureKey = getCaptureKey(piece, outputWidth, outputHeight, format, animated, cacheKey); 2325 + 2326 + // Check for existing capture (deduplication) 2327 + if (!skipCache) { 2328 + const existing = await checkExistingCapture(captureKey); 2329 + if (existing.exists && existing.cdnUrl) { 2330 + console.log(`♻️ Returning cached capture: ${captureKey}`); 2331 + return { 2332 + success: true, 2333 + grabId: existing.grab?.id || captureKey, 2334 + piece, 2335 + format, 2336 + cdnUrl: existing.cdnUrl, 2337 + cached: true, 2338 + captureKey, 2339 + }; 2340 + } 2341 + } 2342 + 2343 + // For ID purposes, strip $ prefix; but keep original piece name for URL generation 2344 + const pieceName = piece.replace(/^\$/, ''); 2345 + const grabId = `${pieceName}-${randomBytes(4).toString('hex')}`; 2346 + 2347 + console.log(`\n🎬 Starting grab: ${grabId}`); 2348 + console.log(` Piece: ${piece}`); 2349 + console.log(` Format: ${format}`); 2350 + console.log(` CaptureKey: ${captureKey}`); 2351 + if (source) console.log(` Source: ${source}${keepId ? ' #' + keepId : ''}`); 2352 + 2353 + serverLog('queue', '📋', `Grab queued: ${piece} (${format} ${width}×${height})`); 2354 + // Track active grab - store original piece name (with $ if KidLisp) 2355 + activeGrabs.set(grabId, { 2356 + id: grabId, 2357 + piece: piece, // Keep original with $ prefix for URL generation 2358 + format, 2359 + status: 'queued', 2360 + startTime: Date.now(), 2361 + captureKey, // For deduplication lookup 2362 + gitVersion: GIT_VERSION, 2363 + dimensions: { width: outputWidth, height: outputHeight }, 2364 + source: source || 'manual', 2365 + keepId: keepId || null, 2366 + author: author || null, 2367 + pieceCreatedAt: pieceCreatedAt || null, 2368 + requestOrigin: requestOrigin || null, 2369 + }); 2370 + 2371 + // Use queue to serialize capture operations (avoid parallel puppeteer sessions) 2372 + // Pass metadata for queue visibility + priority scheduling 2373 + return enqueueGrab(async () => { 2374 + try { 2375 + let result; 2376 + activeGrabs.get(grabId).status = 'capturing'; 2377 + 2378 + // Initialize progress state for this grab 2379 + updateProgress(grabId, { 2380 + piece: piece, 2381 + format: format, 2382 + stage: 'loading', 2383 + stageDetail: `Starting ${piece}...`, 2384 + percent: 0, 2385 + framesCaptured: 0, 2386 + framesTotal: Math.ceil((duration / 1000) * fps), 2387 + author: author || null, 2388 + pieceCreatedAt: pieceCreatedAt || null, 2389 + requestedAt: activeGrabs.get(grabId)?.startTime || Date.now(), 2390 + source: source || 'manual', 2391 + requestOrigin: requestOrigin || null, 2392 + }); 2393 + 2394 + if (format === 'png') { 2395 + // Single frame PNG — captureFrame filters blank frames via captureFrames, 2396 + // but nearly-black frames (a few pixels above threshold) can slip through. 2397 + // Validate explicitly before caching to prevent black preview images. 2398 + const frame = await captureFrame(piece, { width, height, density, viewportScale, baseUrl, grabId }); 2399 + if (!frame) { 2400 + throw new Error('Failed to capture frame — blank or black image detected'); 2401 + } 2402 + const blank = await isBlankFrame(frame); 2403 + if (blank) { 2404 + throw new Error('Captured frame is blank/black — piece may not have rendered in time'); 2405 + } 2406 + result = await frameToThumbnail(frame, { width: outputWidth, height: outputHeight }); 2407 + 2408 + } else { 2409 + // Animated WebP or GIF 2410 + const capturedFrames = await captureFrames(piece, { 2411 + width, height, duration, fps, density, viewportScale, baseUrl, grabId 2412 + }); 2413 + 2414 + if (capturedFrames.length === 0) { 2415 + throw new Error('No frames captured'); 2416 + } 2417 + 2418 + // Check if all frames are identical (frozen animation) 2419 + const isFrozen = await areFramesIdentical(capturedFrames); 2420 + if (isFrozen) { 2421 + // Return the still frame in the requested format (webp/gif/png) 2422 + const outW = width * density; 2423 + const outH = height * density; 2424 + console.log(` 🥶 Frozen animation — returning still ${format.toUpperCase()}`); 2425 + 2426 + const sharpMod = (await import('sharp')).default; 2427 + let pipeline = sharpMod(capturedFrames[0]) 2428 + .resize(outW, outH, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 }, kernel: 'nearest' }); 2429 + 2430 + if (format === 'webp') { 2431 + pipeline = pipeline.webp({ quality }); 2432 + } else if (format === 'gif') { 2433 + pipeline = pipeline.gif(); 2434 + } else { 2435 + pipeline = pipeline.png(); 2436 + } 2437 + result = await pipeline.toBuffer(); 2438 + 2439 + // Also upload frozen preview to Spaces for the frozen panel 2440 + let frozenPreviewUrl = null; 2441 + if (process.env.ART_SPACES_KEY && result) { 2442 + try { 2443 + const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png'; 2444 + const mime = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png'; 2445 + const frozenKey = `oven/frozen/${piece.replace(/^\$/, '')}-frozen.${ext}`; 2446 + await spacesClient.send(new PutObjectCommand({ 2447 + Bucket: SPACES_BUCKET, 2448 + Key: frozenKey, 2449 + Body: result, 2450 + ContentType: mime, 2451 + ACL: 'public-read', 2452 + CacheControl: 'public, max-age=604800', 2453 + })); 2454 + frozenPreviewUrl = `${SPACES_CDN_BASE}/${frozenKey}`; 2455 + } catch (previewErr) { 2456 + console.error('⚠️ Failed to upload frozen preview:', previewErr.message); 2457 + } 2458 + } 2459 + 2460 + recordFrozenPiece(piece, 'Frozen — still frame returned', frozenPreviewUrl); 2461 + // Skip encoding — result is already in the right format 2462 + } else { 2463 + // Check for uniform color content (solid color "dud" images) 2464 + const uniformCheck = await isUniformColorContent(capturedFrames); 2465 + if (uniformCheck.isUniform) { 2466 + // Return still frame in requested format (same as frozen) 2467 + const outW = width * density; 2468 + const outH = height * density; 2469 + console.log(` 🎨 Dud animation (${uniformCheck.reason}) — returning still ${format.toUpperCase()}`); 2470 + 2471 + const sharpMod = (await import('sharp')).default; 2472 + let pipeline = sharpMod(capturedFrames[0]) 2473 + .resize(outW, outH, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 }, kernel: 'nearest' }); 2474 + 2475 + if (format === 'webp') { 2476 + pipeline = pipeline.webp({ quality }); 2477 + } else if (format === 'gif') { 2478 + pipeline = pipeline.gif(); 2479 + } else { 2480 + pipeline = pipeline.png(); 2481 + } 2482 + result = await pipeline.toBuffer(); 2483 + 2484 + // Upload dud preview to Spaces 2485 + let dudPreviewUrl = null; 2486 + if (process.env.ART_SPACES_KEY && result) { 2487 + try { 2488 + const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png'; 2489 + const mime = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png'; 2490 + const dudKey = `oven/frozen/${piece.replace(/^\$/, '')}-dud.${ext}`; 2491 + await spacesClient.send(new PutObjectCommand({ 2492 + Bucket: SPACES_BUCKET, 2493 + Key: dudKey, 2494 + Body: result, 2495 + ContentType: mime, 2496 + ACL: 'public-read', 2497 + CacheControl: 'public, max-age=604800', 2498 + })); 2499 + dudPreviewUrl = `${SPACES_CDN_BASE}/${dudKey}`; 2500 + } catch (previewErr) { 2501 + console.error('⚠️ Failed to upload dud preview:', previewErr.message); 2502 + } 2503 + } 2504 + 2505 + recordFrozenPiece(piece, `Dud — ${uniformCheck.reason}, still frame returned`, dudPreviewUrl); 2506 + } else { 2507 + activeGrabs.get(grabId).status = 'encoding'; 2508 + 2509 + // Update progress: encoding stage 2510 + updateProgress(grabId, { 2511 + stage: 'encoding', 2512 + stageDetail: `Encoding ${format.toUpperCase()}...`, 2513 + percent: 85, 2514 + }); 2515 + 2516 + if (format === 'webp') { 2517 + result = await framesToWebp(capturedFrames, { 2518 + playbackFps, 2519 + width: width * density, 2520 + height: height * density, 2521 + quality 2522 + }); 2523 + } else { 2524 + result = await framesToGif(capturedFrames, { 2525 + fps: playbackFps, 2526 + width: width * density, 2527 + height: height * density 2528 + }); 2529 + } 2530 + } 2531 + } 2532 + } 2533 + 2534 + // Update status 2535 + const grab = activeGrabs.get(grabId); 2536 + grab.status = 'complete'; 2537 + grab.completedAt = Date.now(); 2538 + grab.duration = grab.completedAt - grab.startTime; 2539 + grab.size = result.length; 2540 + 2541 + // Upload to Spaces for dashboard preview 2542 + if (process.env.ART_SPACES_KEY) { 2543 + try { 2544 + // Update progress: uploading stage 2545 + updateProgress(grabId, { 2546 + stage: 'uploading', 2547 + stageDetail: `Uploading to CDN...`, 2548 + percent: 95, 2549 + }); 2550 + 2551 + const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png'; 2552 + const contentType = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png'; 2553 + const spacesKey = `oven/grabs/${grabId}.${ext}`; 2554 + 2555 + await spacesClient.send(new PutObjectCommand({ 2556 + Bucket: SPACES_BUCKET, 2557 + Key: spacesKey, 2558 + Body: result, 2559 + ContentType: contentType, 2560 + ACL: 'public-read', 2561 + CacheControl: 'public, max-age=604800', // 7 day cache 2562 + })); 2563 + 2564 + grab.cdnUrl = `${SPACES_CDN_BASE}/${spacesKey}`; 2565 + console.log(`📦 Stored grab in Spaces: ${grab.cdnUrl}`); 2566 + } catch (e) { 2567 + console.error('❌ Failed to store grab in Spaces:', e.message); 2568 + } 2569 + } 2570 + 2571 + // Move to recent and save to MongoDB 2572 + activeGrabs.delete(grabId); 2573 + recentGrabs.unshift(grab); 2574 + if (recentGrabs.length > 20) recentGrabs.pop(); 2575 + saveGrab(grab); // Persist to MongoDB 2576 + 2577 + // Clear progress state (grab complete) 2578 + clearProgress(grabId); 2579 + 2580 + // Record duration for ETA estimation 2581 + recordGrabDuration(grab.duration); 2582 + 2583 + // Notify WebSocket subscribers 2584 + notifySubscribers(); 2585 + 2586 + console.log(`✅ Grab complete: ${grabId} (${(result.length / 1024).toFixed(2)} KB)`); 2587 + serverLog('success', '✅', `Grab complete: ${piece} (${(result.length / 1024).toFixed(1)} KB)`); 2588 + 2589 + return { 2590 + success: true, 2591 + grabId, 2592 + piece: pieceName, 2593 + format, 2594 + buffer: result, 2595 + size: result.length, 2596 + duration: grab.duration, 2597 + cdnUrl: grab.cdnUrl, 2598 + captureKey, 2599 + cached: false, 2600 + }; 2601 + 2602 + } catch (error) { 2603 + console.error(`❌ Grab failed: ${grabId}`, error.message); 2604 + serverLog('error', '❌', `Grab failed: ${piece} - ${error.message}`); 2605 + 2606 + // Clear progress state on error 2607 + clearProgress(grabId); 2608 + 2609 + const grab = activeGrabs.get(grabId); 2610 + if (grab) { 2611 + grab.status = 'failed'; 2612 + grab.error = error.message; 2613 + grab.completedAt = Date.now(); 2614 + 2615 + activeGrabs.delete(grabId); 2616 + recentGrabs.unshift(grab); 2617 + if (recentGrabs.length > 20) recentGrabs.pop(); 2618 + saveGrab(grab); // Persist to MongoDB 2619 + 2620 + // Notify WebSocket subscribers 2621 + notifySubscribers(); 2622 + } 2623 + 2624 + return { 2625 + success: false, 2626 + grabId, 2627 + piece: pieceName, 2628 + format, 2629 + error: error.message, 2630 + }; 2631 + } 2632 + }, { piece, format, captureKey, source }); // End of enqueueGrab, pass metadata for queue visibility + dedup 2633 + } 2634 + 2635 + /** 2636 + * Upload a buffer to IPFS via Pinata 2637 + * @param {Buffer} buffer - The file buffer to upload 2638 + * @param {string} filename - Filename for the upload 2639 + * @param {Object} credentials - { pinataKey, pinataSecret } 2640 + * @returns {Promise<string>} IPFS URI (ipfs://...) 2641 + */ 2642 + export async function uploadToIPFS(buffer, filename, credentials) { 2643 + if (!credentials?.pinataKey || !credentials?.pinataSecret) { 2644 + throw new Error('Pinata credentials required (pinataKey, pinataSecret)'); 2645 + } 2646 + 2647 + const formData = new FormData(); 2648 + const blob = new Blob([buffer]); 2649 + formData.append('file', blob, filename); 2650 + 2651 + // Add content hash to metadata for deduplication 2652 + const contentHash = createHash('sha256').update(buffer).digest('hex').slice(0, 16); 2653 + formData.append('pinataMetadata', JSON.stringify({ 2654 + name: `${filename}-${contentHash}` 2655 + })); 2656 + 2657 + const response = await fetch(`${PINATA_API_URL}/pinning/pinFileToIPFS`, { 2658 + method: 'POST', 2659 + headers: { 2660 + 'pinata_api_key': credentials.pinataKey, 2661 + 'pinata_secret_api_key': credentials.pinataSecret 2662 + }, 2663 + body: formData, 2664 + signal: AbortSignal.timeout(90_000), // 90s timeout for IPFS pinning 2665 + }); 2666 + 2667 + if (!response.ok) { 2668 + const error = await response.text(); 2669 + throw new Error(`Pinata upload failed: ${error}`); 2670 + } 2671 + 2672 + const result = await response.json(); 2673 + return `ipfs://${result.IpfsHash}`; 2674 + } 2675 + 2676 + /** 2677 + * Grab a piece and upload the thumbnail to IPFS 2678 + * @param {string} piece - Piece name (with or without $) 2679 + * @param {Object} credentials - Pinata credentials { pinataKey, pinataSecret } 2680 + * @param {Object} options - Grab options 2681 + * @returns {Promise<Object>} { success, ipfsUri, grabResult, ... } 2682 + */ 2683 + export async function grabAndUploadToIPFS(piece, credentials, options = {}) { 2684 + const pieceName = piece.replace(/^\$/, ''); 2685 + const format = options.format || 'webp'; 2686 + const source = options.source || 'manual'; 2687 + const keepId = options.keepId || null; 2688 + 2689 + console.log(`\n📸 Grabbing and uploading $${pieceName} to IPFS...`); 2690 + if (source === 'keep' && keepId) { 2691 + console.log(` Source: Keep #${keepId}`); 2692 + } 2693 + if (options.skipCache) { 2694 + console.log(` skipCache: true (forcing fresh grab)`); 2695 + } 2696 + 2697 + // Grab the piece 2698 + const grabResult = await grabPiece(piece, { 2699 + format, 2700 + width: options.width || 512, 2701 + height: options.height || 512, 2702 + duration: options.duration || 12000, 2703 + fps: options.fps || 7.5, 2704 + playbackFps: options.playbackFps || 15, 2705 + density: options.density || 1, 2706 + quality: options.quality || 90, 2707 + skipCache: options.skipCache || false, 2708 + baseUrl: options.baseUrl || 'https://aesthetic.computer', 2709 + source, 2710 + keepId, 2711 + author: options.author || null, 2712 + pieceCreatedAt: options.pieceCreatedAt || null, 2713 + }); 2714 + 2715 + if (!grabResult.success) { 2716 + return { 2717 + success: false, 2718 + error: grabResult.error, 2719 + grabResult 2720 + }; 2721 + } 2722 + 2723 + // Get the buffer - either from grab result or fetch from CDN if cached 2724 + let buffer = grabResult.buffer; 2725 + if (!buffer && grabResult.cached && grabResult.cdnUrl) { 2726 + console.log(`📥 Fetching cached thumbnail from CDN: ${grabResult.cdnUrl}`); 2727 + try { 2728 + const cdnResponse = await fetch(grabResult.cdnUrl); 2729 + if (!cdnResponse.ok) { 2730 + throw new Error(`CDN fetch failed: ${cdnResponse.status}`); 2731 + } 2732 + buffer = Buffer.from(await cdnResponse.arrayBuffer()); 2733 + console.log(` Downloaded ${(buffer.length / 1024).toFixed(1)} KB from CDN`); 2734 + } catch (cdnErr) { 2735 + console.error(`❌ Failed to fetch from CDN:`, cdnErr.message); 2736 + // Try regenerating without cache 2737 + console.log(`🔄 Regenerating thumbnail without cache...`); 2738 + const freshGrab = await grabPiece(piece, { 2739 + ...options, 2740 + skipCache: true, 2741 + }); 2742 + if (!freshGrab.success || !freshGrab.buffer) { 2743 + return { 2744 + success: false, 2745 + error: `Failed to generate thumbnail: ${freshGrab.error || 'no buffer returned'}`, 2746 + grabResult: freshGrab 2747 + }; 2748 + } 2749 + buffer = freshGrab.buffer; 2750 + } 2751 + } 2752 + 2753 + if (!buffer) { 2754 + return { 2755 + success: false, 2756 + error: 'No thumbnail buffer available', 2757 + grabResult 2758 + }; 2759 + } 2760 + 2761 + // Upload to IPFS (re-use grabId for progress tracking so the keep-mint heartbeat can see it) 2762 + console.log(`📤 Uploading ${format} to IPFS...`); 2763 + const mimeType = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png'; 2764 + const filename = `${pieceName}-thumbnail.${format}`; 2765 + if (grabResult?.grabId) { 2766 + updateProgress(grabResult.grabId, { 2767 + piece: grabResult.piece, 2768 + format, 2769 + stage: 'uploading', 2770 + stageDetail: 'Pinning to IPFS...', 2771 + percent: 92, 2772 + }); 2773 + } 2774 + 2775 + try { 2776 + const ipfsUri = await uploadToIPFS(buffer, filename, credentials); 2777 + const ipfsCid = ipfsUri.replace('ipfs://', ''); 2778 + console.log(`✅ Thumbnail uploaded: ${ipfsUri}`); 2779 + 2780 + // Track this upload for the live endpoint 2781 + const uploadInfo = { 2782 + ipfsCid, 2783 + ipfsUri, 2784 + piece: pieceName, 2785 + format, 2786 + mimeType, 2787 + size: buffer.length, 2788 + timestamp: Date.now(), 2789 + }; 2790 + latestIPFSUploads.set(pieceName, uploadInfo); 2791 + latestKeepThumbnail = uploadInfo; 2792 + 2793 + // Update grab in MongoDB with IPFS info and clear progress 2794 + if (grabResult.grabId) { 2795 + updateGrabInMongo(grabResult.grabId, { ipfsCid, ipfsUri }); 2796 + // Also update in-memory grab 2797 + const inMemoryGrab = recentGrabs.find(g => g.grabId === grabResult.grabId); 2798 + if (inMemoryGrab) { 2799 + inMemoryGrab.ipfsCid = ipfsCid; 2800 + inMemoryGrab.ipfsUri = ipfsUri; 2801 + } 2802 + // Clear progress now that IPFS upload is done 2803 + grabProgressMap.delete(grabResult.grabId); 2804 + } 2805 + 2806 + // Notify WebSocket subscribers about new IPFS upload 2807 + notifySubscribers(); 2808 + 2809 + return { 2810 + success: true, 2811 + ipfsUri, 2812 + ipfsCid, 2813 + piece: pieceName, 2814 + format, 2815 + mimeType, 2816 + size: buffer.length, 2817 + grabDuration: grabResult.duration, 2818 + cached: grabResult.cached || false, 2819 + }; 2820 + } catch (error) { 2821 + console.error(`❌ IPFS upload failed:`, error.message); 2822 + // Clean up progress so the card doesn't stay stuck on the dashboard 2823 + if (grabResult?.grabId) { 2824 + grabProgressMap.delete(grabResult.grabId); 2825 + notifySubscribers(); 2826 + } 2827 + return { 2828 + success: false, 2829 + error: error.message, 2830 + grabResult 2831 + }; 2832 + } 2833 + } 2834 + 2835 + /** 2836 + * Express handler for /grab endpoint 2837 + */ 2838 + export async function grabHandler(req, res) { 2839 + const { 2840 + piece, 2841 + format = 'webp', 2842 + width = 512, 2843 + height = 512, 2844 + duration = 12000, 2845 + fps = 7.5, 2846 + density = 1, 2847 + quality = 80, 2848 + skipCache = false, 2849 + cacheKey = '', 2850 + } = req.body; 2851 + 2852 + if (!piece) { 2853 + return res.status(400).json({ error: 'Missing required field: piece' }); 2854 + } 2855 + 2856 + // Reject blacklisted pieces early 2857 + if (isPieceBlacklisted(piece)) { 2858 + return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` }); 2859 + } 2860 + 2861 + // Validate format 2862 + if (!['webp', 'gif', 'png'].includes(format)) { 2863 + return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' }); 2864 + } 2865 + 2866 + // Validate dimensions 2867 + if (width < MIN_GRAB_DIMENSION || width > MAX_GRAB_DIMENSION || height < MIN_GRAB_DIMENSION || height > MAX_GRAB_DIMENSION) { 2868 + return res.status(400).json({ error: `Dimensions must be between ${MIN_GRAB_DIMENSION} and ${MAX_GRAB_DIMENSION}` }); 2869 + } 2870 + 2871 + try { 2872 + const result = await grabPiece(piece, { 2873 + format, 2874 + width: parseInt(width), 2875 + height: parseInt(height), 2876 + duration: parseInt(duration), 2877 + fps: parseInt(fps), 2878 + density: parseFloat(density) || 1, 2879 + quality: parseInt(quality) || 80, 2880 + skipCache: skipCache === true || skipCache === 'true', 2881 + cacheKey, 2882 + requestOrigin: req.get('referer') || req.get('origin') || null, 2883 + }); 2884 + 2885 + if (result.success) { 2886 + // If result is cached and has CDN URL 2887 + if (result.cached && result.cdnUrl) { 2888 + res.setHeader('X-Cache', 'HIT'); 2889 + res.setHeader('X-Grab-Id', result.grabId); 2890 + 2891 + // Check if request has Origin header (browser CORS request) 2892 + // If so, proxy the image instead of redirecting (CDN lacks CORS headers) 2893 + const origin = req.get('Origin'); 2894 + if (origin) { 2895 + // Proxy the cached image to preserve CORS headers 2896 + try { 2897 + const proxyRes = await fetch(result.cdnUrl); 2898 + if (proxyRes.ok) { 2899 + const buffer = Buffer.from(await proxyRes.arrayBuffer()); 2900 + const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; 2901 + const contentType = contentTypes[format] || 'image/webp'; 2902 + res.setHeader('Content-Type', contentType); 2903 + res.setHeader('Content-Length', buffer.length); 2904 + res.setHeader('X-Proxy', 'CDN'); 2905 + return res.send(buffer); 2906 + } 2907 + } catch (proxyErr) { 2908 + console.error('CDN proxy error:', proxyErr.message); 2909 + // Fall through to redirect 2910 + } 2911 + } 2912 + 2913 + // No Origin header or proxy failed - redirect to CDN 2914 + return res.redirect(301, result.cdnUrl); 2915 + } 2916 + 2917 + // Return the image directly 2918 + const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; 2919 + const contentType = contentTypes[format] || 'image/webp'; 2920 + res.setHeader('Content-Type', contentType); 2921 + res.setHeader('Content-Length', result.buffer.length); 2922 + res.setHeader('X-Grab-Id', result.grabId); 2923 + res.setHeader('X-Grab-Duration', result.duration); 2924 + res.setHeader('X-Cache', 'MISS'); 2925 + res.send(result.buffer); 2926 + } else { 2927 + res.status(500).json({ 2928 + error: result.error, 2929 + grabId: result.grabId 2930 + }); 2931 + } 2932 + 2933 + } catch (error) { 2934 + console.error('Grab handler error:', error); 2935 + res.status(500).json({ error: error.message }); 2936 + } 2937 + } 2938 + 2939 + /** 2940 + * GET handler for convenient URL-based grabbing 2941 + * GET /grab/:format/:width/:height/:piece 2942 + * e.g., /grab/gif/400/400/$roz 2943 + * 2944 + * Query params: 2945 + * - duration, fps, density, quality, source, skipCache (standard grab options) 2946 + * - nowait=true: Return a placeholder image if grab is in progress/queued instead of waiting 2947 + */ 2948 + export async function grabGetHandler(req, res) { 2949 + const { format, width, height, piece } = req.params; 2950 + const { duration = 12000, fps = 7.5, density = 1, quality = 80, source = 'manual', skipCache = 'false', nowait = 'false', cacheKey = '' } = req.query; 2951 + 2952 + if (!piece) { 2953 + return res.status(400).json({ error: 'Missing piece parameter' }); 2954 + } 2955 + 2956 + // Reject blacklisted pieces early 2957 + if (isPieceBlacklisted(piece)) { 2958 + return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` }); 2959 + } 2960 + 2961 + // Validate format 2962 + if (!['webp', 'gif', 'png'].includes(format)) { 2963 + return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' }); 2964 + } 2965 + 2966 + const w = parseInt(width) || 512; 2967 + const h = parseInt(height) || 512; 2968 + 2969 + if (w < MIN_GRAB_DIMENSION || w > MAX_GRAB_DIMENSION || h < MIN_GRAB_DIMENSION || h > MAX_GRAB_DIMENSION) { 2970 + return res.status(400).json({ error: `Dimensions must be between ${MIN_GRAB_DIMENSION} and ${MAX_GRAB_DIMENSION}` }); 2971 + } 2972 + 2973 + // Calculate captureKey to check for in-progress grabs 2974 + const animated = format !== 'png'; 2975 + const parsedDensity = parseFloat(density) || 1; 2976 + const captureKey = getCaptureKey(piece, w * parsedDensity, h * parsedDensity, format, animated, cacheKey); 2977 + 2978 + // If nowait=true, check if there's already a grab in progress for this key 2979 + if (nowait === 'true' || nowait === true) { 2980 + const inProgress = getInProgressGrab(captureKey); 2981 + if (inProgress.inProgress) { 2982 + console.log(`🔥 Returning baking placeholder for ${piece} (queue: ${inProgress.queuePosition})`); 2983 + 2984 + try { 2985 + const placeholder = await generateBakingPlaceholder({ 2986 + width: w, 2987 + height: h, 2988 + format, 2989 + piece, 2990 + queuePosition: inProgress.queuePosition, 2991 + estimatedWait: inProgress.estimatedWait, 2992 + }); 2993 + 2994 + const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; 2995 + const contentType = contentTypes[format] || 'image/webp'; 2996 + 2997 + res.setHeader('Content-Type', contentType); 2998 + res.setHeader('Content-Length', placeholder.length); 2999 + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // Don't cache placeholders 3000 + res.setHeader('X-Oven-Status', 'baking'); 3001 + res.setHeader('X-Queue-Position', inProgress.queuePosition || 0); 3002 + res.setHeader('X-Estimated-Wait', inProgress.estimatedWait || 30000); 3003 + res.setHeader('X-Grab-Id', inProgress.grabId || ''); 3004 + return res.send(placeholder); 3005 + } catch (placeholderErr) { 3006 + console.error('Failed to generate placeholder:', placeholderErr.message); 3007 + // Fall through to normal grab 3008 + } 3009 + } 3010 + } 3011 + 3012 + try { 3013 + const result = await grabPiece(piece, { 3014 + format, 3015 + width: w, 3016 + height: h, 3017 + skipCache: skipCache === 'true' || skipCache === true, 3018 + duration: parseInt(duration), 3019 + fps: parseInt(fps), 3020 + density: parsedDensity, 3021 + quality: parseInt(quality) || 80, 3022 + source: source || 'manual', 3023 + cacheKey, 3024 + requestOrigin: req.get('referer') || req.get('origin') || null, 3025 + }); 3026 + 3027 + if (result.success) { 3028 + // If cached, check for CORS proxy need 3029 + if (result.cached && result.cdnUrl) { 3030 + res.setHeader('X-Grab-Id', result.grabId || result.captureKey); 3031 + res.setHeader('X-Cache', 'HIT'); 3032 + 3033 + // Check if request has Origin header (browser CORS request) 3034 + // If so, proxy the image instead of redirecting (CDN lacks CORS headers) 3035 + const origin = req.get('Origin'); 3036 + if (origin) { 3037 + // Proxy the cached image to preserve CORS headers 3038 + try { 3039 + const proxyRes = await fetch(result.cdnUrl); 3040 + if (proxyRes.ok) { 3041 + const buffer = Buffer.from(await proxyRes.arrayBuffer()); 3042 + const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; 3043 + const contentType = contentTypes[format] || 'image/webp'; 3044 + res.setHeader('Content-Type', contentType); 3045 + res.setHeader('Content-Length', buffer.length); 3046 + res.setHeader('Cache-Control', 'public, max-age=86400'); 3047 + res.setHeader('X-Proxy', 'CDN'); 3048 + return res.send(buffer); 3049 + } 3050 + } catch (proxyErr) { 3051 + console.error('CDN proxy error:', proxyErr.message); 3052 + // Fall through to redirect 3053 + } 3054 + } 3055 + 3056 + return res.redirect(301, result.cdnUrl); 3057 + } 3058 + 3059 + const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; 3060 + const contentType = contentTypes[format] || 'image/webp'; 3061 + res.setHeader('Content-Type', contentType); 3062 + res.setHeader('Content-Length', result.buffer.length); 3063 + res.setHeader('Cache-Control', 'public, max-age=86400'); 3064 + res.setHeader('X-Grab-Id', result.grabId); 3065 + res.setHeader('X-Cache', 'MISS'); 3066 + res.send(result.buffer); 3067 + } else { 3068 + res.status(500).json({ 3069 + error: result.error, 3070 + grabId: result.grabId 3071 + }); 3072 + } 3073 + 3074 + } catch (error) { 3075 + console.error('Grab GET handler error:', error); 3076 + res.status(500).json({ error: error.message }); 3077 + } 3078 + } 3079 + 3080 + /** 3081 + * Express handler for /grab-ipfs endpoint 3082 + * Grabs a piece and uploads the thumbnail to IPFS 3083 + */ 3084 + export async function grabIPFSHandler(req, res) { 3085 + const { 3086 + piece, 3087 + format = 'webp', 3088 + width = 96, // Small thumbnail (was 512) 3089 + height = 96, 3090 + duration = 8000, // 8 seconds 3091 + fps = 10, // 10fps capture 3092 + playbackFps = 20, // 20fps playback = 2x speed 3093 + density = 2, // 2x density for crisp pixels (was 1) 3094 + quality = 70, // Lower quality for smaller files 3095 + skipCache = false, // Force regeneration (bypass CDN cache) 3096 + cacheKey = '', // Optional cache discriminator (e.g. source hash) 3097 + pinataKey, 3098 + pinataSecret, 3099 + source, // Optional: 'keep', 'manual', etc. 3100 + keepId, // Optional: Tezos keep token ID 3101 + author, // Optional: author handle (e.g. "@jeffrey") 3102 + pieceCreatedAt, // Optional: when the KidLisp piece was created 3103 + } = req.body; 3104 + 3105 + if (!piece) { 3106 + return res.status(400).json({ error: 'Missing required field: piece' }); 3107 + } 3108 + 3109 + // Reject blacklisted pieces early 3110 + if (isPieceBlacklisted(piece)) { 3111 + return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` }); 3112 + } 3113 + 3114 + if (!pinataKey || !pinataSecret) { 3115 + return res.status(400).json({ error: 'Missing Pinata credentials (pinataKey, pinataSecret)' }); 3116 + } 3117 + 3118 + // Validate format 3119 + if (!['webp', 'gif', 'png'].includes(format)) { 3120 + return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' }); 3121 + } 3122 + 3123 + try { 3124 + const result = await grabAndUploadToIPFS(piece, { pinataKey, pinataSecret }, { 3125 + format, 3126 + width: parseInt(width), 3127 + height: parseInt(height), 3128 + duration: parseInt(duration), 3129 + fps: parseFloat(fps), 3130 + playbackFps: parseFloat(playbackFps), 3131 + density: parseFloat(density) || 1, 3132 + quality: parseInt(quality) || 90, 3133 + skipCache: skipCache === true || skipCache === 'true', 3134 + cacheKey, 3135 + source: source || 'manual', 3136 + keepId: keepId || null, 3137 + author: author || null, 3138 + pieceCreatedAt: pieceCreatedAt || null, 3139 + }); 3140 + 3141 + if (result.success) { 3142 + res.json({ 3143 + success: true, 3144 + ipfsUri: result.ipfsUri, 3145 + piece: result.piece, 3146 + format: result.format, 3147 + mimeType: result.mimeType, 3148 + size: result.size, 3149 + grabDuration: result.grabDuration, 3150 + }); 3151 + } else { 3152 + res.status(500).json({ 3153 + success: false, 3154 + error: result.error 3155 + }); 3156 + } 3157 + 3158 + } catch (error) { 3159 + console.error('Grab IPFS handler error:', error); 3160 + res.status(500).json({ error: error.message }); 3161 + } 3162 + } 3163 + 3164 + // Status exports 3165 + export function getActiveGrabs() { 3166 + return Array.from(activeGrabs.values()); 3167 + } 3168 + 3169 + export function getRecentGrabs() { 3170 + return recentGrabs; 3171 + } 3172 + 3173 + // Latest IPFS upload exports for live collection thumbnail 3174 + export function getLatestKeepThumbnail() { 3175 + return latestKeepThumbnail; 3176 + } 3177 + 3178 + export function getLatestIPFSUpload(piece) { 3179 + return latestIPFSUploads.get(piece?.replace(/^\$/, '')); 3180 + } 3181 + 3182 + export function getAllLatestIPFSUploads() { 3183 + return Object.fromEntries(latestIPFSUploads); 3184 + } 3185 + 3186 + /** 3187 + * Close the browser instance (for graceful shutdown) 3188 + */ 3189 + export async function closeBrowser() { 3190 + if (browser) { 3191 + console.log('🧹 Closing browser...'); 3192 + await browser.close(); 3193 + browser = null; 3194 + browserLaunchPromise = null; 3195 + console.log('✅ Browser closed'); 3196 + } 3197 + } 3198 + 3199 + /** 3200 + * Close all connections (browser + MongoDB) for clean exit 3201 + */ 3202 + export async function closeAll() { 3203 + await closeBrowser(); 3204 + if (mongoClient) { 3205 + console.log('🧹 Closing MongoDB...'); 3206 + await mongoClient.close(); 3207 + mongoClient = null; 3208 + db = null; 3209 + console.log('✅ MongoDB closed'); 3210 + } 3211 + } 3212 + 3213 + // ============================================================================= 3214 + // KidLisp.com Dynamic OG Preview Image Generation 3215 + // ============================================================================= 3216 + 3217 + // Cache for OG image URL (memory cache with TTL) 3218 + const ogImageCache = { 3219 + url: null, 3220 + expires: 0, 3221 + generatedAt: null, 3222 + featuredPiece: null, 3223 + }; 3224 + 3225 + // OG Image layout options 3226 + const OG_LAYOUTS = { 3227 + FEATURED: 'featured', // Single featured piece (Option A) 3228 + MOSAIC: 'mosaic', // Grid of top 6 pieces (Option B) 3229 + FILMSTRIP: 'filmstrip', // Same piece at multiple timepoints (Option C) 3230 + CODE_SPLIT: 'code-split', // Code + preview side by side (Option D) 3231 + }; 3232 + 3233 + /** 3234 + * Format hit count for display (e.g., 4634 -> "4.6k") 3235 + */ 3236 + function formatHits(hits) { 3237 + if (!hits || hits < 1000) return String(hits || 0); 3238 + if (hits < 10000) return (hits / 1000).toFixed(1) + 'k'; 3239 + return Math.floor(hits / 1000) + 'k'; 3240 + } 3241 + 3242 + /** 3243 + * Get today's date string for cache keys (YYYY-MM-DD) 3244 + */ 3245 + function getTodayKey() { 3246 + return new Date().toISOString().split('T')[0]; 3247 + } 3248 + 3249 + /** 3250 + * Get deterministic "day of year" number for rotating featured content 3251 + */ 3252 + function getDayOfYear() { 3253 + const now = new Date(); 3254 + const start = new Date(now.getFullYear(), 0, 0); 3255 + const diff = now - start; 3256 + const oneDay = 1000 * 60 * 60 * 24; 3257 + return Math.floor(diff / oneDay); 3258 + } 3259 + 3260 + // ============================================================================= 3261 + // Source Similarity Detection (from give.aesthetic.computer) 3262 + // ============================================================================= 3263 + 3264 + const SIMILARITY_THRESHOLD = 0.90; // 90% similar = duplicate 3265 + const MIN_SOURCE_LENGTH = 50; // Only check sources longer than this 3266 + 3267 + /** 3268 + * Get trigrams (3-char sequences) from a string 3269 + */ 3270 + function getTrigrams(str) { 3271 + const trigrams = new Set(); 3272 + for (let i = 0; i <= str.length - 3; i++) { 3273 + trigrams.add(str.slice(i, i + 3)); 3274 + } 3275 + return trigrams; 3276 + } 3277 + 3278 + /** 3279 + * Calculate similarity between two source strings using trigram Jaccard similarity 3280 + */ 3281 + function getSourceSimilarity(source1, source2) { 3282 + if (!source1 || !source2) return 0; 3283 + 3284 + // Normalize: lowercase, remove extra whitespace 3285 + const norm1 = source1.toLowerCase().replace(/\s+/g, ' ').trim(); 3286 + const norm2 = source2.toLowerCase().replace(/\s+/g, ' ').trim(); 3287 + 3288 + if (norm1 === norm2) return 1; 3289 + if (norm1.length < 10 || norm2.length < 10) return 0; 3290 + 3291 + const t1 = getTrigrams(norm1); 3292 + const t2 = getTrigrams(norm2); 3293 + 3294 + // Jaccard similarity: intersection / union 3295 + let intersection = 0; 3296 + for (const t of t1) { 3297 + if (t2.has(t)) intersection++; 3298 + } 3299 + const union = t1.size + t2.size - intersection; 3300 + return union > 0 ? intersection / union : 0; 3301 + } 3302 + 3303 + /** 3304 + * Filter pieces to remove duplicates with >90% similar source code 3305 + * Returns a deduplicated list 3306 + */ 3307 + function deduplicatePieces(pieces, threshold = SIMILARITY_THRESHOLD) { 3308 + const selected = []; 3309 + const selectedSources = new Map(); // code -> source 3310 + 3311 + for (const piece of pieces) { 3312 + const source = piece.source; 3313 + 3314 + // Skip if no source or too short 3315 + if (!source || source.length < MIN_SOURCE_LENGTH) { 3316 + selected.push(piece); 3317 + continue; 3318 + } 3319 + 3320 + // Check against all previously selected pieces 3321 + let isDuplicate = false; 3322 + for (const [existingCode, existingSource] of selectedSources) { 3323 + const similarity = getSourceSimilarity(source, existingSource); 3324 + if (similarity >= threshold) { 3325 + console.log(` ⚠️ Skipping $${piece.code}: ${(similarity * 100).toFixed(0)}% similar to $${existingCode}`); 3326 + isDuplicate = true; 3327 + break; 3328 + } 3329 + } 3330 + 3331 + if (!isDuplicate) { 3332 + selected.push(piece); 3333 + selectedSources.set(piece.code, source); 3334 + } 3335 + } 3336 + 3337 + return selected; 3338 + } 3339 + 3340 + /** 3341 + * Fetch top KidLisp hits from the TV API 3342 + */ 3343 + async function fetchTopKidlispHits(limit = 20) { 3344 + try { 3345 + const apiUrl = process.env.API_BASE_URL || 'https://aesthetic.computer'; 3346 + const response = await fetch(`${apiUrl}/api/tv?types=kidlisp&sort=hits&limit=${limit}`); 3347 + if (!response.ok) { 3348 + throw new Error(`TV API returned ${response.status}`); 3349 + } 3350 + const data = await response.json(); 3351 + return data.media?.kidlisp || []; 3352 + } catch (error) { 3353 + console.error('❌ Failed to fetch top hits:', error.message); 3354 + return []; 3355 + } 3356 + } 3357 + 3358 + /** 3359 + * Check if we have a cached OG image for today 3360 + * Returns CDN URL if exists, null otherwise 3361 + */ 3362 + async function getCachedOGImage(layout = 'featured') { 3363 + const today = getTodayKey(); 3364 + const key = `og/kidlisp/${today}-${layout}.png`; 3365 + 3366 + // Check memory cache first 3367 + if (ogImageCache.url && Date.now() < ogImageCache.expires) { 3368 + console.log(`📦 OG image from memory cache: ${ogImageCache.url}`); 3369 + return ogImageCache.url; 3370 + } 3371 + 3372 + // Check Spaces for today's image 3373 + try { 3374 + await spacesClient.send(new HeadObjectCommand({ 3375 + Bucket: SPACES_BUCKET, 3376 + Key: key, 3377 + })); 3378 + 3379 + const url = `${SPACES_CDN_BASE}/${key}`; 3380 + 3381 + // Update memory cache (1hr TTL) 3382 + ogImageCache.url = url; 3383 + ogImageCache.expires = Date.now() + 60 * 60 * 1000; 3384 + 3385 + console.log(`📦 OG image from Spaces cache: ${url}`); 3386 + return url; 3387 + } catch (err) { 3388 + // Not found in cache 3389 + return null; 3390 + } 3391 + } 3392 + 3393 + /** 3394 + * Upload OG image to Spaces CDN 3395 + */ 3396 + async function uploadOGImageToSpaces(buffer, layout = 'featured') { 3397 + const today = getTodayKey(); 3398 + const key = `og/kidlisp/${today}-${layout}.png`; 3399 + 3400 + await spacesClient.send(new PutObjectCommand({ 3401 + Bucket: SPACES_BUCKET, 3402 + Key: key, 3403 + Body: buffer, 3404 + ContentType: 'image/png', 3405 + ACL: 'public-read', 3406 + CacheControl: 'public, max-age=86400', 3407 + })); 3408 + 3409 + const url = `${SPACES_CDN_BASE}/${key}`; 3410 + 3411 + // Update memory cache 3412 + ogImageCache.url = url; 3413 + ogImageCache.expires = Date.now() + 60 * 60 * 1000; 3414 + ogImageCache.generatedAt = new Date().toISOString(); 3415 + 3416 + console.log(`📤 OG image uploaded to Spaces: ${url}`); 3417 + return url; 3418 + } 3419 + 3420 + /** 3421 + * Create SVG branding overlay for OG images 3422 + */ 3423 + function createBrandingOverlay(featured, width = 1200, height = 80) { 3424 + const code = featured?.code || 'kidlisp'; 3425 + const hits = formatHits(featured?.hits); 3426 + const handle = featured?.owner?.handle || ''; 3427 + 3428 + return Buffer.from(` 3429 + <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> 3430 + <defs> 3431 + <linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%"> 3432 + <stop offset="0%" style="stop-color:rgba(0,0,0,0);stop-opacity:0" /> 3433 + <stop offset="100%" style="stop-color:rgba(0,0,0,0.85);stop-opacity:1" /> 3434 + </linearGradient> 3435 + </defs> 3436 + <rect width="100%" height="100%" fill="url(#grad)"/> 3437 + <text x="24" y="${height - 24}" font-family="monospace, 'Courier New'" font-size="28" font-weight="bold" fill="white"> 3438 + KidLisp.com 3439 + </text> 3440 + <text x="${width - 24}" y="${height - 24}" font-family="monospace, 'Courier New'" font-size="20" fill="#cccccc" text-anchor="end"> 3441 + $${code} · ${hits} plays ${handle ? '· ' + handle : ''} 3442 + </text> 3443 + </svg> 3444 + `); 3445 + } 3446 + 3447 + /** 3448 + * Create SVG overlay for mosaic layout (smaller per-tile labels) 3449 + */ 3450 + function createMosaicLabel(piece, index, tileWidth = 400, tileHeight = 315) { 3451 + const code = piece?.code || '???'; 3452 + const hits = formatHits(piece?.hits); 3453 + 3454 + return Buffer.from(` 3455 + <svg width="${tileWidth}" height="40" xmlns="http://www.w3.org/2000/svg"> 3456 + <rect width="100%" height="100%" fill="rgba(0,0,0,0.7)"/> 3457 + <text x="8" y="26" font-family="monospace" font-size="14" fill="white"> 3458 + $${code} 3459 + </text> 3460 + <text x="${tileWidth - 8}" y="26" font-family="monospace" font-size="12" fill="#aaa" text-anchor="end"> 3461 + ${hits} ▶ 3462 + </text> 3463 + </svg> 3464 + `); 3465 + } 3466 + 3467 + /** 3468 + * Generate KidLisp OG image - Featured layout (Option A) 3469 + * Single top piece fills the frame with branding overlay 3470 + */ 3471 + async function generateFeaturedOGImage(topPieces) { 3472 + const dayOfYear = getDayOfYear(); 3473 + const featuredIndex = dayOfYear % Math.min(topPieces.length, 10); 3474 + const featured = topPieces[featuredIndex]; 3475 + 3476 + if (!featured) { 3477 + throw new Error('No featured piece available'); 3478 + } 3479 + 3480 + console.log(`🎨 Generating featured OG: $${featured.code} (${formatHits(featured.hits)} hits)`); 3481 + 3482 + // Capture screenshot at 1200x630 (OG image standard) 3483 + const width = 1200; 3484 + const height = 630; 3485 + 3486 + const browser = await getBrowser(); 3487 + const page = await browser.newPage(); 3488 + await interceptSelfRequests(page); 3489 + await injectFontGlyphs(page); 3490 + 3491 + try { 3492 + await page.setViewport({ width, height, deviceScaleFactor: 1 }); 3493 + 3494 + const url = `https://aesthetic.computer/$${featured.code}?density=1&tv=true&nolabel=true&nogap=true&spoofaudio=true`; 3495 + console.log(` Loading: ${url}`); 3496 + 3497 + await page.goto(url, { 3498 + waitUntil: 'domcontentloaded', 3499 + timeout: 30000 3500 + }); 3501 + await populateGlyphCache(page); 3502 + 3503 + // Wait for canvas and content 3504 + await page.waitForSelector('canvas', { timeout: 10000 }); 3505 + 3506 + // Wait for KidLisp content to render (animation frame) 3507 + await new Promise(r => setTimeout(r, 4000)); 3508 + 3509 + // Take screenshot 3510 + const screenshot = await page.screenshot({ type: 'png' }); 3511 + 3512 + // Composite with branding overlay using sharp 3513 + const brandingOverlay = createBrandingOverlay(featured, width, 100); 3514 + 3515 + const composite = await sharp(screenshot) 3516 + .composite([ 3517 + { 3518 + input: brandingOverlay, 3519 + gravity: 'south', 3520 + } 3521 + ]) 3522 + .png() 3523 + .toBuffer(); 3524 + 3525 + ogImageCache.featuredPiece = featured; 3526 + 3527 + return composite; 3528 + 3529 + } finally { 3530 + await page.close(); 3531 + } 3532 + } 3533 + 3534 + /** 3535 + * Generate KidLisp OG image - Mosaic layout (Option B) 3536 + * 4x4 grid of top 16 pieces with large KidLisp.com branding 3537 + */ 3538 + async function generateMosaicOGImage(topPieces) { 3539 + const width = 1200; 3540 + const height = 630; 3541 + const cols = 4; 3542 + const rows = 4; 3543 + const tileWidth = width / cols; // 300px 3544 + const tileHeight = height / rows; // 157.5px 3545 + 3546 + const pieces = topPieces.slice(0, 16); 3547 + if (pieces.length < 16) { 3548 + // Pad with duplicates if needed 3549 + while (pieces.length < 16) { 3550 + pieces.push(pieces[pieces.length % topPieces.length] || { code: 'blank', hits: 0 }); 3551 + } 3552 + } 3553 + 3554 + console.log(`🎨 Generating 4x4 mosaic OG: ${pieces.map(p => '$' + p.code).join(', ')}`); 3555 + 3556 + const browser = await getBrowser(); 3557 + const page = await browser.newPage(); 3558 + await interceptSelfRequests(page); 3559 + await injectFontGlyphs(page); 3560 + 3561 + try { 3562 + const tiles = []; 3563 + 3564 + for (let i = 0; i < pieces.length; i++) { 3565 + const piece = pieces[i]; 3566 + 3567 + await page.setViewport({ width: Math.round(tileWidth), height: Math.round(tileHeight), deviceScaleFactor: 0.5 }); 3568 + 3569 + const url = `https://aesthetic.computer/$${piece.code}?density=0.5&tv=true&nolabel=true&nogap=true&spoofaudio=true`; 3570 + console.log(` [${i + 1}/16] Loading: $${piece.code}`); 3571 + 3572 + await page.goto(url, { 3573 + waitUntil: 'domcontentloaded', 3574 + timeout: 20000 3575 + }); 3576 + if (i === 0) await populateGlyphCache(page); 3577 + 3578 + await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {}); 3579 + // Let pieces play longer before capture (8 seconds) 3580 + await new Promise(r => setTimeout(r, 8000)); 3581 + 3582 + const screenshot = await page.screenshot({ type: 'png' }); 3583 + 3584 + // No labels, just the raw visual 3585 + const tile = await sharp(screenshot) 3586 + .resize(Math.round(tileWidth), Math.round(tileHeight), { fit: 'cover' }) 3587 + .png() 3588 + .toBuffer(); 3589 + 3590 + tiles.push(tile); 3591 + } 3592 + 3593 + // Assemble mosaic - no black bars 3594 + const composites = tiles.map((tile, i) => ({ 3595 + input: tile, 3596 + left: (i % cols) * Math.round(tileWidth), 3597 + top: Math.floor(i / cols) * Math.round(tileHeight), 3598 + })); 3599 + 3600 + // Create base mosaic 3601 + let mosaic = await sharp({ 3602 + create: { 3603 + width, 3604 + height, 3605 + channels: 4, 3606 + background: { r: 0, g: 0, b: 0, alpha: 1 } 3607 + } 3608 + }) 3609 + .composite(composites) 3610 + .png() 3611 + .toBuffer(); 3612 + 3613 + // Apply Gaussian blur for ambient background effect 3614 + mosaic = await sharp(mosaic) 3615 + .blur(8) // sigma value - higher = more blur 3616 + .toBuffer(); 3617 + 3618 + // Add dark overlay to make text pop, then add branding 3619 + const darkOverlay = Buffer.from(` 3620 + <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> 3621 + <rect width="100%" height="100%" fill="rgba(0,0,0,0.35)"/> 3622 + </svg> 3623 + `); 3624 + 3625 + // Use Puppeteer for branding + floating codes to get proper Comic Relief font rendering 3626 + const brandingOverlay = await createKidLispBrandingWithPuppeteer(width, height, pieces.map(p => p.code)); 3627 + mosaic = await sharp(mosaic) 3628 + .composite([ 3629 + { input: darkOverlay, gravity: 'center' }, 3630 + { input: brandingOverlay, gravity: 'center' }, 3631 + ]) 3632 + .png() 3633 + .toBuffer(); 3634 + 3635 + return mosaic; 3636 + 3637 + } finally { 3638 + await page.close(); 3639 + } 3640 + } 3641 + 3642 + /** 3643 + * Create floating $codes overlay with limegreen syntax highlighting 3644 + * Codes float upward with motion trails, evenly distributed 3645 + */ 3646 + function createFloatingCodesOverlay(width, height, codes) { 3647 + // Use seeded random for consistent layout 3648 + const seed = codes.join('').split('').reduce((a, c) => a + c.charCodeAt(0), 0); 3649 + const seededRandom = (i) => { 3650 + const x = Math.sin(seed + i * 9999) * 10000; 3651 + return x - Math.floor(x); 3652 + }; 3653 + 3654 + // Even grid distribution - extend beyond edges for cut-off effect 3655 + const cols = 6; 3656 + const rows = 5; 3657 + const cellWidth = (width + 100) / cols; // Extend past edges 3658 + const cellHeight = (height + 80) / rows; 3659 + const numCodes = cols * rows; 3660 + 3661 + const baseFontSize = 42; 3662 + const fontFamily = "'Comic Relief', 'Comic Sans MS', cursive, sans-serif"; 3663 + const shadowOffset = 2; // Tighter shadow 3664 + 3665 + // Colors: $ is bright cyan-green, rest is limegreen (like kidlisp.com) 3666 + const dollarColor = '#00ff88'; 3667 + const codeColor = 'limegreen'; 3668 + 3669 + let codeElements = ''; 3670 + 3671 + for (let i = 0; i < numCodes; i++) { 3672 + const code = codes[i % codes.length]; 3673 + const col = i % cols; 3674 + const row = Math.floor(i / cols); 3675 + 3676 + // Center in cell with small jitter, offset left so some cut off on edges 3677 + const baseX = col * cellWidth - 50 + cellWidth / 2; 3678 + const baseY = row * cellHeight - 40 + cellHeight / 2; 3679 + const jitterX = (seededRandom(i * 5) - 0.5) * cellWidth * 0.4; 3680 + const jitterY = (seededRandom(i * 5 + 1) - 0.5) * cellHeight * 0.3; 3681 + const x = baseX + jitterX; 3682 + const y = baseY + jitterY; 3683 + 3684 + // Slight rotation for organic feel 3685 + const rotation = (seededRandom(i * 5 + 2) - 0.5) * 16; // -8 to +8 degrees 3686 + 3687 + // Various sizes for different codes (but consistent within each code) 3688 + const scale = 0.7 + seededRandom(i * 5 + 3) * 0.8; // 0.7 to 1.5 3689 + const actualSize = baseFontSize * scale; 3690 + 3691 + // Opacity varies 3692 + const opacity = 0.2 + seededRandom(i * 5 + 4) * 0.25; // 0.2 to 0.45 3693 + 3694 + // Motion trail - 3 fading copies below the main text (floating UP effect) 3695 + const trailCount = 3; 3696 + const trailSpacing = actualSize * 0.35; 3697 + 3698 + for (let t = trailCount; t >= 0; t--) { 3699 + const trailY = y + t * trailSpacing; 3700 + const trailOpacity = t === 0 ? opacity : opacity * (0.15 / (t + 1)); // Main is full, trails fade 3701 + const trailBlur = t === 0 ? 0 : t * 0.5; 3702 + 3703 + // Only draw shadow for the main text (t === 0) 3704 + if (t === 0) { 3705 + // Shadow (tight, solid black) 3706 + codeElements += ` 3707 + <text 3708 + x="${x + shadowOffset}" 3709 + y="${trailY + shadowOffset}" 3710 + font-family="${fontFamily}" 3711 + font-size="${actualSize}px" 3712 + font-weight="bold" 3713 + fill="black" 3714 + opacity="${trailOpacity * 0.5}" 3715 + transform="rotate(${rotation}, ${x}, ${trailY})" 3716 + >$${code}</text> 3717 + `; 3718 + } 3719 + 3720 + // $ character in bright cyan-green 3721 + codeElements += ` 3722 + <text 3723 + x="${x}" 3724 + y="${trailY}" 3725 + font-family="${fontFamily}" 3726 + font-size="${actualSize}px" 3727 + font-weight="bold" 3728 + fill="${dollarColor}" 3729 + opacity="${trailOpacity}" 3730 + transform="rotate(${rotation}, ${x}, ${trailY})" 3731 + ${trailBlur > 0 ? `filter="url(#blur${t})"` : ''} 3732 + >$</text> 3733 + `; 3734 + 3735 + // Code characters in limegreen (offset by $ width) 3736 + const dollarWidth = actualSize * 0.55; 3737 + codeElements += ` 3738 + <text 3739 + x="${x + dollarWidth}" 3740 + y="${trailY}" 3741 + font-family="${fontFamily}" 3742 + font-size="${actualSize}px" 3743 + font-weight="bold" 3744 + fill="${codeColor}" 3745 + opacity="${trailOpacity}" 3746 + transform="rotate(${rotation}, ${x}, ${trailY})" 3747 + ${trailBlur > 0 ? `filter="url(#blur${t})"` : ''} 3748 + >${code}</text> 3749 + `; 3750 + } 3751 + } 3752 + 3753 + return Buffer.from(` 3754 + <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> 3755 + <defs> 3756 + ${comicReliefBoldBase64 ? ` 3757 + <style type="text/css"> 3758 + @font-face { 3759 + font-family: 'Comic Relief'; 3760 + src: url('data:font/truetype;base64,${comicReliefBoldBase64}') format('truetype'); 3761 + font-weight: bold; 3762 + } 3763 + </style> 3764 + ` : ''} 3765 + <filter id="blur1" x="-50%" y="-50%" width="200%" height="200%"> 3766 + <feGaussianBlur in="SourceGraphic" stdDeviation="1" /> 3767 + </filter> 3768 + <filter id="blur2" x="-50%" y="-50%" width="200%" height="200%"> 3769 + <feGaussianBlur in="SourceGraphic" stdDeviation="2" /> 3770 + </filter> 3771 + <filter id="blur3" x="-50%" y="-50%" width="200%" height="200%"> 3772 + <feGaussianBlur in="SourceGraphic" stdDeviation="3" /> 3773 + </filter> 3774 + </defs> 3775 + ${codeElements} 3776 + </svg> 3777 + `); 3778 + } 3779 + 3780 + /** 3781 + * Create large KidLisp.com branding overlay using Puppeteer for proper font rendering 3782 + * This uses an HTML page with Google Fonts to ensure Comic Relief loads correctly 3783 + */ 3784 + async function createKidLispBrandingWithPuppeteer(width, height, codes = []) { 3785 + // KidLisp letter colors - com uses delete(red)/stop(purple)/play(green) button colors 3786 + const letterColors = { 3787 + 'K': '#FF6B6B', 'i1': '#4ECDC4', 'd': '#FFE66D', 3788 + 'L': '#95E1D3', 'i2': '#F38181', 's': '#AA96DA', 'p': '#70D6FF', 3789 + '.': '#95E1D3', 'c': '#FF6B6B', 'o': '#9370DB', 'm': '#90EE90', 3790 + }; 3791 + 3792 + const letters = [ 3793 + { char: 'K', color: letterColors['K'] }, 3794 + { char: 'i', color: letterColors['i1'] }, 3795 + { char: 'd', color: letterColors['d'] }, 3796 + { char: 'L', color: letterColors['L'] }, 3797 + { char: 'i', color: letterColors['i2'] }, 3798 + { char: 's', color: letterColors['s'] }, 3799 + { char: 'p', color: letterColors['p'] }, 3800 + { char: '.', color: letterColors['.'] }, 3801 + { char: 'c', color: letterColors['c'] }, 3802 + { char: 'o', color: letterColors['o'] }, 3803 + { char: 'm', color: letterColors['m'] }, 3804 + ]; 3805 + 3806 + const letterSpans = letters.map(l => `<span style="color: ${l.color}">${l.char}</span>`).join(''); 3807 + 3808 + // Generate floating codes HTML with seeded random positions 3809 + const seed = codes.join('').split('').reduce((a, c) => a + c.charCodeAt(0), 0); 3810 + const seededRandom = (i) => { 3811 + const x = Math.sin(seed + i * 9999) * 10000; 3812 + return x - Math.floor(x); 3813 + }; 3814 + 3815 + const cols = 6; 3816 + const rows = 5; 3817 + const cellWidth = (width + 100) / cols; 3818 + const cellHeight = (height + 80) / rows; 3819 + const numCodes = cols * rows; 3820 + 3821 + let floatingCodesHtml = ''; 3822 + for (let i = 0; i < numCodes; i++) { 3823 + const code = codes[i % codes.length] || 'abc'; 3824 + const col = i % cols; 3825 + const row = Math.floor(i / cols); 3826 + 3827 + const baseX = col * cellWidth - 50 + cellWidth / 2; 3828 + const baseY = row * cellHeight - 40 + cellHeight / 2; 3829 + const jitterX = (seededRandom(i * 5) - 0.5) * cellWidth * 0.4; 3830 + const jitterY = (seededRandom(i * 5 + 1) - 0.5) * cellHeight * 0.3; 3831 + const x = baseX + jitterX; 3832 + const y = baseY + jitterY; 3833 + const rotation = (seededRandom(i * 5 + 2) - 0.5) * 16; 3834 + const scale = 0.7 + seededRandom(i * 5 + 3) * 0.8; 3835 + const opacity = 0.2 + seededRandom(i * 5 + 4) * 0.25; 3836 + const fontSize = 42 * scale; 3837 + 3838 + floatingCodesHtml += ` 3839 + <div class="floating-code" style=" 3840 + left: ${x}px; 3841 + top: ${y}px; 3842 + font-size: ${fontSize}px; 3843 + opacity: ${opacity}; 3844 + transform: rotate(${rotation}deg); 3845 + "><span class="dollar">$</span><span class="code">${code}</span></div> 3846 + `; 3847 + } 3848 + 3849 + const html = `<!DOCTYPE html> 3850 + <html> 3851 + <head> 3852 + <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:wght@700&display=swap" rel="stylesheet"> 3853 + <style> 3854 + * { margin: 0; padding: 0; box-sizing: border-box; } 3855 + body { 3856 + width: ${width}px; 3857 + height: ${height}px; 3858 + display: flex; 3859 + align-items: center; 3860 + justify-content: center; 3861 + background: transparent; 3862 + position: relative; 3863 + overflow: hidden; 3864 + } 3865 + .floating-code { 3866 + position: absolute; 3867 + font-family: 'Comic Relief', cursive; 3868 + font-weight: 700; 3869 + white-space: nowrap; 3870 + text-shadow: 2px 2px 0 rgba(0,0,0,0.5); 3871 + } 3872 + .floating-code .dollar { 3873 + color: #00ff88; 3874 + } 3875 + .floating-code .code { 3876 + color: limegreen; 3877 + } 3878 + .branding { 3879 + font-family: 'Comic Relief', cursive; 3880 + font-size: 160px; 3881 + font-weight: 700; 3882 + letter-spacing: 0.05em; 3883 + text-shadow: 8px 8px 0 #000; 3884 + position: relative; 3885 + z-index: 10; 3886 + } 3887 + </style> 3888 + </head> 3889 + <body> 3890 + ${floatingCodesHtml} 3891 + <div class="branding">${letterSpans}</div> 3892 + </body> 3893 + </html>`; 3894 + 3895 + const browser = await getBrowser(); 3896 + const page = await browser.newPage(); 3897 + await interceptSelfRequests(page); 3898 + await injectFontGlyphs(page); 3899 + 3900 + try { 3901 + await page.setViewport({ width, height, deviceScaleFactor: 1 }); 3902 + await page.setContent(html, { waitUntil: 'networkidle0' }); 3903 + 3904 + // Wait for font to load 3905 + await page.evaluate(() => document.fonts.ready); 3906 + await new Promise(r => setTimeout(r, 500)); // Extra time for font render 3907 + 3908 + const screenshot = await page.screenshot({ 3909 + type: 'png', 3910 + omitBackground: true // Transparent background 3911 + }); 3912 + 3913 + return screenshot; 3914 + } finally { 3915 + await page.close(); 3916 + } 3917 + } 3918 + 3919 + /** 3920 + * Create large KidLisp.com branding overlay with Comic Relief font (SVG fallback) 3921 + */ 3922 + function createKidLispBranding(width, height) { 3923 + // KidLisp letter colors - com uses delete(red)/stop(purple)/play(green) button colors 3924 + const letterColors = { 3925 + 'K': '#FF6B6B', 'i1': '#4ECDC4', 'd': '#FFE66D', 3926 + 'L': '#95E1D3', 'i2': '#F38181', 's': '#AA96DA', 'p': '#70D6FF', 3927 + '.': '#95E1D3', 'c': '#FF6B6B', 'o': '#9370DB', 'm': '#90EE90', 3928 + }; 3929 + 3930 + const fontSize = 160; // Even bigger 3931 + const fontFamily = "'Comic Relief', 'Comic Sans MS', cursive, sans-serif"; 3932 + const shadowOffset = 8; // Offset down and right 3933 + 3934 + // Build colored text with tspans 3935 + const letters = [ 3936 + { char: 'K', color: letterColors['K'] }, 3937 + { char: 'i', color: letterColors['i1'] }, 3938 + { char: 'd', color: letterColors['d'] }, 3939 + { char: 'L', color: letterColors['L'] }, 3940 + { char: 'i', color: letterColors['i2'] }, 3941 + { char: 's', color: letterColors['s'] }, 3942 + { char: 'p', color: letterColors['p'] }, 3943 + { char: '.', color: letterColors['.'] }, 3944 + { char: 'c', color: letterColors['c'] }, 3945 + { char: 'o', color: letterColors['o'] }, 3946 + { char: 'm', color: letterColors['m'] }, 3947 + ]; 3948 + 3949 + const tspans = letters.map(l => `<tspan fill="${l.color}">${l.char}</tspan>`).join(''); 3950 + const blackTspans = letters.map(l => `<tspan fill="#000">${l.char}</tspan>`).join(''); 3951 + 3952 + const yOffset = 30; // Move text down for better visual centering 3953 + const svg = ` 3954 + <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> 3955 + <defs> 3956 + ${comicReliefBoldBase64 ? ` 3957 + <style type="text/css"> 3958 + @font-face { 3959 + font-family: 'Comic Relief'; 3960 + src: url('data:font/truetype;base64,${comicReliefBoldBase64}') format('truetype'); 3961 + font-weight: bold; 3962 + } 3963 + </style> 3964 + ` : ''} 3965 + </defs> 3966 + <!-- Black shadow offset down-right --> 3967 + <text x="${width/2 + shadowOffset}" y="${height/2 + yOffset + shadowOffset}" 3968 + font-family="${fontFamily}" 3969 + font-size="${fontSize}" font-weight="bold" 3970 + text-anchor="middle" dominant-baseline="middle" 3971 + letter-spacing="0.05em">${blackTspans}</text> 3972 + <!-- Main colored text --> 3973 + <text x="${width/2}" y="${height/2 + yOffset}" 3974 + font-family="${fontFamily}" 3975 + font-size="${fontSize}" font-weight="bold" 3976 + text-anchor="middle" dominant-baseline="middle" 3977 + letter-spacing="0.05em">${tspans}</text> 3978 + </svg> 3979 + `; 3980 + 3981 + return Buffer.from(svg); 3982 + } 3983 + 3984 + /** 3985 + * Generate KidLisp OG image - Filmstrip layout (Option C) 3986 + * Same piece captured at 5 different time offsets 3987 + */ 3988 + async function generateFilmstripOGImage(topPieces) { 3989 + const dayOfYear = getDayOfYear(); 3990 + const featuredIndex = dayOfYear % Math.min(topPieces.length, 10); 3991 + const featured = topPieces[featuredIndex]; 3992 + 3993 + if (!featured) { 3994 + throw new Error('No featured piece available'); 3995 + } 3996 + 3997 + const width = 1200; 3998 + const height = 630; 3999 + const frameCount = 5; 4000 + const frameWidth = 200; 4001 + const frameHeight = 200; 4002 + const spacing = 20; 4003 + const totalFrameWidth = frameCount * frameWidth + (frameCount - 1) * spacing; 4004 + const startX = (width - totalFrameWidth) / 2; 4005 + const frameY = 180; 4006 + 4007 + console.log(`🎨 Generating filmstrip OG: $${featured.code} (${frameCount} frames)`); 4008 + 4009 + const browser = await getBrowser(); 4010 + const page = await browser.newPage(); 4011 + await interceptSelfRequests(page); 4012 + await injectFontGlyphs(page); 4013 + 4014 + try { 4015 + await page.setViewport({ width: frameWidth, height: frameHeight, deviceScaleFactor: 2 }); 4016 + 4017 + const url = `https://aesthetic.computer/$${featured.code}?density=2&tv=true&nolabel=true&nogap=true&spoofaudio=true`; 4018 + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); 4019 + await populateGlyphCache(page); 4020 + await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {}); 4021 + 4022 + const frames = []; 4023 + 4024 + for (let i = 0; i < frameCount; i++) { 4025 + // Wait between frames to capture animation progress 4026 + await new Promise(r => setTimeout(r, i === 0 ? 2000 : 800)); 4027 + 4028 + const screenshot = await page.screenshot({ type: 'png' }); 4029 + const frame = await sharp(screenshot) 4030 + .resize(frameWidth, frameHeight, { fit: 'cover' }) 4031 + .extend({ top: 2, bottom: 2, left: 2, right: 2, background: { r: 255, g: 255, b: 255, alpha: 1 } }) 4032 + .png() 4033 + .toBuffer(); 4034 + 4035 + frames.push(frame); 4036 + console.log(` Frame ${i + 1}/${frameCount} captured`); 4037 + } 4038 + 4039 + // Create base image with dark background 4040 + const composites = frames.map((frame, i) => ({ 4041 + input: frame, 4042 + left: Math.round(startX + i * (frameWidth + spacing)), 4043 + top: frameY, 4044 + })); 4045 + 4046 + // Add arrows between frames 4047 + const arrowSvg = Buffer.from(` 4048 + <svg width="20" height="30" xmlns="http://www.w3.org/2000/svg"> 4049 + <path d="M5 5 L15 15 L5 25" stroke="white" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/> 4050 + </svg> 4051 + `); 4052 + 4053 + for (let i = 0; i < frameCount - 1; i++) { 4054 + composites.push({ 4055 + input: arrowSvg, 4056 + left: Math.round(startX + (i + 1) * frameWidth + i * spacing + spacing / 2 - 10), 4057 + top: frameY + frameHeight / 2 - 15, 4058 + }); 4059 + } 4060 + 4061 + // Add title and info 4062 + const titleSvg = Buffer.from(` 4063 + <svg width="${width}" height="100" xmlns="http://www.w3.org/2000/svg"> 4064 + <text x="${width / 2}" y="60" font-family="monospace, 'Courier New'" font-size="36" font-weight="bold" fill="white" text-anchor="middle"> 4065 + KidLisp.com 4066 + </text> 4067 + </svg> 4068 + `); 4069 + 4070 + const infoSvg = Buffer.from(` 4071 + <svg width="${width}" height="80" xmlns="http://www.w3.org/2000/svg"> 4072 + <text x="${width / 2}" y="50" font-family="monospace" font-size="24" fill="#cccccc" text-anchor="middle"> 4073 + $${featured.code} · ${formatHits(featured.hits)} plays ${featured.owner?.handle ? '· ' + featured.owner.handle : ''} 4074 + </text> 4075 + </svg> 4076 + `); 4077 + 4078 + composites.push({ input: titleSvg, left: 0, top: 40 }); 4079 + composites.push({ input: infoSvg, left: 0, top: height - 120 }); 4080 + 4081 + const filmstrip = await sharp({ 4082 + create: { 4083 + width, 4084 + height, 4085 + channels: 4, 4086 + background: { r: 20, g: 20, b: 30, alpha: 1 } 4087 + } 4088 + }) 4089 + .composite(composites) 4090 + .png() 4091 + .toBuffer(); 4092 + 4093 + return filmstrip; 4094 + 4095 + } finally { 4096 + await page.close(); 4097 + } 4098 + } 4099 + 4100 + /** 4101 + * Generate KidLisp OG image - Code Split layout (Option D) 4102 + * Source code on left, visual output on right 4103 + */ 4104 + async function generateCodeSplitOGImage(topPieces) { 4105 + const dayOfYear = getDayOfYear(); 4106 + const featuredIndex = dayOfYear % Math.min(topPieces.length, 10); 4107 + const featured = topPieces[featuredIndex]; 4108 + 4109 + if (!featured) { 4110 + throw new Error('No featured piece available'); 4111 + } 4112 + 4113 + const width = 1200; 4114 + const height = 630; 4115 + const codeWidth = 500; 4116 + const previewWidth = 650; 4117 + const previewHeight = 500; 4118 + const padding = 25; 4119 + 4120 + console.log(`🎨 Generating code-split OG: $${featured.code}`); 4121 + 4122 + // Get source code (truncate for display) 4123 + const source = featured.source || '(wipe "black")\n(ink "white")\n(box 50 50 100 100)'; 4124 + const sourceLines = source.split('\n').slice(0, 12); 4125 + const displaySource = sourceLines.join('\n') + (source.split('\n').length > 12 ? '\n...' : ''); 4126 + 4127 + const browser = await getBrowser(); 4128 + const page = await browser.newPage(); 4129 + await interceptSelfRequests(page); 4130 + await injectFontGlyphs(page); 4131 + 4132 + try { 4133 + // Capture preview 4134 + await page.setViewport({ width: previewWidth, height: previewHeight, deviceScaleFactor: 1 }); 4135 + 4136 + const url = `https://aesthetic.computer/$${featured.code}?density=1&tv=true&nolabel=true&nogap=true&spoofaudio=true`; 4137 + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); 4138 + await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {}); 4139 + await new Promise(r => setTimeout(r, 4000)); 4140 + 4141 + const screenshot = await page.screenshot({ type: 'png' }); 4142 + const preview = await sharp(screenshot) 4143 + .resize(previewWidth - padding * 2, previewHeight - padding * 2, { fit: 'cover' }) 4144 + .extend({ top: 4, bottom: 4, left: 4, right: 4, background: { r: 80, g: 80, b: 100, alpha: 1 } }) 4145 + .png() 4146 + .toBuffer(); 4147 + 4148 + // Create code panel as SVG 4149 + const escapedSource = displaySource 4150 + .replace(/&/g, '&amp;') 4151 + .replace(/</g, '&lt;') 4152 + .replace(/>/g, '&gt;') 4153 + .replace(/"/g, '&quot;'); 4154 + 4155 + const codeLines = escapedSource.split('\n'); 4156 + const lineHeight = 28; 4157 + const codeY = 80; 4158 + 4159 + const codeSvg = Buffer.from(` 4160 + <svg width="${codeWidth}" height="${height}" xmlns="http://www.w3.org/2000/svg"> 4161 + <rect width="100%" height="100%" fill="#1a1a2e"/> 4162 + <text x="20" y="40" font-family="monospace" font-size="14" fill="#666"> 4163 + // $${featured.code} 4164 + </text> 4165 + ${codeLines.map((line, i) => ` 4166 + <text x="20" y="${codeY + i * lineHeight}" font-family="monospace, 'Courier New'" font-size="18" fill="#88ff88"> 4167 + ${line || ' '} 4168 + </text> 4169 + `).join('')} 4170 + </svg> 4171 + `); 4172 + 4173 + // Bottom info bar 4174 + const infoSvg = Buffer.from(` 4175 + <svg width="${width}" height="60" xmlns="http://www.w3.org/2000/svg"> 4176 + <rect width="100%" height="100%" fill="rgba(0,0,0,0.8)"/> 4177 + <text x="24" y="40" font-family="monospace" font-size="24" font-weight="bold" fill="white"> 4178 + KidLisp.com 4179 + </text> 4180 + <text x="${width - 24}" y="40" font-family="monospace" font-size="18" fill="#aaa" text-anchor="end"> 4181 + $${featured.code} · ${formatHits(featured.hits)} plays 4182 + </text> 4183 + </svg> 4184 + `); 4185 + 4186 + const composite = await sharp({ 4187 + create: { 4188 + width, 4189 + height, 4190 + channels: 4, 4191 + background: { r: 26, g: 26, b: 46, alpha: 1 } 4192 + } 4193 + }) 4194 + .composite([ 4195 + { input: codeSvg, left: 0, top: 0 }, 4196 + { input: preview, left: codeWidth + padding, top: padding }, 4197 + { input: infoSvg, left: 0, top: height - 60 }, 4198 + ]) 4199 + .png() 4200 + .toBuffer(); 4201 + 4202 + return composite; 4203 + 4204 + } finally { 4205 + await page.close(); 4206 + } 4207 + } 4208 + 4209 + /** 4210 + * Main entry point: Generate KidLisp.com OG preview image 4211 + * Checks cache first, generates new image if needed 4212 + * 4213 + * @param {string} layout - Layout option: 'featured', 'mosaic', 'filmstrip', 'code-split' 4214 + * @param {boolean} forceRegenerate - Skip cache and regenerate 4215 + * @param {object} options - Additional options 4216 + * @param {string} options.handle - Filter by handle (e.g., '@jeffrey') 4217 + * @returns {Promise<{buffer: Buffer, url: string, cached: boolean, featured?: object}>} 4218 + */ 4219 + export async function generateKidlispOGImage(layout = 'featured', forceRegenerate = false, options = {}) { 4220 + const { handle } = options; 4221 + console.log(`\n🖼️ KidLisp OG Image Request (layout: ${layout}, force: ${forceRegenerate}${handle ? `, handle: ${handle}` : ''})`); 4222 + 4223 + // Check cache first (unless forcing regeneration) 4224 + if (!forceRegenerate) { 4225 + const cachedUrl = await getCachedOGImage(layout); 4226 + if (cachedUrl) { 4227 + return { 4228 + url: cachedUrl, 4229 + cached: true, 4230 + layout, 4231 + generatedAt: ogImageCache.generatedAt, 4232 + featuredPiece: ogImageCache.featuredPiece, 4233 + }; 4234 + } 4235 + } 4236 + 4237 + // Fetch top hits 4238 + const topPieces = await fetchTopKidlispHits(40); // Fetch extra to account for deduplication 4239 + if (topPieces.length === 0) { 4240 + throw new Error('No KidLisp pieces available from API'); 4241 + } 4242 + 4243 + // Filter by handle if specified 4244 + let filteredPieces = topPieces; 4245 + if (handle) { 4246 + const normalizedHandle = handle.startsWith('@') ? handle : `@${handle}`; 4247 + filteredPieces = topPieces.filter(p => p.owner?.handle === normalizedHandle); 4248 + console.log(` Filtered to ${filteredPieces.length} pieces by ${normalizedHandle}`); 4249 + } 4250 + 4251 + // Deduplicate by source similarity (90% threshold) 4252 + const uniquePieces = deduplicatePieces(filteredPieces); 4253 + console.log(` Found ${filteredPieces.length} pieces, ${uniquePieces.length} unique after deduplication`); 4254 + 4255 + // Generate based on layout 4256 + let buffer; 4257 + switch (layout) { 4258 + case 'mosaic': 4259 + buffer = await generateMosaicOGImage(uniquePieces); 4260 + break; 4261 + case 'filmstrip': 4262 + buffer = await generateFilmstripOGImage(uniquePieces); 4263 + break; 4264 + case 'code-split': 4265 + buffer = await generateCodeSplitOGImage(uniquePieces); 4266 + break; 4267 + case 'featured': 4268 + default: 4269 + buffer = await generateFeaturedOGImage(uniquePieces); 4270 + break; 4271 + } 4272 + 4273 + // Upload to Spaces 4274 + const url = await uploadOGImageToSpaces(buffer, layout); 4275 + 4276 + return { 4277 + buffer, 4278 + url, 4279 + cached: false, 4280 + layout, 4281 + generatedAt: new Date().toISOString(), 4282 + featuredPiece: ogImageCache.featuredPiece, 4283 + }; 4284 + } 4285 + 4286 + /** 4287 + * Get OG image cache status 4288 + */ 4289 + export function getOGImageCacheStatus() { 4290 + return { 4291 + cached: !!ogImageCache.url && Date.now() < ogImageCache.expires, 4292 + url: ogImageCache.url, 4293 + expires: ogImageCache.expires ? new Date(ogImageCache.expires).toISOString() : null, 4294 + generatedAt: ogImageCache.generatedAt, 4295 + featuredPiece: ogImageCache.featuredPiece, 4296 + }; 4297 + } 4298 + 4299 + /** 4300 + * Get the latest cached OG image URL without triggering generation. 4301 + * Returns the CDN URL if it exists for today, null otherwise. 4302 + * This is fast and safe for social media crawlers. 4303 + */ 4304 + export async function getLatestOGImageUrl(layout = 'mosaic') { 4305 + const today = getTodayKey(); 4306 + const key = `og/kidlisp/${today}-${layout}.png`; 4307 + 4308 + // Check memory cache first 4309 + if (ogImageCache.url && ogImageCache.url.includes(today)) { 4310 + return ogImageCache.url; 4311 + } 4312 + 4313 + // Check Spaces for today's image 4314 + try { 4315 + await spacesClient.send(new HeadObjectCommand({ 4316 + Bucket: SPACES_BUCKET, 4317 + Key: key, 4318 + })); 4319 + const url = `${SPACES_CDN_BASE}/${key}`; 4320 + ogImageCache.url = url; 4321 + ogImageCache.expires = Date.now() + 60 * 60 * 1000; 4322 + return url; 4323 + } catch (err) { 4324 + // Not found - return yesterday's image if available 4325 + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; 4326 + const yesterdayKey = `og/kidlisp/${yesterday}-${layout}.png`; 4327 + try { 4328 + await spacesClient.send(new HeadObjectCommand({ 4329 + Bucket: SPACES_BUCKET, 4330 + Key: yesterdayKey, 4331 + })); 4332 + return `${SPACES_CDN_BASE}/${yesterdayKey}`; 4333 + } catch { 4334 + return null; 4335 + } 4336 + } 4337 + } 4338 + 4339 + /** 4340 + * Regenerate OG images in the background. 4341 + * Safe to call from server startup or scheduled jobs. 4342 + */ 4343 + export async function regenerateOGImagesBackground() { 4344 + const layouts = ['mosaic', 'featured']; // Main layouts we care about 4345 + console.log('🔄 Starting background OG image regeneration...'); 4346 + 4347 + for (const layout of layouts) { 4348 + try { 4349 + console.log(` Regenerating ${layout}...`); 4350 + await generateKidlispOGImage(layout, true); // force=true 4351 + console.log(` ✅ ${layout} done`); 4352 + } catch (err) { 4353 + console.error(` ❌ ${layout} failed:`, err.message); 4354 + } 4355 + } 4356 + 4357 + console.log('🔄 Background OG regeneration complete'); 4358 + } 4359 + 4360 + // ============================================================================= 4361 + // KidLisp Backdrop - Animated WebP for login screens, etc. 4362 + // ============================================================================= 4363 + 4364 + // Backdrop cache (memory) 4365 + const backdropCache = { 4366 + url: null, 4367 + date: null, 4368 + piece: null, 4369 + }; 4370 + 4371 + /** 4372 + * Get or generate KidLisp backdrop - a 2048px animated webp of a featured piece. 4373 + * Rotates daily based on top hits. Caches to CDN for fast access. 4374 + * @param {boolean} force - Force regeneration even if cached 4375 + * @returns {{ url: string, piece: string, cached: boolean }} 4376 + */ 4377 + export async function generateKidlispBackdrop(force = false) { 4378 + const today = getTodayKey(); 4379 + const key = `backdrop/kidlisp/${today}.webp`; 4380 + 4381 + // Check memory cache 4382 + if (!force && backdropCache.url && backdropCache.date === today) { 4383 + console.log(`📦 Backdrop from memory cache: ${backdropCache.url}`); 4384 + return { url: backdropCache.url, piece: backdropCache.piece, cached: true }; 4385 + } 4386 + 4387 + // Check Spaces for today's backdrop 4388 + if (!force) { 4389 + try { 4390 + await spacesClient.send(new HeadObjectCommand({ 4391 + Bucket: SPACES_BUCKET, 4392 + Key: key, 4393 + })); 4394 + const url = `${SPACES_CDN_BASE}/${key}`; 4395 + backdropCache.url = url; 4396 + backdropCache.date = today; 4397 + console.log(`📦 Backdrop from Spaces cache: ${url}`); 4398 + return { url, piece: backdropCache.piece, cached: true }; 4399 + } catch { 4400 + // Not cached, continue to generate 4401 + } 4402 + } 4403 + 4404 + // Fetch top KidLisp pieces by @jeffrey only 4405 + const topPieces = await fetchTopKidlispHits(50); 4406 + const jeffreyPieces = topPieces.filter(p => p.owner?.handle === '@jeffrey'); 4407 + 4408 + if (!jeffreyPieces.length) { 4409 + throw new Error('No KidLisp pieces by @jeffrey available for backdrop'); 4410 + } 4411 + 4412 + // Pick piece based on day of year (rotates daily through jeffrey's top pieces) 4413 + const dayOfYear = getDayOfYear(); 4414 + const featuredIndex = dayOfYear % Math.min(jeffreyPieces.length, 10); 4415 + const featured = jeffreyPieces[featuredIndex]; 4416 + const piece = `$${featured.code}`; 4417 + 4418 + console.log(`🎨 Generating backdrop: ${piece} by ${featured.owner?.handle} (${formatHits(featured.hits)} hits)`); 4419 + 4420 + // Generate 256x256 animated webp at 4x density (gives 1024x1024 output with chunky pixels) 4421 + // Lower resolution (1024 vs 2048) makes recording faster for Auth0 login backgrounds 4422 + const result = await grabPiece(piece, { 4423 + format: 'webp', 4424 + width: 256, 4425 + height: 256, 4426 + duration: 12000, 4427 + fps: 7.5, 4428 + playbackFps: 15, 4429 + density: 4, 4430 + quality: 85, 4431 + skipCache: force, 4432 + source: 'backdrop', 4433 + }); 4434 + 4435 + if (!result.success) { 4436 + throw new Error(result.error || 'Failed to generate backdrop'); 4437 + } 4438 + 4439 + // grabPiece returns cdnUrl from oven/grabs/ - use that directly 4440 + const url = result.cdnUrl; 4441 + 4442 + if (!url) { 4443 + throw new Error('No CDN URL returned from grabPiece'); 4444 + } 4445 + 4446 + console.log(`📤 Backdrop generated: ${url}`); 4447 + 4448 + // Update cache with the grab URL 4449 + backdropCache.url = url; 4450 + backdropCache.date = today; 4451 + backdropCache.piece = piece; 4452 + 4453 + return { url, piece, cached: false }; 4454 + } 4455 + 4456 + /** 4457 + * Get cached backdrop URL without triggering generation. 4458 + * Returns the in-memory cached URL if available for today, null otherwise. 4459 + */ 4460 + export async function getLatestBackdropUrl() { 4461 + const today = getTodayKey(); 4462 + 4463 + // Check memory cache - only return if it's from today 4464 + if (backdropCache.url && backdropCache.date === today) { 4465 + return backdropCache.url; 4466 + } 4467 + 4468 + // No cached URL for today - caller should trigger generation 4469 + return null; 4470 + } 4471 + 4472 + // ============================================================================= 4473 + // Notepat.com OG Preview Image 4474 + // ============================================================================= 4475 + 4476 + // Notepat OG image cache (memory) 4477 + const notepatOGCache = { 4478 + url: null, 4479 + expires: 0, 4480 + }; 4481 + 4482 + /** 4483 + * Generate a branded OG image for notepat.com (1200x630 PNG). 4484 + * Shows the actual split-layout chromatic pad interface with keyboard shortcuts. 4485 + */ 4486 + export async function generateNotepatOGImage(forceRegenerate = false) { 4487 + console.log(`\n🎹 Notepat OG Image Request (force: ${forceRegenerate})`); 4488 + 4489 + // Check cache first 4490 + if (!forceRegenerate) { 4491 + const cachedUrl = await getLatestNotepatOGUrl(); 4492 + if (cachedUrl) { 4493 + return { url: cachedUrl, cached: true }; 4494 + } 4495 + } 4496 + 4497 + const W = 1200; 4498 + const H = 630; 4499 + 4500 + // Full chromatic scale note colors (matching note-colors.mjs logic) 4501 + const getNoteColor = (noteName, octave) => { 4502 + const baseColors = { 4503 + 'C': [255, 50, 50], // Red 4504 + 'C#': [255, 100, 30], // Red-Orange 4505 + 'D': [255, 160, 0], // Orange 4506 + 'D#': [255, 200, 0], // Yellow-Orange 4507 + 'E': [255, 230, 0], // Yellow 4508 + 'F': [50, 200, 50], // Green 4509 + 'F#': [50, 160, 120], // Teal 4510 + 'G': [50, 120, 255], // Blue 4511 + 'G#': [90, 80, 220], // Blue-Purple 4512 + 'A': [130, 50, 200], // Purple 4513 + 'A#': [160, 50, 220], // Purple-Magenta 4514 + 'B': [180, 80, 255], // Magenta 4515 + }; 4516 + 4517 + // Octave 5 gets brighter dayglo treatment 4518 + if (octave === 5) { 4519 + const boost = 40; 4520 + const [r, g, b] = baseColors[noteName]; 4521 + return [ 4522 + Math.min(255, r + boost), 4523 + Math.min(255, g + boost), 4524 + Math.min(255, b + boost), 4525 + ]; 4526 + } 4527 + return baseColors[noteName]; 4528 + }; 4529 + 4530 + // All 24 chromatic notes in notepat layout 4531 + const leftOctave = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; 4532 + const rightOctave = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; 4533 + 4534 + // Keyboard mappings (matching NOTE_TO_KEYBOARD_KEY) 4535 + const leftKeys = ['c', 'v', 'd', 's', 'e', 'f', 'w', 'g', 'r', 'a', 'q', 'b']; 4536 + const rightKeys = ['h', 't', 'i', 'y', 'j', 'k', 'u', 'l', 'o', 'm', 'p', 'n']; 4537 + 4538 + // Layout: MUCH LARGER pads to fill the space 4539 + const padW = 90; // More than doubled 4540 + const padH = 130; // Significantly taller 4541 + const padGap = 10; 4542 + const padRadius = 6; 4543 + 4544 + const cols = 4; // 4 columns per octave side 4545 + const rows = 3; // 3 rows per octave side 4546 + 4547 + const octaveW = cols * padW + (cols - 1) * padGap; 4548 + const octaveH = rows * padH + (rows - 1) * padGap; 4549 + 4550 + const splitGap = 80; // Tighter split gap 4551 + const totalW = octaveW * 2 + splitGap; 4552 + 4553 + const leftX = (W - totalW) / 2; 4554 + const rightX = leftX + octaveW + splitGap; 4555 + const topY = 60; // Move up significantly 4556 + 4557 + // Build pads SVG 4558 + let padsSvg = ''; 4559 + 4560 + // Left octave (octave 4) 4561 + for (let i = 0; i < 12; i++) { 4562 + const row = Math.floor(i / cols); 4563 + const col = i % cols; 4564 + const x = leftX + col * (padW + padGap); 4565 + const y = topY + row * (padH + padGap); 4566 + 4567 + const noteName = leftOctave[i]; 4568 + const key = leftKeys[i]; 4569 + const [r, g, b] = getNoteColor(noteName, 4); 4570 + 4571 + // Pad 4572 + padsSvg += `<rect x="${x}" y="${y}" width="${padW}" height="${padH}" rx="${padRadius}" fill="rgb(${r},${g},${b})" opacity="0.92"/>`; 4573 + 4574 + // Note label (top) 4575 + const noteLabel = noteName.replace('#', '♯'); 4576 + const textColor = (noteName.includes('E') || noteName.includes('F')) ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.85)'; 4577 + padsSvg += `<text x="${x + padW/2}" y="${y + 18}" font-family="monospace" font-size="13" font-weight="bold" fill="${textColor}" text-anchor="middle">${noteLabel}</text>`; 4578 + 4579 + // Key label (bottom) 4580 + padsSvg += `<text x="${x + padW/2}" y="${y + padH - 8}" font-family="monospace" font-size="11" fill="${textColor}" opacity="0.7" text-anchor="middle">${key}</text>`; 4581 + } 4582 + 4583 + // Right octave (octave 5) 4584 + for (let i = 0; i < 12; i++) { 4585 + const row = Math.floor(i / cols); 4586 + const col = i % cols; 4587 + const x = rightX + col * (padW + padGap); 4588 + const y = topY + row * (padH + padGap); 4589 + 4590 + const noteName = rightOctave[i]; 4591 + const key = rightKeys[i]; 4592 + const [r, g, b] = getNoteColor(noteName, 5); 4593 + 4594 + // Pad 4595 + padsSvg += `<rect x="${x}" y="${y}" width="${padW}" height="${padH}" rx="${padRadius}" fill="rgb(${r},${g},${b})" opacity="0.92"/>`; 4596 + 4597 + // Note label (top) 4598 + const noteLabel = noteName.replace('#', '♯'); 4599 + const textColor = (noteName.includes('E') || noteName.includes('F')) ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.85)'; 4600 + padsSvg += `<text x="${x + padW/2}" y="${y + 18}" font-family="monospace" font-size="13" font-weight="bold" fill="${textColor}" text-anchor="middle">${noteLabel}</text>`; 4601 + 4602 + // Key label (bottom) 4603 + padsSvg += `<text x="${x + padW/2}" y="${y + padH - 8}" font-family="monospace" font-size="11" fill="${textColor}" opacity="0.7" text-anchor="middle">${key}</text>`; 4604 + } 4605 + 4606 + // Decorative waveform across top 4607 + let wavePath = `M 60 100`; 4608 + for (let i = 0; i <= 120; i++) { 4609 + const x = 60 + (i / 120) * (W - 120); 4610 + const y = 100 + Math.sin(i * 0.15) * 18 + Math.cos(i * 0.08) * 12; 4611 + wavePath += ` L ${x.toFixed(1)} ${y.toFixed(1)}`; 4612 + } 4613 + 4614 + const svg = `<?xml version="1.0" encoding="UTF-8"?> 4615 + <svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"> 4616 + <defs> 4617 + <linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%"> 4618 + <stop offset="0%" style="stop-color:#1a1a3c"/> 4619 + <stop offset="100%" style="stop-color:#0d0d28"/> 4620 + </linearGradient> 4621 + <linearGradient id="waveGrad" x1="0%" y1="0%" x2="100%" y2="0%"> 4622 + <stop offset="0%" style="stop-color:#6080ff;stop-opacity:0.15"/> 4623 + <stop offset="50%" style="stop-color:#8090ff;stop-opacity:0.35"/> 4624 + <stop offset="100%" style="stop-color:#6080ff;stop-opacity:0.15"/> 4625 + </linearGradient> 4626 + </defs> 4627 + 4628 + <!-- Background (notepat's blue theme) --> 4629 + <rect width="${W}" height="${H}" fill="url(#bg)"/> 4630 + 4631 + <!-- Waveform decoration --> 4632 + <path d="${wavePath}" fill="none" stroke="url(#waveGrad)" stroke-width="2" stroke-linecap="round" opacity="0.6"/> 4633 + 4634 + <!-- Pads (split layout) --> 4635 + ${padsSvg} 4636 + 4637 + <!-- Octave labels --> 4638 + <text x="${leftX + octaveW/2}" y="${topY - 20}" font-family="monospace" font-size="14" fill="rgba(200,200,255,0.5)" text-anchor="middle">octave 4</text> 4639 + <text x="${rightX + octaveW/2}" y="${topY - 20}" font-family="monospace" font-size="14" fill="rgba(200,200,255,0.5)" text-anchor="middle">octave 5</text> 4640 + 4641 + <!-- "notepat" branding --> 4642 + <text x="${W / 2}" y="${topY + octaveH + 70}" font-family="monospace" font-size="48" font-weight="bold" fill="#e8e4de" text-anchor="middle" letter-spacing="4">notepat</text> 4643 + <text x="${W / 2}" y="${topY + octaveH + 100}" font-family="monospace" font-size="16" fill="rgba(200,210,255,0.6)" text-anchor="middle">split-layout chromatic piano</text> 4644 + 4645 + <!-- Footer tagline --> 4646 + <text x="${W / 2}" y="${H - 25}" font-family="monospace" font-size="14" fill="rgba(200,200,220,0.4)" text-anchor="middle">aesthetic.computer/notepat</text> 4647 + </svg>`; 4648 + 4649 + // Convert SVG to PNG using sharp 4650 + const buffer = await sharp(Buffer.from(svg)) 4651 + .png() 4652 + .toBuffer(); 4653 + 4654 + // Upload to Spaces 4655 + const key = `og/notepat/notepat-og.png`; 4656 + await spacesClient.send(new PutObjectCommand({ 4657 + Bucket: SPACES_BUCKET, 4658 + Key: key, 4659 + Body: buffer, 4660 + ContentType: 'image/png', 4661 + ACL: 'public-read', 4662 + CacheControl: 'public, max-age=604800', // 7-day cache 4663 + })); 4664 + 4665 + const url = `${SPACES_CDN_BASE}/${key}`; 4666 + 4667 + // Update memory cache 4668 + notepatOGCache.url = url; 4669 + notepatOGCache.expires = Date.now() + 7 * 24 * 60 * 60 * 1000; 4670 + 4671 + console.log(`📤 Notepat OG image uploaded: ${url} (${(buffer.length / 1024).toFixed(1)} KB)`); 4672 + return { url, cached: false, buffer }; 4673 + } 4674 + 4675 + /** 4676 + * Get the latest cached notepat OG image URL without triggering generation. 4677 + */ 4678 + export async function getLatestNotepatOGUrl() { 4679 + // Check memory cache 4680 + if (notepatOGCache.url && Date.now() < notepatOGCache.expires) { 4681 + return notepatOGCache.url; 4682 + } 4683 + 4684 + // Check Spaces 4685 + const key = `og/notepat/notepat-og.png`; 4686 + try { 4687 + await spacesClient.send(new HeadObjectCommand({ 4688 + Bucket: SPACES_BUCKET, 4689 + Key: key, 4690 + })); 4691 + const url = `${SPACES_CDN_BASE}/${key}`; 4692 + notepatOGCache.url = url; 4693 + notepatOGCache.expires = Date.now() + 60 * 60 * 1000; // 1hr memory cache 4694 + return url; 4695 + } catch { 4696 + return null; 4697 + } 4698 + } 4699 + 4700 + export { IPFS_GATEWAY };
+12
oven/infra/Caddyfile
··· 1 + { 2 + email me@jas.life 3 + } 4 + 5 + oven.aesthetic.computer, oven-origin.aesthetic.computer { 6 + reverse_proxy localhost:3002 7 + encode gzip 8 + 9 + log { 10 + output file /var/log/caddy/oven.log 11 + } 12 + }
+17
oven/infra/oven.service
··· 1 + [Unit] 2 + Description=Oven Video Processing Service 3 + After=network.target 4 + 5 + [Service] 6 + Type=simple 7 + User=oven 8 + WorkingDirectory=/opt/oven 9 + Environment=NODE_ENV=production 10 + ExecStart=/usr/bin/node /opt/oven/server.mjs 11 + Restart=always 12 + RestartSec=10 13 + StandardOutput=append:/var/log/oven/oven.log 14 + StandardError=append:/var/log/oven/oven-error.log 15 + 16 + [Install] 17 + WantedBy=multi-user.target
+276
oven/native-builder.mjs
··· 1 + // native-builder.mjs — FedAC Native kernel OTA builds for oven 2 + // 3 + // Triggered via POST /native-build after commits to fedac/native/ on main. 4 + // Runs build-and-flash.sh (no --flash) then upload-release.sh. 5 + // Models os-base-build.mjs. 6 + 7 + import { promises as fs } from "fs"; 8 + import path from "path"; 9 + import { randomUUID } from "crypto"; 10 + import { spawn } from "child_process"; 11 + 12 + const MAX_RECENT_JOBS = 10; 13 + const MAX_LOG_LINES = 2000; 14 + 15 + // fedac/native/ lives in the native-git repo on oven (polled by native-git-poller). 16 + const NATIVE_DIR = 17 + process.env.NATIVE_DIR || "/opt/oven/native-git/fedac/native"; 18 + 19 + // Kernel build cache: symlinked from fedac/native/build so kernel object 20 + // files survive rsync --delete between commits (5-10x faster warm builds). 21 + const CACHE_DIR = 22 + process.env.NATIVE_CACHE_DIR || "/opt/oven/native-cache"; 23 + 24 + const jobs = new Map(); 25 + const jobOrder = []; 26 + let activeJobId = null; 27 + 28 + function nowISO() { 29 + return new Date().toISOString(); 30 + } 31 + 32 + function stripAnsi(s) { 33 + return String(s || "").replace(/\u001b\[[0-9;]*m/g, ""); 34 + } 35 + 36 + function addLogLine(job, stream, line) { 37 + const clean = stripAnsi(line).replace(/\r/g, "").trimEnd(); 38 + if (!clean) return; 39 + job.logs.push({ ts: nowISO(), stream, line: clean }); 40 + if (job.logs.length > MAX_LOG_LINES) 41 + job.logs.splice(0, job.logs.length - MAX_LOG_LINES); 42 + job.updatedAt = nowISO(); 43 + 44 + // Parse progress hints from build-and-flash.sh + upload-release.sh output 45 + if (clean.includes("[build]")) { 46 + if (clean.match(/kernel|bzImage|vmlinuz/i)) { 47 + job.stage = "kernel"; 48 + job.percent = Math.max(job.percent, 55); 49 + } else if (clean.match(/initramfs|cpio|lz4/i)) { 50 + job.stage = "initramfs"; 51 + job.percent = Math.max(job.percent, 30); 52 + } else if (clean.match(/binary|ac-native|gcc|musl/i)) { 53 + job.stage = "binary"; 54 + job.percent = Math.max(job.percent, 10); 55 + } 56 + } 57 + if (clean.match(/Uploading|uploaded:/i)) { 58 + job.stage = "upload"; 59 + job.percent = Math.max(job.percent, 90); 60 + } 61 + if (clean.includes("Release published")) { 62 + job.stage = "done"; 63 + job.percent = 100; 64 + } 65 + } 66 + 67 + function makeSnapshot(job, opts = {}) { 68 + const { includeLogs = false, tail = 200 } = opts; 69 + const snap = { 70 + id: job.id, 71 + ref: job.ref, 72 + status: job.status, 73 + stage: job.stage, 74 + percent: job.percent, 75 + flags: job.flags, 76 + createdAt: job.createdAt, 77 + startedAt: job.startedAt, 78 + updatedAt: job.updatedAt, 79 + finishedAt: job.finishedAt, 80 + exitCode: job.exitCode, 81 + error: job.error, 82 + logCount: job.logs.length, 83 + elapsedMs: job.startedAt 84 + ? (job.finishedAt ? Date.parse(job.finishedAt) : Date.now()) - 85 + Date.parse(job.startedAt) 86 + : 0, 87 + }; 88 + if (includeLogs) { 89 + const start = Math.max(0, job.logs.length - Math.max(0, tail)); 90 + snap.logs = job.logs.slice(start); 91 + } 92 + return snap; 93 + } 94 + 95 + // Determine build-and-flash.sh flags based on which paths changed. 96 + // Never skip binary: AC_BUILD_NAME and AC_GIT_HASH are compiled into the 97 + // binary via CFLAGS and change on every commit. The Makefile's CFLAGS 98 + // signature check (`.cflags` md5) handles incremental rebuilds efficiently — 99 + // only object files are recompiled when flags change, not the full kernel. 100 + // Skipping the binary causes version string mismatch (device shows stale name). 101 + function buildFlagsFor(changedPaths = "") { 102 + return []; 103 + } 104 + 105 + // Symlink fedac/native/build → CACHE_DIR so kernel object files survive 106 + // the rsync --delete that happens on every deploy/push sync. 107 + async function setupBuildCache() { 108 + await fs.mkdir(CACHE_DIR, { recursive: true }); 109 + const buildLink = path.join(NATIVE_DIR, "build"); 110 + let stat; 111 + try { 112 + stat = await fs.lstat(buildLink); 113 + } catch { 114 + await fs.symlink(CACHE_DIR, buildLink); 115 + return; 116 + } 117 + if (stat.isSymbolicLink()) return; 118 + if (stat.isDirectory()) { 119 + try { 120 + await fs.rename(buildLink, CACHE_DIR); 121 + } catch { 122 + await fs.rm(buildLink, { recursive: true, force: true }); 123 + } 124 + } 125 + await fs.symlink(CACHE_DIR, buildLink); 126 + } 127 + 128 + function wireStream(job, proc, streamName) { 129 + let pending = ""; 130 + const s = streamName === "stdout" ? proc.stdout : proc.stderr; 131 + s.on("data", (chunk) => { 132 + pending += chunk.toString(); 133 + let idx; 134 + while ((idx = pending.indexOf("\n")) >= 0) { 135 + addLogLine(job, streamName, pending.slice(0, idx)); 136 + pending = pending.slice(idx + 1); 137 + } 138 + }); 139 + s.on("end", () => { 140 + if (pending) addLogLine(job, streamName, pending); 141 + }); 142 + } 143 + 144 + async function runPhase(job, label, cmd, args, cwd, extraEnv = {}) { 145 + job.stage = label; 146 + job.updatedAt = nowISO(); 147 + return new Promise((resolve, reject) => { 148 + const proc = spawn(cmd, args, { 149 + cwd, 150 + env: { 151 + ...process.env, 152 + TERM: "dumb", 153 + CLICOLOR: "0", 154 + FORCE_COLOR: "0", 155 + ...extraEnv, 156 + }, 157 + stdio: ["ignore", "pipe", "pipe"], 158 + }); 159 + job.process = proc; 160 + job.pid = proc.pid; 161 + wireStream(job, proc, "stdout"); 162 + wireStream(job, proc, "stderr"); 163 + proc.on("error", reject); 164 + proc.on("close", (code) => { 165 + job.process = null; 166 + if (code !== 0) reject(new Error(`${label} failed (exit ${code})`)); 167 + else resolve(); 168 + }); 169 + }); 170 + } 171 + 172 + async function runBuildJob(job) { 173 + try { 174 + await setupBuildCache(); 175 + 176 + job.status = "running"; 177 + job.startedAt = nowISO(); 178 + job.percent = 0; 179 + 180 + // Phase 1: build vmlinuz (no --flash) 181 + const buildScript = path.join(NATIVE_DIR, "scripts/build-and-flash.sh"); 182 + await runPhase(job, "build", "bash", [buildScript, ...job.flags], NATIVE_DIR); 183 + 184 + job.percent = 85; 185 + 186 + // Phase 2: upload vmlinuz to DO Spaces CDN 187 + const vmlinuz = path.join(CACHE_DIR, "vmlinuz"); 188 + const uploadScript = path.join(NATIVE_DIR, "scripts/upload-release.sh"); 189 + await runPhase(job, "upload", "bash", [uploadScript, vmlinuz], NATIVE_DIR, { 190 + DO_SPACES_KEY: process.env.DO_SPACES_KEY || process.env.ART_SPACES_KEY || "", 191 + DO_SPACES_SECRET: 192 + process.env.DO_SPACES_SECRET || process.env.ART_SPACES_SECRET || "", 193 + }); 194 + 195 + job.status = "success"; 196 + job.stage = "done"; 197 + job.percent = 100; 198 + job.finishedAt = nowISO(); 199 + } catch (err) { 200 + job.finishedAt = nowISO(); 201 + job.status = job.status === "cancelled" ? "cancelled" : "failed"; 202 + job.stage = job.status; 203 + job.error = err.message || String(err); 204 + } finally { 205 + if (activeJobId === job.id) activeJobId = null; 206 + } 207 + } 208 + 209 + export async function startNativeBuild(options = {}) { 210 + if (activeJobId) { 211 + const err = new Error(`Native build already running: ${activeJobId}`); 212 + err.code = "NATIVE_BUILD_BUSY"; 213 + err.activeJobId = activeJobId; 214 + throw err; 215 + } 216 + 217 + const id = randomUUID().slice(0, 10); 218 + const flags = buildFlagsFor(options.changed_paths || ""); 219 + const job = { 220 + id, 221 + ref: options.ref || "unknown", 222 + flags, 223 + status: "queued", 224 + stage: "queued", 225 + percent: 0, 226 + createdAt: nowISO(), 227 + startedAt: null, 228 + updatedAt: nowISO(), 229 + finishedAt: null, 230 + pid: null, 231 + process: null, 232 + exitCode: null, 233 + error: null, 234 + logs: [], 235 + }; 236 + 237 + jobs.set(id, job); 238 + jobOrder.unshift(id); 239 + while (jobOrder.length > MAX_RECENT_JOBS) { 240 + const old = jobOrder.pop(); 241 + if (old !== activeJobId) jobs.delete(old); 242 + } 243 + activeJobId = id; 244 + runBuildJob(job).catch(() => {}); 245 + return makeSnapshot(job); 246 + } 247 + 248 + export function getNativeBuild(jobId, opts = {}) { 249 + const job = jobs.get(jobId); 250 + return job ? makeSnapshot(job, opts) : null; 251 + } 252 + 253 + export function getNativeBuildsSummary() { 254 + return { 255 + activeJobId, 256 + active: activeJobId ? makeSnapshot(jobs.get(activeJobId)) : null, 257 + recent: jobOrder 258 + .map((id) => jobs.get(id)) 259 + .filter(Boolean) 260 + .map((j) => makeSnapshot(j)), 261 + }; 262 + } 263 + 264 + export function cancelNativeBuild(jobId) { 265 + const job = jobs.get(jobId); 266 + if (!job) return { ok: false, error: "not found" }; 267 + if (job.status !== "running" || !job.process) 268 + return { ok: false, error: "not running" }; 269 + try { 270 + job.process.kill("SIGTERM"); 271 + job.status = "cancelled"; 272 + return { ok: true }; 273 + } catch (err) { 274 + return { ok: false, error: err.message }; 275 + } 276 + }
+185
oven/native-git-poller.mjs
··· 1 + // native-git-poller.mjs — polls git for fedac/native/ changes, auto-triggers OTA builds 2 + // 3 + // Runs inside the oven server. Every POLL_INTERVAL_MS (default 60s), fetches 4 + // origin/main and checks if any fedac/native/ paths changed since the last 5 + // successful build. If so, pulls and triggers startNativeBuild(). 6 + // 7 + // Requires a git clone at GIT_REPO_DIR (default /opt/oven/native-git/). 8 + // deploy.sh sets this up on first deploy. 9 + 10 + import { execFile } from "child_process"; 11 + import { promises as fs } from "fs"; 12 + import path from "path"; 13 + 14 + const POLL_INTERVAL_MS = parseInt(process.env.NATIVE_POLL_INTERVAL_MS || "60000", 10); 15 + const GIT_REPO_DIR = process.env.NATIVE_GIT_DIR || "/opt/oven/native-git"; 16 + const BRANCH = process.env.NATIVE_GIT_BRANCH || "main"; 17 + const HASH_FILE = path.join(GIT_REPO_DIR, ".last-built-hash"); 18 + 19 + let polling = false; 20 + let timer = null; 21 + let startBuildFn = null; // set via startPoller() 22 + let logFn = (level, icon, msg) => console.log(`[native-git-poller] ${msg}`); 23 + 24 + function git(args, cwd = GIT_REPO_DIR) { 25 + return new Promise((resolve, reject) => { 26 + execFile("git", args, { cwd, timeout: 30_000 }, (err, stdout, stderr) => { 27 + if (err) { 28 + err.stderr = stderr; 29 + return reject(err); 30 + } 31 + resolve(stdout.trim()); 32 + }); 33 + }); 34 + } 35 + 36 + async function readLastBuiltHash() { 37 + try { 38 + return (await fs.readFile(HASH_FILE, "utf8")).trim(); 39 + } catch { 40 + return null; 41 + } 42 + } 43 + 44 + async function writeLastBuiltHash(hash) { 45 + await fs.writeFile(HASH_FILE, hash + "\n", "utf8"); 46 + } 47 + 48 + async function poll() { 49 + if (polling) return; 50 + polling = true; 51 + 52 + try { 53 + // Fetch latest from origin 54 + await git(["fetch", "origin", BRANCH, "--quiet"]); 55 + 56 + const remoteHead = await git(["rev-parse", `origin/${BRANCH}`]); 57 + const lastBuilt = await readLastBuiltHash(); 58 + 59 + if (remoteHead === lastBuilt) { 60 + // No new commits 61 + polling = false; 62 + return; 63 + } 64 + 65 + // Check which files changed 66 + let changedPaths = ""; 67 + if (lastBuilt) { 68 + try { 69 + const diffOutput = await git([ 70 + "diff", 71 + "--name-only", 72 + lastBuilt, 73 + remoteHead, 74 + ]); 75 + changedPaths = diffOutput; 76 + } catch { 77 + // lastBuilt hash might not exist (force push, etc) — treat as full build 78 + changedPaths = "fedac/native/src/force-rebuild"; 79 + } 80 + } else { 81 + // First run — treat as full build 82 + changedPaths = "fedac/native/src/force-rebuild"; 83 + } 84 + 85 + // Filter to fedac/native/ paths only 86 + const nativePaths = changedPaths 87 + .split("\n") 88 + .filter((p) => p.startsWith("fedac/native/")); 89 + 90 + if (nativePaths.length === 0) { 91 + // Changes exist but not in fedac/native/ — update hash, skip build 92 + logFn("info", "⏭️", `New commits (${remoteHead.slice(0, 8)}) but no fedac/native/ changes — skipping build`); 93 + await writeLastBuiltHash(remoteHead); 94 + polling = false; 95 + return; 96 + } 97 + 98 + // Pull the changes so build-and-flash.sh works on up-to-date code 99 + await git(["checkout", BRANCH, "--quiet"]); 100 + await git(["merge", `origin/${BRANCH}`, "--ff-only", "--quiet"]); 101 + 102 + logFn( 103 + "info", 104 + "🔨", 105 + `Native changes detected (${remoteHead.slice(0, 8)}): ${nativePaths.length} file(s) — triggering OTA build` 106 + ); 107 + 108 + // Trigger build 109 + const job = await startBuildFn({ 110 + ref: remoteHead, 111 + changed_paths: nativePaths.join(","), 112 + }); 113 + 114 + logFn( 115 + "info", 116 + "🚀", 117 + `OTA build ${job.id} started (flags: ${job.flags.join(" ") || "full"})` 118 + ); 119 + 120 + // Update hash after successfully starting (not completing) the build 121 + await writeLastBuiltHash(remoteHead); 122 + } catch (err) { 123 + if (err?.code === "NATIVE_BUILD_BUSY") { 124 + // Build already running — skip, will retry next poll 125 + logFn("info", "⏳", "Build already running — will retry next poll"); 126 + } else { 127 + logFn( 128 + "error", 129 + "❌", 130 + `Git poll error: ${err.message}${err.stderr ? " | " + err.stderr.trim() : ""}` 131 + ); 132 + } 133 + } finally { 134 + polling = false; 135 + } 136 + } 137 + 138 + // ── Public API ────────────────────────────────────────────────────────────── 139 + 140 + export function startPoller({ startNativeBuild, addServerLog, nativeDir }) { 141 + startBuildFn = startNativeBuild; 142 + if (addServerLog) logFn = addServerLog; 143 + 144 + // Override NATIVE_DIR in the builder's env so it uses our git checkout 145 + if (nativeDir !== false) { 146 + process.env.NATIVE_DIR = path.join(GIT_REPO_DIR, "fedac", "native"); 147 + } 148 + 149 + // Check that GIT_REPO_DIR exists before starting 150 + fs.access(GIT_REPO_DIR) 151 + .then(() => { 152 + logFn( 153 + "info", 154 + "👁️", 155 + `Native git poller started (every ${POLL_INTERVAL_MS / 1000}s, repo: ${GIT_REPO_DIR})` 156 + ); 157 + // First poll after a short delay to let the server settle 158 + setTimeout(poll, 5000); 159 + timer = setInterval(poll, POLL_INTERVAL_MS); 160 + }) 161 + .catch(() => { 162 + logFn( 163 + "error", 164 + "⚠️", 165 + `Native git poller disabled — repo dir not found: ${GIT_REPO_DIR}. Run: git clone --branch main https://github.com/whistlegraph/aesthetic-computer.git ${GIT_REPO_DIR}` 166 + ); 167 + }); 168 + } 169 + 170 + export function stopPoller() { 171 + if (timer) { 172 + clearInterval(timer); 173 + timer = null; 174 + logFn("info", "🛑", "Native git poller stopped"); 175 + } 176 + } 177 + 178 + export function getPollerStatus() { 179 + return { 180 + running: timer !== null, 181 + intervalMs: POLL_INTERVAL_MS, 182 + repoDir: GIT_REPO_DIR, 183 + branch: BRANCH, 184 + }; 185 + }
+577
oven/os-base-build.mjs
··· 1 + // os-base-build.mjs — background FedOS base image builds for oven 2 + // 3 + // Runs fedac/scripts/make-kiosk-piece-usb.sh asynchronously, tracks progress, 4 + // and uploads artifacts to Spaces so /os can use fresh base images. 5 + 6 + import { promises as fs } from "fs"; 7 + import fsSync from "fs"; 8 + import path from "path"; 9 + import { randomUUID } from "crypto"; 10 + import { spawn } from "child_process"; 11 + import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; 12 + 13 + const MAX_RECENT_JOBS = 20; 14 + const MAX_LOG_LINES = 1500; 15 + const STEP_COUNT = 6; 16 + 17 + const BUILD_SCRIPTS = { 18 + fedora: process.env.OS_BASE_BUILD_SCRIPT || 19 + path.resolve(process.cwd(), "fedac/scripts/make-kiosk-piece-usb.sh"), 20 + alpine: process.env.OS_ALPINE_BUILD_SCRIPT || 21 + path.resolve(process.cwd(), "fedac/scripts/make-alpine-kiosk.sh"), 22 + }; 23 + const DEFAULT_BUILD_SCRIPT = BUILD_SCRIPTS.fedora; 24 + const DEFAULT_BUILD_CWD = 25 + process.env.OS_BASE_BUILD_CWD || path.resolve(process.cwd()); 26 + const DEFAULT_WORK_BASE = process.env.OS_BASE_WORK_BASE || "/tmp"; 27 + const DEFAULT_IMAGE_SIZE_GB = parsePositiveInt(process.env.OS_BASE_IMAGE_SIZE_GB, 4); 28 + const IMAGE_SIZE_DEFAULTS = { fedora: 4, alpine: 1 }; 29 + const KEEP_LOCAL_ARTIFACTS = process.env.OS_BASE_KEEP_ARTIFACTS === "1"; 30 + 31 + const SPACES_REGION = process.env.OS_SPACES_REGION || "us-east-1"; 32 + const SPACES_ENDPOINT = 33 + process.env.OS_SPACES_ENDPOINT || 34 + process.env.ART_SPACES_ENDPOINT || 35 + "https://sfo3.digitaloceanspaces.com"; 36 + const SPACES_BUCKET = process.env.OS_SPACES_BUCKET || "releases-aesthetic-computer"; 37 + const SPACES_CDN_BASE = ( 38 + process.env.OS_SPACES_CDN_BASE || 39 + "https://releases.aesthetic.computer" 40 + ).replace(/\/+$/, ""); 41 + const SPACES_PREFIX = (process.env.OS_SPACES_PREFIX || "os").replace(/^\/+|\/+$/g, ""); 42 + 43 + const jobs = new Map(); 44 + const jobOrder = []; 45 + let activeJobId = null; 46 + 47 + function parsePositiveInt(value, fallback) { 48 + const parsed = parseInt(value, 10); 49 + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; 50 + } 51 + 52 + function nowISO() { 53 + return new Date().toISOString(); 54 + } 55 + 56 + function clampPercent(value) { 57 + if (!Number.isFinite(value)) return null; 58 + return Math.max(0, Math.min(100, Math.round(value))); 59 + } 60 + 61 + function stripAnsi(value) { 62 + return String(value || "").replace(/\u001b\[[0-9;]*m/g, ""); 63 + } 64 + 65 + function formatBytes(bytes) { 66 + if (!Number.isFinite(bytes) || bytes < 0) return null; 67 + if (bytes < 1024) return `${bytes}B`; 68 + const units = ["KB", "MB", "GB", "TB"]; 69 + let size = bytes; 70 + let idx = -1; 71 + while (size >= 1024 && idx < units.length - 1) { 72 + size /= 1024; 73 + idx += 1; 74 + } 75 + return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)}${units[idx]}`; 76 + } 77 + 78 + function makeSnapshot(job, options = {}) { 79 + const { includeLogs = false, tail = 200 } = options; 80 + const snapshot = { 81 + id: job.id, 82 + flavor: job.flavor, 83 + status: job.status, 84 + stage: job.stage, 85 + message: job.message, 86 + percent: job.percent, 87 + createdAt: job.createdAt, 88 + startedAt: job.startedAt, 89 + updatedAt: job.updatedAt, 90 + finishedAt: job.finishedAt, 91 + elapsedMs: job.startedAt 92 + ? (job.finishedAt ? Date.parse(job.finishedAt) : Date.now()) - Date.parse(job.startedAt) 93 + : 0, 94 + pid: job.pid, 95 + step: job.step, 96 + command: job.command, 97 + workBase: job.workBase, 98 + output: { ...job.output }, 99 + metrics: { ...job.metrics }, 100 + upload: { ...job.upload }, 101 + exitCode: job.exitCode, 102 + signal: job.signal, 103 + error: job.error, 104 + logCount: job.logs.length, 105 + }; 106 + 107 + if (includeLogs) { 108 + const start = Math.max(0, job.logs.length - Math.max(0, tail)); 109 + snapshot.logs = job.logs.slice(start); 110 + } 111 + 112 + return snapshot; 113 + } 114 + 115 + function setStage(job, stage, message, percent = null) { 116 + job.stage = stage; 117 + if (message) job.message = message; 118 + if (percent != null) job.percent = clampPercent(percent); 119 + job.updatedAt = nowISO(); 120 + } 121 + 122 + function addLogLine(job, stream, line) { 123 + const clean = stripAnsi(line).replace(/\r/g, "").trimEnd(); 124 + if (!clean) return; 125 + job.logs.push({ ts: nowISO(), stream, line: clean }); 126 + if (job.logs.length > MAX_LOG_LINES) { 127 + job.logs.splice(0, job.logs.length - MAX_LOG_LINES); 128 + } 129 + job.updatedAt = nowISO(); 130 + 131 + const stepMatch = clean.match(/\[(\d+)\/(\d+)\]\s*(.+)$/); 132 + if (stepMatch) { 133 + const stepNum = parseInt(stepMatch[1], 10); 134 + const total = parseInt(stepMatch[2], 10); 135 + const message = stepMatch[3] || `Step ${stepNum}/${total}`; 136 + const denom = total > 0 ? total : STEP_COUNT; 137 + const percent = ((stepNum - 1) / denom) * 100; 138 + job.step = stepNum; 139 + setStage(job, `step-${stepNum}`, message, percent); 140 + return; 141 + } 142 + 143 + const workDirMatch = clean.match(/^Work dir:\s*(.+)$/); 144 + if (workDirMatch) { 145 + job.workDir = workDirMatch[1]; 146 + job.metrics.workDir = job.workDir; 147 + return; 148 + } 149 + 150 + if (clean.includes("Extracting rootfs")) { 151 + setStage(job, "step-2", "Extracting rootfs from base ISO...", 22); 152 + return; 153 + } 154 + 155 + if (clean.includes("Fallback extraction")) { 156 + setStage(job, "step-2", "Fallback rootfs extraction in progress...", 22); 157 + return; 158 + } 159 + 160 + if (clean.includes("Building disk image")) { 161 + setStage(job, "step-5", "Building disk image...", 72); 162 + return; 163 + } 164 + 165 + if (clean.includes("Disk image ready")) { 166 + setStage(job, "step-5", "Disk image built", 88); 167 + return; 168 + } 169 + 170 + if (clean.includes("Manifest written")) { 171 + setStage(job, "step-6", "Manifest generated", 93); 172 + return; 173 + } 174 + 175 + if (clean.includes("FedAC Kiosk Build Ready")) { 176 + setStage(job, "step-6", "Build complete, preparing upload...", 95); 177 + } 178 + } 179 + 180 + function trimJobHistory() { 181 + while (jobOrder.length > MAX_RECENT_JOBS) { 182 + const staleId = jobOrder[jobOrder.length - 1]; 183 + if (staleId === activeJobId) break; 184 + jobs.delete(staleId); 185 + jobOrder.pop(); 186 + } 187 + } 188 + 189 + async function statSize(filePath) { 190 + try { 191 + const stat = await fs.stat(filePath); 192 + return stat.size; 193 + } catch { 194 + return null; 195 + } 196 + } 197 + 198 + async function duSizeBytes(targetPath) { 199 + return new Promise((resolve) => { 200 + const proc = spawn("du", ["-s", targetPath]); 201 + let output = ""; 202 + proc.stdout.on("data", (chunk) => { 203 + output += chunk.toString(); 204 + }); 205 + proc.on("error", () => resolve(null)); 206 + proc.on("close", (code) => { 207 + if (code !== 0) return resolve(null); 208 + const parts = output.trim().split(/\s+/); 209 + const kb = parseInt(parts[0], 10); 210 + if (!Number.isFinite(kb)) return resolve(null); 211 + resolve(kb * 1024); 212 + }); 213 + }); 214 + } 215 + 216 + function startMetricsSampler(job) { 217 + const timer = setInterval(async () => { 218 + if (!job.workDir) return; 219 + const rootfsPath = path.join(job.workDir, "rootfs"); 220 + const [rootfsBytes, imageBytes] = await Promise.all([ 221 + duSizeBytes(rootfsPath), 222 + statSize(job.output.imagePath), 223 + ]); 224 + 225 + if (Number.isFinite(rootfsBytes)) { 226 + job.metrics.rootfsBytes = rootfsBytes; 227 + job.metrics.rootfsHuman = formatBytes(rootfsBytes); 228 + if (job.stage === "step-2" && job.metrics.rootfsHuman) { 229 + setStage(job, "step-2", `Extracting rootfs... ${job.metrics.rootfsHuman}`, job.percent); 230 + } 231 + } 232 + 233 + if (Number.isFinite(imageBytes)) { 234 + job.metrics.imageBytes = imageBytes; 235 + job.metrics.imageHuman = formatBytes(imageBytes); 236 + } 237 + 238 + job.updatedAt = nowISO(); 239 + }, 15000); 240 + 241 + return timer; 242 + } 243 + 244 + function getUploadCredentials() { 245 + const accessKeyId = process.env.OS_SPACES_KEY || process.env.ART_SPACES_KEY; 246 + const secretAccessKey = process.env.OS_SPACES_SECRET || process.env.ART_SPACES_SECRET; 247 + if (!accessKeyId || !secretAccessKey) { 248 + throw new Error( 249 + "Spaces credentials missing: set OS_SPACES_KEY/OS_SPACES_SECRET or ART_SPACES_KEY/ART_SPACES_SECRET", 250 + ); 251 + } 252 + 253 + return { accessKeyId, secretAccessKey }; 254 + } 255 + 256 + function buildObjectKey(prefix, filename) { 257 + return prefix ? `${prefix}/${filename}` : filename; 258 + } 259 + 260 + async function uploadArtifacts(job) { 261 + const creds = getUploadCredentials(); 262 + const client = new S3Client({ 263 + region: SPACES_REGION, 264 + endpoint: SPACES_ENDPOINT, 265 + credentials: creds, 266 + }); 267 + 268 + const imageKey = buildObjectKey(job.upload.prefix, job.upload.imageName); 269 + const manifestKey = buildObjectKey(job.upload.prefix, job.upload.manifestName); 270 + 271 + const imageStat = await fs.stat(job.output.imagePath); 272 + setStage( 273 + job, 274 + "upload", 275 + `Uploading ${formatBytes(imageStat.size)} base image to Spaces...`, 276 + 96, 277 + ); 278 + 279 + await client.send( 280 + new PutObjectCommand({ 281 + Bucket: job.upload.bucket, 282 + Key: imageKey, 283 + Body: fsSync.createReadStream(job.output.imagePath), 284 + ContentType: "application/octet-stream", 285 + ACL: "public-read", 286 + CacheControl: "no-cache", 287 + }), 288 + ); 289 + 290 + setStage(job, "upload", "Uploading base manifest...", 98); 291 + const manifestBuffer = await fs.readFile(job.output.manifestPath); 292 + await client.send( 293 + new PutObjectCommand({ 294 + Bucket: job.upload.bucket, 295 + Key: manifestKey, 296 + Body: manifestBuffer, 297 + ContentType: "application/json", 298 + ACL: "public-read", 299 + CacheControl: "no-cache", 300 + }), 301 + ); 302 + 303 + job.upload.imageKey = imageKey; 304 + job.upload.manifestKey = manifestKey; 305 + job.upload.imageUrl = `${job.upload.cdnBase}/${imageKey}`; 306 + job.upload.manifestUrl = `${job.upload.cdnBase}/${manifestKey}`; 307 + } 308 + 309 + async function cleanupLocalArtifacts(job) { 310 + if (KEEP_LOCAL_ARTIFACTS) return; 311 + try { 312 + await fs.unlink(job.output.imagePath); 313 + } catch { 314 + // ignore 315 + } 316 + try { 317 + await fs.unlink(job.output.manifestPath); 318 + } catch { 319 + // ignore 320 + } 321 + } 322 + 323 + function createJob(options = {}) { 324 + const id = randomUUID().slice(0, 10); 325 + const createdAt = nowISO(); 326 + const flavor = options.flavor || "alpine"; 327 + const defaultSize = IMAGE_SIZE_DEFAULTS[flavor] || DEFAULT_IMAGE_SIZE_GB; 328 + const imageSizeGB = parsePositiveInt(options.imageSizeGB, defaultSize); 329 + const imageName = options.imageName || `${flavor}-base-latest.img`; 330 + const manifestName = options.manifestName || `${flavor}-base-manifest.json`; 331 + const uploadPrefix = (options.uploadPrefix || SPACES_PREFIX).replace(/^\/+|\/+$/g, ""); 332 + const workBase = options.workBase || DEFAULT_WORK_BASE; 333 + const outputBase = path.join(workBase, `${flavor}-base-${id}`); 334 + 335 + return { 336 + id, 337 + flavor, 338 + status: "queued", 339 + stage: "queued", 340 + message: "Queued", 341 + percent: 0, 342 + step: 0, 343 + createdAt, 344 + startedAt: null, 345 + updatedAt: createdAt, 346 + finishedAt: null, 347 + pid: null, 348 + command: null, 349 + workBase, 350 + workDir: null, 351 + output: { 352 + imagePath: `${outputBase}.img`, 353 + manifestPath: `${outputBase}-manifest.json`, 354 + imageSizeGB, 355 + }, 356 + upload: { 357 + enabled: options.publish !== false, 358 + bucket: options.bucket || SPACES_BUCKET, 359 + endpoint: options.endpoint || SPACES_ENDPOINT, 360 + cdnBase: (options.cdnBase || SPACES_CDN_BASE).replace(/\/+$/, ""), 361 + prefix: uploadPrefix, 362 + imageName, 363 + manifestName, 364 + imageKey: null, 365 + manifestKey: null, 366 + imageUrl: null, 367 + manifestUrl: null, 368 + }, 369 + metrics: { 370 + workDir: null, 371 + rootfsBytes: null, 372 + rootfsHuman: null, 373 + imageBytes: null, 374 + imageHuman: null, 375 + }, 376 + logs: [], 377 + process: null, 378 + exitCode: null, 379 + signal: null, 380 + error: null, 381 + }; 382 + } 383 + 384 + async function runBuildJob(job, options = {}, hooks = {}) { 385 + const flavor = job.flavor || "alpine"; 386 + const scriptPath = options.scriptPath || BUILD_SCRIPTS[flavor] || DEFAULT_BUILD_SCRIPT; 387 + const cwd = options.cwd || DEFAULT_BUILD_CWD; 388 + const workBase = options.workBase || job.workBase || DEFAULT_WORK_BASE; 389 + 390 + try { 391 + await fs.access(scriptPath, fsSync.constants.R_OK); 392 + } catch { 393 + throw new Error(`Build script not found: ${scriptPath}`); 394 + } 395 + 396 + const args = [ 397 + scriptPath, 398 + "__base__", 399 + "--base-image", 400 + "--image", 401 + job.output.imagePath, 402 + "--image-size", 403 + String(job.output.imageSizeGB), 404 + "--yes", 405 + "--no-eject", 406 + ]; 407 + 408 + if (workBase) { 409 + args.push("--work-base", workBase); 410 + } 411 + 412 + try { 413 + // The build script requires root. The oven user has sudoers rules: 414 + // oven ALL=(root) NOPASSWD: /usr/bin/bash /opt/oven/fedac/scripts/make-kiosk-piece-usb.sh * 415 + // oven ALL=(root) NOPASSWD: /usr/bin/bash /opt/oven/fedac/scripts/make-alpine-kiosk.sh * 416 + // So we spawn via sudo when not already root. 417 + const needsSudo = process.getuid?.() !== 0; 418 + const spawnCmd = needsSudo ? "sudo" : "bash"; 419 + const spawnArgs = needsSudo ? ["/usr/bin/bash", ...args] : args; 420 + 421 + job.command = `${needsSudo ? "sudo " : ""}bash ${args.join(" ")}`; 422 + job.status = "running"; 423 + job.startedAt = nowISO(); 424 + setStage(job, "starting", "Starting base image build...", 1); 425 + 426 + const proc = spawn(spawnCmd, spawnArgs, { 427 + cwd, 428 + env: { 429 + ...process.env, 430 + TERM: "dumb", 431 + CLICOLOR: "0", 432 + FORCE_COLOR: "0", 433 + }, 434 + }); 435 + 436 + job.process = proc; 437 + job.pid = proc.pid || null; 438 + hooks.onStart?.(makeSnapshot(job)); 439 + setStage(job, "step-1", "Running build steps...", 5); 440 + 441 + const metricsTimer = startMetricsSampler(job); 442 + 443 + const wireStream = (stream, streamName) => { 444 + let pending = ""; 445 + stream.on("data", (chunk) => { 446 + pending += chunk.toString(); 447 + let idx = pending.indexOf("\n"); 448 + while (idx >= 0) { 449 + const line = pending.slice(0, idx); 450 + pending = pending.slice(idx + 1); 451 + addLogLine(job, streamName, line); 452 + idx = pending.indexOf("\n"); 453 + } 454 + }); 455 + stream.on("end", () => { 456 + if (pending.length > 0) { 457 + addLogLine(job, streamName, pending); 458 + } 459 + }); 460 + }; 461 + 462 + wireStream(proc.stdout, "stdout"); 463 + wireStream(proc.stderr, "stderr"); 464 + 465 + await new Promise((resolve, reject) => { 466 + proc.on("error", reject); 467 + proc.on("close", (code, signal) => { 468 + job.exitCode = code; 469 + job.signal = signal; 470 + resolve(); 471 + }); 472 + }); 473 + 474 + clearInterval(metricsTimer); 475 + job.process = null; 476 + if (job.exitCode !== 0) { 477 + throw new Error(`Build failed (exit ${job.exitCode}${job.signal ? `, signal ${job.signal}` : ""})`); 478 + } 479 + 480 + setStage(job, "verify", "Verifying build artifacts...", 94); 481 + await fs.access(job.output.imagePath, fsSync.constants.R_OK); 482 + await fs.access(job.output.manifestPath, fsSync.constants.R_OK); 483 + const imageSize = await statSize(job.output.imagePath); 484 + if (Number.isFinite(imageSize)) { 485 + job.metrics.imageBytes = imageSize; 486 + job.metrics.imageHuman = formatBytes(imageSize); 487 + } 488 + 489 + if (job.upload.enabled) { 490 + await uploadArtifacts(job); 491 + hooks.onUploadComplete?.(makeSnapshot(job)); 492 + } else { 493 + setStage(job, "done", "Build complete (upload disabled)", 100); 494 + } 495 + 496 + job.status = "success"; 497 + job.finishedAt = nowISO(); 498 + setStage(job, "done", "Base image build complete", 100); 499 + hooks.onSuccess?.(makeSnapshot(job)); 500 + 501 + await cleanupLocalArtifacts(job); 502 + } catch (error) { 503 + job.finishedAt = nowISO(); 504 + if (job.status === "cancelled") { 505 + job.error = "Cancelled"; 506 + setStage(job, "cancelled", "Build cancelled", job.percent); 507 + hooks.onError?.(makeSnapshot(job), error); 508 + return; 509 + } 510 + job.status = "failed"; 511 + job.error = error.message || String(error); 512 + setStage(job, "failed", job.error, job.percent); 513 + hooks.onError?.(makeSnapshot(job), error); 514 + } 515 + } 516 + 517 + export async function startOSBaseBuild(options = {}, hooks = {}) { 518 + if (activeJobId) { 519 + const error = new Error(`OS base build already running: ${activeJobId}`); 520 + error.code = "OS_BASE_BUSY"; 521 + error.activeJobId = activeJobId; 522 + throw error; 523 + } 524 + 525 + const job = createJob(options); 526 + jobs.set(job.id, job); 527 + jobOrder.unshift(job.id); 528 + trimJobHistory(); 529 + activeJobId = job.id; 530 + 531 + runBuildJob(job, options, hooks) 532 + .catch((err) => { 533 + if (!job.error) job.error = err.message || String(err); 534 + if (job.status !== "cancelled") { 535 + job.status = "failed"; 536 + } 537 + job.finishedAt = nowISO(); 538 + setStage(job, job.status === "cancelled" ? "cancelled" : "failed", job.error, job.percent); 539 + }) 540 + .finally(() => { 541 + if (activeJobId === job.id) activeJobId = null; 542 + hooks.onSettled?.(makeSnapshot(job)); 543 + }); 544 + 545 + return makeSnapshot(job); 546 + } 547 + 548 + export function getOSBaseBuild(jobId, options = {}) { 549 + const job = jobs.get(jobId); 550 + if (!job) return null; 551 + return makeSnapshot(job, options); 552 + } 553 + 554 + export function getOSBaseBuildsSummary() { 555 + return { 556 + activeJobId, 557 + active: activeJobId ? makeSnapshot(jobs.get(activeJobId)) : null, 558 + recent: jobOrder.map((id) => jobs.get(id)).filter(Boolean).map((job) => makeSnapshot(job)), 559 + }; 560 + } 561 + 562 + export function cancelOSBaseBuild(jobId) { 563 + const job = jobs.get(jobId); 564 + if (!job) return { ok: false, error: "job not found" }; 565 + if (job.status !== "running" || !job.process) { 566 + return { ok: false, error: "job is not running" }; 567 + } 568 + 569 + try { 570 + job.process.kill("SIGTERM"); 571 + setStage(job, "cancelled", "Cancellation requested", job.percent); 572 + job.status = "cancelled"; 573 + return { ok: true }; 574 + } catch (error) { 575 + return { ok: false, error: error.message || String(error) }; 576 + } 577 + }
+1317
oven/os-builder.mjs
··· 1 + // os-builder.mjs — FedAC OS image assembly for the /os endpoint 2 + // 3 + // Downloads a pre-baked base image from CDN, injects a piece bundle into 4 + // the FEDAC-PIECE ext4 partition via debugfs, and streams the result. 5 + // 6 + // After building, uploads the finished ISO to DO Spaces CDN so repeat 7 + // downloads are served at CDN speed (~100+ MB/s) instead of droplet speed. 8 + // 9 + // Requires: e2fsprogs (debugfs) installed on the server. 10 + 11 + import { promises as fs } from "fs"; 12 + import fsSync from "fs"; 13 + import path from "path"; 14 + import { execSync } from "child_process"; 15 + import { createHash, randomUUID } from "crypto"; 16 + import { createBundle, createJSPieceBundle } from "./bundler.mjs"; 17 + import { S3Client, PutObjectCommand, HeadObjectCommand, ListObjectsV2Command, DeleteObjectsCommand } from "@aws-sdk/client-s3"; 18 + import { MongoClient } from "mongodb"; 19 + 20 + // ─── MongoDB (OS build history) ───────────────────────────────────── 21 + 22 + let mongoClient; 23 + let mongoDB; 24 + 25 + async function connectMongo() { 26 + if (mongoDB) return mongoDB; 27 + const uri = process.env.MONGODB_CONNECTION_STRING; 28 + const dbName = process.env.MONGODB_NAME; 29 + if (!uri || !dbName) return null; 30 + try { 31 + mongoClient = await MongoClient.connect(uri); 32 + mongoDB = mongoClient.db(dbName); 33 + console.log("✅ [os] Connected to MongoDB for OS build history"); 34 + return mongoDB; 35 + } catch (err) { 36 + console.error("❌ [os] MongoDB connect failed:", err.message); 37 + return null; 38 + } 39 + } 40 + 41 + connectMongo(); 42 + 43 + async function logOSBuild(record) { 44 + try { 45 + const db = await connectMongo(); 46 + if (!db) return; 47 + await db.collection("oven-os-builds").insertOne({ ...record, when: new Date() }); 48 + } catch (err) { 49 + console.error("[os] Failed to log build:", err.message); 50 + } 51 + } 52 + 53 + // ─── Configuration ────────────────────────────────────────────────── 54 + 55 + const TEMP_DIR = process.env.OS_TEMP_DIR || "/tmp"; 56 + const CONFIGURED_CACHE_DIR = (process.env.OS_CACHE_DIR || "").trim(); 57 + const DEFAULT_CACHE_DIR = "/opt/oven/cache"; 58 + const FALLBACK_CACHE_DIR = path.join(TEMP_DIR, "oven-cache"); 59 + const WORKDIR_CACHE_DIR = path.join(process.cwd(), ".cache", "oven-os"); 60 + const BASE_IMAGE_URLS = { 61 + fedora: process.env.FEDAC_BASE_IMAGE_URL || 62 + "https://releases.aesthetic.computer/os/fedora-base-latest.img", 63 + alpine: process.env.ALPINE_BASE_IMAGE_URL || 64 + "https://releases.aesthetic.computer/os/alpine-base-latest.img", 65 + }; 66 + const MANIFEST_URLS = { 67 + fedora: process.env.FEDAC_MANIFEST_URL || 68 + "https://releases.aesthetic.computer/os/fedora-base-manifest.json", 69 + alpine: process.env.ALPINE_MANIFEST_URL || 70 + "https://releases.aesthetic.computer/os/alpine-base-manifest.json", 71 + }; 72 + // Legacy aliases 73 + const BASE_IMAGE_URL = BASE_IMAGE_URLS.fedora; 74 + const MANIFEST_URL = MANIFEST_URLS.fedora; 75 + 76 + let resolvedCacheDir = null; 77 + let resolvingCacheDirPromise = null; 78 + 79 + function getCacheDirCandidates() { 80 + return [...new Set([ 81 + CONFIGURED_CACHE_DIR || DEFAULT_CACHE_DIR, 82 + DEFAULT_CACHE_DIR, 83 + FALLBACK_CACHE_DIR, 84 + WORKDIR_CACHE_DIR, 85 + ])]; 86 + } 87 + 88 + async function resolveCacheDir() { 89 + if (resolvedCacheDir) return resolvedCacheDir; 90 + if (resolvingCacheDirPromise) return resolvingCacheDirPromise; 91 + 92 + resolvingCacheDirPromise = (async () => { 93 + const candidates = getCacheDirCandidates(); 94 + const errors = []; 95 + const preferred = CONFIGURED_CACHE_DIR || DEFAULT_CACHE_DIR; 96 + 97 + for (const dir of candidates) { 98 + const probePath = path.join(dir, `.cache-probe-${process.pid}-${Date.now()}`); 99 + try { 100 + await fs.mkdir(dir, { recursive: true }); 101 + await fs.writeFile(probePath, "ok"); 102 + await fs.unlink(probePath); 103 + resolvedCacheDir = dir; 104 + if (dir !== preferred) { 105 + console.warn(`[os] Cache fallback active: ${preferred} is not writable. Using ${dir}`); 106 + } 107 + return dir; 108 + } catch (err) { 109 + errors.push(`${dir} (${err.code || err.message})`); 110 + } 111 + } 112 + 113 + throw new Error( 114 + `No writable OS cache directory. Tried: ${errors.join(", ")}`, 115 + ); 116 + })(); 117 + 118 + try { 119 + return await resolvingCacheDirPromise; 120 + } finally { 121 + resolvingCacheDirPromise = null; 122 + } 123 + } 124 + 125 + function buildLocalCacheFilenamePattern(flavor) { 126 + if (flavor) { 127 + const safeFlavor = String(flavor).replace(/[^a-z0-9_-]/gi, ""); 128 + return new RegExp(`^${safeFlavor}-base\\.img(?:\\.sha256|\\.downloading)?$`, "i"); 129 + } 130 + return /^[a-z0-9_-]+-base\.img(?:\.sha256|\.downloading)?$/i; 131 + } 132 + 133 + export async function clearOSBuildLocalCache(flavor) { 134 + const cacheDirs = [...new Set([ 135 + resolvedCacheDir, 136 + ...getCacheDirCandidates(), 137 + ].filter(Boolean))]; 138 + const filenamePattern = buildLocalCacheFilenamePattern(flavor); 139 + 140 + let deleted = 0; 141 + const errors = []; 142 + for (const cacheDir of cacheDirs) { 143 + let entries = []; 144 + try { 145 + entries = await fs.readdir(cacheDir); 146 + } catch (err) { 147 + if (err.code === "ENOENT") continue; 148 + errors.push(`${cacheDir}: ${err.code || err.message}`); 149 + continue; 150 + } 151 + 152 + for (const entry of entries) { 153 + if (!filenamePattern.test(entry)) continue; 154 + try { 155 + await fs.unlink(path.join(cacheDir, entry)); 156 + deleted++; 157 + } catch (err) { 158 + errors.push(`${cacheDir}/${entry}: ${err.code || err.message}`); 159 + } 160 + } 161 + } 162 + 163 + if (errors.length) { 164 + console.warn("[os] Local cache clear warnings:", errors.join("; ")); 165 + } 166 + 167 + return { deleted, dirs: cacheDirs, errors }; 168 + } 169 + 170 + // ─── CDN Cache (DO Spaces) ────────────────────────────────────────── 171 + // After building an ISO, upload it to Spaces so repeat downloads bypass 172 + // the droplet entirely and go through the CDN edge network. 173 + 174 + const SPACES_REGION = process.env.OS_SPACES_REGION || "us-east-1"; 175 + const SPACES_ENDPOINT = 176 + process.env.OS_SPACES_ENDPOINT || 177 + process.env.ART_SPACES_ENDPOINT || 178 + "https://sfo3.digitaloceanspaces.com"; 179 + const SPACES_BUCKET = process.env.OS_SPACES_BUCKET || "releases-aesthetic-computer"; 180 + const SPACES_CDN_BASE = ( 181 + process.env.OS_SPACES_CDN_BASE || 182 + "https://releases.aesthetic.computer" 183 + ).replace(/\/+$/, ""); 184 + const SPACES_PREFIX = "os/builds"; 185 + 186 + function getSpacesClient() { 187 + const accessKeyId = process.env.OS_SPACES_KEY || process.env.ART_SPACES_KEY; 188 + const secretAccessKey = process.env.OS_SPACES_SECRET || process.env.ART_SPACES_SECRET; 189 + if (!accessKeyId || !secretAccessKey) return null; 190 + return new S3Client({ 191 + region: SPACES_REGION, 192 + endpoint: SPACES_ENDPOINT, 193 + credentials: { accessKeyId, secretAccessKey }, 194 + }); 195 + } 196 + 197 + function buildCDNKey(target, density, bundleHash, baseVersion, flavor = "alpine") { 198 + // e.g. os/builds/notepat-d8-ab12cd34-v2025-02-26-alpine.img 199 + const safe = String(target).replace(/[^a-zA-Z0-9_-]/g, "_"); 200 + return `${SPACES_PREFIX}/${safe}-d${density}-${bundleHash}-v${baseVersion}-${flavor}.img`; 201 + } 202 + 203 + function buildCDNUrl(key) { 204 + return `${SPACES_CDN_BASE}/${key}`; 205 + } 206 + 207 + // Check if a cached ISO already exists on CDN. 208 + async function checkCDNCache(key) { 209 + const client = getSpacesClient(); 210 + if (!client) return false; 211 + try { 212 + await client.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key })); 213 + return true; 214 + } catch { 215 + return false; 216 + } 217 + } 218 + 219 + // Upload a built ISO to CDN for fast repeat downloads. 220 + async function uploadToCDN(imagePath, key, target, flavor) { 221 + const client = getSpacesClient(); 222 + if (!client) return null; 223 + await client.send( 224 + new PutObjectCommand({ 225 + Bucket: SPACES_BUCKET, 226 + Key: key, 227 + Body: fsSync.createReadStream(imagePath), 228 + ContentType: "application/octet-stream", 229 + ContentDisposition: `attachment; filename="${target}-${flavor}-os.iso"`, 230 + ACL: "public-read", 231 + CacheControl: "public, max-age=86400", // 24h — rebuild invalidates via new hash 232 + }), 233 + ); 234 + return buildCDNUrl(key); 235 + } 236 + 237 + // Purge all cached OS build images from CDN, optionally filtered by flavor. 238 + export async function purgeOSBuildCache(flavor) { 239 + const client = getSpacesClient(); 240 + if (!client) return { deleted: 0, error: "S3 not configured" }; 241 + 242 + let deleted = 0; 243 + let continuationToken; 244 + 245 + try { 246 + do { 247 + const listCmd = new ListObjectsV2Command({ 248 + Bucket: SPACES_BUCKET, 249 + Prefix: `${SPACES_PREFIX}/`, 250 + ContinuationToken: continuationToken, 251 + }); 252 + const listing = await client.send(listCmd); 253 + continuationToken = listing.IsTruncated ? listing.NextContinuationToken : undefined; 254 + 255 + if (!listing.Contents?.length) break; 256 + 257 + // Filter by flavor suffix if specified (e.g. "-alpine.img" or "-fedora.img"). 258 + const toDelete = flavor 259 + ? listing.Contents.filter((o) => o.Key.endsWith(`-${flavor}.img`)) 260 + : listing.Contents; 261 + 262 + if (!toDelete.length) continue; 263 + 264 + await client.send( 265 + new DeleteObjectsCommand({ 266 + Bucket: SPACES_BUCKET, 267 + Delete: { Objects: toDelete.map((o) => ({ Key: o.Key })) }, 268 + }), 269 + ); 270 + deleted += toDelete.length; 271 + } while (continuationToken); 272 + } catch (err) { 273 + console.error("[os] CDN purge error:", err.message); 274 + return { deleted, error: err.message }; 275 + } 276 + 277 + console.log(`[os] Purged ${deleted} cached OS build(s) from CDN${flavor ? ` (${flavor})` : ""}`); 278 + return { deleted }; 279 + } 280 + 281 + function hashContent(content) { 282 + return createHash("sha256").update(content).digest("hex").slice(0, 8); 283 + } 284 + 285 + // Concurrency limit — each build copies ~3GB, so cap parallel builds. 286 + const MAX_CONCURRENT_BUILDS = 2; 287 + let activeBuildCount = 0; 288 + const recentBuilds = []; 289 + const MAX_RECENT = 20; 290 + 291 + function formatSeconds(ms) { 292 + return `${(ms / 1000).toFixed(1)}s`; 293 + } 294 + 295 + function runCommand(command) { 296 + execSync(command, { stdio: "pipe" }); 297 + } 298 + 299 + function copyBaseImageFast(basePath, tempImagePath) { 300 + // Reflink/sparse copy is usually much faster for large images on CoW filesystems. 301 + try { 302 + runCommand(`cp --reflink=auto --sparse=always "${basePath}" "${tempImagePath}"`); 303 + return "reflink"; 304 + } catch { 305 + return "regular"; 306 + } 307 + } 308 + 309 + function detectPiecePartitionFromImage(imagePath) { 310 + // Parse GPT table directly from the image so builds keep working even if a 311 + // stale/incorrect manifest offset was published. 312 + const cmd = `parted -s -m "${imagePath}" unit B print`; 313 + const output = execSync(cmd, { encoding: "utf8" }); 314 + const lines = output.split("\n").map((line) => line.trim()).filter(Boolean); 315 + 316 + let fallbackPart3 = null; 317 + for (const line of lines) { 318 + // Format: "<num>:<start>B:<end>B:<size>B:<fs>:<name>:<flags>" 319 + if (!/^[0-9]+:/.test(line)) continue; 320 + const parts = line.split(":"); 321 + if (parts.length < 4) continue; 322 + const num = parts[0]; 323 + const start = parseInt((parts[1] || "").replace(/B/g, ""), 10); 324 + const size = parseInt((parts[3] || "").replace(/B/g, ""), 10); 325 + if (!Number.isFinite(start) || !Number.isFinite(size) || size <= 0) continue; 326 + 327 + const name = (parts[5] || "").replace(/;$/, "").trim(); 328 + const isPieceByName = name.toUpperCase() === "PIECE"; 329 + const isPart3 = num === "3"; 330 + 331 + if (isPieceByName) { 332 + return { piecePartitionOffset: start, piecePartitionSize: size }; 333 + } 334 + if (isPart3) { 335 + fallbackPart3 = { piecePartitionOffset: start, piecePartitionSize: size }; 336 + } 337 + } 338 + 339 + return fallbackPart3; 340 + } 341 + 342 + // ─── Manifest ─────────────────────────────────────────────────────── 343 + 344 + const cachedManifests = {}; 345 + 346 + async function fetchManifest(onProgress, flavor = "alpine") { 347 + if (cachedManifests[flavor]) return cachedManifests[flavor]; 348 + const manifestUrl = MANIFEST_URLS[flavor] || MANIFEST_URLS.fedora; 349 + onProgress?.({ stage: "manifest", message: `Fetching ${flavor} base image manifest...`, step: 1, totalSteps: 9 }); 350 + const res = await fetch(manifestUrl); 351 + if (!res.ok) throw new Error(`Manifest fetch failed (${flavor}): ${res.status}`); 352 + cachedManifests[flavor] = await res.json(); 353 + const m = cachedManifests[flavor]; 354 + const distroLabel = m.alpine ? `Alpine ${m.alpine}` : m.fedora ? `Fedora ${m.fedora}` : flavor; 355 + onProgress?.({ 356 + stage: "manifest", 357 + message: `Base image v${m.version}, ${distroLabel}`, 358 + step: 1, 359 + totalSteps: 9, 360 + }); 361 + return m; 362 + } 363 + 364 + // Invalidate manifest cache (call after base image update). 365 + export function invalidateManifest(flavor) { 366 + if (flavor) { 367 + delete cachedManifests[flavor]; 368 + } else { 369 + for (const key of Object.keys(cachedManifests)) delete cachedManifests[key]; 370 + } 371 + } 372 + 373 + // ─── Base Image Cache ─────────────────────────────────────────────── 374 + 375 + async function ensureBaseImage(onProgress, flavor = "alpine") { 376 + const cacheDir = await resolveCacheDir(); 377 + await fs.mkdir(cacheDir, { recursive: true }); 378 + const manifest = await fetchManifest(onProgress, flavor); 379 + const basePath = path.join(cacheDir, `${flavor}-base.img`); 380 + const hashPath = `${basePath}.sha256`; 381 + 382 + // Check if cached image matches manifest size AND sha256 hash. 383 + try { 384 + const stat = await fs.stat(basePath); 385 + const cachedHash = (await fs.readFile(hashPath, "utf-8")).trim(); 386 + if (stat.size === manifest.totalSize && cachedHash === manifest.sha256) { 387 + onProgress?.({ stage: "base", message: `${flavor} base image cached and ready`, step: 1, totalSteps: 9 }); 388 + return { basePath, manifest }; 389 + } 390 + onProgress?.({ stage: "base", message: `${flavor} base image outdated (hash mismatch), re-downloading...`, step: 1, totalSteps: 9 }); 391 + } catch { 392 + // File or hash sidecar doesn't exist — need to download. 393 + } 394 + 395 + // Download base image. 396 + const baseImageUrl = BASE_IMAGE_URLS[flavor] || BASE_IMAGE_URLS.fedora; 397 + onProgress?.({ stage: "base", message: `Downloading ${flavor} base image from CDN...`, step: 1, totalSteps: 9 }); 398 + const res = await fetch(baseImageUrl); 399 + if (!res.ok) throw new Error(`Base image download failed (${flavor}): ${res.status}`); 400 + 401 + const tmpPath = `${basePath}.downloading`; 402 + const writer = fsSync.createWriteStream(tmpPath); 403 + const reader = res.body.getReader(); 404 + let downloaded = 0; 405 + const total = manifest.totalSize || parseInt(res.headers.get("content-length"), 10) || 0; 406 + 407 + while (true) { 408 + const { done, value } = await reader.read(); 409 + if (done) break; 410 + writer.write(value); 411 + downloaded += value.length; 412 + if (total > 0 && downloaded % (50 * 1024 * 1024) < value.length) { 413 + const pct = Math.round((downloaded / total) * 100); 414 + onProgress?.({ stage: "base", message: `Downloading base image... ${pct}%` }); 415 + } 416 + } 417 + 418 + writer.end(); 419 + await new Promise((resolve, reject) => { 420 + writer.on("finish", resolve); 421 + writer.on("error", reject); 422 + }); 423 + 424 + await fs.rename(tmpPath, basePath); 425 + // Write sha256 sidecar so future cache checks validate the hash, not just size. 426 + if (manifest.sha256) await fs.writeFile(hashPath, manifest.sha256 + "\n"); 427 + onProgress?.({ stage: "base", message: "Base image downloaded and cached" }); 428 + return { basePath, manifest }; 429 + } 430 + 431 + // ─── Piece Injection ──────────────────────────────────────────────── 432 + 433 + function extractPartition(imagePath, manifest, tempPartPath) { 434 + const { piecePartitionOffset, piecePartitionSize } = manifest; 435 + try { 436 + runCommand( 437 + `dd if="${imagePath}" of="${tempPartPath}" bs=4M iflag=skip_bytes,count_bytes skip=${piecePartitionOffset} count=${piecePartitionSize} status=none`, 438 + ); 439 + } catch { 440 + runCommand( 441 + `dd if="${imagePath}" of="${tempPartPath}" bs=512 skip=${Math.floor(piecePartitionOffset / 512)} count=${Math.floor(piecePartitionSize / 512)} 2>/dev/null`, 442 + ); 443 + } 444 + } 445 + 446 + function injectFilesIntoPartition(tempPartPath, files) { 447 + for (const [filename, content] of Object.entries(files)) { 448 + const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); 449 + const tmpPath = `${tempPartPath}.${safeName}.${randomUUID().slice(0, 8)}`; 450 + fsSync.writeFileSync(tmpPath, content); 451 + 452 + try { 453 + try { 454 + runCommand(`debugfs -w -R "rm ${filename}" "${tempPartPath}" 2>/dev/null`); 455 + } catch { 456 + // File may not exist yet. 457 + } 458 + runCommand(`debugfs -w -R "write ${tmpPath} ${filename}" "${tempPartPath}"`); 459 + } finally { 460 + try { 461 + fsSync.unlinkSync(tmpPath); 462 + } catch { 463 + // ignore 464 + } 465 + } 466 + } 467 + } 468 + 469 + function writePartitionBack(imagePath, manifest, tempPartPath) { 470 + const { piecePartitionOffset, piecePartitionSize } = manifest; 471 + try { 472 + runCommand( 473 + `dd if="${tempPartPath}" of="${imagePath}" bs=4M oflag=seek_bytes seek=${piecePartitionOffset} count=${piecePartitionSize} iflag=count_bytes conv=notrunc,fsync status=none`, 474 + ); 475 + } catch { 476 + runCommand( 477 + `dd if="${tempPartPath}" of="${imagePath}" bs=512 seek=${Math.floor(piecePartitionOffset / 512)} count=${Math.floor(piecePartitionSize / 512)} conv=notrunc 2>/dev/null`, 478 + ); 479 + } 480 + } 481 + 482 + function buildFedOSShellHTML(target) { 483 + const escapedTarget = String(target || "piece").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 484 + return `<!DOCTYPE html> 485 + <html lang="en"> 486 + <head> 487 + <meta charset="utf-8"> 488 + <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> 489 + <title>FedOS - ${escapedTarget}</title> 490 + <style> 491 + :root { 492 + --bg: #06060b; 493 + --surface: rgba(17, 13, 27, 0.88); 494 + --surface-strong: rgba(29, 20, 44, 0.95); 495 + --border: rgba(248, 134, 206, 0.28); 496 + --text: #f8efff; 497 + --muted: #a99bc7; 498 + --accent: #f46ac7; 499 + --ok: #85f2c8; 500 + --warn: #ffd08a; 501 + --danger: #ff9bb4; 502 + } 503 + * { box-sizing: border-box; } 504 + html, body { 505 + width: 100%; 506 + height: 100%; 507 + margin: 0; 508 + padding: 0; 509 + overflow: hidden; 510 + background: 511 + radial-gradient(1200px 500px at 10% -20%, rgba(244, 106, 199, 0.28), transparent 60%), 512 + radial-gradient(1200px 500px at 100% 0%, rgba(110, 120, 255, 0.18), transparent 55%), 513 + var(--bg); 514 + color: var(--text); 515 + font-family: "IBM Plex Mono", "Fira Mono", monospace; 516 + } 517 + .shell { 518 + position: absolute; 519 + inset: 0; 520 + display: grid; 521 + grid-template-rows: auto 1fr; 522 + gap: 8px; 523 + padding: 10px; 524 + } 525 + .topbar { 526 + min-height: 44px; 527 + border: 1px solid var(--border); 528 + border-radius: 14px; 529 + background: var(--surface); 530 + display: flex; 531 + align-items: center; 532 + justify-content: space-between; 533 + gap: 8px; 534 + padding: 6px 10px; 535 + backdrop-filter: blur(8px); 536 + } 537 + .title { 538 + font-size: 11px; 539 + letter-spacing: 0.08em; 540 + text-transform: uppercase; 541 + color: var(--muted); 542 + white-space: nowrap; 543 + overflow: hidden; 544 + text-overflow: ellipsis; 545 + max-width: 34vw; 546 + } 547 + .status-row { 548 + display: flex; 549 + align-items: center; 550 + gap: 6px; 551 + margin-left: auto; 552 + min-width: 0; 553 + } 554 + .pill { 555 + border: 1px solid var(--border); 556 + border-radius: 999px; 557 + padding: 4px 9px; 558 + min-width: 84px; 559 + text-align: center; 560 + font-size: 11px; 561 + line-height: 1; 562 + background: rgba(8, 7, 14, 0.7); 563 + color: var(--text); 564 + white-space: nowrap; 565 + } 566 + .pill.offline { color: var(--danger); border-color: rgba(255, 155, 180, 0.6); } 567 + .pill.low { color: var(--warn); border-color: rgba(255, 208, 138, 0.6); } 568 + .pill.ok { color: var(--ok); border-color: rgba(133, 242, 200, 0.55); } 569 + .pill.muted { color: var(--muted); border-color: rgba(169, 155, 199, 0.4); } 570 + .vol-wrap { 571 + position: relative; 572 + display: flex; 573 + align-items: center; 574 + } 575 + .vol-slider-panel { 576 + position: absolute; 577 + top: calc(100% + 8px); 578 + right: 0; 579 + border: 1px solid var(--border); 580 + border-radius: 10px; 581 + background: var(--surface-strong); 582 + padding: 10px 14px; 583 + display: none; 584 + z-index: 40; 585 + backdrop-filter: blur(10px); 586 + min-width: 160px; 587 + } 588 + .vol-slider-panel.show { display: block; } 589 + .vol-slider-panel input[type="range"] { 590 + width: 100%; 591 + accent-color: var(--accent); 592 + cursor: pointer; 593 + } 594 + .wifi-toggle { 595 + border: 1px solid var(--border); 596 + border-radius: 999px; 597 + background: rgba(8, 7, 14, 0.72); 598 + color: var(--text); 599 + font: inherit; 600 + font-size: 11px; 601 + padding: 4px 10px; 602 + cursor: pointer; 603 + display: none; 604 + } 605 + .wifi-toggle.show { display: inline-block; } 606 + .frame-wrap { 607 + position: relative; 608 + min-height: 0; 609 + border: 1px solid var(--border); 610 + border-radius: 16px; 611 + overflow: hidden; 612 + background: #000; 613 + box-shadow: 0 16px 34px rgba(0, 0, 0, 0.42); 614 + } 615 + iframe { 616 + width: 100%; 617 + height: 100%; 618 + border: 0; 619 + display: block; 620 + background: #000; 621 + } 622 + .wifi-panel { 623 + position: absolute; 624 + top: 58px; 625 + right: 10px; 626 + width: min(360px, calc(100vw - 20px)); 627 + max-height: calc(100vh - 78px); 628 + overflow: auto; 629 + border: 1px solid var(--border); 630 + border-radius: 14px; 631 + background: var(--surface-strong); 632 + padding: 10px; 633 + display: none; 634 + z-index: 30; 635 + backdrop-filter: blur(10px); 636 + } 637 + .wifi-panel.show { display: block; } 638 + .wifi-meta { 639 + font-size: 11px; 640 + color: var(--muted); 641 + margin-bottom: 8px; 642 + } 643 + .wifi-list { 644 + display: grid; 645 + gap: 6px; 646 + margin-bottom: 8px; 647 + } 648 + .wifi-item { 649 + border: 1px solid var(--border); 650 + border-radius: 10px; 651 + padding: 8px; 652 + background: rgba(8, 7, 14, 0.64); 653 + cursor: pointer; 654 + font-size: 12px; 655 + color: var(--text); 656 + } 657 + .wifi-item.selected { 658 + border-color: rgba(133, 242, 200, 0.8); 659 + box-shadow: inset 0 0 0 1px rgba(133, 242, 200, 0.55); 660 + } 661 + .wifi-row { 662 + display: flex; 663 + align-items: center; 664 + gap: 6px; 665 + margin-top: 8px; 666 + } 667 + .wifi-row input { 668 + flex: 1; 669 + min-width: 0; 670 + border-radius: 10px; 671 + border: 1px solid var(--border); 672 + background: rgba(8, 7, 14, 0.7); 673 + color: var(--text); 674 + padding: 8px; 675 + font: inherit; 676 + font-size: 12px; 677 + } 678 + .wifi-row button { 679 + border-radius: 10px; 680 + border: 1px solid var(--border); 681 + background: rgba(244, 106, 199, 0.2); 682 + color: var(--text); 683 + padding: 8px 10px; 684 + font: inherit; 685 + font-size: 12px; 686 + cursor: pointer; 687 + } 688 + .wifi-row button:disabled { opacity: 0.45; cursor: default; } 689 + </style> 690 + </head> 691 + <body> 692 + <div class="shell"> 693 + <div class="topbar" aria-live="polite"> 694 + <div class="title">fedos - ${escapedTarget}</div> 695 + <div class="status-row"> 696 + <div class="vol-wrap"> 697 + <div id="status-volume" class="pill" style="cursor:pointer">VOL --</div> 698 + <div id="vol-slider-panel" class="vol-slider-panel"> 699 + <input id="vol-slider" type="range" min="0" max="150" value="50" step="1"> 700 + </div> 701 + </div> 702 + <div id="status-battery" class="pill">BAT --</div> 703 + <div id="status-network" class="pill">NET --</div> 704 + <button id="wifi-toggle" class="wifi-toggle" type="button">WIFI</button> 705 + </div> 706 + </div> 707 + 708 + <div class="frame-wrap"> 709 + <iframe id="piece-frame" src="./piece-app.html" allow="autoplay; fullscreen"></iframe> 710 + </div> 711 + </div> 712 + 713 + <aside id="wifi-panel" class="wifi-panel" aria-live="polite"> 714 + <div id="wifi-meta" class="wifi-meta">WiFi API unavailable</div> 715 + <div id="wifi-list" class="wifi-list"></div> 716 + <div class="wifi-row"> 717 + <input id="wifi-pass" type="password" placeholder="password"> 718 + <button id="wifi-connect" type="button" disabled>Connect</button> 719 + </div> 720 + </aside> 721 + 722 + <script> 723 + (function () { 724 + const batteryEl = document.getElementById("status-battery"); 725 + const networkEl = document.getElementById("status-network"); 726 + const volumeEl = document.getElementById("status-volume"); 727 + const volPanel = document.getElementById("vol-slider-panel"); 728 + const volSlider = document.getElementById("vol-slider"); 729 + const wifiToggle = document.getElementById("wifi-toggle"); 730 + const wifiPanel = document.getElementById("wifi-panel"); 731 + const wifiMeta = document.getElementById("wifi-meta"); 732 + const wifiList = document.getElementById("wifi-list"); 733 + const wifiPass = document.getElementById("wifi-pass"); 734 + const wifiConnect = document.getElementById("wifi-connect"); 735 + 736 + const suppressedKeys = new Set(["?", "/", "'"]); 737 + // When launched from file:// (Fedora kiosk fallback), use the local API server. 738 + const API_BASE = window.location.protocol === "file:" ? "http://127.0.0.1:8080" : ""; 739 + const apiUrl = (pathname) => API_BASE + pathname; 740 + const wifiApi = { 741 + status: apiUrl("/api/status"), 742 + networks: apiUrl("/api/networks"), 743 + connect: apiUrl("/api/connect"), 744 + }; 745 + 746 + let batteryManager = null; 747 + let wifiAvailable = false; 748 + let selectedNetwork = null; 749 + let networks = []; 750 + let volDragging = false; 751 + 752 + function isEditable(target) { 753 + if (!target) return false; 754 + if (target.isContentEditable) return true; 755 + const tag = target.tagName; 756 + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; 757 + } 758 + 759 + window.addEventListener("keydown", (event) => { 760 + if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return; 761 + if (isEditable(event.target)) return; 762 + if (!suppressedKeys.has(event.key)) return; 763 + event.preventDefault(); 764 + event.stopPropagation(); 765 + }, true); 766 + 767 + function setBattery(levelText, cls) { 768 + batteryEl.textContent = levelText; 769 + batteryEl.classList.remove("low", "ok"); 770 + if (cls) batteryEl.classList.add(cls); 771 + } 772 + 773 + function setNetwork(labelText, isOffline) { 774 + networkEl.textContent = labelText; 775 + networkEl.classList.toggle("offline", Boolean(isOffline)); 776 + } 777 + 778 + async function initBattery() { 779 + if (!navigator.getBattery) { 780 + setBattery("BAT N/A"); 781 + return; 782 + } 783 + 784 + try { 785 + batteryManager = await navigator.getBattery(); 786 + } catch { 787 + setBattery("BAT N/A"); 788 + return; 789 + } 790 + 791 + const update = () => { 792 + const level = Number.isFinite(batteryManager.level) 793 + ? Math.round(batteryManager.level * 100) 794 + : null; 795 + if (level == null) { 796 + setBattery("BAT N/A"); 797 + return; 798 + } 799 + const charging = batteryManager.charging ? " CHG" : ""; 800 + const cls = level <= 20 ? "low" : "ok"; 801 + setBattery("BAT " + level + "%" + charging, cls); 802 + }; 803 + 804 + update(); 805 + batteryManager.addEventListener("levelchange", update); 806 + batteryManager.addEventListener("chargingchange", update); 807 + } 808 + 809 + function updateNetworkStatus() { 810 + if (!navigator.onLine) { 811 + setNetwork("NET OFFLINE", true); 812 + return; 813 + } 814 + 815 + const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 816 + const typeLabel = connection && connection.effectiveType 817 + ? String(connection.effectiveType).toUpperCase() 818 + : "ONLINE"; 819 + const downlink = connection && Number.isFinite(connection.downlink) 820 + ? (" " + connection.downlink.toFixed(1) + "M") 821 + : ""; 822 + setNetwork("NET " + typeLabel + downlink, false); 823 + } 824 + 825 + async function fetchJSON(url, options, timeoutMs) { 826 + const controller = new AbortController(); 827 + const timeout = setTimeout(() => controller.abort(), timeoutMs || 2500); 828 + try { 829 + const res = await fetch(url, { ...(options || {}), signal: controller.signal }); 830 + if (!res.ok) throw new Error("HTTP " + res.status); 831 + return await res.json(); 832 + } finally { 833 + clearTimeout(timeout); 834 + } 835 + } 836 + 837 + function renderWifiList() { 838 + wifiList.innerHTML = ""; 839 + for (let i = 0; i < networks.length; i += 1) { 840 + const item = networks[i]; 841 + const row = document.createElement("button"); 842 + row.type = "button"; 843 + row.className = "wifi-item" + (selectedNetwork && selectedNetwork.ssid === item.ssid ? " selected" : ""); 844 + const locked = item.security ? " [L]" : ""; 845 + row.textContent = item.ssid + " | " + (item.signal || 0) + "%" + locked; 846 + row.addEventListener("click", () => { 847 + selectedNetwork = item; 848 + renderWifiList(); 849 + wifiConnect.disabled = false; 850 + }); 851 + wifiList.appendChild(row); 852 + } 853 + 854 + if (networks.length === 0) { 855 + const empty = document.createElement("div"); 856 + empty.className = "wifi-meta"; 857 + empty.textContent = "No WiFi networks found"; 858 + wifiList.appendChild(empty); 859 + } 860 + } 861 + 862 + async function refreshNetworks() { 863 + wifiMeta.textContent = "Scanning WiFi networks..."; 864 + try { 865 + const data = await fetchJSON(wifiApi.networks, {}, 5000); 866 + networks = Array.isArray(data) ? data : []; 867 + if (selectedNetwork) { 868 + selectedNetwork = networks.find((n) => n.ssid === selectedNetwork.ssid) || null; 869 + } 870 + wifiConnect.disabled = !selectedNetwork; 871 + wifiMeta.textContent = "Select network"; 872 + renderWifiList(); 873 + } catch { 874 + wifiMeta.textContent = "WiFi scan failed"; 875 + networks = []; 876 + selectedNetwork = null; 877 + wifiConnect.disabled = true; 878 + renderWifiList(); 879 + } 880 + } 881 + 882 + async function connectWifi() { 883 + if (!selectedNetwork) return; 884 + wifiConnect.disabled = true; 885 + wifiMeta.textContent = "Connecting to " + selectedNetwork.ssid + "..."; 886 + 887 + const payload = { ssid: selectedNetwork.ssid }; 888 + if (selectedNetwork.security) payload.password = wifiPass.value; 889 + 890 + try { 891 + const result = await fetchJSON( 892 + wifiApi.connect, 893 + { 894 + method: "POST", 895 + headers: { "Content-Type": "application/json" }, 896 + body: JSON.stringify(payload), 897 + }, 898 + 30000, 899 + ); 900 + if (result && result.ok) { 901 + wifiMeta.textContent = "Connected to " + selectedNetwork.ssid; 902 + setTimeout(updateWifiStatus, 250); 903 + } else { 904 + wifiMeta.textContent = (result && result.error) || "WiFi connection failed"; 905 + wifiConnect.disabled = false; 906 + } 907 + } catch { 908 + wifiMeta.textContent = "WiFi connection error"; 909 + wifiConnect.disabled = false; 910 + } 911 + } 912 + 913 + async function updateWifiStatus() { 914 + if (!wifiAvailable) return; 915 + try { 916 + const status = await fetchJSON(wifiApi.status, {}, 1500); 917 + const connected = Boolean(status && status.connected); 918 + wifiToggle.textContent = connected ? "WIFI ON" : "WIFI"; 919 + wifiToggle.classList.toggle("show", true); 920 + wifiMeta.textContent = connected 921 + ? "Network connected" 922 + : "Not connected - select a network"; 923 + } catch { 924 + wifiToggle.textContent = "WIFI"; 925 + } 926 + } 927 + 928 + async function initWifi() { 929 + try { 930 + await fetchJSON(wifiApi.status, {}, 1200); 931 + wifiAvailable = true; 932 + } catch { 933 + wifiAvailable = false; 934 + } 935 + 936 + if (!wifiAvailable) { 937 + wifiPanel.classList.remove("show"); 938 + wifiToggle.classList.remove("show"); 939 + return; 940 + } 941 + 942 + wifiToggle.classList.add("show"); 943 + wifiToggle.addEventListener("click", async () => { 944 + const next = !wifiPanel.classList.contains("show"); 945 + wifiPanel.classList.toggle("show", next); 946 + if (next) await refreshNetworks(); 947 + }); 948 + 949 + wifiConnect.addEventListener("click", connectWifi); 950 + await updateWifiStatus(); 951 + } 952 + 953 + // ── Volume ── 954 + function setVolume(level, muted) { 955 + if (muted) { 956 + volumeEl.textContent = "VOL MUTE"; 957 + volumeEl.classList.add("muted"); 958 + volumeEl.classList.remove("ok", "low"); 959 + } else { 960 + const pct = Math.round(level * 100); 961 + volumeEl.textContent = "VOL " + pct + "%"; 962 + volumeEl.classList.remove("muted"); 963 + volumeEl.classList.toggle("low", pct <= 15); 964 + volumeEl.classList.toggle("ok", pct > 15); 965 + } 966 + if (!volDragging) volSlider.value = Math.round(level * 100); 967 + } 968 + 969 + async function fetchVolume() { 970 + try { 971 + const res = await fetchJSON(apiUrl("/api/volume"), {}, 1500); 972 + if (res && res.volume != null) setVolume(res.volume, res.muted); 973 + } catch { 974 + volumeEl.textContent = "VOL N/A"; 975 + } 976 + } 977 + 978 + volumeEl.addEventListener("click", () => { 979 + // Toggle mute on click 980 + fetch(apiUrl("/api/volume"), { 981 + method: "POST", 982 + headers: { "Content-Type": "application/json" }, 983 + body: JSON.stringify({ mute: "toggle" }), 984 + }).then((r) => r.json()).then((d) => { 985 + if (d && d.volume != null) setVolume(d.volume, d.muted); 986 + }).catch(() => {}); 987 + }); 988 + 989 + volSlider.addEventListener("input", () => { 990 + volDragging = true; 991 + const vol = volSlider.value / 100; 992 + volumeEl.textContent = "VOL " + volSlider.value + "%"; 993 + }); 994 + 995 + volSlider.addEventListener("change", () => { 996 + volDragging = false; 997 + const vol = (volSlider.value / 100).toFixed(2); 998 + fetch(apiUrl("/api/volume"), { 999 + method: "POST", 1000 + headers: { "Content-Type": "application/json" }, 1001 + body: JSON.stringify({ volume: vol }), 1002 + }).then((r) => r.json()).then((d) => { 1003 + if (d && d.volume != null) setVolume(d.volume, d.muted); 1004 + }).catch(() => {}); 1005 + }); 1006 + 1007 + // Show/hide slider panel on hover or focus within the vol-wrap 1008 + const volWrap = volumeEl.parentElement; 1009 + let volHideTimer = null; 1010 + volWrap.addEventListener("mouseenter", () => { 1011 + clearTimeout(volHideTimer); 1012 + volPanel.classList.add("show"); 1013 + }); 1014 + volWrap.addEventListener("mouseleave", () => { 1015 + volHideTimer = setTimeout(() => volPanel.classList.remove("show"), 400); 1016 + }); 1017 + 1018 + updateNetworkStatus(); 1019 + window.addEventListener("online", updateNetworkStatus); 1020 + window.addEventListener("offline", updateNetworkStatus); 1021 + const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 1022 + if (conn && typeof conn.addEventListener === "function") { 1023 + conn.addEventListener("change", updateNetworkStatus); 1024 + } 1025 + 1026 + initBattery(); 1027 + initWifi(); 1028 + fetchVolume(); 1029 + setInterval(fetchVolume, 5000); 1030 + setInterval(updateWifiStatus, 10000); 1031 + })(); 1032 + </script> 1033 + </body> 1034 + </html>`; 1035 + } 1036 + 1037 + // ─── Main Build Flow ──────────────────────────────────────────────── 1038 + 1039 + export async function streamOSImage(res, target, isJSPiece, density, onProgress, flavor = "alpine", { nocache = false } = {}) { 1040 + if (activeBuildCount >= MAX_CONCURRENT_BUILDS) { 1041 + throw new Error( 1042 + `Server busy: ${activeBuildCount}/${MAX_CONCURRENT_BUILDS} OS builds in progress. Try again shortly.`, 1043 + ); 1044 + } 1045 + 1046 + activeBuildCount++; 1047 + const buildId = randomUUID().slice(0, 8); 1048 + const startTime = Date.now(); 1049 + const timing = {}; 1050 + const tempImagePath = path.join(TEMP_DIR, `fedac-os-${buildId}.img`); 1051 + const tempPartPath = path.join(TEMP_DIR, `fedac-part-${buildId}.img`); 1052 + 1053 + try { 1054 + // 1. Ensure base image is cached + fetch manifest for version tag. 1055 + const baseStart = Date.now(); 1056 + const { basePath, manifest } = await ensureBaseImage(onProgress, flavor); 1057 + timing.base = Date.now() - baseStart; 1058 + 1059 + let resolvedManifest = { ...manifest }; 1060 + try { 1061 + const detected = detectPiecePartitionFromImage(basePath); 1062 + if (detected?.piecePartitionOffset > 0 && detected?.piecePartitionSize > 0) { 1063 + const mismatch = 1064 + detected.piecePartitionOffset !== manifest.piecePartitionOffset || 1065 + detected.piecePartitionSize !== manifest.piecePartitionSize; 1066 + if (mismatch) { 1067 + console.warn( 1068 + `[os] Manifest partition mismatch (${flavor}):` + 1069 + ` manifest=${manifest.piecePartitionOffset}/${manifest.piecePartitionSize}` + 1070 + ` detected=${detected.piecePartitionOffset}/${detected.piecePartitionSize}.` + 1071 + ` Using detected values.`, 1072 + ); 1073 + } 1074 + resolvedManifest = { ...manifest, ...detected }; 1075 + } 1076 + } catch (err) { 1077 + console.warn(`[os] Failed to detect partition table from image: ${err.message}`); 1078 + } 1079 + 1080 + if (!resolvedManifest.piecePartitionOffset || resolvedManifest.piecePartitionOffset <= 0) { 1081 + throw new Error( 1082 + `Base image missing valid piecePartitionOffset (${flavor}) — rebuild base image with --base-image flag`, 1083 + ); 1084 + } 1085 + 1086 + // 2. Build piece bundle. 1087 + const bundleStart = Date.now(); 1088 + onProgress?.({ stage: "bundle", message: `Bundling ${target}...`, step: 2, totalSteps: 9 }); 1089 + const bundleResult = isJSPiece 1090 + ? await createJSPieceBundle(target, (p) => onProgress?.({ stage: "bundle", message: p.message, step: 2, totalSteps: 9 }), true, density) 1091 + : await createBundle(target, (p) => onProgress?.({ stage: "bundle", message: p.message, step: 2, totalSteps: 9 }), true, density); 1092 + const pieceHtml = bundleResult.html; 1093 + timing.bundle = Date.now() - bundleStart; 1094 + onProgress?.({ 1095 + stage: "bundle", 1096 + message: `Bundle ready: ${bundleResult.sizeKB}KB (${formatSeconds(timing.bundle)})`, 1097 + step: 2, 1098 + totalSteps: 9, 1099 + }); 1100 + 1101 + // 2b. Compute bundle hash + CDN key for caching. 1102 + const bundleHash = hashContent(pieceHtml); 1103 + const baseVersion = manifest.version || "unknown"; 1104 + const cdnKey = buildCDNKey(target, density, bundleHash, baseVersion, flavor); 1105 + 1106 + // 3. Check CDN cache — skip the entire build if an identical ISO exists. 1107 + const cacheStart = Date.now(); 1108 + onProgress?.({ stage: "cache-check", message: nocache ? "Skipping cache (nocache)..." : "Checking CDN cache...", step: 3, totalSteps: 9 }); 1109 + const cdnHit = nocache ? false : await checkCDNCache(cdnKey); 1110 + timing.cacheCheck = Date.now() - cacheStart; 1111 + 1112 + if (cdnHit) { 1113 + const cdnUrl = buildCDNUrl(cdnKey); 1114 + const elapsed = Date.now() - startTime; 1115 + timing.total = elapsed; 1116 + onProgress?.({ 1117 + stage: "done", 1118 + message: `CDN cache hit — redirecting (${formatSeconds(elapsed)})`, 1119 + cdnUrl, 1120 + cached: true, 1121 + timings: timing, 1122 + step: 9, 1123 + totalSteps: 9, 1124 + }); 1125 + 1126 + recentBuilds.unshift({ 1127 + buildId, 1128 + target, 1129 + isJSPiece, 1130 + density, 1131 + flavor, 1132 + elapsed, 1133 + timings: timing, 1134 + cached: true, 1135 + cdnUrl, 1136 + time: new Date().toISOString(), 1137 + }); 1138 + if (recentBuilds.length > MAX_RECENT) recentBuilds.pop(); 1139 + 1140 + logOSBuild({ buildId, target, isJSPiece, density, flavor, elapsed, timings: timing, cached: true, cdnUrl, baseVersion: manifest.version, baseSha256: manifest.sha256, success: true }); 1141 + 1142 + // Redirect to CDN for download (fast edge delivery). 1143 + if (res) { 1144 + res.redirect(302, cdnUrl); 1145 + } 1146 + return { buildId, elapsed, timings: timing, cdnUrl, cached: true, filename: `${target}-${flavor}-os.iso` }; 1147 + } 1148 + 1149 + // 4. Copy base image to temp. 1150 + const copyStart = Date.now(); 1151 + onProgress?.({ stage: "copy", message: "Copying base image to workspace...", step: 4, totalSteps: 9 }); 1152 + const copyMode = copyBaseImageFast(basePath, tempImagePath); 1153 + if (copyMode === "regular") { 1154 + await fs.copyFile(basePath, tempImagePath); 1155 + } 1156 + timing.copy = Date.now() - copyStart; 1157 + onProgress?.({ 1158 + stage: "copy", 1159 + message: `Base image copied (${formatSeconds(timing.copy)}, ${copyMode})`, 1160 + step: 4, 1161 + totalSteps: 9, 1162 + }); 1163 + 1164 + // 5. Extract FEDAC-PIECE partition. 1165 + const extractStart = Date.now(); 1166 + onProgress?.({ stage: "extract", message: "Extracting piece partition from image...", step: 5, totalSteps: 9 }); 1167 + extractPartition(tempImagePath, resolvedManifest, tempPartPath); 1168 + timing.extract = Date.now() - extractStart; 1169 + onProgress?.({ 1170 + stage: "extract", 1171 + message: `Partition extracted (${formatSeconds(timing.extract)})`, 1172 + step: 5, 1173 + totalSteps: 9, 1174 + }); 1175 + 1176 + // 6. Inject piece-app.html and shell piece.html via debugfs. 1177 + const injectStart = Date.now(); 1178 + onProgress?.({ stage: "inject", message: "Writing piece-app.html into partition via debugfs...", step: 6, totalSteps: 9 }); 1179 + injectFilesIntoPartition(tempPartPath, { 1180 + "piece-app.html": pieceHtml, 1181 + "piece.html": buildFedOSShellHTML(target), 1182 + }); 1183 + timing.inject = Date.now() - injectStart; 1184 + onProgress?.({ 1185 + stage: "inject", 1186 + message: `Piece files injected (${formatSeconds(timing.inject)})`, 1187 + step: 6, 1188 + totalSteps: 9, 1189 + }); 1190 + 1191 + // 7. Write modified partition back into the image. 1192 + const writeStart = Date.now(); 1193 + onProgress?.({ stage: "write-back", message: "Writing modified partition back to image...", step: 7, totalSteps: 9 }); 1194 + writePartitionBack(tempImagePath, resolvedManifest, tempPartPath); 1195 + timing.writeBack = Date.now() - writeStart; 1196 + onProgress?.({ 1197 + stage: "write-back", 1198 + message: `Partition written back (${formatSeconds(timing.writeBack)})`, 1199 + step: 7, 1200 + totalSteps: 9, 1201 + }); 1202 + 1203 + // 8. Upload to CDN (concurrent with streaming to client). 1204 + onProgress?.({ stage: "upload", message: "Uploading ISO to CDN for caching...", step: 8, totalSteps: 9 }); 1205 + const uploadPromise = uploadToCDN(tempImagePath, cdnKey, target, flavor) 1206 + .then((url) => { 1207 + if (url) { 1208 + console.log(`[os] CDN upload complete: ${url}`); 1209 + onProgress?.({ stage: "upload", message: "ISO cached to CDN", step: 8, totalSteps: 9 }); 1210 + } 1211 + return url; 1212 + }) 1213 + .catch((err) => { 1214 + console.error(`[os] CDN upload failed (non-fatal): ${err.message}`); 1215 + onProgress?.({ stage: "upload", message: `CDN upload skipped: ${err.message}`, step: 8, totalSteps: 9 }); 1216 + return null; 1217 + }); 1218 + 1219 + // 9. Stream to client (if res provided). 1220 + let cdnUrl = null; 1221 + if (res) { 1222 + const streamStart = Date.now(); 1223 + const fileStat = await fs.stat(tempImagePath); 1224 + const filename = `${target}-${flavor}-os.iso`; 1225 + res.set({ 1226 + "Content-Type": "application/octet-stream", 1227 + "Content-Disposition": `attachment; filename="${filename}"`, 1228 + "Content-Length": fileStat.size, 1229 + "Cache-Control": "no-cache", 1230 + }); 1231 + 1232 + const sizeMB = Math.round(fileStat.size / 1024 / 1024); 1233 + onProgress?.({ stage: "stream", message: `Streaming ${sizeMB}MB ISO to client...`, step: 9, totalSteps: 9 }); 1234 + 1235 + const readStream = fsSync.createReadStream(tempImagePath); 1236 + await new Promise((resolve, reject) => { 1237 + readStream.pipe(res); 1238 + readStream.on("end", resolve); 1239 + readStream.on("error", reject); 1240 + res.on("close", () => { 1241 + readStream.destroy(); 1242 + resolve(); 1243 + }); 1244 + }); 1245 + timing.stream = Date.now() - streamStart; 1246 + } 1247 + 1248 + // Wait for CDN upload before cleaning up temp file. 1249 + cdnUrl = await uploadPromise; 1250 + 1251 + const elapsed = Date.now() - startTime; 1252 + timing.total = elapsed; 1253 + onProgress?.({ 1254 + stage: "done", 1255 + message: `OS ISO ready in ${formatSeconds(elapsed)} (copy ${formatSeconds(timing.copy)}, extract ${formatSeconds(timing.extract)}, inject ${formatSeconds(timing.inject)}, write-back ${formatSeconds(timing.writeBack)})${cdnUrl ? " — cached to CDN" : ""}`, 1256 + cdnUrl, 1257 + timings: timing, 1258 + step: 9, 1259 + totalSteps: 9, 1260 + }); 1261 + 1262 + recentBuilds.unshift({ 1263 + buildId, 1264 + target, 1265 + isJSPiece, 1266 + density, 1267 + flavor, 1268 + elapsed, 1269 + timings: timing, 1270 + cdnUrl, 1271 + time: new Date().toISOString(), 1272 + }); 1273 + if (recentBuilds.length > MAX_RECENT) recentBuilds.pop(); 1274 + 1275 + logOSBuild({ buildId, target, isJSPiece, density, flavor, elapsed, timings: timing, cached: false, cdnUrl, bundleHash, baseVersion, baseSha256: manifest.sha256, success: true }); 1276 + 1277 + return { 1278 + buildId, 1279 + elapsed, 1280 + timings: timing, 1281 + cdnUrl, 1282 + flavor, 1283 + filename: `${target}-${flavor}-os.iso`, 1284 + }; 1285 + } catch (err) { 1286 + const elapsed = Date.now() - startTime; 1287 + logOSBuild({ buildId, target, isJSPiece, density, flavor, elapsed, timings: timing, cached: false, success: false, error: err.message }); 1288 + throw err; 1289 + } finally { 1290 + activeBuildCount--; 1291 + try { 1292 + await fs.unlink(tempImagePath); 1293 + } catch { 1294 + // ignore 1295 + } 1296 + try { 1297 + await fs.unlink(tempPartPath); 1298 + } catch { 1299 + // ignore 1300 + } 1301 + } 1302 + } 1303 + 1304 + // ─── Status ───────────────────────────────────────────────────────── 1305 + 1306 + export function getOSBuildStatus() { 1307 + return { 1308 + activeBuildCount, 1309 + maxConcurrent: MAX_CONCURRENT_BUILDS, 1310 + recentBuilds: recentBuilds.slice(0, 10), 1311 + cacheDir: resolvedCacheDir || (CONFIGURED_CACHE_DIR || DEFAULT_CACHE_DIR), 1312 + baseImageUrl: BASE_IMAGE_URL, 1313 + manifestUrl: MANIFEST_URL, 1314 + }; 1315 + } 1316 + 1317 + export { ensureBaseImage };
+31
oven/package.json
··· 1 + { 2 + "name": "oven", 3 + "version": "1.0.0", 4 + "description": "Video processing service for aesthetic.computer tapes", 5 + "type": "module", 6 + "main": "server.mjs", 7 + "scripts": { 8 + "start": "node server.mjs", 9 + "dev": "nodemon -I --watch server.mjs --watch baker.mjs --watch grabber.mjs --watch bundler.mjs server.mjs" 10 + }, 11 + "dependencies": { 12 + "@aws-sdk/client-s3": "^3.540.0", 13 + "adm-zip": "^0.5.10", 14 + "archiver": "^7.0.1", 15 + "brotli-dec-wasm": "^2.3.1", 16 + "dotenv": "^17.2.3", 17 + "express": "^4.18.2", 18 + "gifenc": "^1.0.3", 19 + "mongodb": "^6.20.0", 20 + "puppeteer": "^24.10.0", 21 + "sharp": "^0.33.0", 22 + "terser": "^5.36.0", 23 + "ws": "^8.18.3" 24 + }, 25 + "devDependencies": { 26 + "nodemon": "^3.1.9" 27 + }, 28 + "engines": { 29 + "node": ">=20.0.0" 30 + } 31 + }
+290
oven/papers-builder.mjs
··· 1 + // papers-builder.mjs — Auto-build all AC paper PDFs from LaTeX sources 2 + // 3 + // Triggered via POST /papers-build or by papers-git-poller.mjs when 4 + // papers/ paths change on main. Runs `node papers/cli.mjs publish` 5 + // (xelatex 3-pass + deploy + index update + verify). 6 + 7 + import { promises as fs } from "fs"; 8 + import path from "path"; 9 + import { randomUUID } from "crypto"; 10 + import { spawn, execFile } from "child_process"; 11 + 12 + const MAX_RECENT_JOBS = 10; 13 + const MAX_LOG_LINES = 2000; 14 + 15 + // papers/cli.mjs lives inside the git clone at /opt/oven/native-git/papers/ 16 + const GIT_REPO_DIR = 17 + process.env.NATIVE_GIT_DIR || "/opt/oven/native-git"; 18 + 19 + const jobs = new Map(); 20 + const jobOrder = []; 21 + let activeJobId = null; 22 + 23 + function nowISO() { 24 + return new Date().toISOString(); 25 + } 26 + 27 + function stripAnsi(s) { 28 + return String(s || "").replace(/\u001b\[[0-9;]*m/g, ""); 29 + } 30 + 31 + // Estimate total papers from PAPER_MAP in cli.mjs (17 papers × 4 langs = 68 builds) 32 + const ESTIMATED_BUILDS = 68; 33 + let buildCount = 0; 34 + 35 + function addLogLine(job, stream, line) { 36 + const clean = stripAnsi(line).replace(/\r/g, "").trimEnd(); 37 + if (!clean) return; 38 + job.logs.push({ ts: nowISO(), stream, line: clean }); 39 + if (job.logs.length > MAX_LOG_LINES) 40 + job.logs.splice(0, job.logs.length - MAX_LOG_LINES); 41 + job.updatedAt = nowISO(); 42 + 43 + // Parse progress from cli.mjs publish output 44 + if (clean.match(/^\s+BUILD /)) { 45 + buildCount++; 46 + job.stage = "build"; 47 + job.percent = Math.min(80, Math.round((buildCount / ESTIMATED_BUILDS) * 80)); 48 + } else if (clean.match(/^\s+DEPLOY /)) { 49 + job.stage = "deploy"; 50 + job.percent = Math.max(job.percent, 85); 51 + } else if (clean.includes("INDEX updated")) { 52 + job.stage = "index"; 53 + job.percent = 90; 54 + } else if (clean.includes("VERIFY")) { 55 + job.stage = "verify"; 56 + job.percent = 95; 57 + } else if (clean.includes("Publish complete")) { 58 + job.stage = "done"; 59 + job.percent = 100; 60 + } 61 + } 62 + 63 + function makeSnapshot(job, opts = {}) { 64 + const { includeLogs = false, tail = 200 } = opts; 65 + const snap = { 66 + id: job.id, 67 + ref: job.ref, 68 + status: job.status, 69 + stage: job.stage, 70 + percent: job.percent, 71 + createdAt: job.createdAt, 72 + startedAt: job.startedAt, 73 + updatedAt: job.updatedAt, 74 + finishedAt: job.finishedAt, 75 + exitCode: job.exitCode, 76 + error: job.error, 77 + logCount: job.logs.length, 78 + elapsedMs: job.startedAt 79 + ? (job.finishedAt ? Date.parse(job.finishedAt) : Date.now()) - 80 + Date.parse(job.startedAt) 81 + : 0, 82 + }; 83 + if (includeLogs) { 84 + const start = Math.max(0, job.logs.length - Math.max(0, tail)); 85 + snap.logs = job.logs.slice(start); 86 + } 87 + return snap; 88 + } 89 + 90 + function wireStream(job, proc, streamName) { 91 + let pending = ""; 92 + const s = streamName === "stdout" ? proc.stdout : proc.stderr; 93 + s.on("data", (chunk) => { 94 + pending += chunk.toString(); 95 + let idx; 96 + while ((idx = pending.indexOf("\n")) >= 0) { 97 + addLogLine(job, streamName, pending.slice(0, idx)); 98 + pending = pending.slice(idx + 1); 99 + } 100 + }); 101 + s.on("end", () => { 102 + if (pending) addLogLine(job, streamName, pending); 103 + }); 104 + } 105 + 106 + function git(args, cwd = GIT_REPO_DIR) { 107 + return new Promise((resolve, reject) => { 108 + execFile("git", args, { cwd, timeout: 60_000 }, (err, stdout, stderr) => { 109 + if (err) { 110 + err.stderr = stderr; 111 + return reject(err); 112 + } 113 + resolve(stdout.trim()); 114 + }); 115 + }); 116 + } 117 + 118 + // After a successful publish, commit the built PDFs + index + metadata and push 119 + // to origin so Netlify can deploy them via the papers.aesthetic.computer subdomain. 120 + async function commitAndPushPDFs(job) { 121 + const SITE_DIR = path.join(GIT_REPO_DIR, "system", "public", "papers.aesthetic.computer"); 122 + const METADATA = path.join(GIT_REPO_DIR, "papers", "metadata.json"); 123 + 124 + addLogLine(job, "stdout", " GIT: staging built PDFs..."); 125 + job.stage = "git-push"; 126 + job.percent = 96; 127 + 128 + // Configure git identity for the oven bot 129 + await git(["config", "user.email", "oven@aesthetic.computer"]); 130 + await git(["config", "user.name", "Oven (aesthetic.computer)"]); 131 + 132 + // Stage ALL changes in the working tree — publish generates PDFs, index.html, 133 + // metadata.json, BUILDLOG.md, .aux files, etc. We need to stage everything 134 + // so that `git pull --rebase` doesn't fail on unstaged changes. 135 + await git(["add", "papers/", "system/public/papers.aesthetic.computer/"]); 136 + 137 + // Check if there are actually staged changes 138 + const status = await git(["diff", "--cached", "--name-only"]); 139 + if (!status) { 140 + addLogLine(job, "stdout", " GIT: no changes to commit — PDFs unchanged"); 141 + return; 142 + } 143 + 144 + const changedFiles = status.split("\n"); 145 + const pdfCount = changedFiles.filter((f) => f.endsWith(".pdf")).length; 146 + const msg = `[papers] oven auto-build: ${pdfCount} PDF${pdfCount !== 1 ? "s" : ""} updated`; 147 + 148 + addLogLine(job, "stdout", ` GIT: committing ${changedFiles.length} file(s)...`); 149 + await git(["commit", "-m", msg]); 150 + 151 + // Pull any changes that landed while we were building (rebase our commit on top) 152 + addLogLine(job, "stdout", " GIT: pulling latest before push..."); 153 + try { 154 + await git(["pull", "--rebase", "origin", "main"]); 155 + } catch (pullErr) { 156 + // If rebase fails (conflict), abort and report — our PDFs are binary so this shouldn't happen 157 + try { await git(["rebase", "--abort"]); } catch {} 158 + throw pullErr; 159 + } 160 + 161 + addLogLine(job, "stdout", " GIT: pushing to origin/main..."); 162 + job.percent = 98; 163 + await git(["push", "origin", "main"]); 164 + 165 + addLogLine(job, "stdout", ` GIT: pushed — ${msg}`); 166 + } 167 + 168 + async function runPapersJob(job) { 169 + try { 170 + job.status = "running"; 171 + job.startedAt = nowISO(); 172 + job.percent = 0; 173 + buildCount = 0; 174 + 175 + // Single phase: node papers/cli.mjs publish 176 + const cliPath = path.join(GIT_REPO_DIR, "papers", "cli.mjs"); 177 + job.stage = "build"; 178 + job.updatedAt = nowISO(); 179 + 180 + await new Promise((resolve, reject) => { 181 + const proc = spawn("node", [cliPath, "publish"], { 182 + cwd: GIT_REPO_DIR, 183 + env: { 184 + ...process.env, 185 + TERM: "dumb", 186 + CLICOLOR: "0", 187 + FORCE_COLOR: "0", 188 + }, 189 + stdio: ["ignore", "pipe", "pipe"], 190 + }); 191 + job.process = proc; 192 + job.pid = proc.pid; 193 + wireStream(job, proc, "stdout"); 194 + wireStream(job, proc, "stderr"); 195 + proc.on("error", reject); 196 + proc.on("close", (code) => { 197 + job.process = null; 198 + if (code !== 0) reject(new Error(`papers publish failed (exit ${code})`)); 199 + else resolve(); 200 + }); 201 + }); 202 + 203 + // Commit and push built PDFs back to the repo for Netlify deployment 204 + try { 205 + await commitAndPushPDFs(job); 206 + } catch (pushErr) { 207 + // Git push failure is non-fatal — PDFs were still built successfully 208 + addLogLine(job, "stderr", ` GIT PUSH FAILED: ${pushErr.message}${pushErr.stderr ? " | " + pushErr.stderr.trim() : ""}`); 209 + } 210 + 211 + job.status = "success"; 212 + job.stage = "done"; 213 + job.percent = 100; 214 + job.finishedAt = nowISO(); 215 + } catch (err) { 216 + job.finishedAt = nowISO(); 217 + job.status = job.status === "cancelled" ? "cancelled" : "failed"; 218 + job.stage = job.status; 219 + job.error = err.message || String(err); 220 + } finally { 221 + if (activeJobId === job.id) activeJobId = null; 222 + } 223 + } 224 + 225 + export async function startPapersBuild(options = {}) { 226 + if (activeJobId) { 227 + const err = new Error(`Papers build already running: ${activeJobId}`); 228 + err.code = "PAPERS_BUILD_BUSY"; 229 + err.activeJobId = activeJobId; 230 + throw err; 231 + } 232 + 233 + const id = randomUUID().slice(0, 10); 234 + const job = { 235 + id, 236 + ref: options.ref || "unknown", 237 + status: "queued", 238 + stage: "queued", 239 + percent: 0, 240 + createdAt: nowISO(), 241 + startedAt: null, 242 + updatedAt: nowISO(), 243 + finishedAt: null, 244 + pid: null, 245 + process: null, 246 + exitCode: null, 247 + error: null, 248 + logs: [], 249 + }; 250 + 251 + jobs.set(id, job); 252 + jobOrder.unshift(id); 253 + while (jobOrder.length > MAX_RECENT_JOBS) { 254 + const old = jobOrder.pop(); 255 + if (old !== activeJobId) jobs.delete(old); 256 + } 257 + activeJobId = id; 258 + runPapersJob(job).catch(() => {}); 259 + return makeSnapshot(job); 260 + } 261 + 262 + export function getPapersBuild(jobId, opts = {}) { 263 + const job = jobs.get(jobId); 264 + return job ? makeSnapshot(job, opts) : null; 265 + } 266 + 267 + export function getPapersBuildsSummary() { 268 + return { 269 + activeJobId, 270 + active: activeJobId ? makeSnapshot(jobs.get(activeJobId)) : null, 271 + recent: jobOrder 272 + .map((id) => jobs.get(id)) 273 + .filter(Boolean) 274 + .map((j) => makeSnapshot(j)), 275 + }; 276 + } 277 + 278 + export function cancelPapersBuild(jobId) { 279 + const job = jobs.get(jobId); 280 + if (!job) return { ok: false, error: "not found" }; 281 + if (job.status !== "running" || !job.process) 282 + return { ok: false, error: "not running" }; 283 + try { 284 + job.process.kill("SIGTERM"); 285 + job.status = "cancelled"; 286 + return { ok: true }; 287 + } catch (err) { 288 + return { ok: false, error: err.message }; 289 + } 290 + }
+217
oven/papers-git-poller.mjs
··· 1 + // papers-git-poller.mjs — polls git for papers/ changes, auto-triggers PDF builds 2 + // 3 + // Runs inside the oven server. Every POLL_INTERVAL_MS (default 60s), fetches 4 + // origin/main and checks if any papers/ paths changed since the last 5 + // successful build. If so, pulls and triggers startPapersBuild(). 6 + // 7 + // Shares the git clone at GIT_REPO_DIR with native-git-poller.mjs. 8 + // Uses a separate hash file (.last-papers-built-hash) to track state. 9 + 10 + import { execFile } from "child_process"; 11 + import { promises as fs } from "fs"; 12 + import path from "path"; 13 + 14 + const POLL_INTERVAL_MS = parseInt(process.env.PAPERS_POLL_INTERVAL_MS || "60000", 10); 15 + const GIT_REPO_DIR = process.env.NATIVE_GIT_DIR || "/opt/oven/native-git"; 16 + const BRANCH = process.env.NATIVE_GIT_BRANCH || "main"; 17 + const HASH_FILE = path.join(GIT_REPO_DIR, ".last-papers-built-hash"); 18 + 19 + // Paths that should trigger a papers rebuild (prefixes) 20 + const TRIGGER_PREFIXES = [ 21 + "papers/", 22 + "system/public/type/webfonts/", // font changes affect paper output 23 + ]; 24 + 25 + // File extensions that are LaTeX source files (trigger a build). 26 + // Excludes .pdf and other build outputs to prevent infinite loops 27 + // when the oven pushes built PDFs back to the repo. 28 + const SOURCE_EXTENSIONS = [ 29 + ".tex", ".bib", ".sty", ".cls", ".mjs", ".js", ".json", 30 + ".png", ".jpg", ".jpeg", ".svg", ".eps", // figures 31 + ]; 32 + 33 + // Paths generated by the oven build — always ignore these 34 + const IGNORE_PATTERNS = [ 35 + "system/public/papers.aesthetic.computer/", // deployed PDFs + index.html 36 + "papers/metadata.json", 37 + "papers/BUILDLOG.md", 38 + ]; 39 + 40 + function isSourceChange(filePath) { 41 + // Synthetic force-rebuild paths always trigger 42 + if (filePath === "papers/force-rebuild") return true; 43 + // Ignore oven-generated output paths 44 + if (IGNORE_PATTERNS.some((pat) => filePath.startsWith(pat) || filePath === pat)) { 45 + return false; 46 + } 47 + // Must match a trigger prefix 48 + if (!TRIGGER_PREFIXES.some((prefix) => filePath.startsWith(prefix))) { 49 + return false; 50 + } 51 + // Font changes always trigger 52 + if (filePath.startsWith("system/public/type/webfonts/")) return true; 53 + // For papers/, only trigger on source file extensions (not .pdf, .log, .aux, etc.) 54 + const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); 55 + return SOURCE_EXTENSIONS.includes(ext); 56 + } 57 + 58 + let polling = false; 59 + let timer = null; 60 + let startBuildFn = null; 61 + let logFn = (level, icon, msg) => console.log(`[papers-git-poller] ${msg}`); 62 + 63 + function git(args, cwd = GIT_REPO_DIR) { 64 + return new Promise((resolve, reject) => { 65 + execFile("git", args, { cwd, timeout: 30_000 }, (err, stdout, stderr) => { 66 + if (err) { 67 + err.stderr = stderr; 68 + return reject(err); 69 + } 70 + resolve(stdout.trim()); 71 + }); 72 + }); 73 + } 74 + 75 + async function readLastBuiltHash() { 76 + try { 77 + return (await fs.readFile(HASH_FILE, "utf8")).trim(); 78 + } catch { 79 + return null; 80 + } 81 + } 82 + 83 + async function writeLastBuiltHash(hash) { 84 + await fs.writeFile(HASH_FILE, hash + "\n", "utf8"); 85 + } 86 + 87 + async function poll() { 88 + if (polling) return; 89 + polling = true; 90 + 91 + try { 92 + // Fetch latest from origin 93 + await git(["fetch", "origin", BRANCH, "--quiet"]); 94 + 95 + const remoteHead = await git(["rev-parse", `origin/${BRANCH}`]); 96 + const lastBuilt = await readLastBuiltHash(); 97 + 98 + if (remoteHead === lastBuilt) { 99 + polling = false; 100 + return; 101 + } 102 + 103 + // Check which files changed 104 + let changedPaths = ""; 105 + if (lastBuilt) { 106 + try { 107 + const diffOutput = await git([ 108 + "diff", 109 + "--name-only", 110 + lastBuilt, 111 + remoteHead, 112 + ]); 113 + changedPaths = diffOutput; 114 + } catch { 115 + // lastBuilt hash might not exist (force push, etc) — treat as full build 116 + changedPaths = "papers/force-rebuild"; 117 + } 118 + } else { 119 + // First run — treat as full build 120 + changedPaths = "papers/force-rebuild"; 121 + } 122 + 123 + // Filter to papers-relevant source paths (excludes .pdf and build outputs) 124 + const papersPaths = changedPaths 125 + .split("\n") 126 + .filter((p) => isSourceChange(p)); 127 + 128 + if (papersPaths.length === 0) { 129 + // Changes exist but not in papers/ — update hash, skip build 130 + logFn("info", "⏭️", `New commits (${remoteHead.slice(0, 8)}) but no papers/ changes — skipping build`); 131 + await writeLastBuiltHash(remoteHead); 132 + polling = false; 133 + return; 134 + } 135 + 136 + // Pull the changes so cli.mjs works on up-to-date code 137 + await git(["checkout", BRANCH, "--quiet"]); 138 + await git(["merge", `origin/${BRANCH}`, "--ff-only", "--quiet"]); 139 + 140 + logFn( 141 + "info", 142 + "📄", 143 + `Papers changes detected (${remoteHead.slice(0, 8)}): ${papersPaths.length} file(s) — triggering PDF build` 144 + ); 145 + 146 + // Trigger build 147 + const job = await startBuildFn({ 148 + ref: remoteHead, 149 + changed_paths: papersPaths.join(","), 150 + }); 151 + 152 + logFn( 153 + "info", 154 + "🚀", 155 + `Papers build ${job.id} started` 156 + ); 157 + 158 + // Update hash after successfully starting the build 159 + await writeLastBuiltHash(remoteHead); 160 + } catch (err) { 161 + if (err?.code === "PAPERS_BUILD_BUSY") { 162 + logFn("info", "⏳", "Papers build already running — will retry next poll"); 163 + } else { 164 + logFn( 165 + "error", 166 + "❌", 167 + `Papers git poll error: ${err.message}${err.stderr ? " | " + err.stderr.trim() : ""}` 168 + ); 169 + } 170 + } finally { 171 + polling = false; 172 + } 173 + } 174 + 175 + // ── Public API ────────────────────────────────────────────────────────────── 176 + 177 + export function startPoller({ startPapersBuild, addServerLog }) { 178 + startBuildFn = startPapersBuild; 179 + if (addServerLog) logFn = addServerLog; 180 + 181 + // Check that GIT_REPO_DIR exists before starting 182 + fs.access(GIT_REPO_DIR) 183 + .then(() => { 184 + logFn( 185 + "info", 186 + "📄", 187 + `Papers git poller started (every ${POLL_INTERVAL_MS / 1000}s, repo: ${GIT_REPO_DIR})` 188 + ); 189 + // Stagger start vs native poller (native uses 5s delay, we use 15s) 190 + setTimeout(poll, 15000); 191 + timer = setInterval(poll, POLL_INTERVAL_MS); 192 + }) 193 + .catch(() => { 194 + logFn( 195 + "error", 196 + "⚠️", 197 + `Papers git poller disabled — repo dir not found: ${GIT_REPO_DIR}. Run: git clone --branch main https://github.com/whistlegraph/aesthetic-computer.git ${GIT_REPO_DIR}` 198 + ); 199 + }); 200 + } 201 + 202 + export function stopPoller() { 203 + if (timer) { 204 + clearInterval(timer); 205 + timer = null; 206 + logFn("info", "🛑", "Papers git poller stopped"); 207 + } 208 + } 209 + 210 + export function getPollerStatus() { 211 + return { 212 + running: timer !== null, 213 + intervalMs: POLL_INTERVAL_MS, 214 + repoDir: GIT_REPO_DIR, 215 + branch: BRANCH, 216 + }; 217 + }
+11
oven/scripts/check-slugs.mjs
··· 1 + import pkg from 'mongodb'; 2 + const {MongoClient} = pkg; 3 + const client = await MongoClient.connect(process.env.MONGODB_CONNECTION_STRING); 4 + const db = client.db('aesthetic'); 5 + const paintings = await db.collection('paintings').find().sort({_id: -1}).limit(5).toArray(); 6 + console.log('Recent paintings:'); 7 + paintings.forEach(p => console.log('Code:', p.code, 'Slug:', p.slug)); 8 + const tapes = await db.collection('tapes').find().sort({_id: -1}).limit(5).toArray(); 9 + console.log('\nRecent tapes:'); 10 + tapes.forEach(t => console.log('Code:', t.code, 'Slug:', t.slug)); 11 + await client.close();
+80
oven/scripts/rename-video-files.mjs
··· 1 + // Rename video files from short slug format to full timestamp format 2 + // Based on the "when" field in the tapes database collection 3 + 4 + import { S3Client, CopyObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; 5 + import { connect } from "../system/backend/database.mjs"; 6 + 7 + const s3 = new S3Client({ 8 + endpoint: "https://sfo3.digitaloceanspaces.com", 9 + region: "us-east-1", 10 + credentials: { 11 + accessKeyId: process.env.SPACES_KEY, 12 + secretAccessKey: process.env.SPACES_SECRET, 13 + }, 14 + }); 15 + 16 + const userId = "auth0|63effeeb2a7d55f8098d62f9"; 17 + const bucket = "user-aesthetic-computer"; 18 + 19 + // Map of short slugs to rename 20 + const shortSlugs = ["2025.254", "2025.353", "2025.373", "2025.384", "2025.447", "2025.740", "2025.770", "2025.855"]; 21 + 22 + async function main() { 23 + const { db, disconnect } = await connect(); 24 + 25 + console.log("Finding tapes with short slugs...\n"); 26 + 27 + const tapes = await db.collection("tapes").find({ 28 + slug: { $in: shortSlugs }, 29 + user: userId 30 + }).toArray(); 31 + 32 + for (const tape of tapes) { 33 + const when = new Date(tape.when); 34 + const fullSlug = when.toISOString() 35 + .replace(/[-:]/g, ".") 36 + .replace("T", ".") 37 + .replace("Z", "") 38 + .split(".").slice(0, 7).join("."); 39 + 40 + const tapeUserId = tape.user || userId; 41 + const oldKey = `${tapeUserId}/video/${tape.slug}.zip`; 42 + const newKey = `${tapeUserId}/video/${fullSlug}.zip`; 43 + 44 + console.log(`Tape ${tape.code} (${tape.slug}):`); 45 + console.log(` Old: ${oldKey}`); 46 + console.log(` New: ${newKey}`); 47 + 48 + try { 49 + // Copy to new location 50 + await s3.send(new CopyObjectCommand({ 51 + Bucket: bucket, 52 + CopySource: `${bucket}/${oldKey}`, 53 + Key: newKey, 54 + })); 55 + console.log(` ✓ Copied`); 56 + 57 + // Delete old file 58 + await s3.send(new DeleteObjectCommand({ 59 + Bucket: bucket, 60 + Key: oldKey, 61 + })); 62 + console.log(` ✓ Deleted old file`); 63 + 64 + // Update database with new slug 65 + await db.collection("tapes").updateOne( 66 + { _id: tape._id }, 67 + { $set: { slug: fullSlug } } 68 + ); 69 + console.log(` ✓ Updated database\n`); 70 + 71 + } catch (error) { 72 + console.error(` ✗ Error: ${error.message}\n`); 73 + } 74 + } 75 + 76 + await disconnect(); 77 + console.log("Done!"); 78 + } 79 + 80 + main().catch(console.error);
+11
oven/scripts/scripts/check-slugs.mjs
··· 1 + import pkg from 'mongodb'; 2 + const {MongoClient} = pkg; 3 + const client = await MongoClient.connect(process.env.MONGODB_CONNECTION_STRING); 4 + const db = client.db('aesthetic'); 5 + const paintings = await db.collection('paintings').find().sort({_id: -1}).limit(5).toArray(); 6 + console.log('Recent paintings:'); 7 + paintings.forEach(p => console.log('Code:', p.code, 'Slug:', p.slug)); 8 + const tapes = await db.collection('tapes').find().sort({_id: -1}).limit(5).toArray(); 9 + console.log('\nRecent tapes:'); 10 + tapes.forEach(t => console.log('Code:', t.code, 'Slug:', t.slug)); 11 + await client.close();
+80
oven/scripts/scripts/rename-video-files.mjs
··· 1 + // Rename video files from short slug format to full timestamp format 2 + // Based on the "when" field in the tapes database collection 3 + 4 + import { S3Client, CopyObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; 5 + import { connect } from "../system/backend/database.mjs"; 6 + 7 + const s3 = new S3Client({ 8 + endpoint: "https://sfo3.digitaloceanspaces.com", 9 + region: "us-east-1", 10 + credentials: { 11 + accessKeyId: process.env.SPACES_KEY, 12 + secretAccessKey: process.env.SPACES_SECRET, 13 + }, 14 + }); 15 + 16 + const userId = "auth0|63effeeb2a7d55f8098d62f9"; 17 + const bucket = "user-aesthetic-computer"; 18 + 19 + // Map of short slugs to rename 20 + const shortSlugs = ["2025.254", "2025.353", "2025.373", "2025.384", "2025.447", "2025.740", "2025.770", "2025.855"]; 21 + 22 + async function main() { 23 + const { db, disconnect } = await connect(); 24 + 25 + console.log("Finding tapes with short slugs...\n"); 26 + 27 + const tapes = await db.collection("tapes").find({ 28 + slug: { $in: shortSlugs }, 29 + user: userId 30 + }).toArray(); 31 + 32 + for (const tape of tapes) { 33 + const when = new Date(tape.when); 34 + const fullSlug = when.toISOString() 35 + .replace(/[-:]/g, ".") 36 + .replace("T", ".") 37 + .replace("Z", "") 38 + .split(".").slice(0, 7).join("."); 39 + 40 + const tapeUserId = tape.user || userId; 41 + const oldKey = `${tapeUserId}/video/${tape.slug}.zip`; 42 + const newKey = `${tapeUserId}/video/${fullSlug}.zip`; 43 + 44 + console.log(`Tape ${tape.code} (${tape.slug}):`); 45 + console.log(` Old: ${oldKey}`); 46 + console.log(` New: ${newKey}`); 47 + 48 + try { 49 + // Copy to new location 50 + await s3.send(new CopyObjectCommand({ 51 + Bucket: bucket, 52 + CopySource: `${bucket}/${oldKey}`, 53 + Key: newKey, 54 + })); 55 + console.log(` ✓ Copied`); 56 + 57 + // Delete old file 58 + await s3.send(new DeleteObjectCommand({ 59 + Bucket: bucket, 60 + Key: oldKey, 61 + })); 62 + console.log(` ✓ Deleted old file`); 63 + 64 + // Update database with new slug 65 + await db.collection("tapes").updateOne( 66 + { _id: tape._id }, 67 + { $set: { slug: fullSlug } } 68 + ); 69 + console.log(` ✓ Updated database\n`); 70 + 71 + } catch (error) { 72 + console.error(` ✗ Error: ${error.message}\n`); 73 + } 74 + } 75 + 76 + await disconnect(); 77 + console.log("Done!"); 78 + } 79 + 80 + main().catch(console.error);
+98
oven/scripts/scripts/setup-cdn-dns.fish
··· 1 + #!/usr/bin/env fish 2 + # Setup Cloudflare CNAME for at-blobs.aesthetic.computer → DigitalOcean Space 3 + # Provides a cleaner CDN URL for tape videos and thumbnails 4 + 5 + echo "🌐 Setting up CDN domain for at-blobs Space..." 6 + echo "" 7 + 8 + # Load environment variables from vault (directly set them) 9 + set -gx CLOUDFLARE_ACCOUNT_ID "a23b54e8877a833a1cf8db7765bce3ca" 10 + set -gx CLOUDFLARE_EMAIL "me@jas.life" 11 + set -gx CLOUDFLARE_API_KEY "0346704765b61e560b36592010c98a23bc2c6" 12 + set -gx ZONE_ID "da794a6ae8f17b80424907f81ed0db7c" 13 + 14 + echo "✅ Loaded Cloudflare credentials" 15 + echo "" 16 + 17 + # Cloudflare configuration 18 + set SUBDOMAIN "at-blobs" 19 + set FULL_DOMAIN "at-blobs.aesthetic.computer" 20 + set TARGET "at-blobs-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com" 21 + 22 + echo " Zone: aesthetic.computer ($ZONE_ID)" 23 + echo " Creating: $FULL_DOMAIN → $TARGET" 24 + echo "" 25 + 26 + # Check if DNS record already exists 27 + echo " Checking for existing DNS record..." 28 + set CHECK_RESPONSE (curl -s -X GET \ 29 + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=CNAME&name=$FULL_DOMAIN" \ 30 + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ 31 + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ 32 + -H "Content-Type: application/json") 33 + 34 + set RECORD_ID (echo $CHECK_RESPONSE | jq -r '.result[0].id // empty') 35 + 36 + if test -n "$RECORD_ID" 37 + echo " Found existing record: $RECORD_ID" 38 + echo " Updating..." 39 + 40 + set UPDATE_RESPONSE (curl -s -X PUT \ 41 + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \ 42 + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ 43 + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ 44 + -H "Content-Type: application/json" \ 45 + --data "{ 46 + \"type\": \"CNAME\", 47 + \"name\": \"$SUBDOMAIN\", 48 + \"content\": \"$TARGET\", 49 + \"ttl\": 1, 50 + \"proxied\": true 51 + }") 52 + 53 + if echo $UPDATE_RESPONSE | jq -e '.success' > /dev/null 54 + echo " ✅ DNS record updated!" 55 + else 56 + echo " ❌ Failed to update DNS record" 57 + echo $UPDATE_RESPONSE | jq 58 + exit 1 59 + end 60 + else 61 + echo " No existing record found. Creating new..." 62 + 63 + set CREATE_RESPONSE (curl -s -X POST \ 64 + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \ 65 + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ 66 + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ 67 + -H "Content-Type: application/json" \ 68 + --data "{ 69 + \"type\": \"CNAME\", 70 + \"name\": \"$SUBDOMAIN\", 71 + \"content\": \"$TARGET\", 72 + \"ttl\": 1, 73 + \"proxied\": true 74 + }") 75 + 76 + if echo $CREATE_RESPONSE | jq -e '.success' > /dev/null 77 + echo " ✅ DNS record created!" 78 + else 79 + echo " ❌ Failed to create DNS record" 80 + echo $CREATE_RESPONSE | jq 81 + exit 1 82 + end 83 + end 84 + 85 + echo "" 86 + echo "✅ CDN domain configured successfully!" 87 + echo "" 88 + echo "📋 Summary:" 89 + echo " Domain: https://$FULL_DOMAIN" 90 + echo " Target: $TARGET" 91 + echo " Proxied: Yes (Cloudflare CDN)" 92 + echo " SSL: Automatic (Cloudflare Universal SSL)" 93 + echo "" 94 + echo "🔗 Manage DNS:" 95 + echo " https://dash.cloudflare.com/$ZONE_ID/aesthetic.computer/dns/records" 96 + echo "" 97 + echo "⏱️ Note: DNS propagation may take a few minutes" 98 + echo " Test with: curl -I https://$FULL_DOMAIN/tapes/"
+98
oven/scripts/setup-cdn-dns.fish
··· 1 + #!/usr/bin/env fish 2 + # Setup Cloudflare CNAME for at-blobs.aesthetic.computer → DigitalOcean Space 3 + # Provides a cleaner CDN URL for tape videos and thumbnails 4 + 5 + echo "🌐 Setting up CDN domain for at-blobs Space..." 6 + echo "" 7 + 8 + # Load environment variables from vault (directly set them) 9 + set -gx CLOUDFLARE_ACCOUNT_ID "a23b54e8877a833a1cf8db7765bce3ca" 10 + set -gx CLOUDFLARE_EMAIL "me@jas.life" 11 + set -gx CLOUDFLARE_API_KEY "0346704765b61e560b36592010c98a23bc2c6" 12 + set -gx ZONE_ID "da794a6ae8f17b80424907f81ed0db7c" 13 + 14 + echo "✅ Loaded Cloudflare credentials" 15 + echo "" 16 + 17 + # Cloudflare configuration 18 + set SUBDOMAIN "at-blobs" 19 + set FULL_DOMAIN "at-blobs.aesthetic.computer" 20 + set TARGET "at-blobs-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com" 21 + 22 + echo " Zone: aesthetic.computer ($ZONE_ID)" 23 + echo " Creating: $FULL_DOMAIN → $TARGET" 24 + echo "" 25 + 26 + # Check if DNS record already exists 27 + echo " Checking for existing DNS record..." 28 + set CHECK_RESPONSE (curl -s -X GET \ 29 + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=CNAME&name=$FULL_DOMAIN" \ 30 + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ 31 + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ 32 + -H "Content-Type: application/json") 33 + 34 + set RECORD_ID (echo $CHECK_RESPONSE | jq -r '.result[0].id // empty') 35 + 36 + if test -n "$RECORD_ID" 37 + echo " Found existing record: $RECORD_ID" 38 + echo " Updating..." 39 + 40 + set UPDATE_RESPONSE (curl -s -X PUT \ 41 + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \ 42 + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ 43 + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ 44 + -H "Content-Type: application/json" \ 45 + --data "{ 46 + \"type\": \"CNAME\", 47 + \"name\": \"$SUBDOMAIN\", 48 + \"content\": \"$TARGET\", 49 + \"ttl\": 1, 50 + \"proxied\": true 51 + }") 52 + 53 + if echo $UPDATE_RESPONSE | jq -e '.success' > /dev/null 54 + echo " ✅ DNS record updated!" 55 + else 56 + echo " ❌ Failed to update DNS record" 57 + echo $UPDATE_RESPONSE | jq 58 + exit 1 59 + end 60 + else 61 + echo " No existing record found. Creating new..." 62 + 63 + set CREATE_RESPONSE (curl -s -X POST \ 64 + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \ 65 + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ 66 + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ 67 + -H "Content-Type: application/json" \ 68 + --data "{ 69 + \"type\": \"CNAME\", 70 + \"name\": \"$SUBDOMAIN\", 71 + \"content\": \"$TARGET\", 72 + \"ttl\": 1, 73 + \"proxied\": true 74 + }") 75 + 76 + if echo $CREATE_RESPONSE | jq -e '.success' > /dev/null 77 + echo " ✅ DNS record created!" 78 + else 79 + echo " ❌ Failed to create DNS record" 80 + echo $CREATE_RESPONSE | jq 81 + exit 1 82 + end 83 + end 84 + 85 + echo "" 86 + echo "✅ CDN domain configured successfully!" 87 + echo "" 88 + echo "📋 Summary:" 89 + echo " Domain: https://$FULL_DOMAIN" 90 + echo " Target: $TARGET" 91 + echo " Proxied: Yes (Cloudflare CDN)" 92 + echo " SSL: Automatic (Cloudflare Universal SSL)" 93 + echo "" 94 + echo "🔗 Manage DNS:" 95 + echo " https://dash.cloudflare.com/$ZONE_ID/aesthetic.computer/dns/records" 96 + echo "" 97 + echo "⏱️ Note: DNS propagation may take a few minutes" 98 + echo " Test with: curl -I https://$FULL_DOMAIN/tapes/"
+35
oven/sync-source.sh
··· 1 + #!/bin/bash 2 + # Sync ac-source files to oven and prewarm the bundle cache. 3 + # Called automatically after git push (via .git/hooks/post-push) 4 + # or manually: ./oven/sync-source.sh 5 + 6 + set -e 7 + 8 + OVEN_HOST="137.184.237.166" 9 + SSH_KEY="${SSH_KEY:-$(dirname "$0")/../aesthetic-computer-vault/oven/ssh/oven-deploy-key}" 10 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 11 + AC_SOURCE="$SCRIPT_DIR/../system/public/aesthetic.computer" 12 + 13 + # Only sync if SSH key exists (skip in CI or environments without vault) 14 + if [ ! -f "$SSH_KEY" ]; then 15 + echo "⏭️ Skipping oven sync (no SSH key at $SSH_KEY)" 16 + exit 0 17 + fi 18 + 19 + echo "📦 Syncing ac-source to oven..." 20 + rsync -az --delete \ 21 + --include='*/' \ 22 + --include='*.mjs' \ 23 + --include='*.js' \ 24 + --include='*.json' \ 25 + --include='*.lisp' \ 26 + --exclude='*' \ 27 + -e "ssh -i $SSH_KEY -o StrictHostKeyChecking=no" \ 28 + "$AC_SOURCE/" \ 29 + "root@$OVEN_HOST:/opt/oven/ac-source/" 30 + 31 + echo "🔥 Prewarming bundle cache..." 32 + ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "root@$OVEN_HOST" \ 33 + "curl -s -X POST http://localhost:3002/bundle-prewarm --max-time 120" 2>/dev/null || true 34 + 35 + echo "✅ Oven bundle cache updated."
+261
oven/test-oven.mjs
··· 1 + #!/usr/bin/env node 2 + // Oven CLI Test Script 3 + // Tests all oven endpoints with optional sixel image output 4 + 5 + import { execSync, spawn } from 'child_process'; 6 + import { writeFileSync, unlinkSync } from 'fs'; 7 + import { tmpdir } from 'os'; 8 + import { join } from 'path'; 9 + 10 + const OVEN_URL = process.env.OVEN_URL || 'https://oven.aesthetic.computer'; 11 + const SIXEL_ENABLED = process.env.SIXEL !== '0' && process.stdout.isTTY; 12 + 13 + // ANSI colors 14 + const c = { 15 + reset: '\x1b[0m', 16 + green: '\x1b[32m', 17 + red: '\x1b[31m', 18 + yellow: '\x1b[33m', 19 + cyan: '\x1b[36m', 20 + dim: '\x1b[2m', 21 + }; 22 + 23 + function log(msg, color = '') { 24 + console.log(`${color}${msg}${c.reset}`); 25 + } 26 + 27 + function success(msg) { log(`✅ ${msg}`, c.green); } 28 + function error(msg) { log(`❌ ${msg}`, c.red); } 29 + function info(msg) { log(`ℹ️ ${msg}`, c.cyan); } 30 + function warn(msg) { log(`⚠️ ${msg}`, c.yellow); } 31 + 32 + // Display image in terminal using sixel (requires img2sixel or chafa) 33 + async function displaySixel(buffer, label = '') { 34 + if (!SIXEL_ENABLED) return; 35 + 36 + const tmpFile = join(tmpdir(), `oven-test-${Date.now()}.png`); 37 + try { 38 + writeFileSync(tmpFile, buffer); 39 + 40 + // Try img2sixel first, fall back to chafa 41 + try { 42 + execSync(`img2sixel -w 200 "${tmpFile}"`, { stdio: 'inherit' }); 43 + } catch { 44 + try { 45 + execSync(`chafa -s 30x15 "${tmpFile}"`, { stdio: 'inherit' }); 46 + } catch { 47 + // No sixel support available 48 + } 49 + } 50 + } finally { 51 + try { unlinkSync(tmpFile); } catch {} 52 + } 53 + } 54 + 55 + // Fetch with timeout 56 + async function fetchWithTimeout(url, options = {}, timeout = 30000) { 57 + const controller = new AbortController(); 58 + const id = setTimeout(() => controller.abort(), timeout); 59 + 60 + try { 61 + const response = await fetch(url, { ...options, signal: controller.signal }); 62 + clearTimeout(id); 63 + return response; 64 + } catch (e) { 65 + clearTimeout(id); 66 + throw e; 67 + } 68 + } 69 + 70 + // Test functions 71 + async function testHealth() { 72 + info('Testing /health endpoint...'); 73 + const start = Date.now(); 74 + const res = await fetchWithTimeout(`${OVEN_URL}/health`); 75 + const data = await res.json(); 76 + const duration = Date.now() - start; 77 + 78 + if (res.ok && data.status === 'ok') { 79 + success(`Health check passed (${duration}ms)`); 80 + return true; 81 + } else { 82 + error(`Health check failed: ${JSON.stringify(data)}`); 83 + return false; 84 + } 85 + } 86 + 87 + async function testIcon(piece = 'prompt') { 88 + info(`Testing /icon/128x128/${piece}.png ...`); 89 + const start = Date.now(); 90 + const res = await fetchWithTimeout(`${OVEN_URL}/icon/128x128/${piece}.png`, {}, 60000); 91 + const duration = Date.now() - start; 92 + 93 + if (res.ok) { 94 + const buffer = Buffer.from(await res.arrayBuffer()); 95 + const bakeId = res.headers.get('X-Bake-Id'); 96 + success(`Icon generated: ${buffer.length} bytes, ${duration}ms ${bakeId ? `(${bakeId})` : ''}`); 97 + await displaySixel(buffer, `${piece} icon`); 98 + return true; 99 + } else { 100 + const text = await res.text(); 101 + error(`Icon failed: ${res.status} - ${text}`); 102 + return false; 103 + } 104 + } 105 + 106 + async function testPreview(piece = 'prompt', size = '1200x630') { 107 + info(`Testing /preview/${size}/${piece}.png ...`); 108 + const start = Date.now(); 109 + const res = await fetchWithTimeout(`${OVEN_URL}/preview/${size}/${piece}.png`, {}, 60000); 110 + const duration = Date.now() - start; 111 + 112 + if (res.ok) { 113 + const buffer = Buffer.from(await res.arrayBuffer()); 114 + const bakeId = res.headers.get('X-Bake-Id'); 115 + success(`Preview generated: ${buffer.length} bytes, ${duration}ms ${bakeId ? `(${bakeId})` : ''}`); 116 + await displaySixel(buffer, `${piece} preview`); 117 + return true; 118 + } else { 119 + const text = await res.text(); 120 + error(`Preview failed: ${res.status} - ${text}`); 121 + return false; 122 + } 123 + } 124 + 125 + async function testGrab(piece = 'prompt', format = 'webp') { 126 + info(`Testing /grab/${format}/512/512/${piece} ...`); 127 + const start = Date.now(); 128 + const res = await fetchWithTimeout(`${OVEN_URL}/grab/${format}/512/512/${piece}?duration=3000`, {}, 60000); 129 + const duration = Date.now() - start; 130 + 131 + if (res.ok) { 132 + const buffer = Buffer.from(await res.arrayBuffer()); 133 + const grabId = res.headers.get('X-Grab-Id'); 134 + success(`Grab generated: ${buffer.length} bytes, ${duration}ms ${grabId ? `(${grabId})` : ''}`); 135 + if (format === 'png') { 136 + await displaySixel(buffer, `${piece} grab`); 137 + } 138 + return true; 139 + } else { 140 + const text = await res.text(); 141 + error(`Grab failed: ${res.status} - ${text}`); 142 + return false; 143 + } 144 + } 145 + 146 + async function testGrabStatus() { 147 + info('Testing /grab-status endpoint...'); 148 + const res = await fetchWithTimeout(`${OVEN_URL}/grab-status`); 149 + const data = await res.json(); 150 + 151 + if (res.ok) { 152 + success(`Grab status: ${data.active?.length || 0} active, ${data.recent?.length || 0} recent`); 153 + return true; 154 + } else { 155 + error(`Grab status failed: ${JSON.stringify(data)}`); 156 + return false; 157 + } 158 + } 159 + 160 + async function testKeepsLatest() { 161 + info('Testing /keeps/latest endpoint...'); 162 + const res = await fetchWithTimeout(`${OVEN_URL}/keeps/latest`, { redirect: 'manual' }); 163 + 164 + if (res.status === 302) { 165 + const location = res.headers.get('location'); 166 + success(`Keeps latest redirects to: ${location}`); 167 + return true; 168 + } else if (res.status === 404) { 169 + warn('No keeps captured yet (expected if no mints with --thumbnail)'); 170 + return true; 171 + } else { 172 + error(`Keeps latest unexpected status: ${res.status}`); 173 + return false; 174 + } 175 + } 176 + 177 + // Run all tests 178 + async function runTests() { 179 + console.log('\n' + '='.repeat(60)); 180 + log(`🔥 OVEN TEST SUITE`, c.yellow); 181 + log(` Target: ${OVEN_URL}`, c.dim); 182 + log(` Sixel: ${SIXEL_ENABLED ? 'enabled' : 'disabled'}`, c.dim); 183 + console.log('='.repeat(60) + '\n'); 184 + 185 + const results = []; 186 + 187 + // Core endpoints 188 + results.push(['health', await testHealth()]); 189 + results.push(['grab-status', await testGrabStatus()]); 190 + results.push(['keeps-latest', await testKeepsLatest()]); 191 + 192 + // Image generation (slower) 193 + const testPiece = process.argv[2] || 'prompt'; 194 + results.push(['icon', await testIcon(testPiece)]); 195 + results.push(['preview', await testPreview(testPiece)]); 196 + results.push(['grab-png', await testGrab(testPiece, 'png')]); 197 + // results.push(['grab-webp', await testGrab(testPiece, 'webp')]); // Skip animated for speed 198 + 199 + // Summary 200 + console.log('\n' + '='.repeat(60)); 201 + log('📊 RESULTS', c.yellow); 202 + console.log('='.repeat(60)); 203 + 204 + let passed = 0, failed = 0; 205 + for (const [name, result] of results) { 206 + if (result) { 207 + log(` ✅ ${name}`, c.green); 208 + passed++; 209 + } else { 210 + log(` ❌ ${name}`, c.red); 211 + failed++; 212 + } 213 + } 214 + 215 + console.log('='.repeat(60)); 216 + log(`Total: ${passed}/${results.length} passed`, passed === results.length ? c.green : c.red); 217 + console.log('='.repeat(60) + '\n'); 218 + 219 + process.exit(failed > 0 ? 1 : 0); 220 + } 221 + 222 + // CLI 223 + const args = process.argv.slice(2); 224 + 225 + if (args.includes('--help') || args.includes('-h')) { 226 + console.log(` 227 + 🔥 Oven Test Script 228 + 229 + Usage: 230 + node test-oven.mjs [piece] [options] 231 + 232 + Examples: 233 + node test-oven.mjs # Test with 'prompt' piece 234 + node test-oven.mjs roz # Test with '$roz' piece 235 + node test-oven.mjs meme --local # Test local server 236 + 237 + Options: 238 + --local Test against localhost:3002 239 + --prod Test against oven.aesthetic.computer (default) 240 + --no-sixel Disable sixel image output 241 + -h, --help Show this help 242 + 243 + Environment: 244 + OVEN_URL Override the oven URL 245 + SIXEL=0 Disable sixel output 246 + `); 247 + process.exit(0); 248 + } 249 + 250 + if (args.includes('--local')) { 251 + process.env.OVEN_URL = 'http://localhost:3002'; 252 + } 253 + 254 + if (args.includes('--no-sixel')) { 255 + process.env.SIXEL = '0'; 256 + } 257 + 258 + runTests().catch(e => { 259 + error(`Test suite failed: ${e.message}`); 260 + process.exit(1); 261 + });
+48
oven/test-query-tapes.mjs
··· 1 + #!/usr/bin/env node 2 + // Quick script to query recent tapes from MongoDB for testing 3 + 4 + import 'dotenv/config'; 5 + import { MongoClient } from 'mongodb'; 6 + 7 + const mongoUri = process.env.MONGODB_CONNECTION_STRING; 8 + const dbName = process.env.MONGODB_NAME; 9 + 10 + if (!mongoUri || !dbName) { 11 + console.error('Missing MONGODB_CONNECTION_STRING or MONGODB_NAME in environment'); 12 + process.exit(1); 13 + } 14 + 15 + async function queryRecentTapes() { 16 + const client = await MongoClient.connect(mongoUri); 17 + const db = client.db(dbName); 18 + const tapes = db.collection('tapes'); 19 + 20 + // Get 5 most recent tapes 21 + const recent = await tapes 22 + .find({}) 23 + .sort({ when: -1 }) 24 + .limit(5) 25 + .toArray(); 26 + 27 + console.log(`\n📼 Found ${recent.length} recent tapes:\n`); 28 + 29 + for (const tape of recent) { 30 + const user = tape.user || 'guest'; 31 + const bucket = user === 'guest' ? 'art-aesthetic-computer' : 'user-aesthetic-computer'; 32 + const key = user === 'guest' ? `${tape.slug}.zip` : `${user}/video/${tape.slug}.zip`; 33 + const zipUrl = `https://${bucket}.sfo3.digitaloceanspaces.com/${key}`; 34 + 35 + console.log(`Slug: ${tape.slug}`); 36 + console.log(`MongoDB ID: ${tape._id}`); 37 + console.log(`User: ${user}`); 38 + console.log(`Created: ${tape.when}`); 39 + console.log(`ZIP URL: ${zipUrl}`); 40 + console.log(`Has MP4: ${tape.mp4Status || 'unknown'}`); 41 + console.log(`Code: ${tape.code || 'none'}`); 42 + console.log('---'); 43 + } 44 + 45 + await client.close(); 46 + } 47 + 48 + queryRecentTapes().catch(console.error);