Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

oven+lith: fail loud on uniform-color thumbnail duds

Oven `grabPiece` was silently downgrading uniform-color captures to a
256x256 still WebP — exactly 528 bytes, byte-identical every time, so
Pinata always returned the same CID for repeated rebakes. The keep
pipeline then recorded that stale CID with `thumbnailFallback: null`,
making the bake look successful while the piece's thumbnail stayed
frozen on whatever black frame had been captured first.

Oven: when caller passes `skipCache:true`, retry the animated capture
up to 3 times on uniform-color frames, and throw on the final attempt
instead of falling through to the dud-still encoder. Frozen content
(legitimately identical frames) is unchanged. Non-fresh callers keep
the legacy still-frame fallback.

Lith: the rebake path now retries the oven /grab call up to 3 times on
forceFresh, and treats "returned CID matches the existing thumbnail"
as a soft failure (silent Pinata dedup) so we retry instead of looping
the same broken bytes forever. Also surfaces oven's response body in
the error message for easier diagnosis.

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

+90 -23
+41 -12
oven/grabber.mjs
··· 2406 2406 result = await frameToThumbnail(frame, { width: outputWidth, height: outputHeight }); 2407 2407 2408 2408 } else { 2409 - // Animated WebP or GIF 2410 - const capturedFrames = await captureFrames(piece, { 2411 - width, height, duration, fps, density, viewportScale, baseUrl, grabId 2412 - }); 2413 - 2414 - if (capturedFrames.length === 0) { 2415 - throw new Error('No frames captured'); 2409 + // Animated WebP or GIF — when caller asked for fresh content 2410 + // (skipCache:true), retry up to twice on uniform-color duds. 2411 + // Pinata content-addresses, so a 528-byte black still always 2412 + // dedups to the same CID; silently returning that downstream 2413 + // looks like a successful bake but freezes the piece's 2414 + // thumbnail forever. Surface the dud instead. 2415 + const MAX_DUD_RETRIES = skipCache ? 2 : 0; 2416 + let capturedFrames; 2417 + let isFrozen = false; 2418 + let uniformCheck = { isUniform: false }; 2419 + 2420 + for (let attempt = 0; attempt <= MAX_DUD_RETRIES; attempt++) { 2421 + capturedFrames = await captureFrames(piece, { 2422 + width, height, duration, fps, density, viewportScale, baseUrl, grabId 2423 + }); 2424 + 2425 + if (capturedFrames.length === 0) { 2426 + throw new Error('No frames captured'); 2427 + } 2428 + 2429 + isFrozen = await areFramesIdentical(capturedFrames); 2430 + // Frozen content (every frame identical) is legitimate; only 2431 + // uniform-color frames indicate a black-canvas / not-rendered dud. 2432 + uniformCheck = isFrozen 2433 + ? { isUniform: false } 2434 + : await isUniformColorContent(capturedFrames); 2435 + 2436 + if (!uniformCheck.isUniform) break; 2437 + if (attempt < MAX_DUD_RETRIES) { 2438 + console.log(` 🔁 Capture ${attempt + 1}/${MAX_DUD_RETRIES + 1} returned uniform color (${uniformCheck.reason}); retrying...`); 2439 + await new Promise(r => setTimeout(r, 2000)); 2440 + } 2441 + } 2442 + 2443 + if (uniformCheck.isUniform && skipCache) { 2444 + throw new Error(`Capture produced uniform-color frames after ${MAX_DUD_RETRIES + 1} attempt(s) (${uniformCheck.reason}); piece may not be rendering`); 2416 2445 } 2417 - 2418 - // Check if all frames are identical (frozen animation) 2419 - const isFrozen = await areFramesIdentical(capturedFrames); 2446 + 2420 2447 if (isFrozen) { 2421 2448 // Return the still frame in the requested format (webp/gif/png) 2422 2449 const outW = width * density; ··· 2460 2487 recordFrozenPiece(piece, 'Frozen — still frame returned', frozenPreviewUrl); 2461 2488 // Skip encoding — result is already in the right format 2462 2489 } else { 2463 - // Check for uniform color content (solid color "dud" images) 2464 - const uniformCheck = await isUniformColorContent(capturedFrames); 2490 + // uniformCheck already computed above (in the dud-retry loop). 2491 + // For !skipCache callers, fall back to a still — they explicitly 2492 + // accepted cached/dedup behavior. skipCache callers were thrown 2493 + // earlier so they never reach this branch. 2465 2494 if (uniformCheck.isUniform) { 2466 2495 // Return still frame in requested format (same as frozen) 2467 2496 const outW = width * density;
+49 -11
system/netlify/functions/keep-prepare-background.mjs
··· 567 567 startOvenProgressPoller(OVEN_URL, "thumbnail"); 568 568 569 569 thumbnailPromise = (async () => { 570 + // Existing CID we're trying to replace. Pinata content-addresses, so 571 + // a silent oven dud (uniform-color black frames re-encoded as a 528- 572 + // byte still) always pins to the same CID — looks like a successful 573 + // bake but freezes the thumbnail forever. Detect and retry. 574 + const previousThumbnailUri = piece.ipfsMedia?.thumbnailUri || null; 575 + 570 576 const tryOven = async (ovenUrl, timeoutMs) => { 571 577 const controller = new AbortController(); 572 578 const tid = setTimeout(() => controller.abort(), timeoutMs); ··· 590 596 signal: controller.signal, 591 597 }); 592 598 clearTimeout(tid); 593 - if (!res.ok) throw new Error(`Oven ${res.status}`); 599 + if (!res.ok) { 600 + const body = await res.text().catch(() => ""); 601 + throw new Error(`Oven ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`); 602 + } 594 603 const buffer = Buffer.from(await res.arrayBuffer()); 595 604 const thumbFilename = `${pieceName}-thumbnail.webp-${pieceSourceHash.slice(0, 16)}`; 596 605 const ipfsUri = await uploadToIPFS(buffer, thumbFilename, "image/webp"); ··· 602 611 } 603 612 }; 604 613 605 - try { 606 - return await tryOven(OVEN_URL, KEEP_MINT_THUMBNAIL_TIMEOUT_MS); 607 - } catch (err) { 608 - log("thumbnail", `Primary oven failed: ${err.message}`); 609 - if (OVEN_URL !== OVEN_FALLBACK_URL) { 610 - try { 611 - return await tryOven(OVEN_FALLBACK_URL, KEEP_MINT_THUMBNAIL_TIMEOUT_MS); 612 - } catch (fbErr) { 613 - return { error: fbErr.message }; 614 + // forceFresh callers (rebake / regenerate) get one extra retry to 615 + // shake out cold-puppeteer warmup flakes; non-fresh callers keep 616 + // the legacy single-attempt + fallback-host behavior. 617 + const maxAttempts = forceFresh ? 3 : 1; 618 + let lastErr; 619 + for (let attempt = 1; attempt <= maxAttempts; attempt++) { 620 + try { 621 + const result = await tryOven(OVEN_URL, KEEP_MINT_THUMBNAIL_TIMEOUT_MS); 622 + // Silent dedup detection: if the CID matches what's already on 623 + // the piece despite forceFresh, oven returned a uniform-color 624 + // dud (post-fix it should throw, but older builds may still 625 + // dedup). Treat as a soft failure and retry. 626 + if ( 627 + forceFresh && 628 + previousThumbnailUri && 629 + result.ipfsUri === previousThumbnailUri && 630 + attempt < maxAttempts 631 + ) { 632 + log("thumbnail", `Attempt ${attempt}/${maxAttempts}: CID matches previous (${result.ipfsUri}); retrying`); 633 + await new Promise(r => setTimeout(r, 2000)); 634 + continue; 635 + } 636 + if (attempt > 1) log("thumbnail", `Succeeded on attempt ${attempt}/${maxAttempts}`); 637 + return result; 638 + } catch (err) { 639 + lastErr = err; 640 + log("thumbnail", `Attempt ${attempt}/${maxAttempts} failed: ${err.message}`); 641 + if (attempt < maxAttempts) { 642 + await new Promise(r => setTimeout(r, 2000)); 614 643 } 615 644 } 616 - return { error: err.message }; 645 + } 646 + 647 + // All primary attempts exhausted — fall back to secondary host once. 648 + if (OVEN_URL !== OVEN_FALLBACK_URL) { 649 + try { 650 + return await tryOven(OVEN_FALLBACK_URL, KEEP_MINT_THUMBNAIL_TIMEOUT_MS); 651 + } catch (fbErr) { 652 + return { error: fbErr.message }; 653 + } 617 654 } 655 + return { error: lastErr?.message || "thumbnail bake failed" }; 618 656 })(); 619 657 } else { 620 658 thumbnailPromise = Promise.resolve({ ipfsUri: thumbnailUri });