Skip to content

Blog: dithered per-post share cards + thumbnails - #92

Open
adamsoffer wants to merge 1 commit into
mainfrom
claude/blog-share-image-redesign-90942e
Open

Blog: dithered per-post share cards + thumbnails#92
adamsoffer wants to merge 1 commit into
mainfrom
claude/blog-share-image-redesign-90942e

Conversation

@adamsoffer

@adamsoffer adamsoffer commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

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 (cardArt downsampled and luminance-thresholded into a green halftone; corner registration marks on the bare thumbnail).
  • FrontmattercardArt drives the treatment; shareImage is an opt-out override. Posts without cardArt fall back to a typographic card.
  • Routes — per-post opengraph-image / twitter-image / thumbnail; thumbnails now render on the listing, home grid, and detail hero.
  • sharp declared + externalized via serverExternalPackages (fixes the Vercel build); drafts kept out of the image routes in production.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added branded, post-specific preview images for blog posts across Open Graph, Twitter, and thumbnails.
    • Added generated thumbnail artwork for blog cards, latest posts, and featured post headers.
    • Added custom card artwork for supported blog posts.
    • Added richer social previews with categories, dates, reading times, and author details.
  • Bug Fixes
    • Standardized blog date display using UTC.
    • Improved social preview image selection and fallback behavior.
    • Preserved consistent image sizing, cropping, and accessibility text across blog views.

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
website Ready Ready Preview Aug 3, 2026 2:51am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The 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 sharp runtime configuration.

Changes

Blog card artwork

Layer / File(s) Summary
Blog artwork data contracts
lib/blog.ts, content/blog/*
BlogPost now includes shareImage and cardArt. Selected posts define cardArt in front matter.
Shared card rendering
lib/og-card.tsx
Adds shared thumbnail and Open Graph card rendering with artwork processing, fallback graphics, metadata, typography, and image constants.
Image routes and metadata
app/blog/opengraph-image.tsx, app/blog/twitter-image.tsx, app/blog/[slug]/opengraph-image.tsx, app/blog/[slug]/twitter-image.tsx, app/blog/[slug]/thumbnail/route.tsx, app/blog/[slug]/page.tsx
Routes use the shared renderer, generate static slugs, expose image metadata, and use shareImage when available.
Blog image consumption
components/blog/BlogPostCard.tsx, components/blog/BlogPostHeader.tsx, components/home/LatestPosts.tsx
Blog components request generated thumbnails, apply image fallbacks and sizing, and format dates in UTC.
Image processing runtime and conventions
next.config.ts, package.json, CLAUDE.md
The server externalizes sharp, the package manifest adds sharp version 0.34.5, and documentation describes card artwork behavior.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main changes: per-post dithered share cards and thumbnails.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/blog-share-image-redesign-90942e

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (4)
lib/og-card.tsx (4)

35-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unify the duplicated panel width constant.

PANEL (line 36) and ART_W (line 183) both hold 560 and describe the same panel. ditherUri renders at ART_W, and the card places the image at PANEL. 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 win

Replace 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" and 70 differ. 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 photoField to 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 value

Centralise the palette and record its relationship to the theme tokens.

The coding guidelines require theme tokens for colors in .ts/.tsx instead of raw hex values. Satori cannot resolve the CSS custom properties from globals.css, so literal values are required inside this renderer. That is a genuine constraint, not a defect.

The residual risk is drift. tone introduces #8FE0C8, #2CAF74, #18794E, and #115C3B that are not among the four named constants at lines 27-30. If the @theme greens change, nothing links these values back. Collect every literal into one exported palette object in this file, and name each entry after its @theme token, 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 tradeoff

Consider 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:

  1. The metadata image routes and the thumbnail route each run this independently. A post costs three full dither renders on cold cache.
  2. The thumbnail route is a Route Handler. Set an explicit long Cache-Control on 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 combined d attribute 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

📥 Commits

Reviewing files that changed from the base of the PR and between c21659e and 5f3eb8c.

⛔ Files ignored due to path filters (14)
  • public/fonts/FavoritMono-Medium.ttf is excluded by !**/*.ttf
  • public/images/blog/cards/a-real-time-update-to-the-livepeer-network-vision.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/ai-x-open-media-forum.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/builder-spotlight-frameworks.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/builder-spotlight-mike-zupper.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/introducing-livepeer-cascade.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/introducing-the-livepeer-foundation.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/livepeer-2-0-video-agent-platform.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/livepeer-blog.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/livepeer-incorporated-and-realtime-ai.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/onchain-builders-streamplace.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/q1-2026-messari.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/using-the-livepeer-community-treasury.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/why-delegation-still-matters.jpg is excluded by !**/*.jpg
📒 Files selected for processing (23)
  • app/blog/[slug]/opengraph-image.tsx
  • app/blog/[slug]/page.tsx
  • app/blog/[slug]/thumbnail/route.tsx
  • app/blog/[slug]/twitter-image.tsx
  • app/blog/opengraph-image.tsx
  • app/blog/twitter-image.tsx
  • components/blog/BlogPostCard.tsx
  • components/blog/BlogPostHeader.tsx
  • components/home/LatestPosts.tsx
  • content/blog/a-real-time-update-to-the-livepeer-network-vision.md
  • content/blog/ai-x-open-media-forum.md
  • content/blog/builder-spotlight-frameworks.md
  • content/blog/builder-spotlight-mike-zupper.md
  • content/blog/introducing-livepeer-cascade-a-vision-for-livepeers-future-in-the-age-of-real-time-ai-video.md
  • content/blog/introducing-the-livepeer-foundation.md
  • content/blog/livepeer-2-0-video-agent-platform.md
  • content/blog/livepeer-incorporated-and-realtime-ai.md
  • content/blog/onchain-builders-streamplace.md
  • content/blog/q1-2026-messari-state-of-livepeer.md
  • content/blog/using-the-livepeer-community-treasury.md
  • content/blog/why-delegation-still-matters-in-a-low-inflation-environment.md
  • lib/blog.ts
  • lib/og-card.tsx

Comment on lines +13 to +15
export function generateStaticParams() {
return getPostSlugs().map((slug) => ({ slug }));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.tsx

Repository: 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

Comment thread app/blog/[slug]/thumbnail/route.tsx
Comment thread app/blog/[slug]/twitter-image.tsx Outdated
Comment thread lib/og-card.tsx
Comment on lines +336 to +383
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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.tsx

Repository: 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.

Comment thread lib/og-card.tsx
Comment on lines +554 to +564
function formatDate(date: string) {
if (!date) return "";
return new Date(date)
.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
.toUpperCase();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
lib/og-card.tsx (1)

684-704: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Memoise the font load.

loadFonts reads 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

📥 Commits

Reviewing files that changed from the base of the PR and between b79ba02 and e036519.

⛔ Files ignored due to path filters (15)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/fonts/FavoritMono-Medium.ttf is excluded by !**/*.ttf
  • public/images/blog/cards/a-real-time-update-to-the-livepeer-network-vision.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/ai-x-open-media-forum.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/builder-spotlight-frameworks.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/builder-spotlight-mike-zupper.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/introducing-livepeer-cascade.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/introducing-the-livepeer-foundation.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/livepeer-2-0-video-agent-platform.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/livepeer-blog.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/livepeer-incorporated-and-realtime-ai.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/onchain-builders-streamplace.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/q1-2026-messari.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/using-the-livepeer-community-treasury.jpg is excluded by !**/*.jpg
  • public/images/blog/cards/why-delegation-still-matters.jpg is excluded by !**/*.jpg
📒 Files selected for processing (26)
  • CLAUDE.md
  • app/blog/[slug]/opengraph-image.tsx
  • app/blog/[slug]/page.tsx
  • app/blog/[slug]/thumbnail/route.tsx
  • app/blog/[slug]/twitter-image.tsx
  • app/blog/opengraph-image.tsx
  • app/blog/twitter-image.tsx
  • components/blog/BlogPostCard.tsx
  • components/blog/BlogPostHeader.tsx
  • components/home/LatestPosts.tsx
  • content/blog/a-real-time-update-to-the-livepeer-network-vision.md
  • content/blog/ai-x-open-media-forum.md
  • content/blog/builder-spotlight-frameworks.md
  • content/blog/builder-spotlight-mike-zupper.md
  • content/blog/introducing-livepeer-cascade-a-vision-for-livepeers-future-in-the-age-of-real-time-ai-video.md
  • content/blog/introducing-the-livepeer-foundation.md
  • content/blog/livepeer-2-0-video-agent-platform.md
  • content/blog/livepeer-incorporated-and-realtime-ai.md
  • content/blog/onchain-builders-streamplace.md
  • content/blog/q1-2026-messari-state-of-livepeer.md
  • content/blog/using-the-livepeer-community-treasury.md
  • content/blog/why-delegation-still-matters-in-a-low-inflation-environment.md
  • lib/blog.ts
  • lib/og-card.tsx
  • next.config.ts
  • package.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

Comment thread CLAUDE.md
Comment on lines +78 to +83
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`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Comment thread CLAUDE.md
Comment on lines +88 to +90
**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."_

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant