Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

at main 4873 lines 167 kB view raw
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, '&amp;') 4189 .replace(/</g, '&lt;') 4190 .replace(/>/g, '&gt;') 4191 .replace(/"/g, '&quot;'); 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, "&amp;") 4790 .replace(/</g, "&lt;") 4791 .replace(/>/g, "&gt;") 4792 .replace(/"/g, "&quot;"); 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 };