Monorepo for Aesthetic.Computer
aesthetic.computer
1// Grabber - KidLisp piece screenshot/GIF capture using Puppeteer
2// Captures frames from running KidLisp pieces for thumbnails
3
4import { spawn, execSync } from 'child_process';
5import { promises as fs } from 'fs';
6import { tmpdir } from 'os';
7import { join } from 'path';
8import { randomBytes, createHash } from 'crypto';
9import puppeteer from 'puppeteer';
10import { MongoClient } from 'mongodb';
11import { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
12import sharp from 'sharp';
13import { readFileSync } from 'fs';
14import { dirname } from 'path';
15import { fileURLToPath } from 'url';
16
17// Load Comic Relief font for OG image generation
18const __dirname = dirname(fileURLToPath(import.meta.url));
19let comicReliefBoldBase64 = '';
20try {
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)
29let GIT_VERSION = process.env.OVEN_VERSION || 'unknown';
30if (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
39const 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});
47const SPACES_BUCKET = process.env.ART_SPACES_BUCKET || 'art-aesthetic-computer';
48const SPACES_CDN_BASE = process.env.ART_CDN_BASE || 'https://art.aesthetic.computer';
49const 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.
53const CACHE_RENDER_VERSION = 'v5';
54
55// MongoDB connection
56let mongoClient;
57let db;
58
59async 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
82const PINATA_API_URL = 'https://api.pinata.cloud';
83
84// IPFS gateway for serving content
85const 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
90export 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
105let browser = null;
106let browserLaunchPromise = null;
107
108// Grab queue with concurrent worker pool
109const grabQueue = [];
110let grabsRunning = 0;
111const MAX_CONCURRENT_GRABS = 8;
112const RESERVED_KEEP_SLOTS = 1; // Reserve 1 slot for keep (NFT mint) grabs
113
114// Per-grab progress tracking (keyed by grabId)
115const grabProgressMap = new Map();
116
117function 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
127let progressNotifyCallback = null;
128
129/**
130 * Set a callback to be notified when progress/status updates occur
131 */
132export function setNotifyCallback(callback) {
133 progressNotifyCallback = callback;
134}
135
136/**
137 * Notify all subscribers of status change
138 */
139function notifySubscribers() {
140 if (progressNotifyCallback) {
141 progressNotifyCallback();
142 }
143}
144
145/**
146 * Update progress state for a specific grab
147 */
148export 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 */
165async 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 */
190async 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 */
203export 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 */
213export 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 */
224export function getConcurrencyStatus() {
225 return { active: grabsRunning, max: MAX_CONCURRENT_GRABS, queueDepth: grabQueue.length };
226}
227
228/**
229 * Clear progress for a completed grab
230 */
231function clearProgress(grabId) {
232 grabProgressMap.delete(grabId);
233}
234
235/**
236 * Get queue status with estimated wait times
237 */
238export 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
249const recentDurations = [];
250const MAX_DURATION_SAMPLES = 10;
251const DEFAULT_GRAB_DURATION_MS = 30000; // 30 seconds default estimate
252
253/**
254 * Record a grab duration for ETA estimation
255 */
256export 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 */
266export 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
274async 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
311async 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)
406const activeGrabs = new Map();
407const recentGrabs = [];
408
409// Track in-progress grabs by captureKey for deduplication
410const 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 */
417export 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 */
442export 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)
509const frozenPieces = new Map(); // piece -> { piece, attempts, lastAttempt, firstDetected, error }
510
511// Track most recent IPFS uploads per piece (for live collection thumbnail)
512const latestIPFSUploads = new Map(); // piece -> { ipfsCid, ipfsUri, timestamp, ... }
513let latestKeepThumbnail = null; // Most recent across all pieces
514const KEEPS_SECRET_ID = process.env.KEEPS_SECRET_ID || 'tezos-kidlisp';
515const KEEP_THUMBNAIL_FALLBACK_TTL_MS = 30 * 1000;
516let latestKeepFallbackCheckedAt = 0;
517
518function 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
534async 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
620export 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
634const 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 */
640export 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 */
675export 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 */
710function 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 */
739async 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 */
761async 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 */
789export 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 */
797export 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
814setInterval(() => {
815 if (activeGrabs.size > 0) {
816 cleanupStaleGrabs();
817 }
818}, 2 * 60 * 1000);
819
820/**
821 * Load recent grabs from MongoDB on startup
822 */
823async 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 */
879async 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 */
898async 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 */
919async 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 */
955async 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 */
1047async 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
1109loadRecentGrabs();
1110loadFrozenPieces();
1111
1112function 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 */
1126function 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 */
1138async 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 */
1170async 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 */
1196async 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 */
1251export 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)
1285let logCallback = null;
1286export function setLogCallback(cb) {
1287 logCallback = cb;
1288}
1289function serverLog(type, icon, msg) {
1290 if (logCallback) logCallback(type, icon, msg);
1291}
1292
1293/**
1294 * Get or launch the shared browser instance
1295 */
1296async 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 */
1371export 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)
1417const 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
1426const 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
1432const LOCAL_FONT_DRAWINGS = join(__dirname, 'ac-source', 'disks', 'drawings');
1433const 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 */
1439function 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 */
1456function 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 */
1489let _cachedFontGlyphs = null; // { char: glyphData, ... } — loaded once per process
1490
1491function 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).
1534async 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).
1543async 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 */
1601async 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 */
1666async 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 */
2070async 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 */
2081async 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 */
2151async 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 */
2177async 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
2259const 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.).
2273const MIN_GRAB_DIMENSION = 32;
2274const MAX_GRAB_DIMENSION = 1000;
2275
2276/**
2277 * Check if a piece name is blacklisted (external domain or invalid)
2278 */
2279function 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 */
2287export 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 — when caller asked for fresh content
2410 // (skipCache:true), retry up to twice on uniform-color duds.
2411 // Pinata content-addresses, so a 528-byte black still always
2412 // dedups to the same CID; silently returning that downstream
2413 // looks like a successful bake but freezes the piece's
2414 // thumbnail forever. Surface the dud instead.
2415 const MAX_DUD_RETRIES = skipCache ? 2 : 0;
2416 let capturedFrames;
2417 let isFrozen = false;
2418 let uniformCheck = { isUniform: false };
2419
2420 for (let attempt = 0; attempt <= MAX_DUD_RETRIES; attempt++) {
2421 capturedFrames = await captureFrames(piece, {
2422 width, height, duration, fps, density, viewportScale, baseUrl, grabId
2423 });
2424
2425 if (capturedFrames.length === 0) {
2426 throw new Error('No frames captured');
2427 }
2428
2429 isFrozen = await areFramesIdentical(capturedFrames);
2430 // Always run the uniform-color check — an all-black capture
2431 // is both "frozen" (frames identical) and "uniform-color",
2432 // and we need to catch it as a dud rather than treating it
2433 // as legitimate frozen art.
2434 uniformCheck = await isUniformColorContent(capturedFrames);
2435
2436 if (!uniformCheck.isUniform) break;
2437 if (attempt < MAX_DUD_RETRIES) {
2438 console.log(` 🔁 Capture ${attempt + 1}/${MAX_DUD_RETRIES + 1} returned uniform color (${uniformCheck.reason}); retrying...`);
2439 await new Promise(r => setTimeout(r, 2000));
2440 }
2441 }
2442
2443 if (uniformCheck.isUniform && skipCache) {
2444 throw new Error(`Capture produced uniform-color frames after ${MAX_DUD_RETRIES + 1} attempt(s) (${uniformCheck.reason}); piece may not be rendering`);
2445 }
2446
2447 if (isFrozen) {
2448 // Return the still frame in the requested format (webp/gif/png)
2449 const outW = width * density;
2450 const outH = height * density;
2451 console.log(` 🥶 Frozen animation — returning still ${format.toUpperCase()}`);
2452
2453 const sharpMod = (await import('sharp')).default;
2454 let pipeline = sharpMod(capturedFrames[0])
2455 .resize(outW, outH, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 }, kernel: 'nearest' });
2456
2457 if (format === 'webp') {
2458 pipeline = pipeline.webp({ quality });
2459 } else if (format === 'gif') {
2460 pipeline = pipeline.gif();
2461 } else {
2462 pipeline = pipeline.png();
2463 }
2464 result = await pipeline.toBuffer();
2465
2466 // Also upload frozen preview to Spaces for the frozen panel
2467 let frozenPreviewUrl = null;
2468 if (process.env.ART_SPACES_KEY && result) {
2469 try {
2470 const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png';
2471 const mime = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png';
2472 const frozenKey = `oven/frozen/${piece.replace(/^\$/, '')}-frozen.${ext}`;
2473 await spacesClient.send(new PutObjectCommand({
2474 Bucket: SPACES_BUCKET,
2475 Key: frozenKey,
2476 Body: result,
2477 ContentType: mime,
2478 ACL: 'public-read',
2479 CacheControl: 'public, max-age=604800',
2480 }));
2481 frozenPreviewUrl = `${SPACES_CDN_BASE}/${frozenKey}`;
2482 } catch (previewErr) {
2483 console.error('⚠️ Failed to upload frozen preview:', previewErr.message);
2484 }
2485 }
2486
2487 recordFrozenPiece(piece, 'Frozen — still frame returned', frozenPreviewUrl);
2488 // Skip encoding — result is already in the right format
2489 } else {
2490 // uniformCheck already computed above (in the dud-retry loop).
2491 // For !skipCache callers, fall back to a still — they explicitly
2492 // accepted cached/dedup behavior. skipCache callers were thrown
2493 // earlier so they never reach this branch.
2494 if (uniformCheck.isUniform) {
2495 // Return still frame in requested format (same as frozen)
2496 const outW = width * density;
2497 const outH = height * density;
2498 console.log(` 🎨 Dud animation (${uniformCheck.reason}) — returning still ${format.toUpperCase()}`);
2499
2500 const sharpMod = (await import('sharp')).default;
2501 let pipeline = sharpMod(capturedFrames[0])
2502 .resize(outW, outH, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 }, kernel: 'nearest' });
2503
2504 if (format === 'webp') {
2505 pipeline = pipeline.webp({ quality });
2506 } else if (format === 'gif') {
2507 pipeline = pipeline.gif();
2508 } else {
2509 pipeline = pipeline.png();
2510 }
2511 result = await pipeline.toBuffer();
2512
2513 // Upload dud preview to Spaces
2514 let dudPreviewUrl = null;
2515 if (process.env.ART_SPACES_KEY && result) {
2516 try {
2517 const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png';
2518 const mime = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png';
2519 const dudKey = `oven/frozen/${piece.replace(/^\$/, '')}-dud.${ext}`;
2520 await spacesClient.send(new PutObjectCommand({
2521 Bucket: SPACES_BUCKET,
2522 Key: dudKey,
2523 Body: result,
2524 ContentType: mime,
2525 ACL: 'public-read',
2526 CacheControl: 'public, max-age=604800',
2527 }));
2528 dudPreviewUrl = `${SPACES_CDN_BASE}/${dudKey}`;
2529 } catch (previewErr) {
2530 console.error('⚠️ Failed to upload dud preview:', previewErr.message);
2531 }
2532 }
2533
2534 recordFrozenPiece(piece, `Dud — ${uniformCheck.reason}, still frame returned`, dudPreviewUrl);
2535 } else {
2536 activeGrabs.get(grabId).status = 'encoding';
2537
2538 // Update progress: encoding stage
2539 updateProgress(grabId, {
2540 stage: 'encoding',
2541 stageDetail: `Encoding ${format.toUpperCase()}...`,
2542 percent: 85,
2543 });
2544
2545 if (format === 'webp') {
2546 result = await framesToWebp(capturedFrames, {
2547 playbackFps,
2548 width: width * density,
2549 height: height * density,
2550 quality
2551 });
2552 } else {
2553 result = await framesToGif(capturedFrames, {
2554 fps: playbackFps,
2555 width: width * density,
2556 height: height * density
2557 });
2558 }
2559 }
2560 }
2561 }
2562
2563 // Update status
2564 const grab = activeGrabs.get(grabId);
2565 grab.status = 'complete';
2566 grab.completedAt = Date.now();
2567 grab.duration = grab.completedAt - grab.startTime;
2568 grab.size = result.length;
2569
2570 // Upload to Spaces for dashboard preview
2571 if (process.env.ART_SPACES_KEY) {
2572 try {
2573 // Update progress: uploading stage
2574 updateProgress(grabId, {
2575 stage: 'uploading',
2576 stageDetail: `Uploading to CDN...`,
2577 percent: 95,
2578 });
2579
2580 const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png';
2581 const contentType = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png';
2582 const spacesKey = `oven/grabs/${grabId}.${ext}`;
2583
2584 await spacesClient.send(new PutObjectCommand({
2585 Bucket: SPACES_BUCKET,
2586 Key: spacesKey,
2587 Body: result,
2588 ContentType: contentType,
2589 ACL: 'public-read',
2590 CacheControl: 'public, max-age=604800', // 7 day cache
2591 }));
2592
2593 grab.cdnUrl = `${SPACES_CDN_BASE}/${spacesKey}`;
2594 console.log(`📦 Stored grab in Spaces: ${grab.cdnUrl}`);
2595 } catch (e) {
2596 console.error('❌ Failed to store grab in Spaces:', e.message);
2597 }
2598 }
2599
2600 // Move to recent and save to MongoDB
2601 activeGrabs.delete(grabId);
2602 recentGrabs.unshift(grab);
2603 if (recentGrabs.length > 20) recentGrabs.pop();
2604 saveGrab(grab); // Persist to MongoDB
2605
2606 // Clear progress state (grab complete)
2607 clearProgress(grabId);
2608
2609 // Record duration for ETA estimation
2610 recordGrabDuration(grab.duration);
2611
2612 // Notify WebSocket subscribers
2613 notifySubscribers();
2614
2615 console.log(`✅ Grab complete: ${grabId} (${(result.length / 1024).toFixed(2)} KB)`);
2616 serverLog('success', '✅', `Grab complete: ${piece} (${(result.length / 1024).toFixed(1)} KB)`);
2617
2618 return {
2619 success: true,
2620 grabId,
2621 piece: pieceName,
2622 format,
2623 buffer: result,
2624 size: result.length,
2625 duration: grab.duration,
2626 cdnUrl: grab.cdnUrl,
2627 captureKey,
2628 cached: false,
2629 };
2630
2631 } catch (error) {
2632 console.error(`❌ Grab failed: ${grabId}`, error.message);
2633 serverLog('error', '❌', `Grab failed: ${piece} - ${error.message}`);
2634
2635 // Clear progress state on error
2636 clearProgress(grabId);
2637
2638 const grab = activeGrabs.get(grabId);
2639 if (grab) {
2640 grab.status = 'failed';
2641 grab.error = error.message;
2642 grab.completedAt = Date.now();
2643
2644 activeGrabs.delete(grabId);
2645 recentGrabs.unshift(grab);
2646 if (recentGrabs.length > 20) recentGrabs.pop();
2647 saveGrab(grab); // Persist to MongoDB
2648
2649 // Notify WebSocket subscribers
2650 notifySubscribers();
2651 }
2652
2653 return {
2654 success: false,
2655 grabId,
2656 piece: pieceName,
2657 format,
2658 error: error.message,
2659 };
2660 }
2661 }, { piece, format, captureKey, source }); // End of enqueueGrab, pass metadata for queue visibility + dedup
2662}
2663
2664/**
2665 * Upload a buffer to IPFS via Pinata
2666 * @param {Buffer} buffer - The file buffer to upload
2667 * @param {string} filename - Filename for the upload
2668 * @param {Object} credentials - { pinataKey, pinataSecret }
2669 * @returns {Promise<string>} IPFS URI (ipfs://...)
2670 */
2671export async function uploadToIPFS(buffer, filename, credentials) {
2672 if (!credentials?.pinataKey || !credentials?.pinataSecret) {
2673 throw new Error('Pinata credentials required (pinataKey, pinataSecret)');
2674 }
2675
2676 const formData = new FormData();
2677 const blob = new Blob([buffer]);
2678 formData.append('file', blob, filename);
2679
2680 // Add content hash to metadata for deduplication
2681 const contentHash = createHash('sha256').update(buffer).digest('hex').slice(0, 16);
2682 formData.append('pinataMetadata', JSON.stringify({
2683 name: `${filename}-${contentHash}`
2684 }));
2685
2686 const response = await fetch(`${PINATA_API_URL}/pinning/pinFileToIPFS`, {
2687 method: 'POST',
2688 headers: {
2689 'pinata_api_key': credentials.pinataKey,
2690 'pinata_secret_api_key': credentials.pinataSecret
2691 },
2692 body: formData,
2693 signal: AbortSignal.timeout(90_000), // 90s timeout for IPFS pinning
2694 });
2695
2696 if (!response.ok) {
2697 const error = await response.text();
2698 throw new Error(`Pinata upload failed: ${error}`);
2699 }
2700
2701 const result = await response.json();
2702 return `ipfs://${result.IpfsHash}`;
2703}
2704
2705/**
2706 * Grab a piece and upload the thumbnail to IPFS
2707 * @param {string} piece - Piece name (with or without $)
2708 * @param {Object} credentials - Pinata credentials { pinataKey, pinataSecret }
2709 * @param {Object} options - Grab options
2710 * @returns {Promise<Object>} { success, ipfsUri, grabResult, ... }
2711 */
2712export async function grabAndUploadToIPFS(piece, credentials, options = {}) {
2713 const pieceName = piece.replace(/^\$/, '');
2714 const format = options.format || 'webp';
2715 const source = options.source || 'manual';
2716 const keepId = options.keepId || null;
2717
2718 console.log(`\n📸 Grabbing and uploading $${pieceName} to IPFS...`);
2719 if (source === 'keep' && keepId) {
2720 console.log(` Source: Keep #${keepId}`);
2721 }
2722 if (options.skipCache) {
2723 console.log(` skipCache: true (forcing fresh grab)`);
2724 }
2725
2726 // Grab the piece
2727 const grabResult = await grabPiece(piece, {
2728 format,
2729 width: options.width || 512,
2730 height: options.height || 512,
2731 duration: options.duration || 12000,
2732 fps: options.fps || 7.5,
2733 playbackFps: options.playbackFps || 15,
2734 density: options.density || 1,
2735 quality: options.quality || 90,
2736 skipCache: options.skipCache || false,
2737 baseUrl: options.baseUrl || 'https://aesthetic.computer',
2738 source,
2739 keepId,
2740 author: options.author || null,
2741 pieceCreatedAt: options.pieceCreatedAt || null,
2742 });
2743
2744 if (!grabResult.success) {
2745 return {
2746 success: false,
2747 error: grabResult.error,
2748 grabResult
2749 };
2750 }
2751
2752 // Get the buffer - either from grab result or fetch from CDN if cached
2753 let buffer = grabResult.buffer;
2754 if (!buffer && grabResult.cached && grabResult.cdnUrl) {
2755 console.log(`📥 Fetching cached thumbnail from CDN: ${grabResult.cdnUrl}`);
2756 try {
2757 const cdnResponse = await fetch(grabResult.cdnUrl);
2758 if (!cdnResponse.ok) {
2759 throw new Error(`CDN fetch failed: ${cdnResponse.status}`);
2760 }
2761 buffer = Buffer.from(await cdnResponse.arrayBuffer());
2762 console.log(` Downloaded ${(buffer.length / 1024).toFixed(1)} KB from CDN`);
2763 } catch (cdnErr) {
2764 console.error(`❌ Failed to fetch from CDN:`, cdnErr.message);
2765 // Try regenerating without cache
2766 console.log(`🔄 Regenerating thumbnail without cache...`);
2767 const freshGrab = await grabPiece(piece, {
2768 ...options,
2769 skipCache: true,
2770 });
2771 if (!freshGrab.success || !freshGrab.buffer) {
2772 return {
2773 success: false,
2774 error: `Failed to generate thumbnail: ${freshGrab.error || 'no buffer returned'}`,
2775 grabResult: freshGrab
2776 };
2777 }
2778 buffer = freshGrab.buffer;
2779 }
2780 }
2781
2782 if (!buffer) {
2783 return {
2784 success: false,
2785 error: 'No thumbnail buffer available',
2786 grabResult
2787 };
2788 }
2789
2790 // Upload to IPFS (re-use grabId for progress tracking so the keep-mint heartbeat can see it)
2791 console.log(`📤 Uploading ${format} to IPFS...`);
2792 const mimeType = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png';
2793 const filename = `${pieceName}-thumbnail.${format}`;
2794 if (grabResult?.grabId) {
2795 updateProgress(grabResult.grabId, {
2796 piece: grabResult.piece,
2797 format,
2798 stage: 'uploading',
2799 stageDetail: 'Pinning to IPFS...',
2800 percent: 92,
2801 });
2802 }
2803
2804 try {
2805 const ipfsUri = await uploadToIPFS(buffer, filename, credentials);
2806 const ipfsCid = ipfsUri.replace('ipfs://', '');
2807 console.log(`✅ Thumbnail uploaded: ${ipfsUri}`);
2808
2809 // Track this upload for the live endpoint
2810 const uploadInfo = {
2811 ipfsCid,
2812 ipfsUri,
2813 piece: pieceName,
2814 format,
2815 mimeType,
2816 size: buffer.length,
2817 timestamp: Date.now(),
2818 };
2819 latestIPFSUploads.set(pieceName, uploadInfo);
2820 latestKeepThumbnail = uploadInfo;
2821
2822 // Update grab in MongoDB with IPFS info and clear progress
2823 if (grabResult.grabId) {
2824 updateGrabInMongo(grabResult.grabId, { ipfsCid, ipfsUri });
2825 // Also update in-memory grab
2826 const inMemoryGrab = recentGrabs.find(g => g.grabId === grabResult.grabId);
2827 if (inMemoryGrab) {
2828 inMemoryGrab.ipfsCid = ipfsCid;
2829 inMemoryGrab.ipfsUri = ipfsUri;
2830 }
2831 // Clear progress now that IPFS upload is done
2832 grabProgressMap.delete(grabResult.grabId);
2833 }
2834
2835 // Notify WebSocket subscribers about new IPFS upload
2836 notifySubscribers();
2837
2838 return {
2839 success: true,
2840 ipfsUri,
2841 ipfsCid,
2842 piece: pieceName,
2843 format,
2844 mimeType,
2845 size: buffer.length,
2846 grabDuration: grabResult.duration,
2847 cached: grabResult.cached || false,
2848 };
2849 } catch (error) {
2850 console.error(`❌ IPFS upload failed:`, error.message);
2851 // Clean up progress so the card doesn't stay stuck on the dashboard
2852 if (grabResult?.grabId) {
2853 grabProgressMap.delete(grabResult.grabId);
2854 notifySubscribers();
2855 }
2856 return {
2857 success: false,
2858 error: error.message,
2859 grabResult
2860 };
2861 }
2862}
2863
2864/**
2865 * Express handler for /grab endpoint
2866 */
2867export async function grabHandler(req, res) {
2868 const {
2869 piece,
2870 format = 'webp',
2871 width = 512,
2872 height = 512,
2873 duration = 12000,
2874 fps = 7.5,
2875 density = 1,
2876 quality = 80,
2877 skipCache = false,
2878 cacheKey = '',
2879 } = req.body;
2880
2881 if (!piece) {
2882 return res.status(400).json({ error: 'Missing required field: piece' });
2883 }
2884
2885 // Reject blacklisted pieces early
2886 if (isPieceBlacklisted(piece)) {
2887 return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` });
2888 }
2889
2890 // Validate format
2891 if (!['webp', 'gif', 'png'].includes(format)) {
2892 return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' });
2893 }
2894
2895 // Validate dimensions
2896 if (width < MIN_GRAB_DIMENSION || width > MAX_GRAB_DIMENSION || height < MIN_GRAB_DIMENSION || height > MAX_GRAB_DIMENSION) {
2897 return res.status(400).json({ error: `Dimensions must be between ${MIN_GRAB_DIMENSION} and ${MAX_GRAB_DIMENSION}` });
2898 }
2899
2900 try {
2901 const result = await grabPiece(piece, {
2902 format,
2903 width: parseInt(width),
2904 height: parseInt(height),
2905 duration: parseInt(duration),
2906 fps: parseInt(fps),
2907 density: parseFloat(density) || 1,
2908 quality: parseInt(quality) || 80,
2909 skipCache: skipCache === true || skipCache === 'true',
2910 cacheKey,
2911 requestOrigin: req.get('referer') || req.get('origin') || null,
2912 });
2913
2914 if (result.success) {
2915 // If result is cached and has CDN URL
2916 if (result.cached && result.cdnUrl) {
2917 res.setHeader('X-Cache', 'HIT');
2918 res.setHeader('X-Grab-Id', result.grabId);
2919
2920 // Check if request has Origin header (browser CORS request)
2921 // If so, proxy the image instead of redirecting (CDN lacks CORS headers)
2922 const origin = req.get('Origin');
2923 if (origin) {
2924 // Proxy the cached image to preserve CORS headers
2925 try {
2926 const proxyRes = await fetch(result.cdnUrl);
2927 if (proxyRes.ok) {
2928 const buffer = Buffer.from(await proxyRes.arrayBuffer());
2929 const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' };
2930 const contentType = contentTypes[format] || 'image/webp';
2931 res.setHeader('Content-Type', contentType);
2932 res.setHeader('Content-Length', buffer.length);
2933 res.setHeader('X-Proxy', 'CDN');
2934 return res.send(buffer);
2935 }
2936 } catch (proxyErr) {
2937 console.error('CDN proxy error:', proxyErr.message);
2938 // Fall through to redirect
2939 }
2940 }
2941
2942 // No Origin header or proxy failed - redirect to CDN
2943 return res.redirect(301, result.cdnUrl);
2944 }
2945
2946 // Return the image directly
2947 const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' };
2948 const contentType = contentTypes[format] || 'image/webp';
2949 res.setHeader('Content-Type', contentType);
2950 res.setHeader('Content-Length', result.buffer.length);
2951 res.setHeader('X-Grab-Id', result.grabId);
2952 res.setHeader('X-Grab-Duration', result.duration);
2953 res.setHeader('X-Cache', 'MISS');
2954 res.send(result.buffer);
2955 } else {
2956 res.status(500).json({
2957 error: result.error,
2958 grabId: result.grabId
2959 });
2960 }
2961
2962 } catch (error) {
2963 console.error('Grab handler error:', error);
2964 res.status(500).json({ error: error.message });
2965 }
2966}
2967
2968/**
2969 * GET handler for convenient URL-based grabbing
2970 * GET /grab/:format/:width/:height/:piece
2971 * e.g., /grab/gif/400/400/$roz
2972 *
2973 * Query params:
2974 * - duration, fps, density, quality, source, skipCache (standard grab options)
2975 * - nowait=true: Return a placeholder image if grab is in progress/queued instead of waiting
2976 */
2977export async function grabGetHandler(req, res) {
2978 const { format, width, height, piece } = req.params;
2979 const { duration = 12000, fps = 7.5, density = 1, quality = 80, source = 'manual', skipCache = 'false', nowait = 'false', cacheKey = '' } = req.query;
2980
2981 if (!piece) {
2982 return res.status(400).json({ error: 'Missing piece parameter' });
2983 }
2984
2985 // Reject blacklisted pieces early
2986 if (isPieceBlacklisted(piece)) {
2987 return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` });
2988 }
2989
2990 // Validate format
2991 if (!['webp', 'gif', 'png'].includes(format)) {
2992 return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' });
2993 }
2994
2995 const w = parseInt(width) || 512;
2996 const h = parseInt(height) || 512;
2997
2998 if (w < MIN_GRAB_DIMENSION || w > MAX_GRAB_DIMENSION || h < MIN_GRAB_DIMENSION || h > MAX_GRAB_DIMENSION) {
2999 return res.status(400).json({ error: `Dimensions must be between ${MIN_GRAB_DIMENSION} and ${MAX_GRAB_DIMENSION}` });
3000 }
3001
3002 // Calculate captureKey to check for in-progress grabs
3003 const animated = format !== 'png';
3004 const parsedDensity = parseFloat(density) || 1;
3005 const captureKey = getCaptureKey(piece, w * parsedDensity, h * parsedDensity, format, animated, cacheKey);
3006
3007 // If nowait=true, check if there's already a grab in progress for this key
3008 if (nowait === 'true' || nowait === true) {
3009 const inProgress = getInProgressGrab(captureKey);
3010 if (inProgress.inProgress) {
3011 console.log(`🔥 Returning baking placeholder for ${piece} (queue: ${inProgress.queuePosition})`);
3012
3013 try {
3014 const placeholder = await generateBakingPlaceholder({
3015 width: w,
3016 height: h,
3017 format,
3018 piece,
3019 queuePosition: inProgress.queuePosition,
3020 estimatedWait: inProgress.estimatedWait,
3021 });
3022
3023 const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' };
3024 const contentType = contentTypes[format] || 'image/webp';
3025
3026 res.setHeader('Content-Type', contentType);
3027 res.setHeader('Content-Length', placeholder.length);
3028 res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // Don't cache placeholders
3029 res.setHeader('X-Oven-Status', 'baking');
3030 res.setHeader('X-Queue-Position', inProgress.queuePosition || 0);
3031 res.setHeader('X-Estimated-Wait', inProgress.estimatedWait || 30000);
3032 res.setHeader('X-Grab-Id', inProgress.grabId || '');
3033 return res.send(placeholder);
3034 } catch (placeholderErr) {
3035 console.error('Failed to generate placeholder:', placeholderErr.message);
3036 // Fall through to normal grab
3037 }
3038 }
3039 }
3040
3041 try {
3042 const result = await grabPiece(piece, {
3043 format,
3044 width: w,
3045 height: h,
3046 skipCache: skipCache === 'true' || skipCache === true,
3047 duration: parseInt(duration),
3048 fps: parseInt(fps),
3049 density: parsedDensity,
3050 quality: parseInt(quality) || 80,
3051 source: source || 'manual',
3052 cacheKey,
3053 requestOrigin: req.get('referer') || req.get('origin') || null,
3054 });
3055
3056 if (result.success) {
3057 // If cached, check for CORS proxy need
3058 if (result.cached && result.cdnUrl) {
3059 res.setHeader('X-Grab-Id', result.grabId || result.captureKey);
3060 res.setHeader('X-Cache', 'HIT');
3061
3062 // Check if request has Origin header (browser CORS request)
3063 // If so, proxy the image instead of redirecting (CDN lacks CORS headers)
3064 const origin = req.get('Origin');
3065 if (origin) {
3066 // Proxy the cached image to preserve CORS headers
3067 try {
3068 const proxyRes = await fetch(result.cdnUrl);
3069 if (proxyRes.ok) {
3070 const buffer = Buffer.from(await proxyRes.arrayBuffer());
3071 const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' };
3072 const contentType = contentTypes[format] || 'image/webp';
3073 res.setHeader('Content-Type', contentType);
3074 res.setHeader('Content-Length', buffer.length);
3075 res.setHeader('Cache-Control', 'public, max-age=86400');
3076 res.setHeader('X-Proxy', 'CDN');
3077 return res.send(buffer);
3078 }
3079 } catch (proxyErr) {
3080 console.error('CDN proxy error:', proxyErr.message);
3081 // Fall through to redirect
3082 }
3083 }
3084
3085 return res.redirect(301, result.cdnUrl);
3086 }
3087
3088 const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' };
3089 const contentType = contentTypes[format] || 'image/webp';
3090 res.setHeader('Content-Type', contentType);
3091 res.setHeader('Content-Length', result.buffer.length);
3092 res.setHeader('Cache-Control', 'public, max-age=86400');
3093 res.setHeader('X-Grab-Id', result.grabId);
3094 res.setHeader('X-Cache', 'MISS');
3095 res.send(result.buffer);
3096 } else {
3097 res.status(500).json({
3098 error: result.error,
3099 grabId: result.grabId
3100 });
3101 }
3102
3103 } catch (error) {
3104 console.error('Grab GET handler error:', error);
3105 res.status(500).json({ error: error.message });
3106 }
3107}
3108
3109/**
3110 * Express handler for /grab-ipfs endpoint
3111 * Grabs a piece and uploads the thumbnail to IPFS
3112 */
3113export async function grabIPFSHandler(req, res) {
3114 const {
3115 piece,
3116 format = 'webp',
3117 width = 96, // Small thumbnail (was 512)
3118 height = 96,
3119 duration = 8000, // 8 seconds
3120 fps = 10, // 10fps capture
3121 playbackFps = 20, // 20fps playback = 2x speed
3122 density = 2, // 2x density for crisp pixels (was 1)
3123 quality = 70, // Lower quality for smaller files
3124 skipCache = false, // Force regeneration (bypass CDN cache)
3125 cacheKey = '', // Optional cache discriminator (e.g. source hash)
3126 pinataKey,
3127 pinataSecret,
3128 source, // Optional: 'keep', 'manual', etc.
3129 keepId, // Optional: Tezos keep token ID
3130 author, // Optional: author handle (e.g. "@jeffrey")
3131 pieceCreatedAt, // Optional: when the KidLisp piece was created
3132 } = req.body;
3133
3134 if (!piece) {
3135 return res.status(400).json({ error: 'Missing required field: piece' });
3136 }
3137
3138 // Reject blacklisted pieces early
3139 if (isPieceBlacklisted(piece)) {
3140 return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` });
3141 }
3142
3143 if (!pinataKey || !pinataSecret) {
3144 return res.status(400).json({ error: 'Missing Pinata credentials (pinataKey, pinataSecret)' });
3145 }
3146
3147 // Validate format
3148 if (!['webp', 'gif', 'png'].includes(format)) {
3149 return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' });
3150 }
3151
3152 try {
3153 const result = await grabAndUploadToIPFS(piece, { pinataKey, pinataSecret }, {
3154 format,
3155 width: parseInt(width),
3156 height: parseInt(height),
3157 duration: parseInt(duration),
3158 fps: parseFloat(fps),
3159 playbackFps: parseFloat(playbackFps),
3160 density: parseFloat(density) || 1,
3161 quality: parseInt(quality) || 90,
3162 skipCache: skipCache === true || skipCache === 'true',
3163 cacheKey,
3164 source: source || 'manual',
3165 keepId: keepId || null,
3166 author: author || null,
3167 pieceCreatedAt: pieceCreatedAt || null,
3168 });
3169
3170 if (result.success) {
3171 res.json({
3172 success: true,
3173 ipfsUri: result.ipfsUri,
3174 piece: result.piece,
3175 format: result.format,
3176 mimeType: result.mimeType,
3177 size: result.size,
3178 grabDuration: result.grabDuration,
3179 });
3180 } else {
3181 res.status(500).json({
3182 success: false,
3183 error: result.error
3184 });
3185 }
3186
3187 } catch (error) {
3188 console.error('Grab IPFS handler error:', error);
3189 res.status(500).json({ error: error.message });
3190 }
3191}
3192
3193// Status exports
3194export function getActiveGrabs() {
3195 return Array.from(activeGrabs.values());
3196}
3197
3198export function getRecentGrabs() {
3199 return recentGrabs;
3200}
3201
3202// Latest IPFS upload exports for live collection thumbnail
3203export function getLatestKeepThumbnail() {
3204 return latestKeepThumbnail;
3205}
3206
3207export function getLatestIPFSUpload(piece) {
3208 return latestIPFSUploads.get(piece?.replace(/^\$/, ''));
3209}
3210
3211export function getAllLatestIPFSUploads() {
3212 return Object.fromEntries(latestIPFSUploads);
3213}
3214
3215/**
3216 * Close the browser instance (for graceful shutdown)
3217 */
3218export async function closeBrowser() {
3219 if (browser) {
3220 console.log('🧹 Closing browser...');
3221 await browser.close();
3222 browser = null;
3223 browserLaunchPromise = null;
3224 console.log('✅ Browser closed');
3225 }
3226}
3227
3228/**
3229 * Close all connections (browser + MongoDB) for clean exit
3230 */
3231export async function closeAll() {
3232 await closeBrowser();
3233 if (mongoClient) {
3234 console.log('🧹 Closing MongoDB...');
3235 await mongoClient.close();
3236 mongoClient = null;
3237 db = null;
3238 console.log('✅ MongoDB closed');
3239 }
3240}
3241
3242// =============================================================================
3243// KidLisp.com Dynamic OG Preview Image Generation
3244// =============================================================================
3245
3246// Cache for OG image URLs, keyed by layout (memory cache with TTL)
3247const ogImageCache = {
3248 byLayout: {}, // { [layout]: { url, expires } }
3249 generatedAt: null,
3250 featuredPiece: null,
3251};
3252
3253// OG Image layout options
3254const OG_LAYOUTS = {
3255 FEATURED: 'featured', // Single featured piece (Option A)
3256 MOSAIC: 'mosaic', // Grid of top 6 pieces (Option B)
3257 FILMSTRIP: 'filmstrip', // Same piece at multiple timepoints (Option C)
3258 CODE_SPLIT: 'code-split', // Code + preview side by side (Option D)
3259};
3260
3261/**
3262 * Format hit count for display (e.g., 4634 -> "4.6k")
3263 */
3264function formatHits(hits) {
3265 if (!hits || hits < 1000) return String(hits || 0);
3266 if (hits < 10000) return (hits / 1000).toFixed(1) + 'k';
3267 return Math.floor(hits / 1000) + 'k';
3268}
3269
3270/**
3271 * Get today's date string for cache keys (YYYY-MM-DD)
3272 */
3273function getTodayKey() {
3274 return new Date().toISOString().split('T')[0];
3275}
3276
3277/**
3278 * Get deterministic "day of year" number for rotating featured content
3279 */
3280function getDayOfYear() {
3281 const now = new Date();
3282 const start = new Date(now.getFullYear(), 0, 0);
3283 const diff = now - start;
3284 const oneDay = 1000 * 60 * 60 * 24;
3285 return Math.floor(diff / oneDay);
3286}
3287
3288// =============================================================================
3289// Source Similarity Detection (from give.aesthetic.computer)
3290// =============================================================================
3291
3292const SIMILARITY_THRESHOLD = 0.90; // 90% similar = duplicate
3293const MIN_SOURCE_LENGTH = 50; // Only check sources longer than this
3294
3295/**
3296 * Get trigrams (3-char sequences) from a string
3297 */
3298function getTrigrams(str) {
3299 const trigrams = new Set();
3300 for (let i = 0; i <= str.length - 3; i++) {
3301 trigrams.add(str.slice(i, i + 3));
3302 }
3303 return trigrams;
3304}
3305
3306/**
3307 * Calculate similarity between two source strings using trigram Jaccard similarity
3308 */
3309function getSourceSimilarity(source1, source2) {
3310 if (!source1 || !source2) return 0;
3311
3312 // Normalize: lowercase, remove extra whitespace
3313 const norm1 = source1.toLowerCase().replace(/\s+/g, ' ').trim();
3314 const norm2 = source2.toLowerCase().replace(/\s+/g, ' ').trim();
3315
3316 if (norm1 === norm2) return 1;
3317 if (norm1.length < 10 || norm2.length < 10) return 0;
3318
3319 const t1 = getTrigrams(norm1);
3320 const t2 = getTrigrams(norm2);
3321
3322 // Jaccard similarity: intersection / union
3323 let intersection = 0;
3324 for (const t of t1) {
3325 if (t2.has(t)) intersection++;
3326 }
3327 const union = t1.size + t2.size - intersection;
3328 return union > 0 ? intersection / union : 0;
3329}
3330
3331/**
3332 * Filter pieces to remove duplicates with >90% similar source code
3333 * Returns a deduplicated list
3334 */
3335function deduplicatePieces(pieces, threshold = SIMILARITY_THRESHOLD) {
3336 const selected = [];
3337 const selectedSources = new Map(); // code -> source
3338
3339 for (const piece of pieces) {
3340 const source = piece.source;
3341
3342 // Skip if no source or too short
3343 if (!source || source.length < MIN_SOURCE_LENGTH) {
3344 selected.push(piece);
3345 continue;
3346 }
3347
3348 // Check against all previously selected pieces
3349 let isDuplicate = false;
3350 for (const [existingCode, existingSource] of selectedSources) {
3351 const similarity = getSourceSimilarity(source, existingSource);
3352 if (similarity >= threshold) {
3353 console.log(` ⚠️ Skipping $${piece.code}: ${(similarity * 100).toFixed(0)}% similar to $${existingCode}`);
3354 isDuplicate = true;
3355 break;
3356 }
3357 }
3358
3359 if (!isDuplicate) {
3360 selected.push(piece);
3361 selectedSources.set(piece.code, source);
3362 }
3363 }
3364
3365 return selected;
3366}
3367
3368/**
3369 * Fetch top KidLisp hits from the TV API
3370 */
3371async function fetchTopKidlispHits(limit = 20) {
3372 try {
3373 const apiUrl = process.env.API_BASE_URL || 'https://aesthetic.computer';
3374 const response = await fetch(`${apiUrl}/api/tv?types=kidlisp&sort=hits&limit=${limit}`);
3375 if (!response.ok) {
3376 throw new Error(`TV API returned ${response.status}`);
3377 }
3378 const data = await response.json();
3379 return data.media?.kidlisp || [];
3380 } catch (error) {
3381 console.error('❌ Failed to fetch top hits:', error.message);
3382 return [];
3383 }
3384}
3385
3386/**
3387 * Check if we have a cached OG image for today
3388 * Returns CDN URL if exists, null otherwise
3389 */
3390async function getCachedOGImage(layout = 'featured') {
3391 const today = getTodayKey();
3392 const key = `og/kidlisp/${today}-${layout}.png`;
3393
3394 // Check memory cache first (per-layout)
3395 const cached = ogImageCache.byLayout[layout];
3396 if (cached?.url && Date.now() < cached.expires) {
3397 console.log(`📦 OG image from memory cache (${layout}): ${cached.url}`);
3398 return cached.url;
3399 }
3400
3401 // Check Spaces for today's image
3402 try {
3403 await spacesClient.send(new HeadObjectCommand({
3404 Bucket: SPACES_BUCKET,
3405 Key: key,
3406 }));
3407
3408 const url = `${SPACES_CDN_BASE}/${key}`;
3409
3410 // Update memory cache (1hr TTL, per-layout)
3411 ogImageCache.byLayout[layout] = {
3412 url,
3413 expires: Date.now() + 60 * 60 * 1000,
3414 };
3415
3416 console.log(`📦 OG image from Spaces cache (${layout}): ${url}`);
3417 return url;
3418 } catch (err) {
3419 // Not found in cache
3420 return null;
3421 }
3422}
3423
3424/**
3425 * Upload OG image to Spaces CDN
3426 */
3427async function uploadOGImageToSpaces(buffer, layout = 'featured') {
3428 const today = getTodayKey();
3429 const key = `og/kidlisp/${today}-${layout}.png`;
3430
3431 await spacesClient.send(new PutObjectCommand({
3432 Bucket: SPACES_BUCKET,
3433 Key: key,
3434 Body: buffer,
3435 ContentType: 'image/png',
3436 ACL: 'public-read',
3437 CacheControl: 'public, max-age=86400',
3438 }));
3439
3440 const url = `${SPACES_CDN_BASE}/${key}`;
3441
3442 // Update memory cache (per-layout)
3443 ogImageCache.byLayout[layout] = {
3444 url,
3445 expires: Date.now() + 60 * 60 * 1000,
3446 };
3447 ogImageCache.generatedAt = new Date().toISOString();
3448
3449 console.log(`📤 OG image uploaded to Spaces: ${url}`);
3450 return url;
3451}
3452
3453/**
3454 * Create SVG branding overlay for OG images
3455 */
3456function createBrandingOverlay(featured, width = 1200, height = 80) {
3457 const code = featured?.code || 'kidlisp';
3458 const hits = formatHits(featured?.hits);
3459 const handle = featured?.owner?.handle || '';
3460
3461 return Buffer.from(`
3462 <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
3463 <defs>
3464 <linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
3465 <stop offset="0%" style="stop-color:rgba(0,0,0,0);stop-opacity:0" />
3466 <stop offset="100%" style="stop-color:rgba(0,0,0,0.85);stop-opacity:1" />
3467 </linearGradient>
3468 </defs>
3469 <rect width="100%" height="100%" fill="url(#grad)"/>
3470 <text x="24" y="${height - 24}" font-family="monospace, 'Courier New'" font-size="28" font-weight="bold" fill="white">
3471 KidLisp.com
3472 </text>
3473 <text x="${width - 24}" y="${height - 24}" font-family="monospace, 'Courier New'" font-size="20" fill="#cccccc" text-anchor="end">
3474 $${code} · ${hits} plays ${handle ? '· ' + handle : ''}
3475 </text>
3476 </svg>
3477 `);
3478}
3479
3480/**
3481 * Create SVG overlay for mosaic layout (smaller per-tile labels)
3482 */
3483function createMosaicLabel(piece, index, tileWidth = 400, tileHeight = 315) {
3484 const code = piece?.code || '???';
3485 const hits = formatHits(piece?.hits);
3486
3487 return Buffer.from(`
3488 <svg width="${tileWidth}" height="40" xmlns="http://www.w3.org/2000/svg">
3489 <rect width="100%" height="100%" fill="rgba(0,0,0,0.7)"/>
3490 <text x="8" y="26" font-family="monospace" font-size="14" fill="white">
3491 $${code}
3492 </text>
3493 <text x="${tileWidth - 8}" y="26" font-family="monospace" font-size="12" fill="#aaa" text-anchor="end">
3494 ${hits} ▶
3495 </text>
3496 </svg>
3497 `);
3498}
3499
3500/**
3501 * Generate KidLisp OG image - Featured layout (Option A)
3502 * Single top piece fills the frame with branding overlay
3503 */
3504async function generateFeaturedOGImage(topPieces) {
3505 const dayOfYear = getDayOfYear();
3506 const featuredIndex = dayOfYear % Math.min(topPieces.length, 10);
3507 const featured = topPieces[featuredIndex];
3508
3509 if (!featured) {
3510 throw new Error('No featured piece available');
3511 }
3512
3513 console.log(`🎨 Generating featured OG: $${featured.code} (${formatHits(featured.hits)} hits)`);
3514
3515 // Capture screenshot at 1200x630 (OG image standard)
3516 const width = 1200;
3517 const height = 630;
3518
3519 const browser = await getBrowser();
3520 const page = await browser.newPage();
3521 await interceptSelfRequests(page);
3522 await injectFontGlyphs(page);
3523
3524 try {
3525 await page.setViewport({ width, height, deviceScaleFactor: 1 });
3526
3527 const url = `https://aesthetic.computer/$${featured.code}?density=1&tv=true&nolabel=true&nogap=true&spoofaudio=true`;
3528 console.log(` Loading: ${url}`);
3529
3530 await page.goto(url, {
3531 waitUntil: 'domcontentloaded',
3532 timeout: 30000
3533 });
3534 await populateGlyphCache(page);
3535
3536 // Wait for canvas and content
3537 await page.waitForSelector('canvas', { timeout: 10000 });
3538
3539 // Wait for KidLisp content to render (animation frame)
3540 await new Promise(r => setTimeout(r, 4000));
3541
3542 // Take screenshot
3543 const screenshot = await page.screenshot({ type: 'png' });
3544
3545 // Composite with branding overlay using sharp
3546 const brandingOverlay = createBrandingOverlay(featured, width, 100);
3547
3548 const composite = await sharp(screenshot)
3549 .composite([
3550 {
3551 input: brandingOverlay,
3552 gravity: 'south',
3553 }
3554 ])
3555 .png()
3556 .toBuffer();
3557
3558 ogImageCache.featuredPiece = featured;
3559
3560 return composite;
3561
3562 } finally {
3563 await page.close();
3564 }
3565}
3566
3567/**
3568 * Generate KidLisp OG image - Mosaic layout (Option B)
3569 * 4x4 grid of top 16 pieces with large KidLisp.com branding
3570 */
3571async function generateMosaicOGImage(topPieces) {
3572 const width = 1200;
3573 const height = 630;
3574 const cols = 4;
3575 const rows = 4;
3576 const tileWidth = width / cols; // 300px
3577 const tileHeight = height / rows; // 157.5px
3578
3579 const pieces = topPieces.slice(0, 16);
3580 if (pieces.length < 16) {
3581 // Pad with duplicates if needed
3582 while (pieces.length < 16) {
3583 pieces.push(pieces[pieces.length % topPieces.length] || { code: 'blank', hits: 0 });
3584 }
3585 }
3586
3587 console.log(`🎨 Generating 4x4 mosaic OG: ${pieces.map(p => '$' + p.code).join(', ')}`);
3588
3589 const browser = await getBrowser();
3590 const page = await browser.newPage();
3591 await interceptSelfRequests(page);
3592 await injectFontGlyphs(page);
3593
3594 try {
3595 const tiles = [];
3596
3597 for (let i = 0; i < pieces.length; i++) {
3598 const piece = pieces[i];
3599
3600 await page.setViewport({ width: Math.round(tileWidth), height: Math.round(tileHeight), deviceScaleFactor: 0.5 });
3601
3602 const url = `https://aesthetic.computer/$${piece.code}?density=0.5&tv=true&nolabel=true&nogap=true&spoofaudio=true`;
3603 console.log(` [${i + 1}/16] Loading: $${piece.code}`);
3604
3605 await page.goto(url, {
3606 waitUntil: 'domcontentloaded',
3607 timeout: 20000
3608 });
3609 if (i === 0) await populateGlyphCache(page);
3610
3611 await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {});
3612 // Let pieces play longer before capture (8 seconds)
3613 await new Promise(r => setTimeout(r, 8000));
3614
3615 const screenshot = await page.screenshot({ type: 'png' });
3616
3617 // No labels, just the raw visual
3618 const tile = await sharp(screenshot)
3619 .resize(Math.round(tileWidth), Math.round(tileHeight), { fit: 'cover' })
3620 .png()
3621 .toBuffer();
3622
3623 tiles.push(tile);
3624 }
3625
3626 // Assemble mosaic - no black bars
3627 const composites = tiles.map((tile, i) => ({
3628 input: tile,
3629 left: (i % cols) * Math.round(tileWidth),
3630 top: Math.floor(i / cols) * Math.round(tileHeight),
3631 }));
3632
3633 // Create base mosaic
3634 let mosaic = await sharp({
3635 create: {
3636 width,
3637 height,
3638 channels: 4,
3639 background: { r: 0, g: 0, b: 0, alpha: 1 }
3640 }
3641 })
3642 .composite(composites)
3643 .png()
3644 .toBuffer();
3645
3646 // Apply Gaussian blur for ambient background effect
3647 mosaic = await sharp(mosaic)
3648 .blur(8) // sigma value - higher = more blur
3649 .toBuffer();
3650
3651 // Upload raw mosaic (no branding) for site-specific OG images to composite on
3652 uploadOGImageToSpaces(mosaic, 'mosaic-raw').catch(err =>
3653 console.error('Failed to upload raw mosaic:', err.message)
3654 );
3655
3656 // Add dark overlay to make text pop, then add branding
3657 const darkOverlay = Buffer.from(`
3658 <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
3659 <rect width="100%" height="100%" fill="rgba(0,0,0,0.35)"/>
3660 </svg>
3661 `);
3662
3663 // Use Puppeteer for branding + floating codes to get proper Comic Relief font rendering
3664 const brandingOverlay = await createKidLispBrandingWithPuppeteer(width, height, pieces.map(p => p.code));
3665 mosaic = await sharp(mosaic)
3666 .composite([
3667 { input: darkOverlay, gravity: 'center' },
3668 { input: brandingOverlay, gravity: 'center' },
3669 ])
3670 .png()
3671 .toBuffer();
3672
3673 return mosaic;
3674
3675 } finally {
3676 await page.close();
3677 }
3678}
3679
3680/**
3681 * Create floating $codes overlay with limegreen syntax highlighting
3682 * Codes float upward with motion trails, evenly distributed
3683 */
3684function createFloatingCodesOverlay(width, height, codes) {
3685 // Use seeded random for consistent layout
3686 const seed = codes.join('').split('').reduce((a, c) => a + c.charCodeAt(0), 0);
3687 const seededRandom = (i) => {
3688 const x = Math.sin(seed + i * 9999) * 10000;
3689 return x - Math.floor(x);
3690 };
3691
3692 // Even grid distribution - extend beyond edges for cut-off effect
3693 const cols = 6;
3694 const rows = 5;
3695 const cellWidth = (width + 100) / cols; // Extend past edges
3696 const cellHeight = (height + 80) / rows;
3697 const numCodes = cols * rows;
3698
3699 const baseFontSize = 42;
3700 const fontFamily = "'Comic Relief', 'Comic Sans MS', cursive, sans-serif";
3701 const shadowOffset = 2; // Tighter shadow
3702
3703 // Colors: $ is bright cyan-green, rest is limegreen (like kidlisp.com)
3704 const dollarColor = '#00ff88';
3705 const codeColor = 'limegreen';
3706
3707 let codeElements = '';
3708
3709 for (let i = 0; i < numCodes; i++) {
3710 const code = codes[i % codes.length];
3711 const col = i % cols;
3712 const row = Math.floor(i / cols);
3713
3714 // Center in cell with small jitter, offset left so some cut off on edges
3715 const baseX = col * cellWidth - 50 + cellWidth / 2;
3716 const baseY = row * cellHeight - 40 + cellHeight / 2;
3717 const jitterX = (seededRandom(i * 5) - 0.5) * cellWidth * 0.4;
3718 const jitterY = (seededRandom(i * 5 + 1) - 0.5) * cellHeight * 0.3;
3719 const x = baseX + jitterX;
3720 const y = baseY + jitterY;
3721
3722 // Slight rotation for organic feel
3723 const rotation = (seededRandom(i * 5 + 2) - 0.5) * 16; // -8 to +8 degrees
3724
3725 // Various sizes for different codes (but consistent within each code)
3726 const scale = 0.7 + seededRandom(i * 5 + 3) * 0.8; // 0.7 to 1.5
3727 const actualSize = baseFontSize * scale;
3728
3729 // Opacity varies
3730 const opacity = 0.2 + seededRandom(i * 5 + 4) * 0.25; // 0.2 to 0.45
3731
3732 // Motion trail - 3 fading copies below the main text (floating UP effect)
3733 const trailCount = 3;
3734 const trailSpacing = actualSize * 0.35;
3735
3736 for (let t = trailCount; t >= 0; t--) {
3737 const trailY = y + t * trailSpacing;
3738 const trailOpacity = t === 0 ? opacity : opacity * (0.15 / (t + 1)); // Main is full, trails fade
3739 const trailBlur = t === 0 ? 0 : t * 0.5;
3740
3741 // Only draw shadow for the main text (t === 0)
3742 if (t === 0) {
3743 // Shadow (tight, solid black)
3744 codeElements += `
3745 <text
3746 x="${x + shadowOffset}"
3747 y="${trailY + shadowOffset}"
3748 font-family="${fontFamily}"
3749 font-size="${actualSize}px"
3750 font-weight="bold"
3751 fill="black"
3752 opacity="${trailOpacity * 0.5}"
3753 transform="rotate(${rotation}, ${x}, ${trailY})"
3754 >$${code}</text>
3755 `;
3756 }
3757
3758 // $ character in bright cyan-green
3759 codeElements += `
3760 <text
3761 x="${x}"
3762 y="${trailY}"
3763 font-family="${fontFamily}"
3764 font-size="${actualSize}px"
3765 font-weight="bold"
3766 fill="${dollarColor}"
3767 opacity="${trailOpacity}"
3768 transform="rotate(${rotation}, ${x}, ${trailY})"
3769 ${trailBlur > 0 ? `filter="url(#blur${t})"` : ''}
3770 >$</text>
3771 `;
3772
3773 // Code characters in limegreen (offset by $ width)
3774 const dollarWidth = actualSize * 0.55;
3775 codeElements += `
3776 <text
3777 x="${x + dollarWidth}"
3778 y="${trailY}"
3779 font-family="${fontFamily}"
3780 font-size="${actualSize}px"
3781 font-weight="bold"
3782 fill="${codeColor}"
3783 opacity="${trailOpacity}"
3784 transform="rotate(${rotation}, ${x}, ${trailY})"
3785 ${trailBlur > 0 ? `filter="url(#blur${t})"` : ''}
3786 >${code}</text>
3787 `;
3788 }
3789 }
3790
3791 return Buffer.from(`
3792 <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
3793 <defs>
3794 ${comicReliefBoldBase64 ? `
3795 <style type="text/css">
3796 @font-face {
3797 font-family: 'Comic Relief';
3798 src: url('data:font/truetype;base64,${comicReliefBoldBase64}') format('truetype');
3799 font-weight: bold;
3800 }
3801 </style>
3802 ` : ''}
3803 <filter id="blur1" x="-50%" y="-50%" width="200%" height="200%">
3804 <feGaussianBlur in="SourceGraphic" stdDeviation="1" />
3805 </filter>
3806 <filter id="blur2" x="-50%" y="-50%" width="200%" height="200%">
3807 <feGaussianBlur in="SourceGraphic" stdDeviation="2" />
3808 </filter>
3809 <filter id="blur3" x="-50%" y="-50%" width="200%" height="200%">
3810 <feGaussianBlur in="SourceGraphic" stdDeviation="3" />
3811 </filter>
3812 </defs>
3813 ${codeElements}
3814 </svg>
3815 `);
3816}
3817
3818/**
3819 * Create large KidLisp.com branding overlay using Puppeteer for proper font rendering
3820 * This uses an HTML page with Google Fonts to ensure Comic Relief loads correctly
3821 */
3822async function createKidLispBrandingWithPuppeteer(width, height, codes = []) {
3823 // KidLisp letter colors - com uses delete(red)/stop(purple)/play(green) button colors
3824 const letterColors = {
3825 'K': '#FF6B6B', 'i1': '#4ECDC4', 'd': '#FFE66D',
3826 'L': '#95E1D3', 'i2': '#F38181', 's': '#AA96DA', 'p': '#70D6FF',
3827 '.': '#95E1D3', 'c': '#FF6B6B', 'o': '#9370DB', 'm': '#90EE90',
3828 };
3829
3830 const letters = [
3831 { char: 'K', color: letterColors['K'] },
3832 { char: 'i', color: letterColors['i1'] },
3833 { char: 'd', color: letterColors['d'] },
3834 { char: 'L', color: letterColors['L'] },
3835 { char: 'i', color: letterColors['i2'] },
3836 { char: 's', color: letterColors['s'] },
3837 { char: 'p', color: letterColors['p'] },
3838 { char: '.', color: letterColors['.'] },
3839 { char: 'c', color: letterColors['c'] },
3840 { char: 'o', color: letterColors['o'] },
3841 { char: 'm', color: letterColors['m'] },
3842 ];
3843
3844 const letterSpans = letters.map(l => `<span style="color: ${l.color}">${l.char}</span>`).join('');
3845
3846 // Generate floating codes HTML with seeded random positions
3847 const seed = codes.join('').split('').reduce((a, c) => a + c.charCodeAt(0), 0);
3848 const seededRandom = (i) => {
3849 const x = Math.sin(seed + i * 9999) * 10000;
3850 return x - Math.floor(x);
3851 };
3852
3853 const cols = 6;
3854 const rows = 5;
3855 const cellWidth = (width + 100) / cols;
3856 const cellHeight = (height + 80) / rows;
3857 const numCodes = cols * rows;
3858
3859 let floatingCodesHtml = '';
3860 for (let i = 0; i < numCodes; i++) {
3861 const code = codes[i % codes.length] || 'abc';
3862 const col = i % cols;
3863 const row = Math.floor(i / cols);
3864
3865 const baseX = col * cellWidth - 50 + cellWidth / 2;
3866 const baseY = row * cellHeight - 40 + cellHeight / 2;
3867 const jitterX = (seededRandom(i * 5) - 0.5) * cellWidth * 0.4;
3868 const jitterY = (seededRandom(i * 5 + 1) - 0.5) * cellHeight * 0.3;
3869 const x = baseX + jitterX;
3870 const y = baseY + jitterY;
3871 const rotation = (seededRandom(i * 5 + 2) - 0.5) * 16;
3872 const scale = 0.7 + seededRandom(i * 5 + 3) * 0.8;
3873 const opacity = 0.2 + seededRandom(i * 5 + 4) * 0.25;
3874 const fontSize = 42 * scale;
3875
3876 floatingCodesHtml += `
3877 <div class="floating-code" style="
3878 left: ${x}px;
3879 top: ${y}px;
3880 font-size: ${fontSize}px;
3881 opacity: ${opacity};
3882 transform: rotate(${rotation}deg);
3883 "><span class="dollar">$</span><span class="code">${code}</span></div>
3884 `;
3885 }
3886
3887 const html = `<!DOCTYPE html>
3888<html>
3889<head>
3890 <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:wght@700&display=swap" rel="stylesheet">
3891 <style>
3892 * { margin: 0; padding: 0; box-sizing: border-box; }
3893 body {
3894 width: ${width}px;
3895 height: ${height}px;
3896 display: flex;
3897 align-items: center;
3898 justify-content: center;
3899 background: transparent;
3900 position: relative;
3901 overflow: hidden;
3902 }
3903 .floating-code {
3904 position: absolute;
3905 font-family: 'Comic Relief', cursive;
3906 font-weight: 700;
3907 white-space: nowrap;
3908 text-shadow: 2px 2px 0 rgba(0,0,0,0.5);
3909 }
3910 .floating-code .dollar {
3911 color: #00ff88;
3912 }
3913 .floating-code .code {
3914 color: limegreen;
3915 }
3916 .branding {
3917 font-family: 'Comic Relief', cursive;
3918 font-size: 160px;
3919 font-weight: 700;
3920 letter-spacing: 0.05em;
3921 text-shadow: 8px 8px 0 #000;
3922 position: relative;
3923 z-index: 10;
3924 }
3925 </style>
3926</head>
3927<body>
3928 ${floatingCodesHtml}
3929 <div class="branding">${letterSpans}</div>
3930</body>
3931</html>`;
3932
3933 const browser = await getBrowser();
3934 const page = await browser.newPage();
3935 await interceptSelfRequests(page);
3936 await injectFontGlyphs(page);
3937
3938 try {
3939 await page.setViewport({ width, height, deviceScaleFactor: 1 });
3940 await page.setContent(html, { waitUntil: 'networkidle0' });
3941
3942 // Wait for font to load
3943 await page.evaluate(() => document.fonts.ready);
3944 await new Promise(r => setTimeout(r, 500)); // Extra time for font render
3945
3946 const screenshot = await page.screenshot({
3947 type: 'png',
3948 omitBackground: true // Transparent background
3949 });
3950
3951 return screenshot;
3952 } finally {
3953 await page.close();
3954 }
3955}
3956
3957/**
3958 * Create large KidLisp.com branding overlay with Comic Relief font (SVG fallback)
3959 */
3960function createKidLispBranding(width, height) {
3961 // KidLisp letter colors - com uses delete(red)/stop(purple)/play(green) button colors
3962 const letterColors = {
3963 'K': '#FF6B6B', 'i1': '#4ECDC4', 'd': '#FFE66D',
3964 'L': '#95E1D3', 'i2': '#F38181', 's': '#AA96DA', 'p': '#70D6FF',
3965 '.': '#95E1D3', 'c': '#FF6B6B', 'o': '#9370DB', 'm': '#90EE90',
3966 };
3967
3968 const fontSize = 160; // Even bigger
3969 const fontFamily = "'Comic Relief', 'Comic Sans MS', cursive, sans-serif";
3970 const shadowOffset = 8; // Offset down and right
3971
3972 // Build colored text with tspans
3973 const letters = [
3974 { char: 'K', color: letterColors['K'] },
3975 { char: 'i', color: letterColors['i1'] },
3976 { char: 'd', color: letterColors['d'] },
3977 { char: 'L', color: letterColors['L'] },
3978 { char: 'i', color: letterColors['i2'] },
3979 { char: 's', color: letterColors['s'] },
3980 { char: 'p', color: letterColors['p'] },
3981 { char: '.', color: letterColors['.'] },
3982 { char: 'c', color: letterColors['c'] },
3983 { char: 'o', color: letterColors['o'] },
3984 { char: 'm', color: letterColors['m'] },
3985 ];
3986
3987 const tspans = letters.map(l => `<tspan fill="${l.color}">${l.char}</tspan>`).join('');
3988 const blackTspans = letters.map(l => `<tspan fill="#000">${l.char}</tspan>`).join('');
3989
3990 const yOffset = 30; // Move text down for better visual centering
3991 const svg = `
3992 <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
3993 <defs>
3994 ${comicReliefBoldBase64 ? `
3995 <style type="text/css">
3996 @font-face {
3997 font-family: 'Comic Relief';
3998 src: url('data:font/truetype;base64,${comicReliefBoldBase64}') format('truetype');
3999 font-weight: bold;
4000 }
4001 </style>
4002 ` : ''}
4003 </defs>
4004 <!-- Black shadow offset down-right -->
4005 <text x="${width/2 + shadowOffset}" y="${height/2 + yOffset + shadowOffset}"
4006 font-family="${fontFamily}"
4007 font-size="${fontSize}" font-weight="bold"
4008 text-anchor="middle" dominant-baseline="middle"
4009 letter-spacing="0.05em">${blackTspans}</text>
4010 <!-- Main colored text -->
4011 <text x="${width/2}" y="${height/2 + yOffset}"
4012 font-family="${fontFamily}"
4013 font-size="${fontSize}" font-weight="bold"
4014 text-anchor="middle" dominant-baseline="middle"
4015 letter-spacing="0.05em">${tspans}</text>
4016 </svg>
4017 `;
4018
4019 return Buffer.from(svg);
4020}
4021
4022/**
4023 * Generate KidLisp OG image - Filmstrip layout (Option C)
4024 * Same piece captured at 5 different time offsets
4025 */
4026async function generateFilmstripOGImage(topPieces) {
4027 const dayOfYear = getDayOfYear();
4028 const featuredIndex = dayOfYear % Math.min(topPieces.length, 10);
4029 const featured = topPieces[featuredIndex];
4030
4031 if (!featured) {
4032 throw new Error('No featured piece available');
4033 }
4034
4035 const width = 1200;
4036 const height = 630;
4037 const frameCount = 5;
4038 const frameWidth = 200;
4039 const frameHeight = 200;
4040 const spacing = 20;
4041 const totalFrameWidth = frameCount * frameWidth + (frameCount - 1) * spacing;
4042 const startX = (width - totalFrameWidth) / 2;
4043 const frameY = 180;
4044
4045 console.log(`🎨 Generating filmstrip OG: $${featured.code} (${frameCount} frames)`);
4046
4047 const browser = await getBrowser();
4048 const page = await browser.newPage();
4049 await interceptSelfRequests(page);
4050 await injectFontGlyphs(page);
4051
4052 try {
4053 await page.setViewport({ width: frameWidth, height: frameHeight, deviceScaleFactor: 2 });
4054
4055 const url = `https://aesthetic.computer/$${featured.code}?density=2&tv=true&nolabel=true&nogap=true&spoofaudio=true`;
4056 await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
4057 await populateGlyphCache(page);
4058 await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {});
4059
4060 const frames = [];
4061
4062 for (let i = 0; i < frameCount; i++) {
4063 // Wait between frames to capture animation progress
4064 await new Promise(r => setTimeout(r, i === 0 ? 2000 : 800));
4065
4066 const screenshot = await page.screenshot({ type: 'png' });
4067 const frame = await sharp(screenshot)
4068 .resize(frameWidth, frameHeight, { fit: 'cover' })
4069 .extend({ top: 2, bottom: 2, left: 2, right: 2, background: { r: 255, g: 255, b: 255, alpha: 1 } })
4070 .png()
4071 .toBuffer();
4072
4073 frames.push(frame);
4074 console.log(` Frame ${i + 1}/${frameCount} captured`);
4075 }
4076
4077 // Create base image with dark background
4078 const composites = frames.map((frame, i) => ({
4079 input: frame,
4080 left: Math.round(startX + i * (frameWidth + spacing)),
4081 top: frameY,
4082 }));
4083
4084 // Add arrows between frames
4085 const arrowSvg = Buffer.from(`
4086 <svg width="20" height="30" xmlns="http://www.w3.org/2000/svg">
4087 <path d="M5 5 L15 15 L5 25" stroke="white" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
4088 </svg>
4089 `);
4090
4091 for (let i = 0; i < frameCount - 1; i++) {
4092 composites.push({
4093 input: arrowSvg,
4094 left: Math.round(startX + (i + 1) * frameWidth + i * spacing + spacing / 2 - 10),
4095 top: frameY + frameHeight / 2 - 15,
4096 });
4097 }
4098
4099 // Add title and info
4100 const titleSvg = Buffer.from(`
4101 <svg width="${width}" height="100" xmlns="http://www.w3.org/2000/svg">
4102 <text x="${width / 2}" y="60" font-family="monospace, 'Courier New'" font-size="36" font-weight="bold" fill="white" text-anchor="middle">
4103 KidLisp.com
4104 </text>
4105 </svg>
4106 `);
4107
4108 const infoSvg = Buffer.from(`
4109 <svg width="${width}" height="80" xmlns="http://www.w3.org/2000/svg">
4110 <text x="${width / 2}" y="50" font-family="monospace" font-size="24" fill="#cccccc" text-anchor="middle">
4111 $${featured.code} · ${formatHits(featured.hits)} plays ${featured.owner?.handle ? '· ' + featured.owner.handle : ''}
4112 </text>
4113 </svg>
4114 `);
4115
4116 composites.push({ input: titleSvg, left: 0, top: 40 });
4117 composites.push({ input: infoSvg, left: 0, top: height - 120 });
4118
4119 const filmstrip = await sharp({
4120 create: {
4121 width,
4122 height,
4123 channels: 4,
4124 background: { r: 20, g: 20, b: 30, alpha: 1 }
4125 }
4126 })
4127 .composite(composites)
4128 .png()
4129 .toBuffer();
4130
4131 return filmstrip;
4132
4133 } finally {
4134 await page.close();
4135 }
4136}
4137
4138/**
4139 * Generate KidLisp OG image - Code Split layout (Option D)
4140 * Source code on left, visual output on right
4141 */
4142async function generateCodeSplitOGImage(topPieces) {
4143 const dayOfYear = getDayOfYear();
4144 const featuredIndex = dayOfYear % Math.min(topPieces.length, 10);
4145 const featured = topPieces[featuredIndex];
4146
4147 if (!featured) {
4148 throw new Error('No featured piece available');
4149 }
4150
4151 const width = 1200;
4152 const height = 630;
4153 const codeWidth = 500;
4154 const previewWidth = 650;
4155 const previewHeight = 500;
4156 const padding = 25;
4157
4158 console.log(`🎨 Generating code-split OG: $${featured.code}`);
4159
4160 // Get source code (truncate for display)
4161 const source = featured.source || '(wipe "black")\n(ink "white")\n(box 50 50 100 100)';
4162 const sourceLines = source.split('\n').slice(0, 12);
4163 const displaySource = sourceLines.join('\n') + (source.split('\n').length > 12 ? '\n...' : '');
4164
4165 const browser = await getBrowser();
4166 const page = await browser.newPage();
4167 await interceptSelfRequests(page);
4168 await injectFontGlyphs(page);
4169
4170 try {
4171 // Capture preview
4172 await page.setViewport({ width: previewWidth, height: previewHeight, deviceScaleFactor: 1 });
4173
4174 const url = `https://aesthetic.computer/$${featured.code}?density=1&tv=true&nolabel=true&nogap=true&spoofaudio=true`;
4175 await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
4176 await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {});
4177 await new Promise(r => setTimeout(r, 4000));
4178
4179 const screenshot = await page.screenshot({ type: 'png' });
4180 const preview = await sharp(screenshot)
4181 .resize(previewWidth - padding * 2, previewHeight - padding * 2, { fit: 'cover' })
4182 .extend({ top: 4, bottom: 4, left: 4, right: 4, background: { r: 80, g: 80, b: 100, alpha: 1 } })
4183 .png()
4184 .toBuffer();
4185
4186 // Create code panel as SVG
4187 const escapedSource = displaySource
4188 .replace(/&/g, '&')
4189 .replace(/</g, '<')
4190 .replace(/>/g, '>')
4191 .replace(/"/g, '"');
4192
4193 const codeLines = escapedSource.split('\n');
4194 const lineHeight = 28;
4195 const codeY = 80;
4196
4197 const codeSvg = Buffer.from(`
4198 <svg width="${codeWidth}" height="${height}" xmlns="http://www.w3.org/2000/svg">
4199 <rect width="100%" height="100%" fill="#1a1a2e"/>
4200 <text x="20" y="40" font-family="monospace" font-size="14" fill="#666">
4201 // $${featured.code}
4202 </text>
4203 ${codeLines.map((line, i) => `
4204 <text x="20" y="${codeY + i * lineHeight}" font-family="monospace, 'Courier New'" font-size="18" fill="#88ff88">
4205 ${line || ' '}
4206 </text>
4207 `).join('')}
4208 </svg>
4209 `);
4210
4211 // Bottom info bar
4212 const infoSvg = Buffer.from(`
4213 <svg width="${width}" height="60" xmlns="http://www.w3.org/2000/svg">
4214 <rect width="100%" height="100%" fill="rgba(0,0,0,0.8)"/>
4215 <text x="24" y="40" font-family="monospace" font-size="24" font-weight="bold" fill="white">
4216 KidLisp.com
4217 </text>
4218 <text x="${width - 24}" y="40" font-family="monospace" font-size="18" fill="#aaa" text-anchor="end">
4219 $${featured.code} · ${formatHits(featured.hits)} plays
4220 </text>
4221 </svg>
4222 `);
4223
4224 const composite = await sharp({
4225 create: {
4226 width,
4227 height,
4228 channels: 4,
4229 background: { r: 26, g: 26, b: 46, alpha: 1 }
4230 }
4231 })
4232 .composite([
4233 { input: codeSvg, left: 0, top: 0 },
4234 { input: preview, left: codeWidth + padding, top: padding },
4235 { input: infoSvg, left: 0, top: height - 60 },
4236 ])
4237 .png()
4238 .toBuffer();
4239
4240 return composite;
4241
4242 } finally {
4243 await page.close();
4244 }
4245}
4246
4247/**
4248 * Main entry point: Generate KidLisp.com OG preview image
4249 * Checks cache first, generates new image if needed
4250 *
4251 * @param {string} layout - Layout option: 'featured', 'mosaic', 'filmstrip', 'code-split'
4252 * @param {boolean} forceRegenerate - Skip cache and regenerate
4253 * @param {object} options - Additional options
4254 * @param {string} options.handle - Filter by handle (e.g., '@jeffrey')
4255 * @returns {Promise<{buffer: Buffer, url: string, cached: boolean, featured?: object}>}
4256 */
4257export async function generateKidlispOGImage(layout = 'featured', forceRegenerate = false, options = {}) {
4258 const { handle } = options;
4259 console.log(`\n🖼️ KidLisp OG Image Request (layout: ${layout}, force: ${forceRegenerate}${handle ? `, handle: ${handle}` : ''})`);
4260
4261 // Check cache first (unless forcing regeneration)
4262 if (!forceRegenerate) {
4263 const cachedUrl = await getCachedOGImage(layout);
4264 if (cachedUrl) {
4265 return {
4266 url: cachedUrl,
4267 cached: true,
4268 layout,
4269 generatedAt: ogImageCache.generatedAt,
4270 featuredPiece: ogImageCache.featuredPiece,
4271 };
4272 }
4273 }
4274
4275 // Fetch top hits
4276 const topPieces = await fetchTopKidlispHits(40); // Fetch extra to account for deduplication
4277 if (topPieces.length === 0) {
4278 throw new Error('No KidLisp pieces available from API');
4279 }
4280
4281 // Filter by handle if specified
4282 let filteredPieces = topPieces;
4283 if (handle) {
4284 const normalizedHandle = handle.startsWith('@') ? handle : `@${handle}`;
4285 filteredPieces = topPieces.filter(p => p.owner?.handle === normalizedHandle);
4286 console.log(` Filtered to ${filteredPieces.length} pieces by ${normalizedHandle}`);
4287 }
4288
4289 // Deduplicate by source similarity (90% threshold)
4290 const uniquePieces = deduplicatePieces(filteredPieces);
4291 console.log(` Found ${filteredPieces.length} pieces, ${uniquePieces.length} unique after deduplication`);
4292
4293 // Generate based on layout
4294 let buffer;
4295 switch (layout) {
4296 case 'mosaic':
4297 buffer = await generateMosaicOGImage(uniquePieces);
4298 break;
4299 case 'filmstrip':
4300 buffer = await generateFilmstripOGImage(uniquePieces);
4301 break;
4302 case 'code-split':
4303 buffer = await generateCodeSplitOGImage(uniquePieces);
4304 break;
4305 case 'featured':
4306 default:
4307 buffer = await generateFeaturedOGImage(uniquePieces);
4308 break;
4309 }
4310
4311 // Upload to Spaces
4312 const url = await uploadOGImageToSpaces(buffer, layout);
4313
4314 return {
4315 buffer,
4316 url,
4317 cached: false,
4318 layout,
4319 generatedAt: new Date().toISOString(),
4320 featuredPiece: ogImageCache.featuredPiece,
4321 };
4322}
4323
4324/**
4325 * Get OG image cache status
4326 */
4327export function getOGImageCacheStatus() {
4328 const layouts = {};
4329 for (const [layout, entry] of Object.entries(ogImageCache.byLayout)) {
4330 layouts[layout] = {
4331 cached: !!entry.url && Date.now() < entry.expires,
4332 url: entry.url,
4333 expires: entry.expires ? new Date(entry.expires).toISOString() : null,
4334 };
4335 }
4336 return {
4337 layouts,
4338 generatedAt: ogImageCache.generatedAt,
4339 featuredPiece: ogImageCache.featuredPiece,
4340 };
4341}
4342
4343/**
4344 * Get the latest cached OG image URL without triggering generation.
4345 * Returns the CDN URL if it exists for today, null otherwise.
4346 * This is fast and safe for social media crawlers.
4347 */
4348export async function getLatestOGImageUrl(layout = 'mosaic') {
4349 const today = getTodayKey();
4350 const key = `og/kidlisp/${today}-${layout}.png`;
4351
4352 // Check memory cache first (per-layout)
4353 const cached = ogImageCache.byLayout[layout];
4354 if (cached?.url && cached.url.includes(today)) {
4355 return cached.url;
4356 }
4357
4358 // Check Spaces for today's image
4359 try {
4360 await spacesClient.send(new HeadObjectCommand({
4361 Bucket: SPACES_BUCKET,
4362 Key: key,
4363 }));
4364 const url = `${SPACES_CDN_BASE}/${key}`;
4365 ogImageCache.byLayout[layout] = {
4366 url,
4367 expires: Date.now() + 60 * 60 * 1000,
4368 };
4369 return url;
4370 } catch (err) {
4371 // Not found - return yesterday's image if available
4372 const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
4373 const yesterdayKey = `og/kidlisp/${yesterday}-${layout}.png`;
4374 try {
4375 await spacesClient.send(new HeadObjectCommand({
4376 Bucket: SPACES_BUCKET,
4377 Key: yesterdayKey,
4378 }));
4379 return `${SPACES_CDN_BASE}/${yesterdayKey}`;
4380 } catch {
4381 return null;
4382 }
4383 }
4384}
4385
4386/**
4387 * Regenerate OG images in the background.
4388 * Safe to call from server startup or scheduled jobs.
4389 */
4390export async function regenerateOGImagesBackground() {
4391 const layouts = ['mosaic', 'featured']; // Main layouts we care about
4392 console.log('🔄 Starting background OG image regeneration...');
4393
4394 for (const layout of layouts) {
4395 try {
4396 console.log(` Regenerating ${layout}...`);
4397 await generateKidlispOGImage(layout, true); // force=true
4398 console.log(` ✅ ${layout} done`);
4399 } catch (err) {
4400 console.error(` ❌ ${layout} failed:`, err.message);
4401 }
4402 }
4403
4404 console.log('🔄 Background OG regeneration complete');
4405}
4406
4407// =============================================================================
4408// KidLisp Backdrop - Animated WebP for login screens, etc.
4409// =============================================================================
4410
4411// Backdrop cache (memory)
4412const backdropCache = {
4413 url: null,
4414 date: null,
4415 piece: null,
4416};
4417
4418/**
4419 * Get or generate KidLisp backdrop - a 2048px animated webp of a featured piece.
4420 * Rotates daily based on top hits. Caches to CDN for fast access.
4421 * @param {boolean} force - Force regeneration even if cached
4422 * @returns {{ url: string, piece: string, cached: boolean }}
4423 */
4424export async function generateKidlispBackdrop(force = false) {
4425 const today = getTodayKey();
4426 const key = `backdrop/kidlisp/${today}.webp`;
4427
4428 // Check memory cache
4429 if (!force && backdropCache.url && backdropCache.date === today) {
4430 console.log(`📦 Backdrop from memory cache: ${backdropCache.url}`);
4431 return { url: backdropCache.url, piece: backdropCache.piece, cached: true };
4432 }
4433
4434 // Check Spaces for today's backdrop
4435 if (!force) {
4436 try {
4437 await spacesClient.send(new HeadObjectCommand({
4438 Bucket: SPACES_BUCKET,
4439 Key: key,
4440 }));
4441 const url = `${SPACES_CDN_BASE}/${key}`;
4442 backdropCache.url = url;
4443 backdropCache.date = today;
4444 console.log(`📦 Backdrop from Spaces cache: ${url}`);
4445 return { url, piece: backdropCache.piece, cached: true };
4446 } catch {
4447 // Not cached, continue to generate
4448 }
4449 }
4450
4451 // Fetch top KidLisp pieces by @jeffrey only
4452 const topPieces = await fetchTopKidlispHits(50);
4453 const jeffreyPieces = topPieces.filter(p => p.owner?.handle === '@jeffrey');
4454
4455 if (!jeffreyPieces.length) {
4456 throw new Error('No KidLisp pieces by @jeffrey available for backdrop');
4457 }
4458
4459 // Pick piece based on day of year (rotates daily through jeffrey's top pieces)
4460 const dayOfYear = getDayOfYear();
4461 const featuredIndex = dayOfYear % Math.min(jeffreyPieces.length, 10);
4462 const featured = jeffreyPieces[featuredIndex];
4463 const piece = `$${featured.code}`;
4464
4465 console.log(`🎨 Generating backdrop: ${piece} by ${featured.owner?.handle} (${formatHits(featured.hits)} hits)`);
4466
4467 // Generate 256x256 animated webp at 4x density (gives 1024x1024 output with chunky pixels)
4468 // Lower resolution (1024 vs 2048) makes recording faster for Auth0 login backgrounds
4469 const result = await grabPiece(piece, {
4470 format: 'webp',
4471 width: 256,
4472 height: 256,
4473 duration: 12000,
4474 fps: 7.5,
4475 playbackFps: 15,
4476 density: 4,
4477 quality: 85,
4478 skipCache: force,
4479 source: 'backdrop',
4480 });
4481
4482 if (!result.success) {
4483 throw new Error(result.error || 'Failed to generate backdrop');
4484 }
4485
4486 // grabPiece returns cdnUrl from oven/grabs/ - use that directly
4487 const url = result.cdnUrl;
4488
4489 if (!url) {
4490 throw new Error('No CDN URL returned from grabPiece');
4491 }
4492
4493 console.log(`📤 Backdrop generated: ${url}`);
4494
4495 // Update cache with the grab URL
4496 backdropCache.url = url;
4497 backdropCache.date = today;
4498 backdropCache.piece = piece;
4499
4500 return { url, piece, cached: false };
4501}
4502
4503/**
4504 * Get cached backdrop URL without triggering generation.
4505 * Returns the in-memory cached URL if available for today, null otherwise.
4506 */
4507export async function getLatestBackdropUrl() {
4508 const today = getTodayKey();
4509
4510 // Check memory cache - only return if it's from today
4511 if (backdropCache.url && backdropCache.date === today) {
4512 return backdropCache.url;
4513 }
4514
4515 // No cached URL for today - caller should trigger generation
4516 return null;
4517}
4518
4519// =============================================================================
4520// Notepat.com OG Preview Image
4521// =============================================================================
4522
4523// Notepat OG image cache (memory)
4524const notepatOGCache = {
4525 url: null,
4526 expires: 0,
4527};
4528
4529/**
4530 * Generate a branded OG image for notepat.com (1200x630 PNG).
4531 * Shows the actual split-layout chromatic pad interface with keyboard shortcuts.
4532 */
4533export async function generateNotepatOGImage(forceRegenerate = false) {
4534 console.log(`\n🎹 Notepat OG Image Request (force: ${forceRegenerate})`);
4535
4536 // Check cache first
4537 if (!forceRegenerate) {
4538 const cachedUrl = await getLatestNotepatOGUrl();
4539 if (cachedUrl) {
4540 return { url: cachedUrl, cached: true };
4541 }
4542 }
4543
4544 const W = 1200;
4545 const H = 630;
4546
4547 // Full chromatic scale note colors (matching note-colors.mjs logic)
4548 const getNoteColor = (noteName, octave) => {
4549 const baseColors = {
4550 'C': [255, 50, 50], // Red
4551 'C#': [255, 100, 30], // Red-Orange
4552 'D': [255, 160, 0], // Orange
4553 'D#': [255, 200, 0], // Yellow-Orange
4554 'E': [255, 230, 0], // Yellow
4555 'F': [50, 200, 50], // Green
4556 'F#': [50, 160, 120], // Teal
4557 'G': [50, 120, 255], // Blue
4558 'G#': [90, 80, 220], // Blue-Purple
4559 'A': [130, 50, 200], // Purple
4560 'A#': [160, 50, 220], // Purple-Magenta
4561 'B': [180, 80, 255], // Magenta
4562 };
4563
4564 // Octave 5 gets brighter dayglo treatment
4565 if (octave === 5) {
4566 const boost = 40;
4567 const [r, g, b] = baseColors[noteName];
4568 return [
4569 Math.min(255, r + boost),
4570 Math.min(255, g + boost),
4571 Math.min(255, b + boost),
4572 ];
4573 }
4574 return baseColors[noteName];
4575 };
4576
4577 // All 24 chromatic notes in notepat layout
4578 const leftOctave = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
4579 const rightOctave = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
4580
4581 // Keyboard mappings (matching NOTE_TO_KEYBOARD_KEY)
4582 const leftKeys = ['c', 'v', 'd', 's', 'e', 'f', 'w', 'g', 'r', 'a', 'q', 'b'];
4583 const rightKeys = ['h', 't', 'i', 'y', 'j', 'k', 'u', 'l', 'o', 'm', 'p', 'n'];
4584
4585 // Layout: MUCH LARGER pads to fill the space
4586 const padW = 90; // More than doubled
4587 const padH = 130; // Significantly taller
4588 const padGap = 10;
4589 const padRadius = 6;
4590
4591 const cols = 4; // 4 columns per octave side
4592 const rows = 3; // 3 rows per octave side
4593
4594 const octaveW = cols * padW + (cols - 1) * padGap;
4595 const octaveH = rows * padH + (rows - 1) * padGap;
4596
4597 const splitGap = 80; // Tighter split gap
4598 const totalW = octaveW * 2 + splitGap;
4599
4600 const leftX = (W - totalW) / 2;
4601 const rightX = leftX + octaveW + splitGap;
4602 const topY = 60; // Move up significantly
4603
4604 // Build pads SVG
4605 let padsSvg = '';
4606
4607 // Left octave (octave 4)
4608 for (let i = 0; i < 12; i++) {
4609 const row = Math.floor(i / cols);
4610 const col = i % cols;
4611 const x = leftX + col * (padW + padGap);
4612 const y = topY + row * (padH + padGap);
4613
4614 const noteName = leftOctave[i];
4615 const key = leftKeys[i];
4616 const [r, g, b] = getNoteColor(noteName, 4);
4617
4618 // Pad
4619 padsSvg += `<rect x="${x}" y="${y}" width="${padW}" height="${padH}" rx="${padRadius}" fill="rgb(${r},${g},${b})" opacity="0.92"/>`;
4620
4621 // Note label (top)
4622 const noteLabel = noteName.replace('#', '♯');
4623 const textColor = (noteName.includes('E') || noteName.includes('F')) ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.85)';
4624 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>`;
4625
4626 // Key label (bottom)
4627 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>`;
4628 }
4629
4630 // Right octave (octave 5)
4631 for (let i = 0; i < 12; i++) {
4632 const row = Math.floor(i / cols);
4633 const col = i % cols;
4634 const x = rightX + col * (padW + padGap);
4635 const y = topY + row * (padH + padGap);
4636
4637 const noteName = rightOctave[i];
4638 const key = rightKeys[i];
4639 const [r, g, b] = getNoteColor(noteName, 5);
4640
4641 // Pad
4642 padsSvg += `<rect x="${x}" y="${y}" width="${padW}" height="${padH}" rx="${padRadius}" fill="rgb(${r},${g},${b})" opacity="0.92"/>`;
4643
4644 // Note label (top)
4645 const noteLabel = noteName.replace('#', '♯');
4646 const textColor = (noteName.includes('E') || noteName.includes('F')) ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.85)';
4647 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>`;
4648
4649 // Key label (bottom)
4650 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>`;
4651 }
4652
4653 // Decorative waveform across top
4654 let wavePath = `M 60 100`;
4655 for (let i = 0; i <= 120; i++) {
4656 const x = 60 + (i / 120) * (W - 120);
4657 const y = 100 + Math.sin(i * 0.15) * 18 + Math.cos(i * 0.08) * 12;
4658 wavePath += ` L ${x.toFixed(1)} ${y.toFixed(1)}`;
4659 }
4660
4661 const svg = `<?xml version="1.0" encoding="UTF-8"?>
4662<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
4663 <defs>
4664 <linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%">
4665 <stop offset="0%" style="stop-color:#1a1a3c"/>
4666 <stop offset="100%" style="stop-color:#0d0d28"/>
4667 </linearGradient>
4668 <linearGradient id="waveGrad" x1="0%" y1="0%" x2="100%" y2="0%">
4669 <stop offset="0%" style="stop-color:#6080ff;stop-opacity:0.15"/>
4670 <stop offset="50%" style="stop-color:#8090ff;stop-opacity:0.35"/>
4671 <stop offset="100%" style="stop-color:#6080ff;stop-opacity:0.15"/>
4672 </linearGradient>
4673 </defs>
4674
4675 <!-- Background (notepat's blue theme) -->
4676 <rect width="${W}" height="${H}" fill="url(#bg)"/>
4677
4678 <!-- Waveform decoration -->
4679 <path d="${wavePath}" fill="none" stroke="url(#waveGrad)" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
4680
4681 <!-- Pads (split layout) -->
4682 ${padsSvg}
4683
4684 <!-- Octave labels -->
4685 <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>
4686 <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>
4687
4688 <!-- "notepat" branding -->
4689 <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>
4690 <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>
4691
4692 <!-- Footer tagline -->
4693 <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>
4694</svg>`;
4695
4696 // Convert SVG to PNG using sharp
4697 const buffer = await sharp(Buffer.from(svg))
4698 .png()
4699 .toBuffer();
4700
4701 // Upload to Spaces
4702 const key = `og/notepat/notepat-og.png`;
4703 await spacesClient.send(new PutObjectCommand({
4704 Bucket: SPACES_BUCKET,
4705 Key: key,
4706 Body: buffer,
4707 ContentType: 'image/png',
4708 ACL: 'public-read',
4709 CacheControl: 'public, max-age=604800', // 7-day cache
4710 }));
4711
4712 const url = `${SPACES_CDN_BASE}/${key}`;
4713
4714 // Update memory cache
4715 notepatOGCache.url = url;
4716 notepatOGCache.expires = Date.now() + 7 * 24 * 60 * 60 * 1000;
4717
4718 console.log(`📤 Notepat OG image uploaded: ${url} (${(buffer.length / 1024).toFixed(1)} KB)`);
4719 return { url, cached: false, buffer };
4720}
4721
4722/**
4723 * Get the latest cached notepat OG image URL without triggering generation.
4724 */
4725export async function getLatestNotepatOGUrl() {
4726 // Check memory cache
4727 if (notepatOGCache.url && Date.now() < notepatOGCache.expires) {
4728 return notepatOGCache.url;
4729 }
4730
4731 // Check Spaces
4732 const key = `og/notepat/notepat-og.png`;
4733 try {
4734 await spacesClient.send(new HeadObjectCommand({
4735 Bucket: SPACES_BUCKET,
4736 Key: key,
4737 }));
4738 const url = `${SPACES_CDN_BASE}/${key}`;
4739 notepatOGCache.url = url;
4740 notepatOGCache.expires = Date.now() + 60 * 60 * 1000; // 1hr memory cache
4741 return url;
4742 } catch {
4743 return null;
4744 }
4745}
4746
4747// =============================================================================
4748// NEWS OG IMAGE GENERATION
4749// =============================================================================
4750
4751// News OG image cache (memory, keyed by post code)
4752const newsOGCache = new Map();
4753
4754/**
4755 * Generate a branded OG image for a news.aesthetic.computer article (1200x630 PNG).
4756 * Shows title, author handle, and "Aesthetic News" branding.
4757 */
4758export async function generateNewsOGImage(post, forceRegenerate = false) {
4759 const code = post.code;
4760 console.log(`\n📰 News OG Image Request: ${code} (force: ${forceRegenerate})`);
4761
4762 // Check memory cache.
4763 if (!forceRegenerate) {
4764 const cached = newsOGCache.get(code);
4765 if (cached && Date.now() < cached.expires) {
4766 return { url: cached.url, cached: true };
4767 }
4768 }
4769
4770 // Check Spaces cache.
4771 const key = `og/news/${code}.png`;
4772 if (!forceRegenerate) {
4773 try {
4774 await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key }));
4775 const url = `${SPACES_CDN_BASE}/${key}`;
4776 newsOGCache.set(code, { url, expires: Date.now() + 60 * 60 * 1000 });
4777 return { url, cached: true };
4778 } catch {
4779 // Not cached, generate below.
4780 }
4781 }
4782
4783 const W = 1200;
4784 const H = 630;
4785
4786 // Escape XML entities for SVG.
4787 const esc = (s) =>
4788 (s || "")
4789 .replace(/&/g, "&")
4790 .replace(/</g, "<")
4791 .replace(/>/g, ">")
4792 .replace(/"/g, """);
4793
4794 const title = esc(post.title || "(untitled)");
4795 const handle = esc(post.handle || "@anon");
4796 const date = post.when ? new Date(post.when).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : "";
4797
4798 // Word-wrap title into lines that fit within the card.
4799 const maxCharsPerLine = 32;
4800 const words = title.split(/\s+/);
4801 const lines = [];
4802 let currentLine = "";
4803 for (const word of words) {
4804 if (currentLine && (currentLine + " " + word).length > maxCharsPerLine) {
4805 lines.push(currentLine);
4806 currentLine = word;
4807 } else {
4808 currentLine = currentLine ? currentLine + " " + word : word;
4809 }
4810 }
4811 if (currentLine) lines.push(currentLine);
4812 // Limit to 5 lines max.
4813 if (lines.length > 5) {
4814 lines.length = 5;
4815 lines[4] = lines[4].slice(0, maxCharsPerLine - 3) + "...";
4816 }
4817
4818 const titleFontSize = lines.length <= 2 ? 52 : lines.length <= 3 ? 44 : 36;
4819 const titleLineHeight = titleFontSize * 1.3;
4820 const titleBlockHeight = lines.length * titleLineHeight;
4821 const titleStartY = (H - titleBlockHeight) / 2 + titleFontSize * 0.8;
4822
4823 const titleLines = lines
4824 .map((line, i) => `<text x="${W / 2}" y="${titleStartY + i * titleLineHeight}" font-family="monospace" font-size="${titleFontSize}" font-weight="bold" fill="#e8e4de" text-anchor="middle">${line}</text>`)
4825 .join("\n ");
4826
4827 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
4828 <defs>
4829 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4830 <stop offset="0%" stop-color="#1a1a2e"/>
4831 <stop offset="100%" stop-color="#16213e"/>
4832 </linearGradient>
4833 </defs>
4834
4835 <!-- Background -->
4836 <rect width="${W}" height="${H}" fill="url(#bg)"/>
4837
4838 <!-- Subtle border -->
4839 <rect x="24" y="24" width="${W - 48}" height="${H - 48}" rx="8" fill="none" stroke="rgba(200,200,220,0.1)" stroke-width="1"/>
4840
4841 <!-- Aesthetic News branding -->
4842 <text x="60" y="72" font-family="monospace" font-size="18" font-weight="bold" fill="rgba(200,200,220,0.5)" letter-spacing="2">AESTHETIC NEWS</text>
4843
4844 <!-- Title -->
4845 ${titleLines}
4846
4847 <!-- Author + date footer -->
4848 <text x="60" y="${H - 45}" font-family="monospace" font-size="18" fill="rgba(180,180,200,0.6)">${handle}${date ? ` · ${esc(date)}` : ""}</text>
4849 <text x="${W - 60}" y="${H - 45}" font-family="monospace" font-size="16" fill="rgba(180,180,200,0.35)" text-anchor="end">news.aesthetic.computer</text>
4850</svg>`;
4851
4852 const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
4853
4854 // Upload to Spaces.
4855 await spacesClient.send(
4856 new PutObjectCommand({
4857 Bucket: SPACES_BUCKET,
4858 Key: key,
4859 Body: buffer,
4860 ContentType: "image/png",
4861 ACL: "public-read",
4862 CacheControl: "public, max-age=604800",
4863 }),
4864 );
4865
4866 const url = `${SPACES_CDN_BASE}/${key}`;
4867 newsOGCache.set(code, { url, expires: Date.now() + 7 * 24 * 60 * 60 * 1000 });
4868
4869 console.log(`📤 News OG image uploaded: ${url} (${(buffer.length / 1024).toFixed(1)} KB)`);
4870 return { url, cached: false, buffer };
4871}
4872
4873export { IPFS_GATEWAY };