Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879// Grabber - KidLisp piece screenshot/GIF capture using Puppeteer// Captures frames from running KidLisp pieces for thumbnails
import { spawn, execSync } from 'child_process';import { promises as fs } from 'fs';import { tmpdir } from 'os';import { join } from 'path';import { randomBytes, createHash } from 'crypto';import puppeteer from 'puppeteer';import { MongoClient } from 'mongodb';import { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';import sharp from 'sharp';import { readFileSync } from 'fs';import { dirname } from 'path';import { fileURLToPath } from 'url';
// Load Comic Relief font for OG image generationconst __dirname = dirname(fileURLToPath(import.meta.url));let comicReliefBoldBase64 = '';try { const fontPath = join(__dirname, 'fonts', 'ComicRelief-Bold.ttf'); comicReliefBoldBase64 = readFileSync(fontPath).toString('base64'); console.log('✅ Loaded Comic Relief Bold font for OG images');} catch (err) { console.warn('⚠️ Comic Relief font not found, OG images will use fallback font');}
// Git version for cache invalidation (set by env or detected)let GIT_VERSION = process.env.OVEN_VERSION || 'unknown';if (GIT_VERSION === 'unknown') { try { GIT_VERSION = execSync('git rev-parse --short HEAD', { encoding: 'utf8', cwd: '/workspaces/aesthetic-computer' }).trim(); } catch { // Not in a git repo }}
// DigitalOcean Spaces (S3-compatible) for caching icons/previewsconst spacesClient = new S3Client({ endpoint: process.env.ART_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com', region: 'sfo3', credentials: { accessKeyId: process.env.ART_SPACES_KEY || '', secretAccessKey: process.env.ART_SPACES_SECRET || '', },});const SPACES_BUCKET = process.env.ART_SPACES_BUCKET || 'art-aesthetic-computer';const SPACES_CDN_BASE = process.env.ART_CDN_BASE || 'https://art.aesthetic.computer';const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Stable cache version — bump manually when rendering pipeline changes meaningfully.// Do NOT tie to GIT_VERSION, which changes every deploy and invalidates all cached icons.const CACHE_RENDER_VERSION = 'v5';
// MongoDB connectionlet mongoClient;let db;
async function connectMongo() { if (!mongoClient) { const mongoUri = process.env.MONGODB_CONNECTION_STRING; const dbName = process.env.MONGODB_NAME; if (!mongoUri || !dbName) { console.warn('⚠️ MongoDB not configured, grab history will not persist'); return null; } try { mongoClient = await MongoClient.connect(mongoUri); db = mongoClient.db(dbName); console.log('✅ Connected to MongoDB for grab history'); } catch (error) { console.error('❌ Failed to connect to MongoDB:', error.message); return null; } } return db;}
// Pinata IPFS upload configurationconst PINATA_API_URL = 'https://api.pinata.cloud';
// IPFS gateway for serving contentconst IPFS_GATEWAY = 'https://ipfs.aesthetic.computer';
// App Store Screenshot Presets (Google Play requirements)// All dimensions meet the 16:9 or 9:16 aspect ratio requirement// Phone screenshots need 1080px minimum for promotion eligibilityexport const APP_SCREENSHOT_PRESETS = { // Phone screenshots (9:16 portrait, meets 1080px promotion requirement) 'phone-portrait': { width: 1080, height: 1920, label: 'Phone Portrait', category: 'phone' }, 'phone-landscape': { width: 1920, height: 1080, label: 'Phone Landscape', category: 'phone' }, // 7-inch tablet (9:16 portrait, 320-3840px range) 'tablet7-portrait': { width: 1200, height: 1920, label: '7" Tablet Portrait', category: 'tablet7' }, 'tablet7-landscape': { width: 1920, height: 1200, label: '7" Tablet Landscape', category: 'tablet7' }, // 10-inch tablet (9:16 portrait, 1080-7680px range for 10" tablets) 'tablet10-portrait': { width: 1600, height: 2560, label: '10" Tablet Portrait', category: 'tablet10' }, 'tablet10-landscape': { width: 2560, height: 1600, label: '10" Tablet Landscape', category: 'tablet10' },};
// Reusable browser instancelet browser = null;let browserLaunchPromise = null;
// Grab queue with concurrent worker poolconst grabQueue = [];let grabsRunning = 0;const MAX_CONCURRENT_GRABS = 8;const RESERVED_KEEP_SLOTS = 1; // Reserve 1 slot for keep (NFT mint) grabs
// Per-grab progress tracking (keyed by grabId)const grabProgressMap = new Map();
function createEmptyProgress(grabId, piece, format) { return { grabId, piece, format, stage: null, stageDetail: null, framesCaptured: 0, framesTotal: 0, percent: 0, previewFrame: null, previewWidth: 0, previewHeight: 0, };}
// Callback for notifying subscribers of progress updateslet progressNotifyCallback = null;
/** * Set a callback to be notified when progress/status updates occur */export function setNotifyCallback(callback) { progressNotifyCallback = callback;}
/** * Notify all subscribers of status change */function notifySubscribers() { if (progressNotifyCallback) { progressNotifyCallback(); }}
/** * Update progress state for a specific grab */export function updateProgress(grabId, updates) { let progress = grabProgressMap.get(grabId); if (!progress) { progress = createEmptyProgress(grabId, updates.piece, updates.format); grabProgressMap.set(grabId, progress); } Object.assign(progress, updates); notifySubscribers();}
/** * Capture a low-res preview screenshot from a Puppeteer page * @param {Page} page - Puppeteer page * @param {number} width - Preview width (default 64) * @param {number} height - Preview height (default 64) * @returns {Promise<string|null>} Base64 encoded JPEG or null on error */async function capturePreviewFrame(page, width = 64, height = 64) { try { // Use page.screenshot() which handles deviceScaleFactor correctly, // then resize with sharp. The old CDP clip.scale approach was wrong — // it captured a top-left crop instead of a scaled-down full-frame preview. const buffer = await Promise.race([ page.screenshot({ type: 'jpeg', quality: 20 }), new Promise((_, reject) => setTimeout(() => reject(new Error('Preview timeout')), 500)) ]); if (!buffer) return null; const resized = await sharp(buffer) .resize(width, height, { fit: 'fill' }) .jpeg({ quality: 20 }) .toBuffer(); return resized.toString('base64'); } catch (err) { return null; // Don't fail on preview errors }}
/** * Update progress with a preview frame * @param {Page} page - Puppeteer page * @param {object} updates - Other progress updates */async function updateProgressWithPreview(grabId, page, updates) { const previewFrame = await capturePreviewFrame(page, 80, 80); updateProgress(grabId, { ...updates, previewFrame, previewWidth: 80, previewHeight: 80, });}
/** * Get current progress state (backward-compat: returns first active grab's progress) */export function getCurrentProgress() { for (const progress of grabProgressMap.values()) { if (progress.stage) return { ...progress }; } return createEmptyProgress(null, null, null);}
/** * Get all active grab progress entries */export function getAllProgress() { const result = {}; for (const [grabId, progress] of grabProgressMap.entries()) { result[grabId] = { ...progress }; } return result;}
/** * Get concurrency status */export function getConcurrencyStatus() { return { active: grabsRunning, max: MAX_CONCURRENT_GRABS, queueDepth: grabQueue.length };}
/** * Clear progress for a completed grab */function clearProgress(grabId) { grabProgressMap.delete(grabId);}
/** * Get queue status with estimated wait times */export function getQueueStatus() { return grabQueue.map((item, index) => ({ position: index + 1, piece: item.metadata?.piece || 'unknown', format: item.metadata?.format || '?', addedAt: item.metadata?.addedAt || Date.now(), estimatedWait: estimateWaitTime(index + 1), }));}
// Track recent grab durations for ETA estimationconst recentDurations = [];const MAX_DURATION_SAMPLES = 10;const DEFAULT_GRAB_DURATION_MS = 30000; // 30 seconds default estimate
/** * Record a grab duration for ETA estimation */export function recordGrabDuration(durationMs) { recentDurations.push(durationMs); if (recentDurations.length > MAX_DURATION_SAMPLES) { recentDurations.shift(); }}
/** * Estimate wait time based on queue position and average duration */export function estimateWaitTime(queuePosition) { const avgDuration = recentDurations.length > 0 ? recentDurations.reduce((a, b) => a + b, 0) / recentDurations.length : DEFAULT_GRAB_DURATION_MS; const batchesAhead = Math.ceil(queuePosition / MAX_CONCURRENT_GRABS); return Math.round(avgDuration * batchesAhead);}
async function enqueueGrab(fn, metadata = {}) { return new Promise((resolve, reject) => { // Deduplicate: silently resolve if the same captureKey is already waiting in queue if (metadata.captureKey) { const alreadyQueued = grabQueue.some( item => item.metadata?.captureKey === metadata.captureKey ); if (alreadyQueued) { console.log(`♻️ Dedup: ${metadata.piece} already queued, skipping`); resolve({ success: true, cached: true, deduplicated: true, piece: metadata.piece }); return; } }
const queueItem = { fn, resolve, reject, metadata: { ...metadata, addedAt: Date.now() } };
// Priority queue: 'keep' (NFT minting) jumps to front, others go to back const isKeep = metadata.source === 'keep'; if (isKeep) { // Find first non-keep item and insert before it (maintains FIFO among keeps) const firstNonKeepIndex = grabQueue.findIndex(item => item.metadata?.source !== 'keep'); if (firstNonKeepIndex === -1) { grabQueue.push(queueItem); // All items are keeps or queue is empty } else { grabQueue.splice(firstNonKeepIndex, 0, queueItem); // Insert before first non-keep } console.log(`🎯 Priority grab queued: ${metadata.piece} (keep #${metadata.keepId || '?'}) - position ${firstNonKeepIndex === -1 ? grabQueue.length : firstNonKeepIndex + 1}`); } else { grabQueue.push(queueItem); }
// Notify subscribers of queue change if (progressNotifyCallback) progressNotifyCallback(); processGrabQueue(); });}
async function processGrabQueue() { while (grabQueue.length > 0) { const nextItem = grabQueue[0]; const isKeepGrab = nextItem.metadata?.source === 'keep';
// Keep grabs can use all slots; non-keep grabs must leave RESERVED_KEEP_SLOTS free const maxForThis = isKeepGrab ? MAX_CONCURRENT_GRABS : MAX_CONCURRENT_GRABS - RESERVED_KEEP_SLOTS; if (grabsRunning >= maxForThis) { // If slots are full for this type, check if a keep is waiting further back if (!isKeepGrab) { const keepIdx = grabQueue.findIndex(item => item.metadata?.source === 'keep'); if (keepIdx > 0 && grabsRunning < MAX_CONCURRENT_GRABS) { // Pull the keep grab forward and run it in the reserved slot const keepItem = grabQueue.splice(keepIdx, 1)[0]; grabQueue.unshift(keepItem); continue; } } break; }
grabsRunning++; const { fn, resolve, reject, metadata } = grabQueue.shift();
const priorityLabel = metadata?.source === 'keep' ? ' [PRIORITY]' : ''; console.log(`📋 Processing queue item: ${metadata?.piece || 'unknown'}${priorityLabel} (${grabsRunning}/${MAX_CONCURRENT_GRABS} active, ${grabQueue.length} queued)`);
// Fire-and-forget async — each grab runs independently with timeout protection (async () => { // Max grab duration: 90 seconds (enough for 30s load + 30s capture/encode + 30s buffer) const GRAB_TIMEOUT_MS = 90000; const startTime = Date.now();
const timeoutId = setTimeout(() => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); const error = new Error(`Grab exceeded ${GRAB_TIMEOUT_MS/1000}s timeout`); console.error(`⏱️ Grab timeout: ${metadata?.piece || 'unknown'} after ${elapsed}s`);
// Mark any active grabs for this piece as timed out const pieceName = metadata?.piece?.replace(/^\$/, ''); if (pieceName) { for (const [grabId, grab] of activeGrabs.entries()) { if (grab.piece === metadata.piece && grab.status === 'capturing') { console.log(` Marking ${grabId} as timed-out`); grab.status = 'timeout'; grab.error = `Exceeded ${GRAB_TIMEOUT_MS/1000}s timeout`; grab.completedAt = Date.now();
// Move to recent and remove from active recentGrabs.unshift({ ...grab, duration: Date.now() - grab.startTime, }); activeGrabs.delete(grabId); } } }
// Reset browser on timeout to prevent cascading failures console.log('🔄 Resetting browser after timeout...'); browser = null;
reject(error); }, GRAB_TIMEOUT_MS);
try { const result = await fn(); clearTimeout(timeoutId); resolve(result); } catch (error) { clearTimeout(timeoutId); console.error(`❌ Queue item failed: ${metadata?.piece || 'unknown'} - ${error.message}`);
// If it's a browser connection error, try to reset the browser if (error.message.includes('Connection closed') || error.message.includes('disconnected') || error.message.includes('Target closed')) { console.log('🔄 Browser connection lost, resetting browser...'); browser = null; }
reject(error); } finally { grabsRunning--; const delay = browser === null ? 500 : 100; if (grabQueue.length > 0) { console.log(`📋 Slot freed (${grabsRunning}/${MAX_CONCURRENT_GRABS} active, ${grabQueue.length} queued)`); setTimeout(processGrabQueue, delay); } } })(); }}
// In-memory tracking (similar to baker.mjs)const activeGrabs = new Map();const recentGrabs = [];
// Track in-progress grabs by captureKey for deduplicationconst inProgressByKey = new Map(); // captureKey -> { grabId, piece, format, startTime, queuePosition }
/** * Check if a grab is currently in progress or queued for this captureKey * @param {string} captureKey - The capture key to check * @returns {{ inProgress: boolean, grabId?: string, piece?: string, queuePosition?: number, estimatedWait?: number }} */export function getInProgressGrab(captureKey) { // Check activeGrabs for matching captureKey for (const [grabId, grab] of activeGrabs.entries()) { if (grab.captureKey === captureKey) { // Find queue position if queued const queueIndex = grabQueue.findIndex(q => q.metadata?.piece === grab.piece); const queuePosition = queueIndex >= 0 ? queueIndex + 1 : 0; return { inProgress: true, grabId, piece: grab.piece, status: grab.status, queuePosition, estimatedWait: queuePosition > 0 ? estimateWaitTime(queuePosition) : 5000, }; } } return { inProgress: false };}
/** * Generate a "baking" placeholder image showing queue status * @param {object} options - { width, height, format, piece, queuePosition, estimatedWait } * @returns {Promise<Buffer>} - WebP/PNG image buffer */export async function generateBakingPlaceholder(options = {}) { const { width = 200, height = 200, format = 'webp', piece = '?', queuePosition = 0, estimatedWait = 30000, } = options; // Simple gradient background with centered text const bgColor = { r: 30, g: 30, b: 40 }; // Dark blue-gray const accentColor = { r: 255, g: 180, b: 50 }; // Warm yellow/orange // Status text const statusText = queuePosition > 0 ? `#${queuePosition} in queue` : 'baking...'; const etaSeconds = Math.ceil(estimatedWait / 1000); const etaText = etaSeconds > 60 ? `~${Math.ceil(etaSeconds / 60)}m` : `~${etaSeconds}s`; // Create SVG with styling const cleanPiece = piece.replace(/^\$/, '').slice(0, 12); const svg = ` <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> <stop offset="0%" style="stop-color:rgb(${bgColor.r},${bgColor.g},${bgColor.b})"/> <stop offset="100%" style="stop-color:rgb(${bgColor.r + 20},${bgColor.g + 20},${bgColor.b + 30})"/> </linearGradient> </defs> <rect width="100%" height="100%" fill="url(#bg)"/> <text x="50%" y="40%" text-anchor="middle" fill="rgb(${accentColor.r},${accentColor.g},${accentColor.b})" font-family="monospace" font-size="${Math.max(12, width / 10)}px" font-weight="bold"> 🔥 </text> <text x="50%" y="55%" text-anchor="middle" fill="white" font-family="monospace" font-size="${Math.max(10, width / 14)}px"> ${statusText} </text> <text x="50%" y="70%" text-anchor="middle" fill="rgba(255,255,255,0.6)" font-family="monospace" font-size="${Math.max(8, width / 18)}px"> ${cleanPiece} </text> <text x="50%" y="85%" text-anchor="middle" fill="rgba(${accentColor.r},${accentColor.g},${accentColor.b},0.8)" font-family="monospace" font-size="${Math.max(8, width / 20)}px"> ${etaText} </text> </svg> `; // Convert SVG to image let image = sharp(Buffer.from(svg)).resize(width, height); if (format === 'webp') { return image.webp({ quality: 80 }).toBuffer(); } else if (format === 'gif') { // GIF doesn't support as easily, output PNG return image.png().toBuffer(); } else { return image.png().toBuffer(); }}
// Track frozen pieces (pieces that failed due to identical frames)const frozenPieces = new Map(); // piece -> { piece, attempts, lastAttempt, firstDetected, error }
// Track most recent IPFS uploads per piece (for live collection thumbnail)const latestIPFSUploads = new Map(); // piece -> { ipfsCid, ipfsUri, timestamp, ... }let latestKeepThumbnail = null; // Most recent across all piecesconst KEEPS_SECRET_ID = process.env.KEEPS_SECRET_ID || 'tezos-kidlisp';const KEEP_THUMBNAIL_FALLBACK_TTL_MS = 30 * 1000;let latestKeepFallbackCheckedAt = 0;
function extractIpfsCidFromUri(uri) { if (typeof uri !== 'string') return null; const trimmed = uri.trim(); if (!trimmed) return null;
if (trimmed.startsWith('ipfs://')) { const noScheme = trimmed.slice('ipfs://'.length).replace(/^ipfs\//, ''); return noScheme.split(/[/?#]/)[0] || null; }
const gatewayMatch = trimmed.match(/\/ipfs\/([^/?#]+)/i); if (gatewayMatch?.[1]) return gatewayMatch[1];
return null;}
async function loadLatestKeepThumbnailFromKidlisp() { const database = await connectMongo(); if (!database) return null;
try { const secrets = database.collection('secrets'); const kidlisp = database.collection('kidlisp');
const secretDoc = await secrets.findOne( { _id: KEEPS_SECRET_ID }, { projection: { currentKeepsContract: 1, keepsContract: 1 } } ); const activeContract = secretDoc?.currentKeepsContract || secretDoc?.keepsContract?.mainnet || null;
let pieceDoc = null; let thumbnailUri = null; let eventTime = null;
if (activeContract) { const contractPath = `tezos.contracts.${activeContract}`; pieceDoc = await kidlisp.find( { [`${contractPath}.minted`]: true, [`${contractPath}.thumbnailUri`]: { $type: 'string' }, }, { projection: { code: 1, tezos: 1, when: 1, }, } ).sort({ [`${contractPath}.mintedAt`]: -1, when: -1, }).limit(1).next();
if (pieceDoc) { const contractKeep = pieceDoc?.tezos?.contracts?.[activeContract]; thumbnailUri = contractKeep?.thumbnailUri || null; eventTime = contractKeep?.mintedAt || contractKeep?.lastConfirmAt || pieceDoc?.when || null; } }
if (!pieceDoc) { pieceDoc = await kidlisp.find( { 'kept.thumbnailUri': { $type: 'string' } }, { projection: { code: 1, kept: 1, when: 1 } } ).sort({ 'kept.keptAt': -1, when: -1, }).limit(1).next();
if (pieceDoc) { thumbnailUri = pieceDoc?.kept?.thumbnailUri || null; eventTime = pieceDoc?.kept?.keptAt || pieceDoc?.when || null; } }
const ipfsCid = extractIpfsCidFromUri(thumbnailUri); if (!ipfsCid || !pieceDoc?.code) return null;
const timestamp = Number.isFinite(new Date(eventTime).getTime()) ? new Date(eventTime).getTime() : Date.now(); const uploadInfo = { ipfsCid, ipfsUri: `ipfs://${ipfsCid}`, piece: pieceDoc.code, format: 'webp', mimeType: 'image/webp', timestamp, source: 'kidlisp-fallback', contractAddress: activeContract || null, };
latestIPFSUploads.set(pieceDoc.code, uploadInfo); latestKeepThumbnail = uploadInfo; console.log(`📸 Loaded latest keep thumbnail fallback: $${pieceDoc.code} (${uploadInfo.ipfsUri})`); return uploadInfo; } catch (error) { console.error('❌ Failed to load latest keep thumbnail fallback:', error.message); return null; }}
export async function ensureLatestKeepThumbnail() { if (latestKeepThumbnail) return latestKeepThumbnail;
const now = Date.now(); if (now - latestKeepFallbackCheckedAt < KEEP_THUMBNAIL_FALLBACK_TTL_MS) { return latestKeepThumbnail; }
latestKeepFallbackCheckedAt = now; await loadLatestKeepThumbnailFromKidlisp(); return latestKeepThumbnail;}
// Stale grab timeout: grabs older than this are considered stuck and can be cleaned upconst STALE_GRAB_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** * Clean up stale grabs that have been active for too long (likely stuck) * @returns {{ cleaned: number, remaining: number }} */export function cleanupStaleGrabs() { const now = Date.now(); let cleaned = 0; for (const [grabId, grab] of activeGrabs.entries()) { const age = now - grab.startTime; if (age > STALE_GRAB_TIMEOUT_MS) { console.log(`🧹 Cleaning up stale grab: ${grabId} (age: ${Math.round(age / 1000)}s)`); serverLog('cleanup', '🧹', `Cleaned stale grab: ${grab.piece} (stuck for ${Math.round(age / 1000)}s)`); // Mark as failed and move to recent grab.status = 'stale-cleaned'; grab.error = 'Grab timed out and was cleaned up'; grab.completedAt = now; activeGrabs.delete(grabId); recentGrabs.unshift(grab); if (recentGrabs.length > 20) recentGrabs.pop(); saveGrab(grab); cleaned++; } } if (cleaned > 0) { notifySubscribers(); console.log(`🧹 Cleaned ${cleaned} stale grabs`); } return { cleaned, remaining: activeGrabs.size };}
/** * Clear all active grabs (emergency reset) * @returns {{ cleared: number }} */export function clearAllActiveGrabs() { const count = activeGrabs.size; const now = Date.now(); for (const [grabId, grab] of activeGrabs.entries()) { console.log(`🗑️ Force clearing grab: ${grabId}`); grab.status = 'force-cleared'; grab.error = 'Manually cleared by admin'; grab.completedAt = now; recentGrabs.unshift(grab); if (recentGrabs.length > 20) recentGrabs.pop(); saveGrab(grab); } activeGrabs.clear(); // Also clear the queue and progress const queueCount = grabQueue.length; grabQueue.length = 0; grabsRunning = 0; grabProgressMap.clear(); notifySubscribers(); serverLog('cleanup', '🗑️', `Force cleared ${count} active grabs and ${queueCount} queued items`); return { cleared: count, queueCleared: queueCount };}
/** * Record a frozen piece (failed due to identical frames) * @param {string} piece - The piece name (e.g. '$woww') * @param {string} error - The error message * @param {string} previewUrl - Optional CDN URL of the frozen preview */function recordFrozenPiece(piece, error, previewUrl = null) { const existing = frozenPieces.get(piece); const now = Date.now(); if (existing) { existing.attempts++; existing.lastAttempt = now; existing.error = error; if (previewUrl) existing.previewUrl = previewUrl; console.log(`🥶 Frozen piece updated: ${piece} (${existing.attempts} attempts)`); } else { frozenPieces.set(piece, { piece, attempts: 1, firstDetected: now, lastAttempt: now, error, previewUrl, }); console.log(`🥶 New frozen piece recorded: ${piece}`); } // Persist to MongoDB saveFrozenPiece(piece);}
/** * Save frozen piece to MongoDB */async function saveFrozenPiece(piece) { const database = await connectMongo(); if (!database) return; const frozen = frozenPieces.get(piece); if (!frozen) return; try { const collection = database.collection('oven-frozen-pieces'); await collection.updateOne( { piece }, { $set: frozen, $setOnInsert: { createdAt: new Date() } }, { upsert: true } ); } catch (error) { console.error('❌ Failed to save frozen piece to MongoDB:', error.message); }}
/** * Load frozen pieces from MongoDB on startup */async function loadFrozenPieces() { const database = await connectMongo(); if (!database) return; try { const collection = database.collection('oven-frozen-pieces'); const pieces = await collection.find({}).sort({ lastAttempt: -1 }).limit(100).toArray(); for (const p of pieces) { frozenPieces.set(p.piece, { piece: p.piece, attempts: p.attempts || 1, firstDetected: p.firstDetected || p.createdAt?.getTime() || Date.now(), lastAttempt: p.lastAttempt || Date.now(), error: p.error || 'Frozen animation', }); } console.log(`📂 Loaded ${pieces.length} frozen pieces from MongoDB`); } catch (error) { console.error('❌ Failed to load frozen pieces from MongoDB:', error.message); }}
/** * Get list of all frozen pieces * @returns {Array} Array of frozen piece objects */export function getFrozenPieces() { return Array.from(frozenPieces.values()).sort((a, b) => b.lastAttempt - a.lastAttempt);}
/** * Clear a piece from the frozen list (e.g., after fixing it) * @param {string} piece - The piece name to clear */export async function clearFrozenPiece(piece) { frozenPieces.delete(piece); const database = await connectMongo(); if (database) { try { await database.collection('oven-frozen-pieces').deleteOne({ piece }); console.log(`✅ Cleared frozen piece: ${piece}`); } catch (error) { console.error('❌ Failed to clear frozen piece from MongoDB:', error.message); } } return { success: true, piece };}
// Run stale cleanup every 2 minutessetInterval(() => { if (activeGrabs.size > 0) { cleanupStaleGrabs(); }}, 2 * 60 * 1000);
/** * Load recent grabs from MongoDB on startup */async function loadRecentGrabs() { const database = await connectMongo(); if (!database) return; try { const collection = database.collection('oven-grabs'); const grabs = await collection.find({}).sort({ completedAt: -1 }).limit(20).toArray(); recentGrabs.length = 0; recentGrabs.push(...grabs.map(g => ({ ...g, completedAt: g.completedAt?.getTime?.() || g.completedAt, startTime: g.startTime?.getTime?.() || g.startTime, }))); // Also restore latestIPFSUploads from grabs that have IPFS data for (const grab of grabs) { if (grab.ipfsCid && grab.piece) { const existing = latestIPFSUploads.get(grab.piece); const grabTime = grab.completedAt?.getTime?.() || grab.completedAt || 0; if (!existing || grabTime > existing.timestamp) { latestIPFSUploads.set(grab.piece, { ipfsCid: grab.ipfsCid, ipfsUri: grab.ipfsUri, timestamp: grabTime, piece: grab.piece, format: grab.format, }); } } } // Set latestKeepThumbnail to most recent IPFS upload if (latestIPFSUploads.size > 0) { let mostRecent = null; for (const upload of latestIPFSUploads.values()) { if (!mostRecent || upload.timestamp > mostRecent.timestamp) { mostRecent = upload; } } latestKeepThumbnail = mostRecent; } if (!latestKeepThumbnail) { await ensureLatestKeepThumbnail(); }
console.log(`📂 Loaded ${recentGrabs.length} recent grabs from MongoDB (${latestIPFSUploads.size} with IPFS)`); } catch (error) { console.error('❌ Failed to load recent grabs:', error.message); }}
/** * Save a grab to MongoDB */async function saveGrab(grab) { const database = await connectMongo(); if (!database) return; try { const collection = database.collection('oven-grabs'); await collection.insertOne({ ...grab, completedAt: new Date(grab.completedAt), startTime: new Date(grab.startTime), }); } catch (error) { console.error('❌ Failed to save grab to MongoDB:', error.message); }}
/** * Update a grab in MongoDB (e.g., after IPFS upload) */async function updateGrabInMongo(grabId, updates) { const database = await connectMongo(); if (!database) return; try { const collection = database.collection('oven-grabs'); await collection.updateOne( { grabId }, { $set: updates } ); } catch (error) { console.error('❌ Failed to update grab in MongoDB:', error.message); }}
/** * Check if all frames are identical (frozen animation) * Compares frame hashes to detect if the capture is static * @param {Buffer[]} frames - Array of PNG frame buffers * @returns {Promise<boolean>} True if all frames are the same (frozen) */async function areFramesIdentical(frames) { if (frames.length <= 1) return false; // Single frame can't be "frozen" try { // Hash each frame and compare const hashes = []; for (const frame of frames) { const hash = createHash('md5').update(frame).digest('hex'); hashes.push(hash); } // Check if all hashes are the same const firstHash = hashes[0]; const allSame = hashes.every(h => h === firstHash); if (allSame) { console.log(` ⚠️ All ${frames.length} frames are identical (frozen)`); } else { // Count unique frames const uniqueHashes = new Set(hashes); console.log(` ✅ Found ${uniqueHashes.size} unique frames out of ${frames.length}`); } return allSame; } catch (error) { console.error('⚠️ Failed to check frame identity:', error.message); return false; // Assume not frozen if check fails }}
/** * Check if frames have uniform/solid color content (a "dud" animation) * This catches pieces that are just solid wipe colors with no actual visual content * @param {Buffer[]} frames - Array of PNG frame buffers * @returns {Promise<{ isUniform: boolean, color?: string, reason?: string }>} */async function isUniformColorContent(frames) { if (frames.length === 0) return { isUniform: false }; try { const sharp = (await import('sharp')).default; // Sample multiple frames across the animation const sampleIndices = [ 0, Math.floor(frames.length / 4), Math.floor(frames.length / 2), Math.floor(frames.length * 3 / 4), frames.length - 1 ].filter((v, i, arr) => arr.indexOf(v) === i && v < frames.length); // Grid sample points (like give page's validateWebpImage) const gridSize = 5; for (const frameIdx of sampleIndices) { const frame = frames[frameIdx]; // Sample at a consistent size for analysis const sampleSize = 64; const { data, info } = await sharp(frame) .resize(sampleSize, sampleSize, { fit: 'fill' }) .raw() .toBuffer({ resolveWithObject: true }); const channels = info.channels; // Generate sample points in a grid pattern const samplePoints = []; const gap = Math.floor(sampleSize / gridSize); for (let gx = 0; gx < gridSize; gx++) { for (let gy = 0; gy < gridSize; gy++) { samplePoints.push({ x: Math.min(gap / 2 + gx * gap, sampleSize - 1), y: Math.min(gap / 2 + gy * gap, sampleSize - 1) }); } } // Sample pixel colors let firstColor = null; let maxColorDiff = 0; for (const pt of samplePoints) { const idx = (Math.floor(pt.y) * sampleSize + Math.floor(pt.x)) * channels; const r = data[idx]; const g = data[idx + 1]; const b = data[idx + 2]; if (firstColor === null) { firstColor = { r, g, b }; } else { const colorDiff = Math.abs(r - firstColor.r) + Math.abs(g - firstColor.g) + Math.abs(b - firstColor.b); maxColorDiff = Math.max(maxColorDiff, colorDiff); } } // If this frame has variance, the animation is not uniform if (maxColorDiff >= 30) { return { isUniform: false }; } } // All sampled frames have uniform color - this is a dud // Get the color from the first frame for reporting const { data } = await sharp(frames[0]) .resize(1, 1, { fit: 'fill' }) .raw() .toBuffer({ resolveWithObject: true }); const hex = `#${data[0].toString(16).padStart(2, '0')}${data[1].toString(16).padStart(2, '0')}${data[2].toString(16).padStart(2, '0')}`; console.log(` ⚠️ Uniform color content detected: ${hex}`); return { isUniform: true, color: hex, reason: `UNIFORM_COLOR:${hex}` }; } catch (error) { console.error('⚠️ Failed to check uniform color:', error.message); return { isUniform: false }; // Assume not uniform if check fails }}
/** * Check if a frame is blank (all black, all transparent, or nearly uniform) * Uses sharp to analyze the image efficiently */async function isBlankFrame(buffer) { try { const sharp = (await import('sharp')).default; // Get image stats - sample a smaller version for speed const { channels, width, height } = await sharp(buffer) .resize(32, 32, { fit: 'fill' }) // Small sample for speed .raw() .toBuffer({ resolveWithObject: true }) .then(({ data, info }) => { // Analyze pixel data let totalR = 0, totalG = 0, totalB = 0, totalA = 0; let nonTransparentPixels = 0; let hasColor = false; const pixelCount = info.width * info.height; const channels = info.channels; for (let i = 0; i < data.length; i += channels) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; const a = channels === 4 ? data[i + 3] : 255; if (a > 10) { // Not fully transparent nonTransparentPixels++; totalR += r; totalG += g; totalB += b; // Check if there's any meaningful color (not just black) if (r > 5 || g > 5 || b > 5) { hasColor = true; } } totalA += a; } // Blank if: all transparent OR all black (no color) const avgAlpha = totalA / pixelCount; const isAllTransparent = avgAlpha < 10; const isAllBlack = nonTransparentPixels > 0 && !hasColor; return { isBlank: isAllTransparent || isAllBlack, avgAlpha, nonTransparentPixels, hasColor, width: info.width, height: info.height, channels: info.channels }; }); return channels.isBlank; } catch (error) { console.error('⚠️ Failed to check if frame is blank:', error.message); return false; // Assume not blank if check fails }}
// Load recent grabs and frozen pieces on startuploadRecentGrabs();loadFrozenPieces();
function normalizeCacheKey(cacheKey) { if (cacheKey === null || cacheKey === undefined) return ''; const normalized = String(cacheKey) .trim() .replace(/[^a-zA-Z0-9_-]/g, '-') .replace(/-+/g, '-') .slice(0, 48); return normalized;}
/** * Generate a unique capture key for deduplication * Format: {piece}_{width}x{height}_{format}_{animated}_{renderVersion}[_key] */function getCaptureKey(piece, width, height, format, animated = false, cacheKey = '') { const cleanPiece = piece.replace(/^\$/, ''); // Normalize piece name const animFlag = animated ? 'anim' : 'still'; const normalizedKey = normalizeCacheKey(cacheKey); const keySuffix = normalizedKey ? `_${normalizedKey}` : ''; return `${cleanPiece}_${width}x${height}_${format}_${animFlag}_${CACHE_RENDER_VERSION}${keySuffix}`;}
/** * Check if a capture already exists with the same key * @returns {{ exists: boolean, cdnUrl?: string, grab?: object }} */async function checkExistingCapture(captureKey) { const database = await connectMongo(); if (!database) return { exists: false }; try { // Check oven-grabs collection for matching capture const grab = await database.collection('oven-grabs').findOne({ captureKey }); if (grab && grab.cdnUrl) { console.log(`✅ Found existing capture: ${captureKey}`); return { exists: true, cdnUrl: grab.cdnUrl, grab }; } // Also check oven-cache for icons/previews const cache = await database.collection('oven-cache').findOne({ key: { $regex: captureKey.replace(/_/g, '.*') } }); if (cache && cache.cdnUrl && cache.expiresAt > new Date()) { console.log(`✅ Found cached capture: ${captureKey}`); return { exists: true, cdnUrl: cache.cdnUrl }; } return { exists: false }; } catch (error) { console.error('❌ Failed to check existing capture:', error.message); return { exists: false }; }}
/** * Check if a cached image exists in Spaces and is still valid * @returns {string|null} CDN URL if valid cache exists, null otherwise */async function checkSpacesCache(cacheKey) { if (!process.env.ART_SPACES_KEY) return null; const database = await connectMongo(); if (!database) return null; try { const cache = database.collection('oven-cache'); const entry = await cache.findOne({ key: cacheKey }); if (entry && entry.expiresAt > new Date()) { // Cache hit - return CDN URL return entry.cdnUrl; } return null; // Cache miss or expired } catch (error) { console.error('❌ Cache check failed:', error.message); return null; }}
/** * Upload image to Spaces and save cache entry * @returns {string} CDN URL */async function uploadToSpaces(buffer, cacheKey, contentType = 'image/png') { if (!process.env.ART_SPACES_KEY) { throw new Error('Spaces not configured'); } const spacesKey = `oven/${cacheKey}`; // Upload to Spaces await spacesClient.send(new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: spacesKey, Body: buffer, ContentType: contentType, ACL: 'public-read', CacheControl: 'public, max-age=604800', // 7 day browser cache })); const cdnUrl = `${SPACES_CDN_BASE}/${spacesKey}`; // Save cache entry to MongoDB const database = await connectMongo(); if (database) { try { const cache = database.collection('oven-cache'); await cache.updateOne( { key: cacheKey }, { $set: { key: cacheKey, spacesKey, cdnUrl, contentType, size: buffer.length, generatedAt: new Date(), expiresAt: new Date(Date.now() + CACHE_TTL_MS), } }, { upsert: true } ); } catch (error) { console.error('❌ Failed to save cache entry:', error.message); } } console.log(`📦 Cached to Spaces: ${cdnUrl}`); return cdnUrl;}
/** * Get cached image or generate and cache * Uses git version in cache key for automatic invalidation on code changes * @param {string} ext - File extension (default: 'png') * @param {boolean} skipCache - Skip cache lookup (default: false) * @returns {{ cdnUrl: string, fromCache: boolean, buffer?: Buffer }} */export async function getCachedOrGenerate(type, piece, width, height, generateFn, ext = 'png', skipCache = false) { // Use stable render version — only changes when rendering pipeline is updated const cacheKey = `${type}/${piece}-${width}x${height}-${CACHE_RENDER_VERSION}.${ext}`; const mimeType = ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/png'; // Check cache first (unless skipCache is true) if (!skipCache) { const cachedUrl = await checkSpacesCache(cacheKey); if (cachedUrl) { console.log(`✅ Cache hit: ${cacheKey}`); serverLog('info', '💾', `Cache hit: ${piece} (${width}×${height})`); return { cdnUrl: cachedUrl, fromCache: true }; } } else { console.log(`⚡ Force regenerate: ${cacheKey}`); serverLog('capture', '⚡', `Force regenerate: ${piece} (${width}×${height})`); } // Generate fresh console.log(`🔄 Cache miss: ${cacheKey}, generating...`); serverLog('capture', '🔄', `Cache miss: ${piece} - generating ${width}×${height}...`); const buffer = await generateFn(); // Upload to Spaces (async, don't block response) if (process.env.ART_SPACES_KEY) { uploadToSpaces(buffer, cacheKey, mimeType).catch(e => { console.error('❌ Failed to cache to Spaces:', e.message); }); } return { cdnUrl: null, fromCache: false, buffer };}
// Activity log callback (will be set by server.mjs)let logCallback = null;export function setLogCallback(cb) { logCallback = cb;}function serverLog(type, icon, msg) { if (logCallback) logCallback(type, icon, msg);}
/** * Get or launch the shared browser instance */async function getBrowser() { // Check if existing browser is still usable if (browser) { try { if (browser.isConnected()) { return browser; } else { console.log('⚠️ Browser disconnected, will relaunch...'); browser = null; } } catch (e) { console.log('⚠️ Browser check failed, will relaunch:', e.message); browser = null; } } // Prevent multiple simultaneous launches if (browserLaunchPromise) { return browserLaunchPromise; } browserLaunchPromise = (async () => { console.log('🌐 Launching Puppeteer browser...'); // Use system Chromium if available (works better on ARM64) const fs = await import('fs'); let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH; if (!executablePath && fs.existsSync('/usr/sbin/chromium-browser')) { executablePath = '/usr/sbin/chromium-browser'; } if (executablePath) { console.log(`📍 Using browser at: ${executablePath}`); } browser = await puppeteer.launch({ headless: 'new', executablePath, protocolTimeout: 120000, // 120s timeout for CDP protocol calls (increased) args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', // Enable WebGL for KidLisp pieces '--enable-webgl', '--use-gl=swiftshader', '--enable-unsafe-webgl', '--window-size=800,800', // Stability flags '--disable-gpu-sandbox', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', ], }); // Set up disconnect handler for automatic cleanup browser.on('disconnected', () => { console.log('⚠️ Browser disconnected unexpectedly'); browser = null; }); console.log('✅ Browser ready'); return browser; })(); const result = await browserLaunchPromise; browserLaunchPromise = null; return result;}
/** * Pre-warm the browser by navigating to a simple AC piece. * This populates the service worker cache so subsequent piece loads are 3-5x faster. * Should be called once after oven startup. */export async function prewarmGrabBrowser() { const BASE_URL = process.env.AC_BASE_URL || 'https://aesthetic.computer'; const b = await getBrowser();
// Phase 1: Load the AC runtime + service worker let page; try { page = await b.newPage(); // Set viewport before navigating so AC boots at a known size (not default 800x600) await page.setViewport({ width: 128, height: 128, deviceScaleFactor: 1 }); const warmupUrl = `${BASE_URL}/prompt?density=1&nogap=true`; console.log(`🔥 Pre-warming browser: ${warmupUrl}`); await page.goto(warmupUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }); await populateGlyphCache(page); await new Promise(r => setTimeout(r, 3000)); console.log('✅ Browser pre-warm (runtime) complete'); } catch (e) { console.warn(`⚠️ Browser pre-warm (runtime) failed (non-fatal): ${e.message}`); } finally { if (page) await page.close().catch(() => {}); }
// Phase 2: Load a blank KidLisp piece to cache kidlisp.mjs (~600KB) // This makes subsequent KidLisp keep grabs much faster. let klPage; try { klPage = await b.newPage(); // Set viewport before navigating so AC boots at a known size (not default 800x600) await klPage.setViewport({ width: 128, height: 128, deviceScaleFactor: 1 }); const klUrl = `${BASE_URL}/black?density=1&nolabel=true&nogap=true`; console.log(`🔥 Pre-warming KidLisp: ${klUrl}`); await klPage.goto(klUrl, { waitUntil: 'domcontentloaded', timeout: 25000 }); // Wait for kidlisp.mjs to fully parse and the piece to boot await klPage.waitForFunction( () => document.querySelector('canvas'), { timeout: 15000 } ).catch(() => {}); console.log('✅ Browser pre-warm (KidLisp) complete'); } catch (e) { console.warn(`⚠️ Browser pre-warm (KidLisp) failed (non-fatal): ${e.message}`); } finally { if (klPage) await klPage.close().catch(() => {}); }}
// Blocked URL patterns for Puppeteer pages (prevent self-referential loops and noise)const BLOCKED_URL_PATTERNS = [ 'oven.aesthetic.computer', // Prevent oven requesting its own icons/grabs 'google-analytics.com', // No analytics in headless captures 'googletagmanager.com', 'www.google-analytics.com',];
// Non-essential API endpoints that can be silently dropped during captures// These generate network traffic but aren't needed for rendering thumbnailsconst DROPPABLE_API_PATTERNS = [ '/api/boot-log', // Telemetry logging — not needed in captures '/api/mood/moods-of-the-day', // UI decoration — not needed for thumbnails];
// Local asset directories for serving font glyphs without hitting aesthetic.computerconst LOCAL_FONT_DRAWINGS = join(__dirname, 'ac-source', 'disks', 'drawings');const LOCAL_BDF_CACHE = join(__dirname, 'assets-type');
/** * Try to serve a font_1 glyph JSON from local filesystem. * Returns the file contents as a Buffer, or null if not found. */function tryLocalFontGlyph(url) { // Match: /disks/drawings/font_1/{category}/{filename}.json const match = url.match(/\/disks\/drawings\/(font_1\/.+\.json)/); if (!match) return null; try { const localPath = join(LOCAL_FONT_DRAWINGS, decodeURIComponent(match[1])); return readFileSync(localPath); } catch { return null; }}
/** * Try to serve a BDF glyph batch from local pre-cached JSONs. * The API URL looks like: /api/bdf-glyph?chars=0066,006F&font=6x10 * Returns a JSON response body, or null if any glyph is missing locally. */function tryLocalBDFGlyphs(url) { try { const parsed = new URL(url); const font = parsed.searchParams.get('font'); const chars = parsed.searchParams.get('chars'); if (!font || !chars) return null;
const codePoints = chars.split(',').filter(Boolean); const fontDir = join(LOCAL_BDF_CACHE, font); const glyphs = {};
for (const cp of codePoints) { try { const data = readFileSync(join(fontDir, `${cp}.json`), 'utf-8'); glyphs[cp] = JSON.parse(data); } catch { // If any glyph is missing locally, fall through to network return null; } }
return JSON.stringify({ glyphs }); } catch { return null; }}
/** * Load all font_1 glyph JSONs from local filesystem into memory. * Used by populateGlyphCache() to pre-populate IndexedDB before piece capture. * This bypasses Puppeteer's broken concurrent XHR handling for web worker * requests by making the glyphs available via IndexedDB cache instead. */let _cachedFontGlyphs = null; // { char: glyphData, ... } — loaded once per process
function loadFontGlyphsSync() { if (_cachedFontGlyphs) return _cachedFontGlyphs;
const fontDir = join(LOCAL_FONT_DRAWINGS, 'font_1'); let font1Data; try { // Dynamic import doesn't work synchronously — use readFileSync + parse const fontsPath = join(__dirname, 'ac-source', 'disks', 'common', 'fonts.mjs'); const fontsSrc = readFileSync(fontsPath, 'utf-8'); // Extract font_1 object from the module source (it's a simple object literal) const font1Match = fontsSrc.match(/export\s+const\s+font_1\s*=\s*(\{[\s\S]*?\n\});/); if (!font1Match) { console.warn('⚠️ Could not parse font_1 from fonts.mjs'); return null; } // Evaluate the object (safe: it's our own source file with simple key-value pairs) font1Data = new Function('return ' + font1Match[1])(); } catch (err) { console.warn(`⚠️ Could not load fonts.mjs: ${err.message}`); return null; }
const metaKeys = new Set(['glyphHeight', 'glyphWidth', 'proportional', 'bdfFallback']); const glyphs = {}; let loaded = 0, failed = 0; for (const [char, location] of Object.entries(font1Data)) { if (metaKeys.has(char)) continue; try { const filePath = join(fontDir, `${location}.json`); const data = readFileSync(filePath, 'utf-8'); glyphs[char] = JSON.parse(data); loaded++; } catch { failed++; } }
console.log(` 🔤 Loaded ${loaded} font_1 glyphs from disk (${failed} failed)`); _cachedFontGlyphs = loaded > 0 ? glyphs : null; return _cachedFontGlyphs;}
// Pre-load glyph data (call before page.goto).async function injectFontGlyphs(page) { loadFontGlyphsSync(); // Warm the cache}
// Populate the page's IndexedDB glyph cache after navigation.// Must be called AFTER page.goto() so IndexedDB is on the correct origin.// The disk.mjs web worker shares IndexedDB with the main page, so when// type.mjs calls preWarmGlyphCache(), it finds all 97 glyphs already cached// and skips the XHR requests entirely (which hang under Puppeteer interception).async function populateGlyphCache(page) { const glyphs = loadFontGlyphsSync(); if (!glyphs) { console.warn('⚠️ No local font_1 glyphs available — captures may show ????'); return; }
try { const result = await page.evaluate(async (glyphData) => { return new Promise((resolve, reject) => { const DB_NAME = 'ac-glyph-cache'; const DB_VERSION = 2; const STORE_NAME = 'glyphs'; const META_STORE_NAME = 'glyph-meta';
const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } if (!db.objectStoreNames.contains(META_STORE_NAME)) { db.createObjectStore(META_STORE_NAME); } }; req.onerror = () => reject(new Error('IDB open failed')); req.onsuccess = (e) => { const db = e.target.result; const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); let count = 0; for (const [char, data] of Object.entries(glyphData)) { store.put(data, `font_1:${char}`); count++; } tx.oncomplete = () => { db.close(); resolve(count); }; tx.onerror = () => { db.close(); reject(new Error('IDB transaction failed')); }; }; }); }, glyphs); console.log(` 🔤 Populated IndexedDB glyph cache: ${result} entries`); } catch (err) { console.warn(` ⚠️ Failed to populate glyph cache: ${err.message}`); }}
/** * Set up request interception on a Puppeteer page. * - Blocks self-referential requests and analytics * - Drops non-essential API calls (boot-log, mood) * - Serves font glyph JSONs from local filesystem (avoids 429 rate limits) */async function interceptSelfRequests(page) { await page.setRequestInterception(true); // Track interception stats for diagnostics const stats = { fontLocal: 0, fontMiss: 0, bdfLocal: 0, bdfMiss: 0, blocked: 0, dropped: 0 }; page._interceptStats = stats;
page.on('request', async (request) => { const url = request.url(); try { // Block analytics and self-referential requests if (BLOCKED_URL_PATTERNS.some(pattern => url.includes(pattern))) { stats.blocked++; await request.abort('blockedbyclient'); return; }
// Drop non-essential API calls with a 200 OK (no-op) if (DROPPABLE_API_PATTERNS.some(pattern => url.includes(pattern))) { stats.dropped++; await request.respond({ status: 200, contentType: 'application/json', body: '{}' }); return; }
// Font_1 glyph JSONs — if IndexedDB was pre-populated (populateGlyphCache), // these requests should never happen. If they do, let them pass through // to aesthetic.computer. Do NOT use request.respond() — it hangs for // worker XHRs (Puppeteer CDP limitation). if (url.includes('/disks/drawings/font_1/') && url.endsWith('.json')) { stats.fontLocal++; await request.continue(); return; }
// Serve BDF glyph batch responses from local cache if (url.includes('/api/bdf-glyph')) { const localResponse = tryLocalBDFGlyphs(url); if (localResponse) { stats.bdfLocal++; await request.respond({ status: 200, contentType: 'application/json', body: localResponse, }); return; } stats.bdfMiss++; console.log(` [LOCAL MISS] bdf-glyph: ${url.slice(url.indexOf('?'))}`); }
await request.continue(); } catch (err) { // Request may already be handled (page navigated, etc.) if (!err.message?.includes('Request is already handled')) { console.log(` [INTERCEPT ERR] ${err.message} for ${url.slice(0, 80)}`); } } });}
/** * Capture frames from a KidLisp piece * @param {string} piece - Piece code (e.g., '$roz' or 'roz') * @param {object} options - Capture options * @returns {Promise<Buffer[]>} Array of PNG frame buffers */async function captureFrames(piece, options = {}) { const { width = 512, height = 512, duration = 12000, fps = 7.5, density = 1, viewportScale = null, // Override deviceScaleFactor (null = use density) baseUrl = 'https://aesthetic.computer', frames: explicitFrames, // Allow explicit frame count override grabId = null, // For per-grab progress tracking } = options; // Calculate frames from duration and fps, or use explicit count const frames = explicitFrames ?? Math.ceil((duration / 1000) * fps); // viewportScale: how much to scale the browser viewport // - null/undefined: use density (legacy behavior - captures at density*width x density*height) // - 1: capture at exact width x height (for app screenshots where density is just for pixel size) const effectiveViewportScale = viewportScale ?? density; // Use piece name as-is (caller decides if $ prefix is needed for KidLisp) // tv=true (non-interactive), nolabel=true (no HUD label), nogap=true (no border) // noboot=true skips the boot canvas animation to prevent zoom/scale glitches in captures const url = `${baseUrl}/${piece}?density=${density}&tv=true&nolabel=true&nogap=true&spoofaudio=true&noboot=true`; console.log(`📸 Capturing ${frames} frames from ${url} (${fps} fps, ${duration}ms)`); console.log(` Size: ${width}x${height}`); const browser = await getBrowser(); const page = await browser.newPage(); await interceptSelfRequests(page); await injectFontGlyphs(page);
// Log page console messages for debugging (skip blocked-request noise) page.on('console', msg => { const type = msg.type(); const text = msg.text(); if (text.includes('ERR_BLOCKED_BY_CLIENT')) return; if (text.includes('Failed to load resource: net::ERR_')) return; 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('🔤')) { console.log(` [PAGE ${type}] ${text}`); } });
page.on('pageerror', error => { if (error.message?.includes('ERR_BLOCKED_BY_CLIENT')) return; console.log(` [PAGE ERROR] ${error.message}`); }); // Log failed requests to identify 404s (skip intentionally blocked requests) page.on('requestfailed', request => { const url = request.url(); if (BLOCKED_URL_PATTERNS.some(p => url.includes(p))) return; console.log(` [REQUEST FAILED] ${url} - ${request.failure()?.errorText}`); }); page.on('response', response => { if (response.status() >= 400) { console.log(` [HTTP ${response.status()}] ${response.url()}`); } }); try { // Set viewport - effectiveViewportScale controls the actual capture resolution // For app screenshots: viewportScale=1 captures at exact width x height // For legacy grabs: viewportScale=density captures at density*width x density*height await page.setViewport({ width, height, deviceScaleFactor: effectiveViewportScale });
// Navigate to piece console.log(` Loading piece...`); await page.goto(url, { waitUntil: 'domcontentloaded', // Changed from networkidle2 - $code pieces continue network activity timeout: 30000 });
// Pre-populate IndexedDB glyph cache on this page so the disk worker // finds font_1 glyphs locally instead of making XHR requests that hang // under Puppeteer's CDP request interception. await populateGlyphCache(page);
// Black background — matches HTML bundle style, prevents white borders in thumbnails await page.evaluate(() => { document.documentElement.style.background = 'black'; document.body.style.background = 'black'; }); // Wait for canvas to be ready await page.waitForSelector('canvas', { timeout: 10000 }); console.log(' ✓ Canvas found'); // Wait for the aesthetic-computer wrapper (created by bios.mjs after boot) const wrapperFound = await page.waitForSelector('#aesthetic-computer', { timeout: 10000 }).then(() => true).catch(() => { console.log(' ⚠️ #aesthetic-computer wrapper not found, continuing anyway...'); return false; }); if (wrapperFound) console.log(' ✓ Wrapper found'); // Wait for piece to signal it's ready via window.acPieceReady // This is set by disk.mjs after the first paint completes console.log(' ⏳ Waiting for piece ready signal (window.acPieceReady)...'); await updateProgressWithPreview(grabId, page, { stage: 'loading', stageDetail: 'Waiting for piece...', percent: 5, }); const pieceWaitStart = Date.now(); // 30s gives cold-start loads (service worker cache miss) time to finish. // Warm loads typically signal ready in 2-8s. const maxPieceWait = 30000; // 30 seconds max for piece to load let pieceReady = false; let lastPreviewTime = 0; // Track boot-hidden state for early exit (piece loaded but acPieceReady never set) let bootHiddenSince = 0; // When noboot=true, boot canvas is absent from the start — the boot-canvas-hidden // heuristic would fire immediately (after 3s) even though the piece hasn't loaded. // Detect this so we can use a longer heuristic timeout for noboot captures. const isNoboot = url.includes('noboot=true');
while (!pieceReady && (Date.now() - pieceWaitStart) < maxPieceWait) { const status = await page.evaluate(() => { const ready = window.acPieceReady === true; const readyTime = window.acPieceReadyTime; const bootCanvas = document.getElementById('boot-canvas'); const bootHidden = !bootCanvas || window.getComputedStyle(bootCanvas).display === 'none' || window.getComputedStyle(bootCanvas).opacity === '0'; // Check if piece paint is running by looking for the main canvas content const mainCanvas = document.querySelector('#aesthetic-computer canvas'); const canvasActive = mainCanvas && mainCanvas.width > 0 && mainCanvas.height > 0; return { ready, readyTime, bootHidden, canvasActive }; });
pieceReady = status.ready;
// Track when boot canvas hides (piece has loaded even if acPieceReady didn't fire) if (status.bootHidden && !bootHiddenSince) { bootHiddenSince = Date.now(); }
// Early exit: if boot canvas has been hidden for a while and canvas is active, // treat as ready (workaround for acPieceReady not firing in headless Chrome). // With noboot=true, the boot canvas is absent from the start, so use a longer // timeout (15s) to let KidLisp pieces fully initialize + load resources. const heuristicTimeout = isNoboot ? 15000 : 3000; if (!pieceReady && bootHiddenSince && status.canvasActive) { const hiddenFor = Date.now() - bootHiddenSince; if (hiddenFor > heuristicTimeout) { console.log(` ✅ Piece detected as ready via boot-canvas-hidden heuristic (${hiddenFor}ms${isNoboot ? ', noboot mode' : ''})`); pieceReady = true; break; } }
if (!pieceReady) { const elapsed = Date.now() - pieceWaitStart; const phase = status.bootHidden ? 'Loading piece' : 'Booting';
// Stream preview every 200ms for smooth updates if (Date.now() - lastPreviewTime > 200) { await updateProgressWithPreview(grabId, page, { stage: 'loading', stageDetail: `${phase}... ${Math.round(elapsed/1000)}s / ${maxPieceWait/1000}s`, percent: Math.min(25, 5 + (elapsed / maxPieceWait) * 20), }); lastPreviewTime = Date.now(); }
await new Promise(r => setTimeout(r, 100)); } }
if (pieceReady) { if (!bootHiddenSince) { console.log(` ✅ Piece ready after ${Date.now() - pieceWaitStart}ms`); } } else { console.log(` ⚠️ Piece ready signal not received after ${maxPieceWait}ms`); // Force hide boot canvas if still visible await page.evaluate(() => { const bootCanvas = document.getElementById('boot-canvas'); if (bootCanvas) bootCanvas.style.display = 'none'; }); } // Wait for any pending async image loads (paste/stamp #code references) // Images fetched via prefetchPicture() are async — acPieceReady fires before // they finish loading. Poll window.acPendingImages() until all resolve. const maxImageWait = 10000; // 10s max for image fetches const imageWaitStart = Date.now(); let pendingImages = 0; try { pendingImages = await page.evaluate(() => typeof window.acPendingImages === 'function' ? window.acPendingImages() : 0 ); } catch {}
if (pendingImages > 0) { console.log(` ⏳ Waiting for ${pendingImages} pending image(s) to load...`); await updateProgressWithPreview(grabId, page, { stage: 'loading', stageDetail: `Loading ${pendingImages} image(s)...`, percent: 26, });
while ((Date.now() - imageWaitStart) < maxImageWait) { await new Promise(r => setTimeout(r, 200)); try { pendingImages = await page.evaluate(() => typeof window.acPendingImages === 'function' ? window.acPendingImages() : 0 ); } catch { break; } if (pendingImages === 0) break; }
if (pendingImages === 0) { console.log(` ✅ All images loaded after ${Date.now() - imageWaitStart}ms`); } else { console.log(` ⚠️ ${pendingImages} image(s) still pending after ${maxImageWait}ms timeout`); } }
// Wait for fonts to finish loading (tf.load() is async and not awaited) // Without this, write() renders ???? because glyphs aren't populated yet const maxFontWait = 10000; const fontWaitStart = Date.now(); let fontsReady = false; try { fontsReady = await page.evaluate(() => window.acFontsReady === true); } catch {}
if (!fontsReady) { console.log(' ⏳ Waiting for fonts to load (window.acFontsReady)...'); while (!fontsReady && (Date.now() - fontWaitStart) < maxFontWait) { await new Promise(r => setTimeout(r, 200)); try { const state = await page.evaluate(() => window.acFontsReady); if (state === true) { fontsReady = true; break; } if (state === 'error') { console.log(' ❌ tf.load() threw an error — fonts failed to load'); break; } } catch { break; } } if (fontsReady) { console.log(` ✅ Fonts ready after ${Date.now() - fontWaitStart}ms`); } else { const finalState = await page.evaluate(() => window.acFontsReady).catch(() => 'eval-error'); console.log(` ⚠️ Fonts not ready after ${Date.now() - fontWaitStart}ms (acFontsReady=${finalState})`); } } else { console.log(' ✅ Fonts already loaded'); }
// Log interception stats const interceptStats = page._interceptStats; if (interceptStats) { console.log(` 📊 Intercept stats: fontLocal=${interceptStats.fontLocal} fontMiss=${interceptStats.fontMiss} bdfLocal=${interceptStats.bdfLocal} bdfMiss=${interceptStats.bdfMiss} blocked=${interceptStats.blocked} dropped=${interceptStats.dropped}`); }
// Diagnostic: inspect typeface glyph state in the page try { const fontDiag = await page.evaluate(() => { const diag = { acFontsReady: window.acFontsReady, acPieceReady: window.acPieceReady, }; try { if (window.__acTypeface) { const tf = window.__acTypeface; diag.tfName = tf.name; diag.glyphKeys = Object.keys(tf.glyphs || {}).length; diag.hasF = !!tf.glyphs?.['f']; diag.hasO = !!tf.glyphs?.['o']; } else { diag.noTfRef = true; } // Check glyph cache stats if (window.acGlyphCache?.stats) { diag.cache = window.acGlyphCache.stats(); } } catch (e) { diag.tfError = e.message; } return diag; }); console.log(` 🔤 Font diagnostics:`, JSON.stringify(fontDiag)); } catch (e) { console.log(` 🔤 Font diagnostics failed: ${e.message}`); }
// Settle time: let the piece run a bit more after ready signal // For stills, wait longer to let animations stabilize // For animations, just a small buffer // KidLisp ($code) pieces need extra time for generative rendering to complete const isStill = frames === 1; const isKidLisp = piece.startsWith('$'); const settleTime = isStill ? (isKidLisp ? 5000 : 500) : 200; console.log(` ${isStill ? '⏳ Settling for still capture' : '⏳ Brief settle'}... (${settleTime}ms)`);
// Send preview before settling await updateProgressWithPreview(grabId, page, { stage: 'settling', stageDetail: `Settling... ${settleTime}ms`, percent: 28, });
await new Promise(r => setTimeout(r, settleTime)); // Capture frames at intervals const frameInterval = duration / frames; const capturedFrames = []; // Update progress with preview: entering capture stage await updateProgressWithPreview(grabId, page, { stage: 'capturing', stageDetail: `Starting frame capture...`, framesCaptured: 0, framesTotal: frames, percent: 30, }); console.log(` Capturing frames...`); // Create a single CDP session for the entire capture loop to avoid the // overhead and potential compositor interference of creating/destroying a // session per frame (90+ times for a 12-second animation). const captureClient = await page.createCDPSession(); try { for (let i = 0; i < frames; i++) { console.log(` [Frame ${i+1}] Starting capture...`);
// Only capture a live preview every 10 frames — calling page.screenshot() // before every single frame added significant overhead and could cause // the WebGL compositor to miss frames in the CDP capture. const capturePercent = 30 + ((i / frames) * 50); // 30% to 80% during capture if (i % 10 === 0) { await updateProgressWithPreview(grabId, page, { framesCaptured: i, stageDetail: `Frame ${i + 1}/${frames}`, percent: Math.round(capturePercent), }); } else { updateProgress(grabId, { framesCaptured: i, stageDetail: `Frame ${i + 1}/${frames}`, percent: Math.round(capturePercent), }); }
// Use the shared CDP session for screenshot - more reliable than // page.screenshot for WebGL, and avoids session churn per frame. let frameBuffer; try { const screenshotPromise = captureClient.send('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width, height, scale: 1 }, captureBeyondViewport: false });
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('CDP Screenshot timeout')), 10000) );
const result = await Promise.race([screenshotPromise, timeoutPromise]); frameBuffer = result.data; console.log(` [Frame ${i+1}] Screenshot captured (${frameBuffer.length} bytes)`); } catch (err) { console.log(` ❌ Frame capture failed: ${err.message}`); frameBuffer = null; }
if (frameBuffer) { const buffer = Buffer.from(frameBuffer, 'base64'); // Check if frame is blank (all black or all transparent) const isBlank = await isBlankFrame(buffer); if (isBlank) { console.log(` ⚠️ Frame ${i + 1} is blank, skipping`); } else { capturedFrames.push(buffer); } }
// Wait for next frame if (i < frames - 1) { await new Promise(r => setTimeout(r, frameInterval)); } } } finally { await captureClient.detach().catch(() => {}); } console.log(` ✅ Captured ${capturedFrames.length} non-blank frames`); return capturedFrames; } finally { await page.close(); }}
/** * Capture a single frame (for PNG thumbnail) */async function captureFrame(piece, options = {}) { const frames = await captureFrames(piece, { ...options, frames: 1, duration: 0 }); return frames[0] || null;}
/** * Convert frames to GIF using ffmpeg * @param {Buffer[]} frames - Array of PNG frame buffers * @param {object} options - GIF options * @returns {Promise<Buffer>} GIF buffer */async function framesToGif(frames, options = {}) { const { fps = 15, width = 400, height = 400, loop = 0, // 0 = infinite loop } = options; if (frames.length === 0) { throw new Error('No frames to convert'); } // Create temp directory const workDir = join(tmpdir(), `grab-${randomBytes(8).toString('hex')}`); await fs.mkdir(workDir, { recursive: true }); try { // Write frames to disk console.log(` Writing ${frames.length} frames to temp directory...`); for (let i = 0; i < frames.length; i++) { const framePath = join(workDir, `frame-${String(i).padStart(5, '0')}.png`); await fs.writeFile(framePath, frames[i]); } // Generate GIF using ffmpeg const outputPath = join(workDir, 'output.gif'); console.log(` Generating GIF with ffmpeg...`); await new Promise((resolve, reject) => { const ffmpeg = spawn('ffmpeg', [ '-y', '-framerate', String(fps), '-i', join(workDir, 'frame-%05d.png'), '-vf', `scale=${width}:${height}:flags=neighbor,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=single[p];[s1][p]paletteuse=dither=none`, '-loop', String(loop), outputPath ]); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`ffmpeg exited with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', reject); }); // Read the GIF const gifBuffer = await fs.readFile(outputPath); const sizeKB = (gifBuffer.length / 1024).toFixed(2); console.log(` ✅ GIF generated: ${sizeKB} KB`); return gifBuffer; } finally { // Cleanup await fs.rm(workDir, { recursive: true, force: true }); }}
/** * Generate a scaled PNG thumbnail from a frame */async function frameToThumbnail(frameBuffer, options = {}) { const { width = 400, height = 400, } = options; const sharp = (await import('sharp')).default; const thumbnail = await sharp(frameBuffer) .resize(width, height, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 }, kernel: 'nearest', // Pixel-perfect scaling }) .png() .toBuffer(); return thumbnail;}
/** * Convert frames to animated WebP using ffmpeg * @param {Buffer[]} frames - Array of PNG frame buffers * @param {object} options - WebP options * @returns {Promise<Buffer>} WebP buffer */async function framesToWebp(frames, options = {}) { const { playbackFps = 15, // Playback speed (faster than capture) width = 512, height = 512, loop = 0, // 0 = infinite loop quality = 90, } = options; if (frames.length === 0) { throw new Error('No frames to convert'); } // Create temp directory const workDir = join(tmpdir(), `grab-${randomBytes(8).toString('hex')}`); await fs.mkdir(workDir, { recursive: true }); try { // Write frames to disk (flattened to black background) console.log(` Writing ${frames.length} frames to temp directory...`); for (let i = 0; i < frames.length; i++) { const framePath = join(workDir, `frame-${String(i).padStart(5, '0')}.png`); const flattened = await sharp(frames[i]) .flatten({ background: { r: 0, g: 0, b: 0 } }) .png() .toBuffer(); await fs.writeFile(framePath, flattened); }
// Generate animated WebP using ffmpeg const outputPath = join(workDir, 'output.webp'); console.log(` Generating animated WebP with ffmpeg (${playbackFps} fps playback)...`); await new Promise((resolve, reject) => { const ffmpeg = spawn('ffmpeg', [ '-y', '-framerate', String(playbackFps), // Playback framerate '-i', join(workDir, 'frame-%05d.png'), '-vf', `scale=${width}:${height}:flags=neighbor`, // Pixel-perfect scaling '-c:v', 'libwebp', '-lossless', '0', '-compression_level', '6', // Higher = better compression (0-6) '-qmin', '50', // Minimum quality '-qmax', String(quality), // Maximum quality '-quality', String(quality), '-loop', String(loop), '-preset', 'drawing', // Better for graphics/art (was 'picture') '-pix_fmt', 'yuv420p', // Standard pixel format (smaller than yuva420p) '-an', outputPath ]); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`ffmpeg exited with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', reject); }); // Read the WebP const webpBuffer = await fs.readFile(outputPath); const sizeKB = (webpBuffer.length / 1024).toFixed(2); console.log(` ✅ WebP generated: ${sizeKB} KB`); return webpBuffer; } finally { // Cleanup await fs.rm(workDir, { recursive: true, force: true }); }}
// Blacklisted domains/patterns that should never be processedconst BLACKLISTED_PIECES = [ /\.com$/i, /\.net$/i, /\.org$/i, /\.io$/i, /\.dev$/i, /\.app$/i, /\.xyz$/i, /\.co$/i, /^https?:\/\//i, /sundarakarma/i,];
// Keep support for small icons/previews (wallet, prompt list, etc.).const MIN_GRAB_DIMENSION = 32;const MAX_GRAB_DIMENSION = 1000;
/** * Check if a piece name is blacklisted (external domain or invalid) */function isPieceBlacklisted(piece) { if (!piece || typeof piece !== 'string') return true; return BLACKLISTED_PIECES.some(pattern => pattern.test(piece));}
/** * Full grab workflow: capture piece and generate thumbnail/GIF/WebP */export async function grabPiece(piece, options = {}) { // Reject blacklisted pieces (external domains, URLs, etc.) if (isPieceBlacklisted(piece)) { console.log(`🚫 Rejecting blacklisted piece: ${piece}`); return { success: false, error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported`, piece, }; } const { format = 'webp', // 'webp', 'gif', or 'png' width = 512, height = 512, duration = 12000, fps = 7.5, // Capture fps playbackFps = 15, // Playback fps (2x speed) density = 1, viewportScale = null, // Override deviceScaleFactor (null = use density) quality = 90, baseUrl = 'https://aesthetic.computer', skipCache = false, // Force regeneration cacheKey = '', // Optional extra cache discriminator (e.g. source hash) source = 'manual', // 'keep', 'manual', 'api', etc. keepId = null, // Tezos keep token ID if source is 'keep' author = null, // Author handle (e.g. "@jeffrey") pieceCreatedAt = null, // When the piece was originally created requestOrigin = null, // Referer/origin of the request } = options; // For captureKey: use actual output size (viewportScale=1 means exact size, otherwise density-scaled) const effectiveScale = viewportScale ?? density; const outputWidth = width * effectiveScale; const outputHeight = height * effectiveScale; const animated = format !== 'png'; const captureKey = getCaptureKey(piece, outputWidth, outputHeight, format, animated, cacheKey); // Check for existing capture (deduplication) if (!skipCache) { const existing = await checkExistingCapture(captureKey); if (existing.exists && existing.cdnUrl) { console.log(`♻️ Returning cached capture: ${captureKey}`); return { success: true, grabId: existing.grab?.id || captureKey, piece, format, cdnUrl: existing.cdnUrl, cached: true, captureKey, }; } } // For ID purposes, strip $ prefix; but keep original piece name for URL generation const pieceName = piece.replace(/^\$/, ''); const grabId = `${pieceName}-${randomBytes(4).toString('hex')}`; console.log(`\n🎬 Starting grab: ${grabId}`); console.log(` Piece: ${piece}`); console.log(` Format: ${format}`); console.log(` CaptureKey: ${captureKey}`); if (source) console.log(` Source: ${source}${keepId ? ' #' + keepId : ''}`); serverLog('queue', '📋', `Grab queued: ${piece} (${format} ${width}×${height})`); // Track active grab - store original piece name (with $ if KidLisp) // Hold a stable local reference. The map entry can be evicted mid-capture by // cleanupStaleGrabs()/clearAllActiveGrabs(); re-fetching via get() then yields // undefined and `.status =` throws "Cannot set properties of undefined". The // local ref survives map mutation so the capture always completes. const grabRecord = { id: grabId, piece: piece, // Keep original with $ prefix for URL generation format, status: 'queued', startTime: Date.now(), captureKey, // For deduplication lookup gitVersion: GIT_VERSION, dimensions: { width: outputWidth, height: outputHeight }, source: source || 'manual', keepId: keepId || null, author: author || null, pieceCreatedAt: pieceCreatedAt || null, requestOrigin: requestOrigin || null, }; activeGrabs.set(grabId, grabRecord);
// Use queue to serialize capture operations (avoid parallel puppeteer sessions) // Pass metadata for queue visibility + priority scheduling return enqueueGrab(async () => { try { let result; grabRecord.status = 'capturing'; // Initialize progress state for this grab updateProgress(grabId, { piece: piece, format: format, stage: 'loading', stageDetail: `Starting ${piece}...`, percent: 0, framesCaptured: 0, framesTotal: Math.ceil((duration / 1000) * fps), author: author || null, pieceCreatedAt: pieceCreatedAt || null, requestedAt: grabRecord.startTime || Date.now(), source: source || 'manual', requestOrigin: requestOrigin || null, }); if (format === 'png') { // Single frame PNG — captureFrame filters blank frames via captureFrames, // but nearly-black frames (a few pixels above threshold) can slip through. // Validate explicitly before caching to prevent black preview images. const frame = await captureFrame(piece, { width, height, density, viewportScale, baseUrl, grabId }); if (!frame) { throw new Error('Failed to capture frame — blank or black image detected'); } const blank = await isBlankFrame(frame); if (blank) { throw new Error('Captured frame is blank/black — piece may not have rendered in time'); } result = await frameToThumbnail(frame, { width: outputWidth, height: outputHeight }); } else { // Animated WebP or GIF — when caller asked for fresh content // (skipCache:true), retry up to twice on uniform-color duds. // Pinata content-addresses, so a 528-byte black still always // dedups to the same CID; silently returning that downstream // looks like a successful bake but freezes the piece's // thumbnail forever. Surface the dud instead. const MAX_DUD_RETRIES = skipCache ? 2 : 0; let capturedFrames; let isFrozen = false; let uniformCheck = { isUniform: false };
for (let attempt = 0; attempt <= MAX_DUD_RETRIES; attempt++) { capturedFrames = await captureFrames(piece, { width, height, duration, fps, density, viewportScale, baseUrl, grabId });
if (capturedFrames.length === 0) { throw new Error('No frames captured'); }
isFrozen = await areFramesIdentical(capturedFrames); // Always run the uniform-color check — an all-black capture // is both "frozen" (frames identical) and "uniform-color", // and we need to catch it as a dud rather than treating it // as legitimate frozen art. uniformCheck = await isUniformColorContent(capturedFrames);
if (!uniformCheck.isUniform) break; if (attempt < MAX_DUD_RETRIES) { console.log(` 🔁 Capture ${attempt + 1}/${MAX_DUD_RETRIES + 1} returned uniform color (${uniformCheck.reason}); retrying...`); await new Promise(r => setTimeout(r, 2000)); } }
if (uniformCheck.isUniform && skipCache) { throw new Error(`Capture produced uniform-color frames after ${MAX_DUD_RETRIES + 1} attempt(s) (${uniformCheck.reason}); piece may not be rendering`); }
if (isFrozen) { // Return the still frame in the requested format (webp/gif/png) const outW = width * density; const outH = height * density; console.log(` 🥶 Frozen animation — returning still ${format.toUpperCase()}`);
const sharpMod = (await import('sharp')).default; let pipeline = sharpMod(capturedFrames[0]) .resize(outW, outH, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 }, kernel: 'nearest' });
if (format === 'webp') { pipeline = pipeline.webp({ quality }); } else if (format === 'gif') { pipeline = pipeline.gif(); } else { pipeline = pipeline.png(); } result = await pipeline.toBuffer();
// Also upload frozen preview to Spaces for the frozen panel let frozenPreviewUrl = null; if (process.env.ART_SPACES_KEY && result) { try { const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png'; const mime = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png'; const frozenKey = `oven/frozen/${piece.replace(/^\$/, '')}-frozen.${ext}`; await spacesClient.send(new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: frozenKey, Body: result, ContentType: mime, ACL: 'public-read', CacheControl: 'public, max-age=604800', })); frozenPreviewUrl = `${SPACES_CDN_BASE}/${frozenKey}`; } catch (previewErr) { console.error('⚠️ Failed to upload frozen preview:', previewErr.message); } }
recordFrozenPiece(piece, 'Frozen — still frame returned', frozenPreviewUrl); // Skip encoding — result is already in the right format } else { // uniformCheck already computed above (in the dud-retry loop). // For !skipCache callers, fall back to a still — they explicitly // accepted cached/dedup behavior. skipCache callers were thrown // earlier so they never reach this branch. if (uniformCheck.isUniform) { // Return still frame in requested format (same as frozen) const outW = width * density; const outH = height * density; console.log(` 🎨 Dud animation (${uniformCheck.reason}) — returning still ${format.toUpperCase()}`);
const sharpMod = (await import('sharp')).default; let pipeline = sharpMod(capturedFrames[0]) .resize(outW, outH, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 }, kernel: 'nearest' });
if (format === 'webp') { pipeline = pipeline.webp({ quality }); } else if (format === 'gif') { pipeline = pipeline.gif(); } else { pipeline = pipeline.png(); } result = await pipeline.toBuffer();
// Upload dud preview to Spaces let dudPreviewUrl = null; if (process.env.ART_SPACES_KEY && result) { try { const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png'; const mime = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png'; const dudKey = `oven/frozen/${piece.replace(/^\$/, '')}-dud.${ext}`; await spacesClient.send(new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: dudKey, Body: result, ContentType: mime, ACL: 'public-read', CacheControl: 'public, max-age=604800', })); dudPreviewUrl = `${SPACES_CDN_BASE}/${dudKey}`; } catch (previewErr) { console.error('⚠️ Failed to upload dud preview:', previewErr.message); } }
recordFrozenPiece(piece, `Dud — ${uniformCheck.reason}, still frame returned`, dudPreviewUrl); } else { grabRecord.status = 'encoding';
// Update progress: encoding stage updateProgress(grabId, { stage: 'encoding', stageDetail: `Encoding ${format.toUpperCase()}...`, percent: 85, });
if (format === 'webp') { result = await framesToWebp(capturedFrames, { playbackFps, width: width * density, height: height * density, quality }); } else { result = await framesToGif(capturedFrames, { fps: playbackFps, width: width * density, height: height * density }); } } } } // Update status (local ref — immune to map eviction mid-capture) const grab = grabRecord; grab.status = 'complete'; grab.completedAt = Date.now(); grab.duration = grab.completedAt - grab.startTime; grab.size = result.length; // Upload to Spaces for dashboard preview if (process.env.ART_SPACES_KEY) { try { // Update progress: uploading stage updateProgress(grabId, { stage: 'uploading', stageDetail: `Uploading to CDN...`, percent: 95, }); const ext = format === 'webp' ? 'webp' : format === 'gif' ? 'gif' : 'png'; const contentType = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png'; const spacesKey = `oven/grabs/${grabId}.${ext}`; await spacesClient.send(new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: spacesKey, Body: result, ContentType: contentType, ACL: 'public-read', CacheControl: 'public, max-age=604800', // 7 day cache })); grab.cdnUrl = `${SPACES_CDN_BASE}/${spacesKey}`; console.log(`📦 Stored grab in Spaces: ${grab.cdnUrl}`); } catch (e) { console.error('❌ Failed to store grab in Spaces:', e.message); } } // Move to recent and save to MongoDB activeGrabs.delete(grabId); recentGrabs.unshift(grab); if (recentGrabs.length > 20) recentGrabs.pop(); saveGrab(grab); // Persist to MongoDB // Clear progress state (grab complete) clearProgress(grabId);
// Record duration for ETA estimation recordGrabDuration(grab.duration); // Notify WebSocket subscribers notifySubscribers(); console.log(`✅ Grab complete: ${grabId} (${(result.length / 1024).toFixed(2)} KB)`); serverLog('success', '✅', `Grab complete: ${piece} (${(result.length / 1024).toFixed(1)} KB)`); return { success: true, grabId, piece: pieceName, format, buffer: result, size: result.length, duration: grab.duration, cdnUrl: grab.cdnUrl, captureKey, cached: false, }; } catch (error) { console.error(`❌ Grab failed: ${grabId}`, error.message); serverLog('error', '❌', `Grab failed: ${piece} - ${error.message}`); // Clear progress state on error clearProgress(grabId); const grab = grabRecord; if (grab) { grab.status = 'failed'; grab.error = error.message; grab.completedAt = Date.now(); activeGrabs.delete(grabId); recentGrabs.unshift(grab); if (recentGrabs.length > 20) recentGrabs.pop(); saveGrab(grab); // Persist to MongoDB // Notify WebSocket subscribers notifySubscribers(); } return { success: false, grabId, piece: pieceName, format, error: error.message, }; } }, { piece, format, captureKey, source }); // End of enqueueGrab, pass metadata for queue visibility + dedup}
/** * Upload a buffer to IPFS via Pinata * @param {Buffer} buffer - The file buffer to upload * @param {string} filename - Filename for the upload * @param {Object} credentials - { pinataKey, pinataSecret } * @returns {Promise<string>} IPFS URI (ipfs://...) */export async function uploadToIPFS(buffer, filename, credentials) { if (!credentials?.pinataKey || !credentials?.pinataSecret) { throw new Error('Pinata credentials required (pinataKey, pinataSecret)'); } const formData = new FormData(); const blob = new Blob([buffer]); formData.append('file', blob, filename); // Add content hash to metadata for deduplication const contentHash = createHash('sha256').update(buffer).digest('hex').slice(0, 16); formData.append('pinataMetadata', JSON.stringify({ name: `${filename}-${contentHash}` })); const response = await fetch(`${PINATA_API_URL}/pinning/pinFileToIPFS`, { method: 'POST', headers: { 'pinata_api_key': credentials.pinataKey, 'pinata_secret_api_key': credentials.pinataSecret }, body: formData, signal: AbortSignal.timeout(90_000), // 90s timeout for IPFS pinning }); if (!response.ok) { const error = await response.text(); throw new Error(`Pinata upload failed: ${error}`); } const result = await response.json(); return `ipfs://${result.IpfsHash}`;}
/** * Grab a piece and upload the thumbnail to IPFS * @param {string} piece - Piece name (with or without $) * @param {Object} credentials - Pinata credentials { pinataKey, pinataSecret } * @param {Object} options - Grab options * @returns {Promise<Object>} { success, ipfsUri, grabResult, ... } */export async function grabAndUploadToIPFS(piece, credentials, options = {}) { const pieceName = piece.replace(/^\$/, ''); const format = options.format || 'webp'; const source = options.source || 'manual'; const keepId = options.keepId || null; console.log(`\n📸 Grabbing and uploading $${pieceName} to IPFS...`); if (source === 'keep' && keepId) { console.log(` Source: Keep #${keepId}`); } if (options.skipCache) { console.log(` skipCache: true (forcing fresh grab)`); } // Grab the piece const grabResult = await grabPiece(piece, { format, width: options.width || 512, height: options.height || 512, duration: options.duration || 12000, fps: options.fps || 7.5, playbackFps: options.playbackFps || 15, density: options.density || 1, quality: options.quality || 90, skipCache: options.skipCache || false, baseUrl: options.baseUrl || 'https://aesthetic.computer', source, keepId, author: options.author || null, pieceCreatedAt: options.pieceCreatedAt || null, });
if (!grabResult.success) { return { success: false, error: grabResult.error, grabResult }; } // Get the buffer - either from grab result or fetch from CDN if cached let buffer = grabResult.buffer; if (!buffer && grabResult.cached && grabResult.cdnUrl) { console.log(`📥 Fetching cached thumbnail from CDN: ${grabResult.cdnUrl}`); try { const cdnResponse = await fetch(grabResult.cdnUrl); if (!cdnResponse.ok) { throw new Error(`CDN fetch failed: ${cdnResponse.status}`); } buffer = Buffer.from(await cdnResponse.arrayBuffer()); console.log(` Downloaded ${(buffer.length / 1024).toFixed(1)} KB from CDN`); } catch (cdnErr) { console.error(`❌ Failed to fetch from CDN:`, cdnErr.message); // Try regenerating without cache console.log(`🔄 Regenerating thumbnail without cache...`); const freshGrab = await grabPiece(piece, { ...options, skipCache: true, }); if (!freshGrab.success || !freshGrab.buffer) { return { success: false, error: `Failed to generate thumbnail: ${freshGrab.error || 'no buffer returned'}`, grabResult: freshGrab }; } buffer = freshGrab.buffer; } } if (!buffer) { return { success: false, error: 'No thumbnail buffer available', grabResult }; } // Upload to IPFS (re-use grabId for progress tracking so the keep-mint heartbeat can see it) console.log(`📤 Uploading ${format} to IPFS...`); const mimeType = format === 'webp' ? 'image/webp' : format === 'gif' ? 'image/gif' : 'image/png'; const filename = `${pieceName}-thumbnail.${format}`; if (grabResult?.grabId) { updateProgress(grabResult.grabId, { piece: grabResult.piece, format, stage: 'uploading', stageDetail: 'Pinning to IPFS...', percent: 92, }); }
try { const ipfsUri = await uploadToIPFS(buffer, filename, credentials); const ipfsCid = ipfsUri.replace('ipfs://', ''); console.log(`✅ Thumbnail uploaded: ${ipfsUri}`); // Track this upload for the live endpoint const uploadInfo = { ipfsCid, ipfsUri, piece: pieceName, format, mimeType, size: buffer.length, timestamp: Date.now(), }; latestIPFSUploads.set(pieceName, uploadInfo); latestKeepThumbnail = uploadInfo; // Update grab in MongoDB with IPFS info and clear progress if (grabResult.grabId) { updateGrabInMongo(grabResult.grabId, { ipfsCid, ipfsUri }); // Also update in-memory grab const inMemoryGrab = recentGrabs.find(g => g.grabId === grabResult.grabId); if (inMemoryGrab) { inMemoryGrab.ipfsCid = ipfsCid; inMemoryGrab.ipfsUri = ipfsUri; } // Clear progress now that IPFS upload is done grabProgressMap.delete(grabResult.grabId); } // Notify WebSocket subscribers about new IPFS upload notifySubscribers(); return { success: true, ipfsUri, ipfsCid, piece: pieceName, format, mimeType, size: buffer.length, grabDuration: grabResult.duration, cached: grabResult.cached || false, }; } catch (error) { console.error(`❌ IPFS upload failed:`, error.message); // Clean up progress so the card doesn't stay stuck on the dashboard if (grabResult?.grabId) { grabProgressMap.delete(grabResult.grabId); notifySubscribers(); } return { success: false, error: error.message, grabResult }; }}
/** * Express handler for /grab endpoint */export async function grabHandler(req, res) { const { piece, format = 'webp', width = 512, height = 512, duration = 12000, fps = 7.5, density = 1, quality = 80, skipCache = false, cacheKey = '', } = req.body; if (!piece) { return res.status(400).json({ error: 'Missing required field: piece' }); } // Reject blacklisted pieces early if (isPieceBlacklisted(piece)) { return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` }); } // Validate format if (!['webp', 'gif', 'png'].includes(format)) { return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' }); } // Validate dimensions if (width < MIN_GRAB_DIMENSION || width > MAX_GRAB_DIMENSION || height < MIN_GRAB_DIMENSION || height > MAX_GRAB_DIMENSION) { return res.status(400).json({ error: `Dimensions must be between ${MIN_GRAB_DIMENSION} and ${MAX_GRAB_DIMENSION}` }); } try { const result = await grabPiece(piece, { format, width: parseInt(width), height: parseInt(height), duration: parseInt(duration), fps: parseInt(fps), density: parseFloat(density) || 1, quality: parseInt(quality) || 80, skipCache: skipCache === true || skipCache === 'true', cacheKey, requestOrigin: req.get('referer') || req.get('origin') || null, });
if (result.success) { // If result is cached and has CDN URL if (result.cached && result.cdnUrl) { res.setHeader('X-Cache', 'HIT'); res.setHeader('X-Grab-Id', result.grabId); // Check if request has Origin header (browser CORS request) // If so, proxy the image instead of redirecting (CDN lacks CORS headers) const origin = req.get('Origin'); if (origin) { // Proxy the cached image to preserve CORS headers try { const proxyRes = await fetch(result.cdnUrl); if (proxyRes.ok) { const buffer = Buffer.from(await proxyRes.arrayBuffer()); const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; const contentType = contentTypes[format] || 'image/webp'; res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', buffer.length); res.setHeader('X-Proxy', 'CDN'); return res.send(buffer); } } catch (proxyErr) { console.error('CDN proxy error:', proxyErr.message); // Fall through to redirect } } // No Origin header or proxy failed - redirect to CDN return res.redirect(301, result.cdnUrl); } // Return the image directly const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; const contentType = contentTypes[format] || 'image/webp'; res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', result.buffer.length); res.setHeader('X-Grab-Id', result.grabId); res.setHeader('X-Grab-Duration', result.duration); res.setHeader('X-Cache', 'MISS'); res.send(result.buffer); } else { res.status(500).json({ error: result.error, grabId: result.grabId }); } } catch (error) { console.error('Grab handler error:', error); res.status(500).json({ error: error.message }); }}
/** * GET handler for convenient URL-based grabbing * GET /grab/:format/:width/:height/:piece * e.g., /grab/gif/400/400/$roz * * Query params: * - duration, fps, density, quality, source, skipCache (standard grab options) * - nowait=true: Return a placeholder image if grab is in progress/queued instead of waiting */export async function grabGetHandler(req, res) { const { format, width, height, piece } = req.params; const { duration = 12000, fps = 7.5, density = 1, quality = 80, source = 'manual', skipCache = 'false', nowait = 'false', cacheKey = '' } = req.query; if (!piece) { return res.status(400).json({ error: 'Missing piece parameter' }); } // Reject blacklisted pieces early if (isPieceBlacklisted(piece)) { return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` }); } // Validate format if (!['webp', 'gif', 'png'].includes(format)) { return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' }); } const w = parseInt(width) || 512; const h = parseInt(height) || 512; if (w < MIN_GRAB_DIMENSION || w > MAX_GRAB_DIMENSION || h < MIN_GRAB_DIMENSION || h > MAX_GRAB_DIMENSION) { return res.status(400).json({ error: `Dimensions must be between ${MIN_GRAB_DIMENSION} and ${MAX_GRAB_DIMENSION}` }); } // Calculate captureKey to check for in-progress grabs const animated = format !== 'png'; const parsedDensity = parseFloat(density) || 1; const captureKey = getCaptureKey(piece, w * parsedDensity, h * parsedDensity, format, animated, cacheKey); // If nowait=true, check if there's already a grab in progress for this key if (nowait === 'true' || nowait === true) { const inProgress = getInProgressGrab(captureKey); if (inProgress.inProgress) { console.log(`🔥 Returning baking placeholder for ${piece} (queue: ${inProgress.queuePosition})`); try { const placeholder = await generateBakingPlaceholder({ width: w, height: h, format, piece, queuePosition: inProgress.queuePosition, estimatedWait: inProgress.estimatedWait, }); const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; const contentType = contentTypes[format] || 'image/webp'; res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', placeholder.length); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // Don't cache placeholders res.setHeader('X-Oven-Status', 'baking'); res.setHeader('X-Queue-Position', inProgress.queuePosition || 0); res.setHeader('X-Estimated-Wait', inProgress.estimatedWait || 30000); res.setHeader('X-Grab-Id', inProgress.grabId || ''); return res.send(placeholder); } catch (placeholderErr) { console.error('Failed to generate placeholder:', placeholderErr.message); // Fall through to normal grab } } } try { const result = await grabPiece(piece, { format, width: w, height: h, skipCache: skipCache === 'true' || skipCache === true, duration: parseInt(duration), fps: parseInt(fps), density: parsedDensity, quality: parseInt(quality) || 80, source: source || 'manual', cacheKey, requestOrigin: req.get('referer') || req.get('origin') || null, });
if (result.success) { // If cached, check for CORS proxy need if (result.cached && result.cdnUrl) { res.setHeader('X-Grab-Id', result.grabId || result.captureKey); res.setHeader('X-Cache', 'HIT'); // Check if request has Origin header (browser CORS request) // If so, proxy the image instead of redirecting (CDN lacks CORS headers) const origin = req.get('Origin'); if (origin) { // Proxy the cached image to preserve CORS headers try { const proxyRes = await fetch(result.cdnUrl); if (proxyRes.ok) { const buffer = Buffer.from(await proxyRes.arrayBuffer()); const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; const contentType = contentTypes[format] || 'image/webp'; res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', buffer.length); res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('X-Proxy', 'CDN'); return res.send(buffer); } } catch (proxyErr) { console.error('CDN proxy error:', proxyErr.message); // Fall through to redirect } } return res.redirect(301, result.cdnUrl); } const contentTypes = { webp: 'image/webp', gif: 'image/gif', png: 'image/png' }; const contentType = contentTypes[format] || 'image/webp'; res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', result.buffer.length); res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('X-Grab-Id', result.grabId); res.setHeader('X-Cache', 'MISS'); res.send(result.buffer); } else { res.status(500).json({ error: result.error, grabId: result.grabId }); } } catch (error) { console.error('Grab GET handler error:', error); res.status(500).json({ error: error.message }); }}
/** * Express handler for /grab-ipfs endpoint * Grabs a piece and uploads the thumbnail to IPFS */export async function grabIPFSHandler(req, res) { const { piece, format = 'webp', width = 96, // Small thumbnail (was 512) height = 96, duration = 8000, // 8 seconds fps = 10, // 10fps capture playbackFps = 20, // 20fps playback = 2x speed density = 2, // 2x density for crisp pixels (was 1) quality = 70, // Lower quality for smaller files skipCache = false, // Force regeneration (bypass CDN cache) cacheKey = '', // Optional cache discriminator (e.g. source hash) pinataKey, pinataSecret, source, // Optional: 'keep', 'manual', etc. keepId, // Optional: Tezos keep token ID author, // Optional: author handle (e.g. "@jeffrey") pieceCreatedAt, // Optional: when the KidLisp piece was created } = req.body; if (!piece) { return res.status(400).json({ error: 'Missing required field: piece' }); } // Reject blacklisted pieces early if (isPieceBlacklisted(piece)) { return res.status(400).json({ error: `Piece "${piece}" is not allowed - only aesthetic.computer pieces are supported` }); } if (!pinataKey || !pinataSecret) { return res.status(400).json({ error: 'Missing Pinata credentials (pinataKey, pinataSecret)' }); } // Validate format if (!['webp', 'gif', 'png'].includes(format)) { return res.status(400).json({ error: 'Invalid format. Use "webp", "gif" or "png"' }); } try { const result = await grabAndUploadToIPFS(piece, { pinataKey, pinataSecret }, { format, width: parseInt(width), height: parseInt(height), duration: parseInt(duration), fps: parseFloat(fps), playbackFps: parseFloat(playbackFps), density: parseFloat(density) || 1, quality: parseInt(quality) || 90, skipCache: skipCache === true || skipCache === 'true', cacheKey, source: source || 'manual', keepId: keepId || null, author: author || null, pieceCreatedAt: pieceCreatedAt || null, }); if (result.success) { res.json({ success: true, ipfsUri: result.ipfsUri, piece: result.piece, format: result.format, mimeType: result.mimeType, size: result.size, grabDuration: result.grabDuration, }); } else { res.status(500).json({ success: false, error: result.error }); } } catch (error) { console.error('Grab IPFS handler error:', error); res.status(500).json({ error: error.message }); }}
// Status exportsexport function getActiveGrabs() { return Array.from(activeGrabs.values());}
export function getRecentGrabs() { return recentGrabs;}
// Latest IPFS upload exports for live collection thumbnailexport function getLatestKeepThumbnail() { return latestKeepThumbnail;}
export function getLatestIPFSUpload(piece) { return latestIPFSUploads.get(piece?.replace(/^\$/, ''));}
export function getAllLatestIPFSUploads() { return Object.fromEntries(latestIPFSUploads);}
/** * Close the browser instance (for graceful shutdown) */export async function closeBrowser() { if (browser) { console.log('🧹 Closing browser...'); await browser.close(); browser = null; browserLaunchPromise = null; console.log('✅ Browser closed'); }}
/** * Close all connections (browser + MongoDB) for clean exit */export async function closeAll() { await closeBrowser(); if (mongoClient) { console.log('🧹 Closing MongoDB...'); await mongoClient.close(); mongoClient = null; db = null; console.log('✅ MongoDB closed'); }}
// =============================================================================// KidLisp.com Dynamic OG Preview Image Generation// =============================================================================
// Cache for OG image URLs, keyed by layout (memory cache with TTL)const ogImageCache = { byLayout: {}, // { [layout]: { url, expires } } generatedAt: null, featuredPiece: null,};
// OG Image layout optionsconst OG_LAYOUTS = { FEATURED: 'featured', // Single featured piece (Option A) MOSAIC: 'mosaic', // Grid of top 6 pieces (Option B) FILMSTRIP: 'filmstrip', // Same piece at multiple timepoints (Option C) CODE_SPLIT: 'code-split', // Code + preview side by side (Option D)};
/** * Format hit count for display (e.g., 4634 -> "4.6k") */function formatHits(hits) { if (!hits || hits < 1000) return String(hits || 0); if (hits < 10000) return (hits / 1000).toFixed(1) + 'k'; return Math.floor(hits / 1000) + 'k';}
/** * Get today's date string for cache keys (YYYY-MM-DD) */function getTodayKey() { return new Date().toISOString().split('T')[0];}
/** * Get deterministic "day of year" number for rotating featured content */function getDayOfYear() { const now = new Date(); const start = new Date(now.getFullYear(), 0, 0); const diff = now - start; const oneDay = 1000 * 60 * 60 * 24; return Math.floor(diff / oneDay);}
// =============================================================================// Source Similarity Detection (from give.aesthetic.computer)// =============================================================================
const SIMILARITY_THRESHOLD = 0.90; // 90% similar = duplicateconst MIN_SOURCE_LENGTH = 50; // Only check sources longer than this
/** * Get trigrams (3-char sequences) from a string */function getTrigrams(str) { const trigrams = new Set(); for (let i = 0; i <= str.length - 3; i++) { trigrams.add(str.slice(i, i + 3)); } return trigrams;}
/** * Calculate similarity between two source strings using trigram Jaccard similarity */function getSourceSimilarity(source1, source2) { if (!source1 || !source2) return 0; // Normalize: lowercase, remove extra whitespace const norm1 = source1.toLowerCase().replace(/\s+/g, ' ').trim(); const norm2 = source2.toLowerCase().replace(/\s+/g, ' ').trim(); if (norm1 === norm2) return 1; if (norm1.length < 10 || norm2.length < 10) return 0; const t1 = getTrigrams(norm1); const t2 = getTrigrams(norm2); // Jaccard similarity: intersection / union let intersection = 0; for (const t of t1) { if (t2.has(t)) intersection++; } const union = t1.size + t2.size - intersection; return union > 0 ? intersection / union : 0;}
/** * Filter pieces to remove duplicates with >90% similar source code * Returns a deduplicated list */function deduplicatePieces(pieces, threshold = SIMILARITY_THRESHOLD) { const selected = []; const selectedSources = new Map(); // code -> source for (const piece of pieces) { const source = piece.source; // Skip if no source or too short if (!source || source.length < MIN_SOURCE_LENGTH) { selected.push(piece); continue; } // Check against all previously selected pieces let isDuplicate = false; for (const [existingCode, existingSource] of selectedSources) { const similarity = getSourceSimilarity(source, existingSource); if (similarity >= threshold) { console.log(` ⚠️ Skipping $${piece.code}: ${(similarity * 100).toFixed(0)}% similar to $${existingCode}`); isDuplicate = true; break; } } if (!isDuplicate) { selected.push(piece); selectedSources.set(piece.code, source); } } return selected;}
/** * Fetch top KidLisp hits from the TV API */async function fetchTopKidlispHits(limit = 20) { try { const apiUrl = process.env.API_BASE_URL || 'https://aesthetic.computer'; const response = await fetch(`${apiUrl}/api/tv?types=kidlisp&sort=hits&limit=${limit}`); if (!response.ok) { throw new Error(`TV API returned ${response.status}`); } const data = await response.json(); return data.media?.kidlisp || []; } catch (error) { console.error('❌ Failed to fetch top hits:', error.message); return []; }}
/** * Check if we have a cached OG image for today * Returns CDN URL if exists, null otherwise */async function getCachedOGImage(layout = 'featured') { const today = getTodayKey(); const key = `og/kidlisp/${today}-${layout}.png`;
// Check memory cache first (per-layout) const cached = ogImageCache.byLayout[layout]; if (cached?.url && Date.now() < cached.expires) { console.log(`📦 OG image from memory cache (${layout}): ${cached.url}`); return cached.url; }
// Check Spaces for today's image try { await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key, }));
const url = `${SPACES_CDN_BASE}/${key}`;
// Update memory cache (1hr TTL, per-layout) ogImageCache.byLayout[layout] = { url, expires: Date.now() + 60 * 60 * 1000, };
console.log(`📦 OG image from Spaces cache (${layout}): ${url}`); return url; } catch (err) { // Not found in cache return null; }}
/** * Upload OG image to Spaces CDN */async function uploadOGImageToSpaces(buffer, layout = 'featured') { const today = getTodayKey(); const key = `og/kidlisp/${today}-${layout}.png`; await spacesClient.send(new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: key, Body: buffer, ContentType: 'image/png', ACL: 'public-read', CacheControl: 'public, max-age=86400', })); const url = `${SPACES_CDN_BASE}/${key}`; // Update memory cache (per-layout) ogImageCache.byLayout[layout] = { url, expires: Date.now() + 60 * 60 * 1000, }; ogImageCache.generatedAt = new Date().toISOString(); console.log(`📤 OG image uploaded to Spaces: ${url}`); return url;}
/** * Create SVG branding overlay for OG images */function createBrandingOverlay(featured, width = 1200, height = 80) { const code = featured?.code || 'kidlisp'; const hits = formatHits(featured?.hits); const handle = featured?.owner?.handle || ''; return Buffer.from(` <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%"> <stop offset="0%" style="stop-color:rgba(0,0,0,0);stop-opacity:0" /> <stop offset="100%" style="stop-color:rgba(0,0,0,0.85);stop-opacity:1" /> </linearGradient> </defs> <rect width="100%" height="100%" fill="url(#grad)"/> <text x="24" y="${height - 24}" font-family="monospace, 'Courier New'" font-size="28" font-weight="bold" fill="white"> KidLisp.com </text> <text x="${width - 24}" y="${height - 24}" font-family="monospace, 'Courier New'" font-size="20" fill="#cccccc" text-anchor="end"> $${code} · ${hits} plays ${handle ? '· ' + handle : ''} </text> </svg> `);}
/** * Create SVG overlay for mosaic layout (smaller per-tile labels) */function createMosaicLabel(piece, index, tileWidth = 400, tileHeight = 315) { const code = piece?.code || '???'; const hits = formatHits(piece?.hits); return Buffer.from(` <svg width="${tileWidth}" height="40" xmlns="http://www.w3.org/2000/svg"> <rect width="100%" height="100%" fill="rgba(0,0,0,0.7)"/> <text x="8" y="26" font-family="monospace" font-size="14" fill="white"> $${code} </text> <text x="${tileWidth - 8}" y="26" font-family="monospace" font-size="12" fill="#aaa" text-anchor="end"> ${hits} ▶ </text> </svg> `);}
/** * Generate KidLisp OG image - Featured layout (Option A) * Single top piece fills the frame with branding overlay */async function generateFeaturedOGImage(topPieces) { const dayOfYear = getDayOfYear(); const featuredIndex = dayOfYear % Math.min(topPieces.length, 10); const featured = topPieces[featuredIndex]; if (!featured) { throw new Error('No featured piece available'); } console.log(`🎨 Generating featured OG: $${featured.code} (${formatHits(featured.hits)} hits)`); // Capture screenshot at 1200x630 (OG image standard) const width = 1200; const height = 630; const browser = await getBrowser(); const page = await browser.newPage(); await interceptSelfRequests(page); await injectFontGlyphs(page);
try { await page.setViewport({ width, height, deviceScaleFactor: 1 });
const url = `https://aesthetic.computer/$${featured.code}?density=1&tv=true&nolabel=true&nogap=true&spoofaudio=true`; console.log(` Loading: ${url}`); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await populateGlyphCache(page);
// Wait for canvas and content await page.waitForSelector('canvas', { timeout: 10000 });
// Wait for KidLisp content to render (animation frame) await new Promise(r => setTimeout(r, 4000));
// Take screenshot const screenshot = await page.screenshot({ type: 'png' }); // Composite with branding overlay using sharp const brandingOverlay = createBrandingOverlay(featured, width, 100); const composite = await sharp(screenshot) .composite([ { input: brandingOverlay, gravity: 'south', } ]) .png() .toBuffer(); ogImageCache.featuredPiece = featured; return composite; } finally { await page.close(); }}
/** * Generate KidLisp OG image - Mosaic layout (Option B) * 4x4 grid of top 16 pieces with large KidLisp.com branding */async function generateMosaicOGImage(topPieces) { const width = 1200; const height = 630; const cols = 4; const rows = 4; const tileWidth = width / cols; // 300px const tileHeight = height / rows; // 157.5px const pieces = topPieces.slice(0, 16); if (pieces.length < 16) { // Pad with duplicates if needed while (pieces.length < 16) { pieces.push(pieces[pieces.length % topPieces.length] || { code: 'blank', hits: 0 }); } } console.log(`🎨 Generating 4x4 mosaic OG: ${pieces.map(p => '$' + p.code).join(', ')}`); const browser = await getBrowser(); const page = await browser.newPage(); await interceptSelfRequests(page); await injectFontGlyphs(page);
try { const tiles = []; for (let i = 0; i < pieces.length; i++) { const piece = pieces[i]; await page.setViewport({ width: Math.round(tileWidth), height: Math.round(tileHeight), deviceScaleFactor: 0.5 }); const url = `https://aesthetic.computer/$${piece.code}?density=0.5&tv=true&nolabel=true&nogap=true&spoofaudio=true`; console.log(` [${i + 1}/16] Loading: $${piece.code}`); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); if (i === 0) await populateGlyphCache(page);
await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {}); // Let pieces play longer before capture (8 seconds) await new Promise(r => setTimeout(r, 8000)); const screenshot = await page.screenshot({ type: 'png' }); // No labels, just the raw visual const tile = await sharp(screenshot) .resize(Math.round(tileWidth), Math.round(tileHeight), { fit: 'cover' }) .png() .toBuffer(); tiles.push(tile); } // Assemble mosaic - no black bars const composites = tiles.map((tile, i) => ({ input: tile, left: (i % cols) * Math.round(tileWidth), top: Math.floor(i / cols) * Math.round(tileHeight), })); // Create base mosaic let mosaic = await sharp({ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } } }) .composite(composites) .png() .toBuffer(); // Apply Gaussian blur for ambient background effect mosaic = await sharp(mosaic) .blur(8) // sigma value - higher = more blur .toBuffer();
// Upload raw mosaic (no branding) for site-specific OG images to composite on uploadOGImageToSpaces(mosaic, 'mosaic-raw').catch(err => console.error('Failed to upload raw mosaic:', err.message) );
// Add dark overlay to make text pop, then add branding const darkOverlay = Buffer.from(` <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <rect width="100%" height="100%" fill="rgba(0,0,0,0.35)"/> </svg> `); // Use Puppeteer for branding + floating codes to get proper Comic Relief font rendering const brandingOverlay = await createKidLispBrandingWithPuppeteer(width, height, pieces.map(p => p.code)); mosaic = await sharp(mosaic) .composite([ { input: darkOverlay, gravity: 'center' }, { input: brandingOverlay, gravity: 'center' }, ]) .png() .toBuffer(); return mosaic; } finally { await page.close(); }}
/** * Create floating $codes overlay with limegreen syntax highlighting * Codes float upward with motion trails, evenly distributed */function createFloatingCodesOverlay(width, height, codes) { // Use seeded random for consistent layout const seed = codes.join('').split('').reduce((a, c) => a + c.charCodeAt(0), 0); const seededRandom = (i) => { const x = Math.sin(seed + i * 9999) * 10000; return x - Math.floor(x); }; // Even grid distribution - extend beyond edges for cut-off effect const cols = 6; const rows = 5; const cellWidth = (width + 100) / cols; // Extend past edges const cellHeight = (height + 80) / rows; const numCodes = cols * rows; const baseFontSize = 42; const fontFamily = "'Comic Relief', 'Comic Sans MS', cursive, sans-serif"; const shadowOffset = 2; // Tighter shadow // Colors: $ is bright cyan-green, rest is limegreen (like kidlisp.com) const dollarColor = '#00ff88'; const codeColor = 'limegreen'; let codeElements = ''; for (let i = 0; i < numCodes; i++) { const code = codes[i % codes.length]; const col = i % cols; const row = Math.floor(i / cols); // Center in cell with small jitter, offset left so some cut off on edges const baseX = col * cellWidth - 50 + cellWidth / 2; const baseY = row * cellHeight - 40 + cellHeight / 2; const jitterX = (seededRandom(i * 5) - 0.5) * cellWidth * 0.4; const jitterY = (seededRandom(i * 5 + 1) - 0.5) * cellHeight * 0.3; const x = baseX + jitterX; const y = baseY + jitterY; // Slight rotation for organic feel const rotation = (seededRandom(i * 5 + 2) - 0.5) * 16; // -8 to +8 degrees // Various sizes for different codes (but consistent within each code) const scale = 0.7 + seededRandom(i * 5 + 3) * 0.8; // 0.7 to 1.5 const actualSize = baseFontSize * scale; // Opacity varies const opacity = 0.2 + seededRandom(i * 5 + 4) * 0.25; // 0.2 to 0.45 // Motion trail - 3 fading copies below the main text (floating UP effect) const trailCount = 3; const trailSpacing = actualSize * 0.35; for (let t = trailCount; t >= 0; t--) { const trailY = y + t * trailSpacing; const trailOpacity = t === 0 ? opacity : opacity * (0.15 / (t + 1)); // Main is full, trails fade const trailBlur = t === 0 ? 0 : t * 0.5; // Only draw shadow for the main text (t === 0) if (t === 0) { // Shadow (tight, solid black) codeElements += ` <text x="${x + shadowOffset}" y="${trailY + shadowOffset}" font-family="${fontFamily}" font-size="${actualSize}px" font-weight="bold" fill="black" opacity="${trailOpacity * 0.5}" transform="rotate(${rotation}, ${x}, ${trailY})" >$${code}</text> `; } // $ character in bright cyan-green codeElements += ` <text x="${x}" y="${trailY}" font-family="${fontFamily}" font-size="${actualSize}px" font-weight="bold" fill="${dollarColor}" opacity="${trailOpacity}" transform="rotate(${rotation}, ${x}, ${trailY})" ${trailBlur > 0 ? `filter="url(#blur${t})"` : ''} >$</text> `; // Code characters in limegreen (offset by $ width) const dollarWidth = actualSize * 0.55; codeElements += ` <text x="${x + dollarWidth}" y="${trailY}" font-family="${fontFamily}" font-size="${actualSize}px" font-weight="bold" fill="${codeColor}" opacity="${trailOpacity}" transform="rotate(${rotation}, ${x}, ${trailY})" ${trailBlur > 0 ? `filter="url(#blur${t})"` : ''} >${code}</text> `; } } return Buffer.from(` <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <defs> ${comicReliefBoldBase64 ? ` <style type="text/css"> @font-face { font-family: 'Comic Relief'; src: url('data:font/truetype;base64,${comicReliefBoldBase64}') format('truetype'); font-weight: bold; } </style> ` : ''} <filter id="blur1" x="-50%" y="-50%" width="200%" height="200%"> <feGaussianBlur in="SourceGraphic" stdDeviation="1" /> </filter> <filter id="blur2" x="-50%" y="-50%" width="200%" height="200%"> <feGaussianBlur in="SourceGraphic" stdDeviation="2" /> </filter> <filter id="blur3" x="-50%" y="-50%" width="200%" height="200%"> <feGaussianBlur in="SourceGraphic" stdDeviation="3" /> </filter> </defs> ${codeElements} </svg> `);}
/** * Create large KidLisp.com branding overlay using Puppeteer for proper font rendering * This uses an HTML page with Google Fonts to ensure Comic Relief loads correctly */async function createKidLispBrandingWithPuppeteer(width, height, codes = []) { // KidLisp letter colors - com uses delete(red)/stop(purple)/play(green) button colors const letterColors = { 'K': '#FF6B6B', 'i1': '#4ECDC4', 'd': '#FFE66D', 'L': '#95E1D3', 'i2': '#F38181', 's': '#AA96DA', 'p': '#70D6FF', '.': '#95E1D3', 'c': '#FF6B6B', 'o': '#9370DB', 'm': '#90EE90', }; const letters = [ { char: 'K', color: letterColors['K'] }, { char: 'i', color: letterColors['i1'] }, { char: 'd', color: letterColors['d'] }, { char: 'L', color: letterColors['L'] }, { char: 'i', color: letterColors['i2'] }, { char: 's', color: letterColors['s'] }, { char: 'p', color: letterColors['p'] }, { char: '.', color: letterColors['.'] }, { char: 'c', color: letterColors['c'] }, { char: 'o', color: letterColors['o'] }, { char: 'm', color: letterColors['m'] }, ]; const letterSpans = letters.map(l => `<span style="color: ${l.color}">${l.char}</span>`).join(''); // Generate floating codes HTML with seeded random positions const seed = codes.join('').split('').reduce((a, c) => a + c.charCodeAt(0), 0); const seededRandom = (i) => { const x = Math.sin(seed + i * 9999) * 10000; return x - Math.floor(x); }; const cols = 6; const rows = 5; const cellWidth = (width + 100) / cols; const cellHeight = (height + 80) / rows; const numCodes = cols * rows; let floatingCodesHtml = ''; for (let i = 0; i < numCodes; i++) { const code = codes[i % codes.length] || 'abc'; const col = i % cols; const row = Math.floor(i / cols); const baseX = col * cellWidth - 50 + cellWidth / 2; const baseY = row * cellHeight - 40 + cellHeight / 2; const jitterX = (seededRandom(i * 5) - 0.5) * cellWidth * 0.4; const jitterY = (seededRandom(i * 5 + 1) - 0.5) * cellHeight * 0.3; const x = baseX + jitterX; const y = baseY + jitterY; const rotation = (seededRandom(i * 5 + 2) - 0.5) * 16; const scale = 0.7 + seededRandom(i * 5 + 3) * 0.8; const opacity = 0.2 + seededRandom(i * 5 + 4) * 0.25; const fontSize = 42 * scale; floatingCodesHtml += ` <div class="floating-code" style=" left: ${x}px; top: ${y}px; font-size: ${fontSize}px; opacity: ${opacity}; transform: rotate(${rotation}deg); "><span class="dollar">$</span><span class="code">${code}</span></div> `; } const html = `<!DOCTYPE html><html><head> <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:wght@700&display=swap" rel="stylesheet"> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { width: ${width}px; height: ${height}px; display: flex; align-items: center; justify-content: center; background: transparent; position: relative; overflow: hidden; } .floating-code { position: absolute; font-family: 'Comic Relief', cursive; font-weight: 700; white-space: nowrap; text-shadow: 2px 2px 0 rgba(0,0,0,0.5); } .floating-code .dollar { color: #00ff88; } .floating-code .code { color: limegreen; } .branding { font-family: 'Comic Relief', cursive; font-size: 160px; font-weight: 700; letter-spacing: 0.05em; text-shadow: 8px 8px 0 #000; position: relative; z-index: 10; } </style></head><body> ${floatingCodesHtml} <div class="branding">${letterSpans}</div></body></html>`;
const browser = await getBrowser(); const page = await browser.newPage(); await interceptSelfRequests(page); await injectFontGlyphs(page);
try { await page.setViewport({ width, height, deviceScaleFactor: 1 }); await page.setContent(html, { waitUntil: 'networkidle0' }); // Wait for font to load await page.evaluate(() => document.fonts.ready); await new Promise(r => setTimeout(r, 500)); // Extra time for font render const screenshot = await page.screenshot({ type: 'png', omitBackground: true // Transparent background }); return screenshot; } finally { await page.close(); }}
/** * Create large KidLisp.com branding overlay with Comic Relief font (SVG fallback) */function createKidLispBranding(width, height) { // KidLisp letter colors - com uses delete(red)/stop(purple)/play(green) button colors const letterColors = { 'K': '#FF6B6B', 'i1': '#4ECDC4', 'd': '#FFE66D', 'L': '#95E1D3', 'i2': '#F38181', 's': '#AA96DA', 'p': '#70D6FF', '.': '#95E1D3', 'c': '#FF6B6B', 'o': '#9370DB', 'm': '#90EE90', }; const fontSize = 160; // Even bigger const fontFamily = "'Comic Relief', 'Comic Sans MS', cursive, sans-serif"; const shadowOffset = 8; // Offset down and right // Build colored text with tspans const letters = [ { char: 'K', color: letterColors['K'] }, { char: 'i', color: letterColors['i1'] }, { char: 'd', color: letterColors['d'] }, { char: 'L', color: letterColors['L'] }, { char: 'i', color: letterColors['i2'] }, { char: 's', color: letterColors['s'] }, { char: 'p', color: letterColors['p'] }, { char: '.', color: letterColors['.'] }, { char: 'c', color: letterColors['c'] }, { char: 'o', color: letterColors['o'] }, { char: 'm', color: letterColors['m'] }, ]; const tspans = letters.map(l => `<tspan fill="${l.color}">${l.char}</tspan>`).join(''); const blackTspans = letters.map(l => `<tspan fill="#000">${l.char}</tspan>`).join(''); const yOffset = 30; // Move text down for better visual centering const svg = ` <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <defs> ${comicReliefBoldBase64 ? ` <style type="text/css"> @font-face { font-family: 'Comic Relief'; src: url('data:font/truetype;base64,${comicReliefBoldBase64}') format('truetype'); font-weight: bold; } </style> ` : ''} </defs> <!-- Black shadow offset down-right --> <text x="${width/2 + shadowOffset}" y="${height/2 + yOffset + shadowOffset}" font-family="${fontFamily}" font-size="${fontSize}" font-weight="bold" text-anchor="middle" dominant-baseline="middle" letter-spacing="0.05em">${blackTspans}</text> <!-- Main colored text --> <text x="${width/2}" y="${height/2 + yOffset}" font-family="${fontFamily}" font-size="${fontSize}" font-weight="bold" text-anchor="middle" dominant-baseline="middle" letter-spacing="0.05em">${tspans}</text> </svg> `; return Buffer.from(svg);}
/** * Generate KidLisp OG image - Filmstrip layout (Option C) * Same piece captured at 5 different time offsets */async function generateFilmstripOGImage(topPieces) { const dayOfYear = getDayOfYear(); const featuredIndex = dayOfYear % Math.min(topPieces.length, 10); const featured = topPieces[featuredIndex]; if (!featured) { throw new Error('No featured piece available'); } const width = 1200; const height = 630; const frameCount = 5; const frameWidth = 200; const frameHeight = 200; const spacing = 20; const totalFrameWidth = frameCount * frameWidth + (frameCount - 1) * spacing; const startX = (width - totalFrameWidth) / 2; const frameY = 180; console.log(`🎨 Generating filmstrip OG: $${featured.code} (${frameCount} frames)`); const browser = await getBrowser(); const page = await browser.newPage(); await interceptSelfRequests(page); await injectFontGlyphs(page);
try { await page.setViewport({ width: frameWidth, height: frameHeight, deviceScaleFactor: 2 }); const url = `https://aesthetic.computer/$${featured.code}?density=2&tv=true&nolabel=true&nogap=true&spoofaudio=true`; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await populateGlyphCache(page); await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {});
const frames = []; for (let i = 0; i < frameCount; i++) { // Wait between frames to capture animation progress await new Promise(r => setTimeout(r, i === 0 ? 2000 : 800)); const screenshot = await page.screenshot({ type: 'png' }); const frame = await sharp(screenshot) .resize(frameWidth, frameHeight, { fit: 'cover' }) .extend({ top: 2, bottom: 2, left: 2, right: 2, background: { r: 255, g: 255, b: 255, alpha: 1 } }) .png() .toBuffer(); frames.push(frame); console.log(` Frame ${i + 1}/${frameCount} captured`); } // Create base image with dark background const composites = frames.map((frame, i) => ({ input: frame, left: Math.round(startX + i * (frameWidth + spacing)), top: frameY, })); // Add arrows between frames const arrowSvg = Buffer.from(` <svg width="20" height="30" xmlns="http://www.w3.org/2000/svg"> <path d="M5 5 L15 15 L5 25" stroke="white" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/> </svg> `); for (let i = 0; i < frameCount - 1; i++) { composites.push({ input: arrowSvg, left: Math.round(startX + (i + 1) * frameWidth + i * spacing + spacing / 2 - 10), top: frameY + frameHeight / 2 - 15, }); } // Add title and info const titleSvg = Buffer.from(` <svg width="${width}" height="100" xmlns="http://www.w3.org/2000/svg"> <text x="${width / 2}" y="60" font-family="monospace, 'Courier New'" font-size="36" font-weight="bold" fill="white" text-anchor="middle"> KidLisp.com </text> </svg> `); const infoSvg = Buffer.from(` <svg width="${width}" height="80" xmlns="http://www.w3.org/2000/svg"> <text x="${width / 2}" y="50" font-family="monospace" font-size="24" fill="#cccccc" text-anchor="middle"> $${featured.code} · ${formatHits(featured.hits)} plays ${featured.owner?.handle ? '· ' + featured.owner.handle : ''} </text> </svg> `); composites.push({ input: titleSvg, left: 0, top: 40 }); composites.push({ input: infoSvg, left: 0, top: height - 120 }); const filmstrip = await sharp({ create: { width, height, channels: 4, background: { r: 20, g: 20, b: 30, alpha: 1 } } }) .composite(composites) .png() .toBuffer(); return filmstrip; } finally { await page.close(); }}
/** * Generate KidLisp OG image - Code Split layout (Option D) * Source code on left, visual output on right */async function generateCodeSplitOGImage(topPieces) { const dayOfYear = getDayOfYear(); const featuredIndex = dayOfYear % Math.min(topPieces.length, 10); const featured = topPieces[featuredIndex]; if (!featured) { throw new Error('No featured piece available'); } const width = 1200; const height = 630; const codeWidth = 500; const previewWidth = 650; const previewHeight = 500; const padding = 25; console.log(`🎨 Generating code-split OG: $${featured.code}`); // Get source code (truncate for display) const source = featured.source || '(wipe "black")\n(ink "white")\n(box 50 50 100 100)'; const sourceLines = source.split('\n').slice(0, 12); const displaySource = sourceLines.join('\n') + (source.split('\n').length > 12 ? '\n...' : ''); const browser = await getBrowser(); const page = await browser.newPage(); await interceptSelfRequests(page); await injectFontGlyphs(page);
try { // Capture preview await page.setViewport({ width: previewWidth, height: previewHeight, deviceScaleFactor: 1 }); const url = `https://aesthetic.computer/$${featured.code}?density=1&tv=true&nolabel=true&nogap=true&spoofaudio=true`; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForSelector('canvas', { timeout: 10000 }).catch(() => {}); await new Promise(r => setTimeout(r, 4000)); const screenshot = await page.screenshot({ type: 'png' }); const preview = await sharp(screenshot) .resize(previewWidth - padding * 2, previewHeight - padding * 2, { fit: 'cover' }) .extend({ top: 4, bottom: 4, left: 4, right: 4, background: { r: 80, g: 80, b: 100, alpha: 1 } }) .png() .toBuffer(); // Create code panel as SVG const escapedSource = displaySource .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); const codeLines = escapedSource.split('\n'); const lineHeight = 28; const codeY = 80; const codeSvg = Buffer.from(` <svg width="${codeWidth}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <rect width="100%" height="100%" fill="#1a1a2e"/> <text x="20" y="40" font-family="monospace" font-size="14" fill="#666"> // $${featured.code} </text> ${codeLines.map((line, i) => ` <text x="20" y="${codeY + i * lineHeight}" font-family="monospace, 'Courier New'" font-size="18" fill="#88ff88"> ${line || ' '} </text> `).join('')} </svg> `); // Bottom info bar const infoSvg = Buffer.from(` <svg width="${width}" height="60" xmlns="http://www.w3.org/2000/svg"> <rect width="100%" height="100%" fill="rgba(0,0,0,0.8)"/> <text x="24" y="40" font-family="monospace" font-size="24" font-weight="bold" fill="white"> KidLisp.com </text> <text x="${width - 24}" y="40" font-family="monospace" font-size="18" fill="#aaa" text-anchor="end"> $${featured.code} · ${formatHits(featured.hits)} plays </text> </svg> `); const composite = await sharp({ create: { width, height, channels: 4, background: { r: 26, g: 26, b: 46, alpha: 1 } } }) .composite([ { input: codeSvg, left: 0, top: 0 }, { input: preview, left: codeWidth + padding, top: padding }, { input: infoSvg, left: 0, top: height - 60 }, ]) .png() .toBuffer(); return composite; } finally { await page.close(); }}
/** * Main entry point: Generate KidLisp.com OG preview image * Checks cache first, generates new image if needed * * @param {string} layout - Layout option: 'featured', 'mosaic', 'filmstrip', 'code-split' * @param {boolean} forceRegenerate - Skip cache and regenerate * @param {object} options - Additional options * @param {string} options.handle - Filter by handle (e.g., '@jeffrey') * @returns {Promise<{buffer: Buffer, url: string, cached: boolean, featured?: object}>} */export async function generateKidlispOGImage(layout = 'featured', forceRegenerate = false, options = {}) { const { handle } = options; console.log(`\n🖼️ KidLisp OG Image Request (layout: ${layout}, force: ${forceRegenerate}${handle ? `, handle: ${handle}` : ''})`); // Check cache first (unless forcing regeneration) if (!forceRegenerate) { const cachedUrl = await getCachedOGImage(layout); if (cachedUrl) { return { url: cachedUrl, cached: true, layout, generatedAt: ogImageCache.generatedAt, featuredPiece: ogImageCache.featuredPiece, }; } } // Fetch top hits const topPieces = await fetchTopKidlispHits(40); // Fetch extra to account for deduplication if (topPieces.length === 0) { throw new Error('No KidLisp pieces available from API'); } // Filter by handle if specified let filteredPieces = topPieces; if (handle) { const normalizedHandle = handle.startsWith('@') ? handle : `@${handle}`; filteredPieces = topPieces.filter(p => p.owner?.handle === normalizedHandle); console.log(` Filtered to ${filteredPieces.length} pieces by ${normalizedHandle}`); } // Deduplicate by source similarity (90% threshold) const uniquePieces = deduplicatePieces(filteredPieces); console.log(` Found ${filteredPieces.length} pieces, ${uniquePieces.length} unique after deduplication`); // Generate based on layout let buffer; switch (layout) { case 'mosaic': buffer = await generateMosaicOGImage(uniquePieces); break; case 'filmstrip': buffer = await generateFilmstripOGImage(uniquePieces); break; case 'code-split': buffer = await generateCodeSplitOGImage(uniquePieces); break; case 'featured': default: buffer = await generateFeaturedOGImage(uniquePieces); break; } // Upload to Spaces const url = await uploadOGImageToSpaces(buffer, layout); return { buffer, url, cached: false, layout, generatedAt: new Date().toISOString(), featuredPiece: ogImageCache.featuredPiece, };}
/** * Get OG image cache status */export function getOGImageCacheStatus() { const layouts = {}; for (const [layout, entry] of Object.entries(ogImageCache.byLayout)) { layouts[layout] = { cached: !!entry.url && Date.now() < entry.expires, url: entry.url, expires: entry.expires ? new Date(entry.expires).toISOString() : null, }; } return { layouts, generatedAt: ogImageCache.generatedAt, featuredPiece: ogImageCache.featuredPiece, };}
/** * Get the latest cached OG image URL without triggering generation. * Returns the CDN URL if it exists for today, null otherwise. * This is fast and safe for social media crawlers. */export async function getLatestOGImageUrl(layout = 'mosaic') { const today = getTodayKey(); const key = `og/kidlisp/${today}-${layout}.png`;
// Check memory cache first (per-layout) const cached = ogImageCache.byLayout[layout]; if (cached?.url && cached.url.includes(today)) { return cached.url; }
// Check Spaces for today's image try { await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key, })); const url = `${SPACES_CDN_BASE}/${key}`; ogImageCache.byLayout[layout] = { url, expires: Date.now() + 60 * 60 * 1000, }; return url; } catch (err) { // Not found - return yesterday's image if available const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; const yesterdayKey = `og/kidlisp/${yesterday}-${layout}.png`; try { await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: yesterdayKey, })); return `${SPACES_CDN_BASE}/${yesterdayKey}`; } catch { return null; } }}
/** * Regenerate OG images in the background. * Safe to call from server startup or scheduled jobs. */export async function regenerateOGImagesBackground() { const layouts = ['mosaic', 'featured']; // Main layouts we care about console.log('🔄 Starting background OG image regeneration...'); for (const layout of layouts) { try { console.log(` Regenerating ${layout}...`); await generateKidlispOGImage(layout, true); // force=true console.log(` ✅ ${layout} done`); } catch (err) { console.error(` ❌ ${layout} failed:`, err.message); } } console.log('🔄 Background OG regeneration complete');}
// =============================================================================// KidLisp Backdrop - Animated WebP for login screens, etc.// =============================================================================
// Backdrop cache (memory)const backdropCache = { url: null, date: null, piece: null,};
/** * Get or generate KidLisp backdrop - a 2048px animated webp of a featured piece. * Rotates daily based on top hits. Caches to CDN for fast access. * @param {boolean} force - Force regeneration even if cached * @returns {{ url: string, piece: string, cached: boolean }} */export async function generateKidlispBackdrop(force = false) { const today = getTodayKey(); const key = `backdrop/kidlisp/${today}.webp`; // Check memory cache if (!force && backdropCache.url && backdropCache.date === today) { console.log(`📦 Backdrop from memory cache: ${backdropCache.url}`); return { url: backdropCache.url, piece: backdropCache.piece, cached: true }; } // Check Spaces for today's backdrop if (!force) { try { await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key, })); const url = `${SPACES_CDN_BASE}/${key}`; backdropCache.url = url; backdropCache.date = today; console.log(`📦 Backdrop from Spaces cache: ${url}`); return { url, piece: backdropCache.piece, cached: true }; } catch { // Not cached, continue to generate } } // Fetch top KidLisp pieces by @jeffrey only const topPieces = await fetchTopKidlispHits(50); const jeffreyPieces = topPieces.filter(p => p.owner?.handle === '@jeffrey'); if (!jeffreyPieces.length) { throw new Error('No KidLisp pieces by @jeffrey available for backdrop'); } // Pick piece based on day of year (rotates daily through jeffrey's top pieces) const dayOfYear = getDayOfYear(); const featuredIndex = dayOfYear % Math.min(jeffreyPieces.length, 10); const featured = jeffreyPieces[featuredIndex]; const piece = `$${featured.code}`; console.log(`🎨 Generating backdrop: ${piece} by ${featured.owner?.handle} (${formatHits(featured.hits)} hits)`); // Generate 256x256 animated webp at 4x density (gives 1024x1024 output with chunky pixels) // Lower resolution (1024 vs 2048) makes recording faster for Auth0 login backgrounds const result = await grabPiece(piece, { format: 'webp', width: 256, height: 256, duration: 12000, fps: 7.5, playbackFps: 15, density: 4, quality: 85, skipCache: force, source: 'backdrop', }); if (!result.success) { throw new Error(result.error || 'Failed to generate backdrop'); } // grabPiece returns cdnUrl from oven/grabs/ - use that directly const url = result.cdnUrl; if (!url) { throw new Error('No CDN URL returned from grabPiece'); } console.log(`📤 Backdrop generated: ${url}`); // Update cache with the grab URL backdropCache.url = url; backdropCache.date = today; backdropCache.piece = piece; return { url, piece, cached: false };}
/** * Get cached backdrop URL without triggering generation. * Returns the in-memory cached URL if available for today, null otherwise. */export async function getLatestBackdropUrl() { const today = getTodayKey(); // Check memory cache - only return if it's from today if (backdropCache.url && backdropCache.date === today) { return backdropCache.url; } // No cached URL for today - caller should trigger generation return null;}
// =============================================================================// Notepat.com OG Preview Image// =============================================================================
// Notepat OG image cache (memory)const notepatOGCache = { url: null, expires: 0,};
/** * Generate a branded OG image for notepat.com (1200x630 PNG). * Shows the actual split-layout chromatic pad interface with keyboard shortcuts. */export async function generateNotepatOGImage(forceRegenerate = false) { console.log(`\n🎹 Notepat OG Image Request (force: ${forceRegenerate})`);
// Check cache first if (!forceRegenerate) { const cachedUrl = await getLatestNotepatOGUrl(); if (cachedUrl) { return { url: cachedUrl, cached: true }; } }
const W = 1200; const H = 630;
// Full chromatic scale note colors (matching note-colors.mjs logic) const getNoteColor = (noteName, octave) => { const baseColors = { 'C': [255, 50, 50], // Red 'C#': [255, 100, 30], // Red-Orange 'D': [255, 160, 0], // Orange 'D#': [255, 200, 0], // Yellow-Orange 'E': [255, 230, 0], // Yellow 'F': [50, 200, 50], // Green 'F#': [50, 160, 120], // Teal 'G': [50, 120, 255], // Blue 'G#': [90, 80, 220], // Blue-Purple 'A': [130, 50, 200], // Purple 'A#': [160, 50, 220], // Purple-Magenta 'B': [180, 80, 255], // Magenta };
// Octave 5 gets brighter dayglo treatment if (octave === 5) { const boost = 40; const [r, g, b] = baseColors[noteName]; return [ Math.min(255, r + boost), Math.min(255, g + boost), Math.min(255, b + boost), ]; } return baseColors[noteName]; };
// All 24 chromatic notes in notepat layout const leftOctave = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const rightOctave = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Keyboard mappings (matching NOTE_TO_KEYBOARD_KEY) const leftKeys = ['c', 'v', 'd', 's', 'e', 'f', 'w', 'g', 'r', 'a', 'q', 'b']; const rightKeys = ['h', 't', 'i', 'y', 'j', 'k', 'u', 'l', 'o', 'm', 'p', 'n'];
// Layout: MUCH LARGER pads to fill the space const padW = 90; // More than doubled const padH = 130; // Significantly taller const padGap = 10; const padRadius = 6;
const cols = 4; // 4 columns per octave side const rows = 3; // 3 rows per octave side
const octaveW = cols * padW + (cols - 1) * padGap; const octaveH = rows * padH + (rows - 1) * padGap;
const splitGap = 80; // Tighter split gap const totalW = octaveW * 2 + splitGap;
const leftX = (W - totalW) / 2; const rightX = leftX + octaveW + splitGap; const topY = 60; // Move up significantly
// Build pads SVG let padsSvg = '';
// Left octave (octave 4) for (let i = 0; i < 12; i++) { const row = Math.floor(i / cols); const col = i % cols; const x = leftX + col * (padW + padGap); const y = topY + row * (padH + padGap);
const noteName = leftOctave[i]; const key = leftKeys[i]; const [r, g, b] = getNoteColor(noteName, 4);
// Pad padsSvg += `<rect x="${x}" y="${y}" width="${padW}" height="${padH}" rx="${padRadius}" fill="rgb(${r},${g},${b})" opacity="0.92"/>`;
// Note label (top) const noteLabel = noteName.replace('#', '♯'); const textColor = (noteName.includes('E') || noteName.includes('F')) ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.85)'; 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>`;
// Key label (bottom) 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>`; }
// Right octave (octave 5) for (let i = 0; i < 12; i++) { const row = Math.floor(i / cols); const col = i % cols; const x = rightX + col * (padW + padGap); const y = topY + row * (padH + padGap);
const noteName = rightOctave[i]; const key = rightKeys[i]; const [r, g, b] = getNoteColor(noteName, 5);
// Pad padsSvg += `<rect x="${x}" y="${y}" width="${padW}" height="${padH}" rx="${padRadius}" fill="rgb(${r},${g},${b})" opacity="0.92"/>`;
// Note label (top) const noteLabel = noteName.replace('#', '♯'); const textColor = (noteName.includes('E') || noteName.includes('F')) ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.85)'; 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>`;
// Key label (bottom) 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>`; }
// Decorative waveform across top let wavePath = `M 60 100`; for (let i = 0; i <= 120; i++) { const x = 60 + (i / 120) * (W - 120); const y = 100 + Math.sin(i * 0.15) * 18 + Math.cos(i * 0.08) * 12; wavePath += ` L ${x.toFixed(1)} ${y.toFixed(1)}`; }
const svg = `<?xml version="1.0" encoding="UTF-8"?><svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%"> <stop offset="0%" style="stop-color:#1a1a3c"/> <stop offset="100%" style="stop-color:#0d0d28"/> </linearGradient> <linearGradient id="waveGrad" x1="0%" y1="0%" x2="100%" y2="0%"> <stop offset="0%" style="stop-color:#6080ff;stop-opacity:0.15"/> <stop offset="50%" style="stop-color:#8090ff;stop-opacity:0.35"/> <stop offset="100%" style="stop-color:#6080ff;stop-opacity:0.15"/> </linearGradient> </defs>
<!-- Background (notepat's blue theme) --> <rect width="${W}" height="${H}" fill="url(#bg)"/>
<!-- Waveform decoration --> <path d="${wavePath}" fill="none" stroke="url(#waveGrad)" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
<!-- Pads (split layout) --> ${padsSvg}
<!-- Octave labels --> <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> <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>
<!-- "notepat" branding --> <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> <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>
<!-- Footer tagline --> <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></svg>`;
// Convert SVG to PNG using sharp const buffer = await sharp(Buffer.from(svg)) .png() .toBuffer();
// Upload to Spaces const key = `og/notepat/notepat-og.png`; await spacesClient.send(new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: key, Body: buffer, ContentType: 'image/png', ACL: 'public-read', CacheControl: 'public, max-age=604800', // 7-day cache }));
const url = `${SPACES_CDN_BASE}/${key}`;
// Update memory cache notepatOGCache.url = url; notepatOGCache.expires = Date.now() + 7 * 24 * 60 * 60 * 1000;
console.log(`📤 Notepat OG image uploaded: ${url} (${(buffer.length / 1024).toFixed(1)} KB)`); return { url, cached: false, buffer };}
/** * Get the latest cached notepat OG image URL without triggering generation. */export async function getLatestNotepatOGUrl() { // Check memory cache if (notepatOGCache.url && Date.now() < notepatOGCache.expires) { return notepatOGCache.url; }
// Check Spaces const key = `og/notepat/notepat-og.png`; try { await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key, })); const url = `${SPACES_CDN_BASE}/${key}`; notepatOGCache.url = url; notepatOGCache.expires = Date.now() + 60 * 60 * 1000; // 1hr memory cache return url; } catch { return null; }}
// =============================================================================// NEWS OG IMAGE GENERATION// =============================================================================
// News OG image cache (memory, keyed by post code)const newsOGCache = new Map();
/** * Generate a branded OG image for a news.aesthetic.computer article (1200x630 PNG). * Shows title, author handle, and "Aesthetic News" branding. */export async function generateNewsOGImage(post, forceRegenerate = false) { const code = post.code; console.log(`\n📰 News OG Image Request: ${code} (force: ${forceRegenerate})`);
// Check memory cache. if (!forceRegenerate) { const cached = newsOGCache.get(code); if (cached && Date.now() < cached.expires) { return { url: cached.url, cached: true }; } }
// Check Spaces cache. const key = `og/news/${code}.png`; if (!forceRegenerate) { try { await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key })); const url = `${SPACES_CDN_BASE}/${key}`; newsOGCache.set(code, { url, expires: Date.now() + 60 * 60 * 1000 }); return { url, cached: true }; } catch { // Not cached, generate below. } }
const W = 1200; const H = 630;
// Escape XML entities for SVG. const esc = (s) => (s || "") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """);
const title = esc(post.title || "(untitled)"); const handle = esc(post.handle || "@anon"); const date = post.when ? new Date(post.when).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : "";
// Word-wrap title into lines that fit within the card. const maxCharsPerLine = 32; const words = title.split(/\s+/); const lines = []; let currentLine = ""; for (const word of words) { if (currentLine && (currentLine + " " + word).length > maxCharsPerLine) { lines.push(currentLine); currentLine = word; } else { currentLine = currentLine ? currentLine + " " + word : word; } } if (currentLine) lines.push(currentLine); // Limit to 5 lines max. if (lines.length > 5) { lines.length = 5; lines[4] = lines[4].slice(0, maxCharsPerLine - 3) + "..."; }
const titleFontSize = lines.length <= 2 ? 52 : lines.length <= 3 ? 44 : 36; const titleLineHeight = titleFontSize * 1.3; const titleBlockHeight = lines.length * titleLineHeight; const titleStartY = (H - titleBlockHeight) / 2 + titleFontSize * 0.8;
const titleLines = lines .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>`) .join("\n ");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"> <defs> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="#1a1a2e"/> <stop offset="100%" stop-color="#16213e"/> </linearGradient> </defs>
<!-- Background --> <rect width="${W}" height="${H}" fill="url(#bg)"/>
<!-- Subtle border --> <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"/>
<!-- Aesthetic News branding --> <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>
<!-- Title --> ${titleLines}
<!-- Author + date footer --> <text x="60" y="${H - 45}" font-family="monospace" font-size="18" fill="rgba(180,180,200,0.6)">${handle}${date ? ` · ${esc(date)}` : ""}</text> <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></svg>`;
const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
// Upload to Spaces. await spacesClient.send( new PutObjectCommand({ Bucket: SPACES_BUCKET, Key: key, Body: buffer, ContentType: "image/png", ACL: "public-read", CacheControl: "public, max-age=604800", }), );
const url = `${SPACES_CDN_BASE}/${key}`; newsOGCache.set(code, { url, expires: Date.now() + 7 * 24 * 60 * 60 * 1000 });
console.log(`📤 News OG image uploaded: ${url} (${(buffer.length / 1024).toFixed(1)} KB)`); return { url, cached: false, buffer };}
export { IPFS_GATEWAY };