Blog: dithered per-post share cards + thumbnails - #92
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe PR adds shared blog card rendering for Open Graph, Twitter, and thumbnails. It adds post artwork metadata, image routes, explicit share-image handling, generated thumbnail usage in blog components, UTC date formatting, and ChangesBlog card artwork
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant BlogComponent
participant ThumbnailRoute
participant BlogData
participant OgCardRenderer
BlogComponent->>ThumbnailRoute: request /blog/{slug}/thumbnail
ThumbnailRoute->>BlogData: load post by slug
BlogData-->>ThumbnailRoute: return cardArt
ThumbnailRoute->>OgCardRenderer: renderThumbnail(cardArt)
OgCardRenderer-->>ThumbnailRoute: return thumbnail ImageResponse
ThumbnailRoute-->>BlogComponent: return thumbnail response
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (4)
lib/og-card.tsx (4)
35-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUnify the duplicated panel width constant.
PANEL(line 36) andART_W(line 183) both hold 560 and describe the same panel.ditherUrirenders atART_W, and the card places the image atPANEL. If one value changes, the artwork scales against the panel box. Define one constant and derive the other.♻️ Proposed change
-/** Width of the artwork panel in photo mode. */ -const PANEL = 560; +/** Width of the artwork panel in photo mode. Also the dither surface width. */ +export const PANEL = 560;Then at line 183:
-const ART_W = 560; +const ART_W = PANEL;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/og-card.tsx` around lines 35 - 36, Unify the duplicated 560px artwork width in lib/og-card.tsx by keeping a single source-of-truth constant and deriving the other value from it. Update the PANEL declaration and the ART_W usage in ditherUri so both the rendered artwork and card panel remain synchronized when the width changes.
476-488: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace the 11-argument positional call with an options object.
This call passes eight positional values after
cell. Six of them restate the defaults declared at lines 307-332 (PHOTO_CELL,0.44,1.05,0,false,0). Only"inside"and70differ. A reader cannot tell which argument is which without counting against the signature, and a single insertion in the parameter list silently changes exposure or polarity for this caller.Convert
photoFieldto take(path, opts)with a named options object.♻️ Sketch of the call site after the change
- const sampled = await photoField( - cardArt, - w, - h, - PHOTO_CELL, - 0.44, - 1.05, - "inside", - 0, - false, - 0, - 70 - ); + const sampled = await photoField(cardArt, { + w, + h, + fit: "inside", + feather: 70, + });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/og-card.tsx` around lines 476 - 488, Refactor photoField and its call sites from the positional signature to (path, opts), using named option properties for cell, threshold, exposure, polarity, and related parameters. Update the invocation near cardArt to pass only the non-default overrides explicitly—preserving "inside" and 70—while relying on the existing defaults for PHOTO_CELL, 0.44, 1.05, 0, false, and 0.
27-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCentralise the palette and record its relationship to the theme tokens.
The coding guidelines require theme tokens for colors in
.ts/.tsxinstead of raw hex values. Satori cannot resolve the CSS custom properties fromglobals.css, so literal values are required inside this renderer. That is a genuine constraint, not a defect.The residual risk is drift.
toneintroduces#8FE0C8,#2CAF74,#18794E, and#115C3Bthat are not among the four named constants at lines 27-30. If the@themegreens change, nothing links these values back. Collect every literal into one exported palette object in this file, and name each entry after its@themetoken, so a future theme change has one place to update.Also applies to: 393-401
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/og-card.tsx` around lines 27 - 30, Centralize all renderer color literals in one exported palette object in lib/og-card.tsx, including the existing BG, GREEN_LIGHT, GREEN_BRIGHT, RULE values and every color used by tone. Name each palette entry after its corresponding `@theme` token, then update tone and other references to use that palette so theme relationships remain centralized.Source: Coding guidelines
419-443: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoffConsider the rect volume and add explicit caching.
At
PHOTO_CELL = 3, this loop evaluates about 39,000 cells for the 560×630 card panel and about 44,000 cells for the 800×500 thumbnail. Each surviving cell appends a<rect>string. The joined SVG can reach roughly 1 MB before base64 expansion, and resvg must parse and rasterise every element.The render is deterministic and cacheable, so this is acceptable on a warm cache. Two points to confirm:
- The metadata image routes and the thumbnail route each run this independently. A post costs three full dither renders on cold cache.
- The thumbnail route is a Route Handler. Set an explicit long
Cache-Controlon its response, because Route Handlers are not cached by default in Next.js 15.An alternative that cuts the element count substantially: emit one
<path>per tone bucket with a combineddattribute instead of one<rect>per dot. Six paths replace tens of thousands of elements.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/og-card.tsx` around lines 419 - 443, Make ditherUri explicitly cache its deterministic output using a key covering field, dimensions, cell, mask, and background so metadata and thumbnail callers reuse renders. Ensure the thumbnail Route Handler response sets a long-lived Cache-Control header. Preserve output correctness, and optionally reduce SVG element count by grouping dots into tone-bucket paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/blog/`[slug]/opengraph-image.tsx:
- Around line 13-15: Update generateStaticParams to apply the same
process.env.VERCEL_ENV === "production" draft filtering used by getAllPosts
before generating Open Graph/Twitter image parameters, ensuring production
builds exclude draft slugs while preserving all slugs in non-production
environments.
In `@app/blog/`[slug]/thumbnail/route.tsx:
- Around line 9-19: Update generateStaticParams to exclude draft posts from its
returned slugs, and update GET to detect a missing or draft post before
accessing cardArt or calling renderThumbnail. Return the established 404
response for production-inaccessible posts while preserving thumbnail rendering
for published posts.
In `@app/blog/`[slug]/twitter-image.tsx:
- Around line 1-7: Update twitter-image.tsx to declare generateStaticParams and
generateImageMetadata directly in that module instead of re-exporting them from
opengraph-image. Duplicate or reuse the existing shared logic while ensuring
Next.js discovers both exports for the twitter-image route; keep the existing
default, size, and contentType exports unchanged.
In `@lib/og-card.tsx`:
- Around line 554-564: Update formatDate to validate the Date created from a
non-empty input before calling toLocaleDateString. Return an empty string for
unparseable values, while preserving the existing UTC formatting and uppercase
output for valid dates.
- Around line 336-383: Update the sharp-processing try/catch around the dynamic
import and image conversion to capture the thrown error as err and log it
explicitly before returning null. Preserve the existing fallback behavior, and
ensure failures from both module loading and malformed or missing images include
the error details in the log.
---
Nitpick comments:
In `@lib/og-card.tsx`:
- Around line 35-36: Unify the duplicated 560px artwork width in lib/og-card.tsx
by keeping a single source-of-truth constant and deriving the other value from
it. Update the PANEL declaration and the ART_W usage in ditherUri so both the
rendered artwork and card panel remain synchronized when the width changes.
- Around line 476-488: Refactor photoField and its call sites from the
positional signature to (path, opts), using named option properties for cell,
threshold, exposure, polarity, and related parameters. Update the invocation
near cardArt to pass only the non-default overrides explicitly—preserving
"inside" and 70—while relying on the existing defaults for PHOTO_CELL, 0.44,
1.05, 0, false, and 0.
- Around line 27-30: Centralize all renderer color literals in one exported
palette object in lib/og-card.tsx, including the existing BG, GREEN_LIGHT,
GREEN_BRIGHT, RULE values and every color used by tone. Name each palette entry
after its corresponding `@theme` token, then update tone and other references to
use that palette so theme relationships remain centralized.
- Around line 419-443: Make ditherUri explicitly cache its deterministic output
using a key covering field, dimensions, cell, mask, and background so metadata
and thumbnail callers reuse renders. Ensure the thumbnail Route Handler response
sets a long-lived Cache-Control header. Preserve output correctness, and
optionally reduce SVG element count by grouping dots into tone-bucket paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 80347558-b489-47d4-a02f-229a16b237b1
⛔ Files ignored due to path filters (14)
public/fonts/FavoritMono-Medium.ttfis excluded by!**/*.ttfpublic/images/blog/cards/a-real-time-update-to-the-livepeer-network-vision.jpgis excluded by!**/*.jpgpublic/images/blog/cards/ai-x-open-media-forum.jpgis excluded by!**/*.jpgpublic/images/blog/cards/builder-spotlight-frameworks.jpgis excluded by!**/*.jpgpublic/images/blog/cards/builder-spotlight-mike-zupper.jpgis excluded by!**/*.jpgpublic/images/blog/cards/introducing-livepeer-cascade.jpgis excluded by!**/*.jpgpublic/images/blog/cards/introducing-the-livepeer-foundation.jpgis excluded by!**/*.jpgpublic/images/blog/cards/livepeer-2-0-video-agent-platform.jpgis excluded by!**/*.jpgpublic/images/blog/cards/livepeer-blog.jpgis excluded by!**/*.jpgpublic/images/blog/cards/livepeer-incorporated-and-realtime-ai.jpgis excluded by!**/*.jpgpublic/images/blog/cards/onchain-builders-streamplace.jpgis excluded by!**/*.jpgpublic/images/blog/cards/q1-2026-messari.jpgis excluded by!**/*.jpgpublic/images/blog/cards/using-the-livepeer-community-treasury.jpgis excluded by!**/*.jpgpublic/images/blog/cards/why-delegation-still-matters.jpgis excluded by!**/*.jpg
📒 Files selected for processing (23)
app/blog/[slug]/opengraph-image.tsxapp/blog/[slug]/page.tsxapp/blog/[slug]/thumbnail/route.tsxapp/blog/[slug]/twitter-image.tsxapp/blog/opengraph-image.tsxapp/blog/twitter-image.tsxcomponents/blog/BlogPostCard.tsxcomponents/blog/BlogPostHeader.tsxcomponents/home/LatestPosts.tsxcontent/blog/a-real-time-update-to-the-livepeer-network-vision.mdcontent/blog/ai-x-open-media-forum.mdcontent/blog/builder-spotlight-frameworks.mdcontent/blog/builder-spotlight-mike-zupper.mdcontent/blog/introducing-livepeer-cascade-a-vision-for-livepeers-future-in-the-age-of-real-time-ai-video.mdcontent/blog/introducing-the-livepeer-foundation.mdcontent/blog/livepeer-2-0-video-agent-platform.mdcontent/blog/livepeer-incorporated-and-realtime-ai.mdcontent/blog/onchain-builders-streamplace.mdcontent/blog/q1-2026-messari-state-of-livepeer.mdcontent/blog/using-the-livepeer-community-treasury.mdcontent/blog/why-delegation-still-matters-in-a-low-inflation-environment.mdlib/blog.tslib/og-card.tsx
| export function generateStaticParams() { | ||
| return getPostSlugs().map((slug) => ({ slug })); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect getPostSlugs and every draft filter in the blog pipeline.
ast-grep outline lib/blog.ts --items all
echo "--- getPostSlugs implementation ---"
ast-grep run --pattern 'export function getPostSlugs() { $$$ }' --lang typescript lib/blog.ts
echo "--- draft handling across the repo ---"
rg -n -C3 '\bdraft\b' --type=ts --type=tsx -g '!node_modules'Repository: livepeer/website
Length of output: 1117
🏁 Script executed:
#!/bin/bash
set -e
echo "--- lib/blog.ts relevant functions ---"
sed -n '1,120p' lib/blog.ts
echo "--- draft handling across TS/TSX files ---"
rg -n -C3 '\bdraft\b|-g ".*\.(ts|tsx)$" -g "!node_modules"' --glob '!node_modules' .
echo "--- opengraph image route file ---"
sed -n '1,220p' app/blog/'[slug]'/opengraph-image.tsxRepository: livepeer/website
Length of output: 17959
Apply the same production draft filter to the static Open Graph params.
getPostSlugs() returns every markdown file, while getAllPosts() only filters drafts for process.env.VERCEL_ENV === "production". Use that same production guard in generateStaticParams() so Next.js does not pre-render Open Graph/Twitter images for drafts that are hidden by app/blog/[slug]/page.tsx.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/blog/`[slug]/opengraph-image.tsx around lines 13 - 15, Update
generateStaticParams to apply the same process.env.VERCEL_ENV === "production"
draft filtering used by getAllPosts before generating Open Graph/Twitter image
parameters, ensuring production builds exclude draft slugs while preserving all
slugs in non-production environments.
Source: Coding guidelines
| try { | ||
| // Imported dynamically and guarded: sharp ships with Next for image | ||
| // optimisation but is not a declared dependency of this app, so a post with | ||
| // a photo degrades to its field rather than failing the render. Add sharp | ||
| // to package.json to make this path guaranteed. | ||
| const sharp = (await import("sharp")).default; | ||
| // "inside" returns the scaled image at its own dimensions (no letterbox | ||
| // pixels), so the mean below is the photo's alone and auto-exposure is not | ||
| // dragged toward black by padding. | ||
| const { data, info } = await sharp(join(process.cwd(), "public", path)) | ||
| .resize(boxCols, boxRows, { fit, position: "center" }) | ||
| .greyscale() | ||
| .normalise() | ||
| .raw() | ||
| .toBuffer({ resolveWithObject: true }); | ||
| if (invert) for (let i = 0; i < data.length; i++) data[i] = 255 - data[i]; | ||
| const cols = info.width; | ||
| const rows = info.height; | ||
| // Centre the scaled image in the full frame; everything outside it is black. | ||
| const offX = Math.round((w - cols * cell) / 2); | ||
| const offY = Math.round((h - rows * cell) / 2); | ||
| const x1 = offX + cols * cell; | ||
| const y1 = offY + rows * cell; | ||
| // Auto-expose off the (possibly inverted) mean, then a mild gamma. A fixed | ||
| // curve cannot serve both a bright source (dithers to a solid slab) and a | ||
| // dim one (dithers to nothing), and these sources vary widely. | ||
| let sum = 0; | ||
| for (let i = 0; i < data.length; i++) sum += data[i]; | ||
| const gain = target / Math.max(0.08, sum / data.length / 255); | ||
| return (x, y) => { | ||
| const c = Math.floor((x - offX) / cell); | ||
| const r = Math.floor((y - offY) / cell); | ||
| if (c < 0 || r < 0 || c >= cols || r >= rows) return 0; | ||
| const e = (data[r * cols + c] / 255) * gain; | ||
| // Black-point cut, then a mild gamma. The cut clears the background so the | ||
| // dither follows the subject's shape; the gamma is near-linear so the | ||
| // subject keeps its tonal range rather than crushing to a flat slab. | ||
| let d = clamp(Math.pow(clamp((e - blackPoint) / (1 - blackPoint)), gamma)); | ||
| if (feather > 0) { | ||
| const g = clamp(Math.min(x - offX, x1 - x, y - offY, y1 - y) / feather); | ||
| d *= g * g * (3 - 2 * g); | ||
| } | ||
| return d; | ||
| }; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm whether sharp is declared, and how it is resolved.
fd -H -t f 'package.json' -E node_modules --exec sh -c 'echo "== $1"; jq "{deps:.dependencies, dev:.devDependencies, opt:.optionalDependencies}" "$1"' _ {}
echo "--- sharp in lockfile ---"
fd -H -t f '(package-lock.json|pnpm-lock.yaml|yarn.lock)' -E node_modules --exec sh -c 'echo "== $1"; rg -n "^\s*\"?sharp" "$1" | head -20' _ {}
echo "--- sharp imports in source ---"
rg -n --type=ts --type=tsx 'from "sharp"|import\("sharp"\)' .Repository: livepeer/website
Length of output: 1217
🏁 Script executed:
#!/bin/bash
set -u
echo "== package json next dependency =="
jq -r '.dependencies.next, .devDependencies["next"]' package.json
echo "== lockfile next sharp relationship excerpts =="
python3 - <<'PY'
from pathlib import Path
for name in ("package-lock.json","pnpm-lock.yaml"):
p=Path(name)
if not p.exists():
continue
text=p.read_text(errors="replace").splitlines()
i=0
while i < len(text):
line=text[i]
if ("next@" in line or '"/next' in line or "next:" in line) and "sharp" not in line:
print(f"===== {name} next excerpt =====")
for j in range(i, min(i, len(text))):
print(text[j])
i += 1
PY
echo "== lockfile sharp and next@ occurrences (concise) =="
for f in package-lock.json pnpm-lock.yaml; do
[ -f "$f" ] || continue
echo "--- $f sharp ---"
rg -n '"sharp"|sharp@|sharp:' "$f" | head -50
echo "--- $f next sharp relation ---"
rg -n '"next"@|"next":|next@|^ node_modules/next|node_modules/next' "$f"
done
echo "== source sharp dynamic imports =="
rg -n 'import\(["'\'']sharp["'\'']\)|import\("sharp"\)' --glob '*.ts' --glob '*.tsx' .Repository: livepeer/website
Length of output: 1419
🏁 Script executed:
#!/bin/bash
set -u
echo "== package-lock next node_modules context =="
sed -n '5520,5555p' package-lock.json
echo "== pnpm next dependency line context =="
sed -n '3860,3882p' pnpm-lock.yaml
echo "== lib/og-card relevant function signatures and fallbacks =="
sed -n '300,385p' lib/og-card.tsxRepository: livepeer/website
Length of output: 6401
Log sharp failures explicitly.
sharp is still absent from package.json, so this call depends on a transitive Next.js dependency. The bare catch then also hides non-module failures, such as a missing or malformed image, which silently produce null in production. Keep the fallback if needed, but log err instead of swallowing every failure.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/og-card.tsx` around lines 336 - 383, Update the sharp-processing
try/catch around the dynamic import and image conversion to capture the thrown
error as err and log it explicitly before returning null. Preserve the existing
fallback behavior, and ensure failures from both module loading and malformed or
missing images include the error details in the log.
| function formatDate(date: string) { | ||
| if (!date) return ""; | ||
| return new Date(date) | ||
| .toLocaleDateString("en-US", { | ||
| year: "numeric", | ||
| month: "short", | ||
| day: "numeric", | ||
| timeZone: "UTC", | ||
| }) | ||
| .toUpperCase(); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Guard against an unparseable date.
The function returns early only for an empty string. If date holds any other unparseable value, new Date(date) produces Invalid Date, and the card renders the literal text INVALID DATE on the footer rail. The timeZone: "UTC" setting is correct for the quoted YYYY-MM-DD frontmatter in use.
🛡️ Proposed fix
function formatDate(date: string) {
if (!date) return "";
- return new Date(date)
+ const d = new Date(date);
+ if (Number.isNaN(d.getTime())) return "";
+ return d
.toLocaleDateString("en-US", {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function formatDate(date: string) { | |
| if (!date) return ""; | |
| return new Date(date) | |
| .toLocaleDateString("en-US", { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| timeZone: "UTC", | |
| }) | |
| .toUpperCase(); | |
| } | |
| function formatDate(date: string) { | |
| if (!date) return ""; | |
| const d = new Date(date); | |
| if (Number.isNaN(d.getTime())) return ""; | |
| return d | |
| .toLocaleDateString("en-US", { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| timeZone: "UTC", | |
| }) | |
| .toUpperCase(); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/og-card.tsx` around lines 554 - 564, Update formatDate to validate the
Date created from a non-empty input before calling toLocaleDateString. Return an
empty string for unparseable values, while preserving the existing UTC
formatting and uppercase output for valid dates.
f217ffe to
b79ba02
Compare
b79ba02 to
06b4eee
Compare
Add an ordered-dither (8×8 Bayer) halftone treatment in brand green, rendered by lib/og-card.tsx and shared by the share/OG image and the listing thumbnails so both are pixel-identical. Each post now leads with its own dithered card art in the blog detail hero, the home "Field Notes" grid, and the listing. - lib/og-card.tsx: dither renderer (centralized palette, options-object photoField, tone ramp, feather, corner marks) - per-post cardArt in content + public/images/blog/cards/* - sharp for image decode/resize; externalized via serverExternalPackages to fix the Vercel build - Livepeer 2.0 card: WALL-E-style robot holding a 3D film reel with a perforated film ribbon peeling off down to the floor - drafts kept out of the OG/Twitter/thumbnail routes in production, with a 404 on direct thumbnail requests; long Cache-Control on the thumbnail Route Handler - CLAUDE.md: document the blog card-art workflow and sourcing recipe so new posts follow the same treatment Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
06b4eee to
e036519
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
lib/og-card.tsx (1)
684-704: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueMemoise the font load.
loadFontsreads both font files from disk on every card render. The files never change at runtime. Cache the promise at module scope so repeated renders reuse one read.♻️ Proposed refactor
-async function loadFonts() { +let fontsPromise: Promise< + { name: string; data: Buffer; weight: 700 | 500; style: "normal" }[] +> | null = null; + +async function readFonts() { const dir = join(process.cwd(), "public/fonts"); const [bold, mono] = await Promise.all([ readFile(join(dir, "FavoritPro-Bold.otf")), readFile(join(dir, "FavoritMono-Medium.ttf")), ]); return [ { name: "Favorit Pro", data: bold, weight: 700 as const, style: "normal" as const, }, { name: "Favorit Mono", data: mono, weight: 500 as const, style: "normal" as const, }, ]; } + +function loadFonts() { + fontsPromise ??= readFonts(); + return fontsPromise; +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/og-card.tsx` around lines 684 - 704, Update loadFonts to memoize its font-loading promise at module scope, ensuring the first invocation performs the existing parallel file reads and subsequent card renders reuse the same promise without rereading the files.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CLAUDE.md`:
- Around line 88-90: Update the “Sourcing recipe” text in CLAUDE.md to remove
the unvalidated “~$0.03/image” claim or replace it with a [FLAG] marker, while
preserving the remaining generation settings and prompt guidance.
- Around line 78-83: Update the blog card artwork documentation to explicitly
state that the shared generated-card behavior and the claim that cardArt drives
every surface apply unless the post defines the shareImage frontmatter override.
Describe shareImage as opting out of the generated share card while preserving
the existing cardArt workflow for other surfaces.
---
Nitpick comments:
In `@lib/og-card.tsx`:
- Around line 684-704: Update loadFonts to memoize its font-loading promise at
module scope, ensuring the first invocation performs the existing parallel file
reads and subsequent card renders reuse the same promise without rereading the
files.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: a9eca05e-0116-4a12-9113-9a00ed0d427b
⛔ Files ignored due to path filters (15)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yamlpublic/fonts/FavoritMono-Medium.ttfis excluded by!**/*.ttfpublic/images/blog/cards/a-real-time-update-to-the-livepeer-network-vision.jpgis excluded by!**/*.jpgpublic/images/blog/cards/ai-x-open-media-forum.jpgis excluded by!**/*.jpgpublic/images/blog/cards/builder-spotlight-frameworks.jpgis excluded by!**/*.jpgpublic/images/blog/cards/builder-spotlight-mike-zupper.jpgis excluded by!**/*.jpgpublic/images/blog/cards/introducing-livepeer-cascade.jpgis excluded by!**/*.jpgpublic/images/blog/cards/introducing-the-livepeer-foundation.jpgis excluded by!**/*.jpgpublic/images/blog/cards/livepeer-2-0-video-agent-platform.jpgis excluded by!**/*.jpgpublic/images/blog/cards/livepeer-blog.jpgis excluded by!**/*.jpgpublic/images/blog/cards/livepeer-incorporated-and-realtime-ai.jpgis excluded by!**/*.jpgpublic/images/blog/cards/onchain-builders-streamplace.jpgis excluded by!**/*.jpgpublic/images/blog/cards/q1-2026-messari.jpgis excluded by!**/*.jpgpublic/images/blog/cards/using-the-livepeer-community-treasury.jpgis excluded by!**/*.jpgpublic/images/blog/cards/why-delegation-still-matters.jpgis excluded by!**/*.jpg
📒 Files selected for processing (26)
CLAUDE.mdapp/blog/[slug]/opengraph-image.tsxapp/blog/[slug]/page.tsxapp/blog/[slug]/thumbnail/route.tsxapp/blog/[slug]/twitter-image.tsxapp/blog/opengraph-image.tsxapp/blog/twitter-image.tsxcomponents/blog/BlogPostCard.tsxcomponents/blog/BlogPostHeader.tsxcomponents/home/LatestPosts.tsxcontent/blog/a-real-time-update-to-the-livepeer-network-vision.mdcontent/blog/ai-x-open-media-forum.mdcontent/blog/builder-spotlight-frameworks.mdcontent/blog/builder-spotlight-mike-zupper.mdcontent/blog/introducing-livepeer-cascade-a-vision-for-livepeers-future-in-the-age-of-real-time-ai-video.mdcontent/blog/introducing-the-livepeer-foundation.mdcontent/blog/livepeer-2-0-video-agent-platform.mdcontent/blog/livepeer-incorporated-and-realtime-ai.mdcontent/blog/onchain-builders-streamplace.mdcontent/blog/q1-2026-messari-state-of-livepeer.mdcontent/blog/using-the-livepeer-community-treasury.mdcontent/blog/why-delegation-still-matters-in-a-low-inflation-environment.mdlib/blog.tslib/og-card.tsxnext.config.tspackage.json
🚧 Files skipped from review as they are similar to previous changes (21)
- package.json
- next.config.ts
- content/blog/livepeer-incorporated-and-realtime-ai.md
- app/blog/twitter-image.tsx
- app/blog/[slug]/thumbnail/route.tsx
- content/blog/using-the-livepeer-community-treasury.md
- content/blog/builder-spotlight-mike-zupper.md
- components/blog/BlogPostCard.tsx
- content/blog/introducing-livepeer-cascade-a-vision-for-livepeers-future-in-the-age-of-real-time-ai-video.md
- content/blog/onchain-builders-streamplace.md
- content/blog/a-real-time-update-to-the-livepeer-network-vision.md
- app/blog/[slug]/opengraph-image.tsx
- components/blog/BlogPostHeader.tsx
- content/blog/introducing-the-livepeer-foundation.md
- content/blog/ai-x-open-media-forum.md
- content/blog/livepeer-2-0-video-agent-platform.md
- app/blog/[slug]/page.tsx
- content/blog/why-delegation-still-matters-in-a-low-inflation-environment.md
- content/blog/builder-spotlight-frameworks.md
- components/home/LatestPosts.tsx
- content/blog/q1-2026-messari-state-of-livepeer.md
| Every blog post's share card (Open Graph + Twitter) and its listing thumbnail are the **same artwork**, rendered by one module — `lib/og-card.tsx` — as an **ordered-dither (8×8 Bayer) halftone in brand green** on a near-black panel. The share card wraps that dither in card furniture (Holographik grid, `LIVEPEER` wordmark, category, title, date, `LIVEPEER.ORG`); the thumbnail is the bare dither with thin corner registration marks. They share the same `photoField` tonal curve and cell pitch, so a post looks identical when shared and when browsed. Keep it that way — don't fork the treatment per surface. | ||
|
|
||
| **To give a new post card art:** | ||
|
|
||
| 1. Source a **square** image and save it to `public/images/blog/cards/<slug>.jpg` (match the post's slug). | ||
| 2. Add `cardArt: /images/blog/cards/<slug>.jpg` to the post's frontmatter. That single field lights up every surface: the share/OG image, the Twitter image, the blog detail hero (`BlogPostHeader`), the home "Field Notes" grid (`LatestPosts`), and the listing (`BlogPostCard`). |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Document the shareImage override.
lib/blog.ts defines shareImage as an opt-out from the generated share card. The statements that every share card uses the same artwork and that cardArt drives every surface are therefore too broad. Add an explicit “unless shareImage is set” exception.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLAUDE.md` around lines 78 - 83, Update the blog card artwork documentation
to explicitly state that the shared generated-card behavior and the claim that
cardArt drives every surface apply unless the post defines the shareImage
frontmatter override. Describe shareImage as opting out of the generated share
card while preserving the existing cardArt workflow for other surfaces.
| **Sourcing recipe** (used for the existing cards): generate with the Livepeer Agent `create_media`, `model_override: "flux-dev"`, `aspect_ratio: "1:1"` (~$0.03/image; Adam has authorized the Agent for this task specifically). Prompt shape that obeys the aesthetic: | ||
|
|
||
| > _"Black and white studio photograph of a single [SUBJECT], centered and isolated on a pure black background, large and filling most of the frame, dramatic directional lighting, [subject] emerging from deep shadow, background fading to solid black at all edges, sharp focus, fine detail, moody cinematic monochrome, high contrast."_ |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove or mark the image cost claim.
~$0.03/image is a concrete, time-sensitive cost. Replace it with a [FLAG] marker until the current cost is validated, or remove the amount.
Based on learnings: do not publish unvalidated cost claims; use [FLAG] markers for open claims.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLAUDE.md` around lines 88 - 90, Update the “Sourcing recipe” text in
CLAUDE.md to remove the unvalidated “~$0.03/image” claim or replace it with a
[FLAG] marker, while preserving the remaining generation settings and prompt
guidance.
Source: Learnings
What
Redesigns the blog's share images (Open Graph + Twitter) and listing thumbnails around one system: an ordered-dither (8×8 Bayer) halftone of each post's own art, in brand green. One renderer (
lib/og-card.tsx) drives every surface — share card, thumbnail, detail hero, home grid, listing — so a post looks the same wherever it appears.Why
The old share images were generic. Dithering gives every post a distinctive, on-brand visual with a real technical origin (rendering an image on a limited palette — apt for a video company).
The share card also sets the post title, publish date, category, and reading time directly on the image. That's good UX: the preview tells you what the link is and how recent it is before you click, and it stays legible when a platform crops or rescales the card.
Key changes
lib/og-card.tsx— shared dither renderer for share cards + thumbnails (cardArtdownsampled and luminance-thresholded into a green halftone; corner registration marks on the bare thumbnail).cardArtdrives the treatment;shareImageis an opt-out override. Posts withoutcardArtfall back to a typographic card.opengraph-image/twitter-image/thumbnail; thumbnails now render on the listing, home grid, and detail hero.sharpdeclared + externalized viaserverExternalPackages(fixes the Vercel build); drafts kept out of the image routes in production.🤖 Generated with Claude Code
Summary by CodeRabbit