diff --git a/content/posts/free-opus.mdx b/content/posts/free-opus.mdx index b5c3084..dd70b37 100644 --- a/content/posts/free-opus.mdx +++ b/content/posts/free-opus.mdx @@ -406,12 +406,12 @@ the important distinction is that the perfected pipeline had two different cost without the ~60-70% asset-cache reduction, the proxy share would have been roughly 177-238 MB per account instead, or about $0.67-0.89 per created account at the same proxy rate. -![anthropic pricing documentation used for the list-price comparison in the cost section](/media/free-opus/anthropic-pricing-docs.png "anthropic list pricing reference") - ### was it actually worth it? so... was all of this actually worth it in the end? +![anthropic pricing documentation used for the list-price comparison in the cost section](/media/free-opus/anthropic-pricing-docs.png "anthropic list pricing reference") + one live usage snapshot looked like this: #### usage summary diff --git a/public/brand/logo.svg b/public/brand/logo.svg new file mode 100644 index 0000000..0b71c58 --- /dev/null +++ b/public/brand/logo.svg @@ -0,0 +1,34 @@ + + + + diff --git a/public/brand/scythe.svg b/public/brand/scythe.svg new file mode 100644 index 0000000..0569494 --- /dev/null +++ b/public/brand/scythe.svg @@ -0,0 +1,4148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/anthropic-serif-regular.ttf b/public/fonts/anthropic-serif-regular.ttf new file mode 100644 index 0000000..a3c37bf Binary files /dev/null and b/public/fonts/anthropic-serif-regular.ttf differ diff --git a/public/fonts/anthropic-serif-static.ttf b/public/fonts/anthropic-serif-static.ttf new file mode 100644 index 0000000..971b4a4 Binary files /dev/null and b/public/fonts/anthropic-serif-static.ttf differ diff --git a/public/fonts/space-grotesk-400.ttf b/public/fonts/space-grotesk-400.ttf new file mode 100644 index 0000000..576f9b5 Binary files /dev/null and b/public/fonts/space-grotesk-400.ttf differ diff --git a/src/app/blog/[slug]/opengraph-image.tsx b/src/app/blog/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..ae27d6a --- /dev/null +++ b/src/app/blog/[slug]/opengraph-image.tsx @@ -0,0 +1,171 @@ +import { ImageResponse } from "next/og"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { getPostBySlug, getAllPostSlugs } from "@/lib/posts"; +import { + DEFAULT_SCYTHE_ROTATION, + DEFAULT_SCYTHE_SCALE, + DEFAULT_TITLE_SCALE, + getOgPreviewTheme, + getOgTitleStyle, + getScytheFrame, + ogLayout, +} from "@/lib/og"; +import type { FontDefinition } from "@/types/post"; + +export const dynamic = "force-static"; + +export async function generateStaticParams() { + const slugs = await getAllPostSlugs(); + return slugs.map((slug) => ({ slug })); +} + +function tintSvg(svg: string, color: string) { + return svg + .replace(/<\?xml[\s\S]*?\?>/i, "") + .replace(//gi, "") + .replace(/#000000/gi, color) + .replace(/#000\b/gi, color) + .replace(/\bblack\b/gi, color); +} + +function cropScytheSvg(svg: string) { + return svg.replace(/viewBox="[^"]*"/i, 'viewBox="600 120 6938 2788"'); +} + +function svgToDataUri(svg: string) { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +async function loadOgHeadingFont(font: FontDefinition) { + let fontPath: string | null = null; + + if (font.source === "local") { + fontPath = font.value === "/fonts/anthropic-serif-regular.woff2" + ? path.join(process.cwd(), "public", "fonts", "anthropic-serif-static.ttf") + : path.join(process.cwd(), "public", font.value.replace(/^\//, "")); + } else if (font.source === "google" && font.family === "Space Grotesk") { + fontPath = path.join(process.cwd(), "public", "fonts", "space-grotesk-400.ttf"); + } + + if (!fontPath) { + return null; + } + + return fs.readFile(fontPath); +} + +export default async function Image({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const post = await getPostBySlug(slug); + + if (!post) { + return new ImageResponse( +
Not found
, + { width: 1200, height: 630 } + ); + } + + const theme = getOgPreviewTheme(post.theme); + const headingFont = post.theme.fonts.heading; + const [fontData, logoSvg, scytheSvg] = await Promise.all([ + loadOgHeadingFont(headingFont), + fs.readFile(path.join(process.cwd(), "public", "brand", "logo.svg"), "utf8"), + fs.readFile(path.join(process.cwd(), "public", "brand", "scythe.svg"), "utf8"), + ]); + + const titleStyle = getOgTitleStyle(post.title, DEFAULT_TITLE_SCALE); + const scytheFrame = getScytheFrame(1); + const logoSrc = svgToDataUri(tintSvg(logoSvg, theme.logo)); + const scytheSrc = svgToDataUri(tintSvg(cropScytheSvg(scytheSvg), theme.scythe)); + + return new ImageResponse( + ( +
+
+
+ +
+ +

+ {post.title} +

+
+ ), + { + width: ogLayout.width, + height: ogLayout.height, + fonts: fontData ? [ + { + name: headingFont.family, + data: fontData, + style: "normal", + weight: 400, + }, + ] : [], + } + ); +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 2ab6cfe..c31ff17 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -36,6 +36,13 @@ export async function generateMetadata({ description: post.excerpt, url: `https://blog.micr.dev/blog/${post.slug}`, type: "article", + images: [ + { + url: `/blog/${post.slug}/opengraph-image`, + width: 1200, + height: 630, + }, + ], }, }; } diff --git a/src/app/og-preview/page.tsx b/src/app/og-preview/page.tsx new file mode 100644 index 0000000..58d0700 --- /dev/null +++ b/src/app/og-preview/page.tsx @@ -0,0 +1,85 @@ +import { getFontStyleSheet } from "@/lib/mdx"; +import { + getOgPreviewTheme, + type OgPreviewTheme, +} from "@/lib/og"; +import { getOgBrandAssetUris } from "@/lib/og-assets"; +import { getAllPostSlugs, getPostBySlug } from "@/lib/posts"; +import { PreviewControls } from "@/app/og-preview/preview-controls"; +import { + previewGalleryTitle, + previewThemePresets, +} from "@/app/og-preview/preview-presets"; + +export const metadata = { + title: "OG Preview", +}; + +async function getPreviewPosts() { + const slugs = await getAllPostSlugs(); + const posts = await Promise.all(slugs.map((slug) => getPostBySlug(slug))); + + return posts.filter((post) => post !== null); +} + +async function getThemeAssets(theme: OgPreviewTheme) { + return getOgBrandAssetUris(theme); +} + +function getThemeKey(theme: OgPreviewTheme) { + return JSON.stringify(theme); +} + +export default async function OgPreviewPage() { + const posts = await getPreviewPosts(); + const fontCss = [...new Set(posts.map((post) => getFontStyleSheet(post.theme)).filter(Boolean))].join("\n"); + const uniquePosts = [...new Map( + posts.map((post) => { + const theme = getOgPreviewTheme(post.theme); + return [getThemeKey(theme), { post, theme }] as const; + }), + ).values()]; + const liveCards = await Promise.all( + uniquePosts.map(async ({ post, theme }) => ({ + id: `live-${post.slug}`, + label: `/blog/${post.slug}`, + title: post.title, + note: "Actual post theme", + theme, + assets: await getThemeAssets(theme), + })), + ); + const presetCards = await Promise.all( + previewThemePresets.map(async (preset) => ({ + id: preset.id, + label: `preset/${preset.id}`, + title: previewGalleryTitle, + note: preset.name, + theme: preset.theme, + assets: await getThemeAssets(preset.theme), + })), + ); + const cards = [...liveCards, ...presetCards]; + + return ( +
+ {fontCss ? : null} + +
+
+

+ Internal OG review route +

+

+ Per-post OG composition preview +

+

+ One card per theme. Actual post themes are deduped, presets use one shared title, and the previews are scaled down into a compact gallery. +

+
+ + +
+
+ ); +} diff --git a/src/app/og-preview/preview-card.tsx b/src/app/og-preview/preview-card.tsx new file mode 100644 index 0000000..3504e7e --- /dev/null +++ b/src/app/og-preview/preview-card.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { + DEFAULT_SCYTHE_ROTATION, + DEFAULT_SCYTHE_SCALE, + DEFAULT_TITLE_SCALE, + getOgTitleStyle, + getScytheFrame, + ogLayout, + type OgPreviewTheme, +} from "@/lib/og"; + +export function PreviewCard({ + title, + theme, + logoSrc, + scytheSrc, + titleScale = DEFAULT_TITLE_SCALE, + scytheScale = DEFAULT_SCYTHE_SCALE, + scytheRotation = DEFAULT_SCYTHE_ROTATION, +}: { + title: string; + theme: OgPreviewTheme; + logoSrc: string; + scytheSrc: string; + titleScale?: number; + scytheScale?: number; + scytheRotation?: number; +}) { + const scytheFrame = getScytheFrame(1); + + return ( +
+
+ {/* Data-URI SVGs are tinted server-side, so Next image optimization is not useful here. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + +

+ {title} +

+
+ +
+ {/* Data-URI SVGs are tinted server-side, so Next image optimization is not useful here. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ ); +} diff --git a/src/app/og-preview/preview-controls.tsx b/src/app/og-preview/preview-controls.tsx new file mode 100644 index 0000000..8d80fe6 --- /dev/null +++ b/src/app/og-preview/preview-controls.tsx @@ -0,0 +1,93 @@ +import { + DEFAULT_SCYTHE_ROTATION, + DEFAULT_SCYTHE_SCALE, + DEFAULT_TITLE_SCALE, + ogLayout, + type OgPreviewTheme, +} from "@/lib/og"; +import { PreviewCard } from "@/app/og-preview/preview-card"; + +const GALLERY_SCALE = 0.28; + +interface PreviewCardData { + id: string; + label: string; + title: string; + note?: string; + theme: OgPreviewTheme; + assets: { + logo: string; + scythe: string; + }; +} + +export function PreviewControls({ + cards, +}: { + cards: PreviewCardData[]; +}) { + return ( +
+
+
+

+ Locked preview values +

+

+ Title scale: {DEFAULT_TITLE_SCALE.toFixed(2)}x +

+

+ Scythe scale: {DEFAULT_SCYTHE_SCALE.toFixed(2)}x +

+

+ Scythe rotation: {DEFAULT_SCYTHE_ROTATION}deg +

+

+ Title line separation: 3px +

+
+
+ +
+ {cards.map((card) => ( +
+
+
+ {card.label} +
+ {card.note ? ( +

+ {card.note} +

+ ) : null} +
+ +
+
+ +
+
+
+ ))} +
+
+ ); +} diff --git a/src/app/og-preview/preview-presets.ts b/src/app/og-preview/preview-presets.ts new file mode 100644 index 0000000..9f69be1 --- /dev/null +++ b/src/app/og-preview/preview-presets.ts @@ -0,0 +1,120 @@ +import type { OgPreviewTheme } from "@/lib/og"; + +export interface PreviewThemePreset { + id: string; + name: string; + theme: OgPreviewTheme; +} + +export const previewGalleryTitle = "how i got unlimited claude opus 4.6 for free"; + +export const previewThemePresets: PreviewThemePreset[] = [ + { + id: "terminal-acid", + name: "Terminal Acid", + theme: { + leftPanel: "#0A1204", + rightPanel: "#13260D", + logo: "#D9FFD1", + title: "#72FD00", + scythe: "#F6DD00", + fontFamily: "Space Grotesk", + }, + }, + { + id: "warning-tape", + name: "Warning Tape", + theme: { + leftPanel: "#121212", + rightPanel: "#2A1A00", + logo: "#FFF3B0", + title: "#FFD400", + scythe: "#FF6A00", + fontFamily: "Space Grotesk", + }, + }, + { + id: "cobalt-noise", + name: "Cobalt Noise", + theme: { + leftPanel: "#08111F", + rightPanel: "#132744", + logo: "#D8E7FF", + title: "#7CC4FF", + scythe: "#F05DFF", + fontFamily: "Space Grotesk", + }, + }, + { + id: "burnt-paper", + name: "Burnt Paper", + theme: { + leftPanel: "#1C120D", + rightPanel: "#362018", + logo: "#F4DDCE", + title: "#FFB38A", + scythe: "#FF4D2D", + fontFamily: "anthropicSerif", + }, + }, + { + id: "rose-lab", + name: "Rose Lab", + theme: { + leftPanel: "#170B14", + rightPanel: "#2E1032", + logo: "#FFD9F6", + title: "#FF84D8", + scythe: "#71FCAA", + fontFamily: "anthropicSerif", + }, + }, + { + id: "ivory-night", + name: "Ivory Night", + theme: { + leftPanel: "#101114", + rightPanel: "#1A1F29", + logo: "#F2EFE8", + title: "#FFF7E8", + scythe: "#F4A261", + fontFamily: "anthropicSerif", + }, + }, + { + id: "cyan-static", + name: "Cyan Static", + theme: { + leftPanel: "#041419", + rightPanel: "#08242D", + logo: "#D8FBFF", + title: "#6BF2FF", + scythe: "#FF5D8F", + fontFamily: "Space Grotesk", + }, + }, + { + id: "ash-violet", + name: "Ash Violet", + theme: { + leftPanel: "#15131C", + rightPanel: "#221E30", + logo: "#F2E8FF", + title: "#C7A6FF", + scythe: "#FFD166", + fontFamily: "anthropicSerif", + }, + }, + { + id: "monochrome-signal", + name: "Monochrome Signal", + theme: { + leftPanel: "#0B0B0C", + rightPanel: "#19191B", + logo: "#F5F5F5", + title: "#FFFFFF", + scythe: "#FF3B30", + fontFamily: "Space Grotesk", + }, + }, +] as const; diff --git a/src/lib/og-assets.ts b/src/lib/og-assets.ts new file mode 100644 index 0000000..728a042 --- /dev/null +++ b/src/lib/og-assets.ts @@ -0,0 +1,45 @@ +import "server-only"; +import { cache } from "react"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { OgPreviewTheme } from "@/lib/og"; + +const BRAND_ASSET_PATHS = { + logo: path.join(process.cwd(), "public", "brand", "logo.svg"), + scythe: path.join(process.cwd(), "public", "brand", "scythe.svg"), +} as const; + +const SCYTHE_CROP_VIEWBOX = "600 120 6938 2788"; + +const readBrandSvg = cache(async (asset: keyof typeof BRAND_ASSET_PATHS) => { + return fs.readFile(BRAND_ASSET_PATHS[asset], "utf8"); +}); + +function tintSvg(svg: string, color: string) { + return svg + .replace(/<\?xml[\s\S]*?\?>/i, "") + .replace(//gi, "") + .replace(/#000000/gi, color) + .replace(/#000\b/gi, color) + .replace(/\bblack\b/gi, color); +} + +function cropScytheSvg(svg: string) { + return svg.replace(/viewBox="[^"]*"/i, `viewBox="${SCYTHE_CROP_VIEWBOX}"`); +} + +function svgToDataUri(svg: string) { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +export async function getOgBrandAssetUris(theme: OgPreviewTheme) { + const [logoSvg, scytheSvg] = await Promise.all([ + readBrandSvg("logo"), + readBrandSvg("scythe"), + ]); + + return { + logo: svgToDataUri(tintSvg(logoSvg, theme.logo)), + scythe: svgToDataUri(tintSvg(cropScytheSvg(scytheSvg), theme.scythe)), + }; +} diff --git a/src/lib/og.tsx b/src/lib/og.tsx new file mode 100644 index 0000000..082899b --- /dev/null +++ b/src/lib/og.tsx @@ -0,0 +1,95 @@ +import type { CSSProperties } from "react"; +import { postHeadingStyle } from "@/lib/post-heading"; +import type { PostTheme } from "@/types/post"; + +const OG_WIDTH = 1200; +const OG_HEIGHT = 630; +const LEFT_PANEL_RATIO = 0.6727; +const RIGHT_PANEL_RATIO = 0.3273; + +const SCYTHE_VIEWBOX = { + width: 8276, + height: 3015, +} as const; + +export const DEFAULT_TITLE_SCALE = 0.75; +export const DEFAULT_SCYTHE_SCALE = 1.51; +export const DEFAULT_SCYTHE_ROTATION = -100; + +const titleStyleByLength = [ + { maxLength: 34, fontSize: 112 }, + { maxLength: 52, fontSize: 98 }, + { maxLength: 72, fontSize: 86 }, + { maxLength: 96, fontSize: 76 }, + { maxLength: Number.POSITIVE_INFINITY, fontSize: 68 }, +] as const; + +export const ogLayout = { + width: OG_WIDTH, + height: OG_HEIGHT, + leftPanelWidth: OG_WIDTH * LEFT_PANEL_RATIO, + rightPanelWidth: OG_WIDTH * RIGHT_PANEL_RATIO, + logo: { + left: 78, + top: 88, + width: 60, + height: 55, + }, + title: { + left: 59, + top: 250, + width: 674, + }, +} as const; + +export interface OgPreviewTheme { + leftPanel: string; + rightPanel: string; + logo: string; + title: string; + scythe: string; + fontFamily: string; +} + +export function getOgPreviewTheme(theme: PostTheme): OgPreviewTheme { + return { + leftPanel: theme.colors.background, + rightPanel: theme.colors.codeBackground, + logo: theme.colors.body, + title: theme.colors.heading, + scythe: theme.colors.accent, + fontFamily: theme.fonts.heading.family, + }; +} + +export function getOgTitleStyle(title: string, titleScale = DEFAULT_TITLE_SCALE): CSSProperties { + const baseFontSize = titleStyleByLength.find((entry) => title.length <= entry.maxLength)?.fontSize ?? 68; + const fontSize = Math.round(baseFontSize * titleScale); + + return { + position: "absolute", + left: ogLayout.title.left, + top: ogLayout.title.top, + width: ogLayout.title.width, + margin: 0, + color: "inherit", + fontFamily: "inherit", + fontSize, + fontWeight: postHeadingStyle.fontWeight, + lineHeight: "calc(0.99em + 3px)", + letterSpacing: "-0.05em", + whiteSpace: "pre-wrap", + }; +} + +export function getScytheFrame(scytheScale: number) { + const scale = Math.max( + ogLayout.rightPanelWidth / SCYTHE_VIEWBOX.height, + ogLayout.height / SCYTHE_VIEWBOX.width, + ) * scytheScale; + + return { + width: SCYTHE_VIEWBOX.width * scale, + height: SCYTHE_VIEWBOX.height * scale, + }; +} diff --git a/src/lib/post-heading.ts b/src/lib/post-heading.ts new file mode 100644 index 0000000..ae58c9b --- /dev/null +++ b/src/lib/post-heading.ts @@ -0,0 +1,4 @@ +export const postHeadingStyle = { + fontFamily: "var(--font-post-heading)", + fontWeight: 320, +} as const;