mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 09:37:47 +03:00
456 lines
18 KiB
TypeScript
456 lines
18 KiB
TypeScript
// Renders the link preview card (1200×630 PNG) for every public page into public/og/ and writes
|
||
// src/lib/og-images.json, which maps each page's path to its card (src/lib/og.ts, ShareMeta).
|
||
// Runs before `vite dev` and `vite build`. A card's file name ends in a hash of its PNG, so a new
|
||
// title, image or template gives it a new URL and link previews refresh. Cards are English at
|
||
// every locale.
|
||
//
|
||
// This file is the cards' source of truth. The layout is David's template frame in Paper (the
|
||
// "OG images" file, frame "/ — dark"): a grid of #2e2e2e lines at x 72, 900 and 1128 and y 72,
|
||
// 557 and 629, the logo icon in the top-left cell, the title and a description of at most two
|
||
// lines centred in the band between the lines, and a 300 × 630 image on the right. The image is a
|
||
// real still (a product card cover, a clip frame or a page rendered headless by
|
||
// script/og-stills.ts) projected onto a tilted plane, like the site's angled card cuts.
|
||
import { createHash } from "node:crypto"
|
||
import { mkdir, readdir, rm } from "node:fs/promises"
|
||
import path from "node:path"
|
||
import satori from "satori"
|
||
import sharp from "sharp"
|
||
import { decompress } from "wawoff2"
|
||
|
||
const root = path.resolve(import.meta.dir, "..")
|
||
const out = path.join(root, "public/og")
|
||
|
||
const W = 1200
|
||
const H = 630
|
||
const IMAGE = { left: 900, width: 300 }
|
||
const INK = "#f2f2f2"
|
||
const RULE = "#2e2e2e"
|
||
// The site's LogoIcon (component/site/marks.tsx) in the template's foreground colour.
|
||
const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="300" viewBox="0 0 240 300"><path d="M180 240H60V120H180V240Z" fill="#4b4b4b"/><path fill-rule="evenodd" d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#f2f2f2"/></svg>`
|
||
const TEXT = { left: 137, width: 699, title: 112, description: 32, line: 48, gap: 28 }
|
||
|
||
/** Where a still sits: its crop in source pixels and the tilted plane it's projected onto. */
|
||
type Shot = {
|
||
file: string
|
||
crop: [left: number, top: number, width: number, height: number]
|
||
/** The plane's height in card pixels before perspective, and where its near edge sits in the image area. */
|
||
height: number
|
||
left: number
|
||
/** Degrees: yaw (negative turns the right side away), pitch and roll. */
|
||
turn: [y: number, x: number, z: number]
|
||
}
|
||
|
||
type Card = { path: string; title: string; description: string; shows: string; shot: Shot | "orb" }
|
||
|
||
const covers = "public/product-videos/card-loops"
|
||
const stills = "src/asset/og"
|
||
// The near edge shows a little inside the grid line and the plane runs off the right, as the
|
||
// angled card cuts do, so a crop puts what the card should show at its left.
|
||
const angled = (file: string, crop: Shot["crop"], height: number): Shot => ({
|
||
file,
|
||
crop,
|
||
height,
|
||
left: 34,
|
||
turn: [-28, 8, -3],
|
||
})
|
||
|
||
const cards: Card[] = [
|
||
{
|
||
path: "/",
|
||
title: "OpenCode",
|
||
description: "The open source coding agent for your desktop, the terminal, your phone and your own code.",
|
||
shows: "the home page with the desktop, terminal, mobile and SDK product cards, at an angle",
|
||
shot: angled(`${stills}/home.jpg`, [290, 20, 1150, 860], 470),
|
||
},
|
||
{
|
||
path: "/product",
|
||
title: "Product",
|
||
description: "One agent on your desktop, the terminal, your phone and in your own code.",
|
||
shows: "the product page's grid of product cards, at an angle",
|
||
shot: angled(`${stills}/product.jpg`, [290, 40, 1150, 860], 470),
|
||
},
|
||
{
|
||
path: "/desktop",
|
||
title: "Desktop",
|
||
description: "Run several agents at once, review every change as a diff and preview it.",
|
||
shows: "the desktop app's start screen with Claude Opus 5.5 selected, at an angle",
|
||
shot: angled(`${covers}/desktop.webp`, [20, 340, 1024, 600], 330),
|
||
},
|
||
{
|
||
path: "/mobile",
|
||
title: "Mobile",
|
||
description: "Start and steer your coding sessions from your phone.",
|
||
shows: "the mobile app's dotted voice orb",
|
||
shot: "orb",
|
||
},
|
||
{
|
||
path: "/terminal",
|
||
title: "Terminal",
|
||
description: "A fast terminal UI that works in any shell, editor or remote server.",
|
||
shows: "the terminal UI's blue themes dialog, at an angle",
|
||
shot: angled(`${stills}/terminal.jpg`, [40, 170, 1500, 680], 360),
|
||
},
|
||
{
|
||
path: "/sdk",
|
||
title: "SDK",
|
||
description: "Drive OpenCode from your own scripts, CI jobs and tools in TypeScript.",
|
||
shows: "TypeScript code that creates an OpenCode client with the SDK, at an angle",
|
||
shot: angled(`${covers}/sdk.webp`, [100, 420, 964, 440], 300),
|
||
},
|
||
{
|
||
path: "/ide",
|
||
title: "IDE extensions",
|
||
description: "Open OpenCode in VS Code, Cursor, Windsurf, Zed and JetBrains IDEs.",
|
||
shows: "the OpenCode terminal UI that the extensions open beside your code, at an angle",
|
||
shot: angled("public/product-videos/heroes/terminal-landscape-dark.webp", [430, 380, 1100, 560], 270),
|
||
},
|
||
{
|
||
path: "/mini",
|
||
title: "Mini",
|
||
description: "A minimal terminal interface. Replies print into your own scrollback.",
|
||
shows: "OpenCode Mini in a terminal with Claude Opus 5.5, at an angle",
|
||
shot: angled(`${covers}/mini.webp`, [250, 540, 680, 260], 150),
|
||
},
|
||
{
|
||
path: "/product/browse",
|
||
title: "Browse",
|
||
description: "Let your agents use your real browser, with your tabs and logins.",
|
||
shows: "the desktop app setting up the Browse extension, at an angle",
|
||
shot: angled("public/product-videos/features/desktop-3.jpg", [812, 60, 788, 900], 470),
|
||
},
|
||
{
|
||
path: "/docs",
|
||
title: "Docs",
|
||
description: "Install OpenCode, connect a provider and find your way around.",
|
||
shows: "the docs intro page with its sidebar and install commands, at an angle",
|
||
shot: angled(`${stills}/docs.jpg`, [0, 0, 1100, 900], 470),
|
||
},
|
||
{
|
||
path: "/press",
|
||
title: "Press",
|
||
description: "Facts, brand assets and contacts for writing about OpenCode.",
|
||
shows: "the press kit's media section with stills of each app, at an angle",
|
||
shot: angled(`${stills}/press.jpg`, [290, 0, 1000, 900], 470),
|
||
},
|
||
{
|
||
path: "/brand",
|
||
title: "Brand",
|
||
description: "Logos, wordmarks, colors and the rules for using the OpenCode brand.",
|
||
shows: "the brand page's light and dark logo files, at an angle",
|
||
shot: angled(`${stills}/brand.jpg`, [290, 20, 820, 700], 470),
|
||
},
|
||
{
|
||
path: "/font",
|
||
title: "OpenCode Font",
|
||
description: "A typeface cut from the OpenCode wordmark, on a grid of modules.",
|
||
shows: "the OpenCode Font specimen, the wordmark set large, at an angle",
|
||
shot: angled(`${stills}/font.jpg`, [250, 250, 1000, 400], 300),
|
||
},
|
||
{
|
||
path: "/changelog",
|
||
title: "Changelog",
|
||
description: "New features and fixes in every OpenCode release.",
|
||
shows: "the changelog timeline with the latest release and its video, at an angle",
|
||
shot: angled(`${stills}/changelog.jpg`, [540, 60, 900, 840], 470),
|
||
},
|
||
// Releases come from GitHub at request time, after the build, so they share one card.
|
||
{
|
||
path: "/changelog/[tag]",
|
||
title: "Release notes",
|
||
description: "What changed in each OpenCode release, with notes and videos.",
|
||
shows: "a changelog release page with its notes and video, at an angle",
|
||
shot: angled(`${stills}/release.jpg`, [540, 120, 900, 780], 470),
|
||
},
|
||
{
|
||
path: "/legal",
|
||
title: "Legal",
|
||
description: "The privacy policy, terms of use and updates to OpenCode's legal documents.",
|
||
shows: "the legal page listing the privacy policy and terms, at an angle",
|
||
shot: angled(`${stills}/legal.jpg`, [290, 40, 1000, 800], 470),
|
||
},
|
||
{
|
||
path: "/legal/privacy-policy",
|
||
title: "Privacy Policy",
|
||
description: "How OpenCode collects, uses, shares and protects your personal data.",
|
||
shows: "the Privacy Policy document with its summary, at an angle",
|
||
shot: angled(`${stills}/privacy-policy.jpg`, [290, 0, 1000, 900], 470),
|
||
},
|
||
{
|
||
path: "/legal/terms-of-service",
|
||
title: "Terms of Use",
|
||
description: "The rules for using the OpenCode website and hosted services.",
|
||
shows: "the Terms of Use document with its summary, at an angle",
|
||
shot: angled(`${stills}/terms-of-service.jpg`, [290, 0, 1000, 900], 470),
|
||
},
|
||
]
|
||
|
||
const fonts = [
|
||
// The site's files (script/opentui-mono.py): Latin first, the rest for anything it lacks.
|
||
...(await Promise.all(
|
||
["latin", "rest"].map(async (group) => ({
|
||
name: "OpenTUI Mono",
|
||
data: exact(await decompress(await read(`src/asset/fonts/opentui-mono-regular-${group}.woff2`))),
|
||
weight: 400 as const,
|
||
})),
|
||
)),
|
||
// The site's Inter (src/asset/fonts/inter-latin.woff2) at the optical size a browser picks for
|
||
// 32 px, as a static file: Satori can't set variable axes. Made with fontTools:
|
||
// uvx --from 'fonttools[woff]==4.60.1' python -c "from fontTools.ttLib import TTFont; from fontTools.varLib
|
||
// import instancer; f = instancer.instantiateVariableFont(TTFont('src/asset/fonts/inter-latin.woff2'),
|
||
// {'opsz': 32, 'wght': 400}); f.flavor = 'woff'; f.save('src/asset/og/inter-display-regular.woff')"
|
||
{ name: "Inter", data: exact(await read("src/asset/og/inter-display-regular.woff")), weight: 400 as const },
|
||
]
|
||
|
||
await mkdir(out, { recursive: true })
|
||
const manifest: Record<string, { src: string; alt: string }> = {}
|
||
for (const card of cards) {
|
||
await checkDescription(card)
|
||
const image = card.shot === "orb" ? await orb() : await project(card.shot)
|
||
const svg = await satori(plate(card, image), { width: W, height: H, fonts })
|
||
const png = await sharp(Buffer.from(svg)).png({ compressionLevel: 9, effort: 10 }).toBuffer()
|
||
if (png.length > 1_000_000) throw new Error(`${card.path}: the card is ${png.length} bytes, over the 1 MB budget`)
|
||
const file = `${slug(card.path)}-${createHash("sha256").update(png).digest("hex").slice(0, 10)}.png`
|
||
await Bun.write(path.join(out, file), png)
|
||
manifest[card.path] = {
|
||
src: `/og/${file}`,
|
||
alt: `OpenCode link card: “${card.title}” and “${card.description}” beside ${card.shows}.`,
|
||
}
|
||
}
|
||
const keep = new Set(Object.values(manifest).map((card) => path.basename(card.src)))
|
||
await Promise.all(
|
||
(await readdir(out)).filter((file) => !keep.has(file)).map((file) => rm(path.join(out, file), { force: true })),
|
||
)
|
||
await Bun.write(path.join(root, "src/lib/og-images.json"), JSON.stringify(manifest, null, 2) + "\n")
|
||
console.log(`og: ${cards.length} cards in public/og`)
|
||
|
||
function plate(card: Card, image: string) {
|
||
const size = titleSize(card.title)
|
||
const line = (style: Record<string, number>) => el("div", { position: "absolute", backgroundColor: RULE, ...style })
|
||
return el(
|
||
"div",
|
||
{ display: "flex", position: "relative", width: W, height: H, backgroundColor: "#0c0c0c" },
|
||
img({ left: IMAGE.left, top: 0 }, image, IMAGE.width, H),
|
||
line({ left: 0, top: 72, width: W, height: 1 }),
|
||
line({ left: 0, top: 557, width: W, height: 1 }),
|
||
line({ left: 0, top: 629, width: W, height: 1 }),
|
||
line({ left: 72, top: 0, width: 1, height: H }),
|
||
line({ left: 900, top: 0, width: 1, height: H }),
|
||
line({ left: 1128, top: 0, width: 1, height: H }),
|
||
// The site's LogoIcon (component/site/marks.tsx) at its own 24 × 30, centred in the 72 px cell.
|
||
img({ left: 24, top: 21 }, svgUri(icon), 24, 30),
|
||
el(
|
||
"div",
|
||
{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
justifyContent: "center",
|
||
position: "absolute",
|
||
left: TEXT.left,
|
||
top: 73,
|
||
width: TEXT.width,
|
||
height: 484,
|
||
gap: TEXT.gap,
|
||
},
|
||
el(
|
||
"div",
|
||
{ fontFamily: "OpenTUI Mono", fontSize: size, lineHeight: 1, letterSpacing: -0.035 * size, color: INK },
|
||
card.title,
|
||
),
|
||
description(card.description),
|
||
),
|
||
)
|
||
}
|
||
|
||
function description(text: string) {
|
||
return el(
|
||
"div",
|
||
{
|
||
fontFamily: "Inter",
|
||
fontSize: TEXT.description,
|
||
lineHeight: `${TEXT.line}px`,
|
||
letterSpacing: -0.01 * TEXT.description,
|
||
color: "#a3a3a3",
|
||
width: TEXT.width,
|
||
},
|
||
text,
|
||
)
|
||
}
|
||
|
||
/** Fails the build when a description sets on more than two lines at the template's size. */
|
||
async function checkDescription(card: Card) {
|
||
const svg = await satori(el("div", { display: "flex", width: TEXT.width }, description(card.description)), {
|
||
width: TEXT.width,
|
||
fonts,
|
||
})
|
||
const height = Number(svg.match(/height="(\d+)"/)?.[1])
|
||
if (height > TEXT.line * 2) throw new Error(`${card.path}: the description takes ${height / TEXT.line} lines`)
|
||
}
|
||
|
||
// The title fills one line at up to the template's 112 px; a longer one sets smaller to fit.
|
||
function titleSize(title: string) {
|
||
return Math.min(TEXT.title, Math.floor(TEXT.width / (title.length * 0.6 * 0.965)))
|
||
}
|
||
|
||
/**
|
||
* A still projected onto a tilted plane and cut to the image area: rendered at twice the size with
|
||
* an inverse homography and bilinear sampling, then scaled down so the edges are smooth.
|
||
*/
|
||
async function project(shot: Shot) {
|
||
const scale = 2
|
||
const w = IMAGE.width * scale
|
||
const h = H * scale
|
||
const [cl, ct, cw, ch] = shot.crop
|
||
const source = await sharp(path.join(root, shot.file))
|
||
.extract({ left: cl, top: ct, width: cw, height: ch })
|
||
.removeAlpha()
|
||
.raw()
|
||
.toBuffer({ resolveWithObject: true })
|
||
const sw = source.info.width
|
||
const sh = source.info.height
|
||
const quad = corners(shot, cw / ch).map(([x, y]) => [x * scale, y * scale] as const)
|
||
const inverse = invert(squareToQuad(quad))
|
||
const pixels = Buffer.alloc(w * h * 3)
|
||
const edge = 1.4 / (shot.height * scale)
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const [u, v] = apply(inverse, x + 0.5, y + 0.5)
|
||
const at = (y * w + x) * 3
|
||
if (u < 0 || u > 1 || v < 0 || v > 1) {
|
||
pixels[at] = pixels[at + 1] = pixels[at + 2] = 12
|
||
continue
|
||
}
|
||
const sx = Math.min(u * sw, sw - 1.001)
|
||
const sy = Math.min(v * sh, sh - 1.001)
|
||
const x0 = Math.floor(sx)
|
||
const y0 = Math.floor(sy)
|
||
const fx = sx - x0
|
||
const fy = sy - y0
|
||
// A faint rim of light on the plane's edges.
|
||
const rim = u < edge || u > 1 - edge || v < edge * (cw / ch) || v > 1 - edge * (cw / ch) ? 0.18 : 0
|
||
for (let c = 0; c < 3; c++) {
|
||
const p = (yy: number, xx: number) => source.data[(yy * sw + xx) * 3 + c]!
|
||
const value =
|
||
p(y0, x0) * (1 - fx) * (1 - fy) +
|
||
p(y0, x0 + 1) * fx * (1 - fy) +
|
||
p(y0 + 1, x0) * (1 - fx) * fy +
|
||
p(y0 + 1, x0 + 1) * fx * fy
|
||
pixels[at + c] = Math.round(value + (255 - value) * rim)
|
||
}
|
||
}
|
||
}
|
||
const data = await sharp(pixels, { raw: { width: w, height: h, channels: 3 } })
|
||
.resize(IMAGE.width, H)
|
||
.png()
|
||
.toBuffer()
|
||
return `data:image/png;base64,${data.toString("base64")}`
|
||
}
|
||
|
||
/** The plane's corners in the image area: turned in 3D, then seen through a 900 px lens. */
|
||
function corners(shot: Shot, aspect: number) {
|
||
const focal = 900
|
||
const [ry, rx, rz] = shot.turn.map((deg) => (deg * Math.PI) / 180) as [number, number, number]
|
||
const hw = (shot.height * aspect) / 2
|
||
const hh = shot.height / 2
|
||
const points = (
|
||
[
|
||
[-hw, -hh],
|
||
[hw, -hh],
|
||
[hw, hh],
|
||
[-hw, hh],
|
||
] as const
|
||
).map(([px, py]) => {
|
||
// Yaw, then pitch, then roll.
|
||
const x1 = px * Math.cos(ry)
|
||
const z1 = -px * Math.sin(ry)
|
||
const y2 = py * Math.cos(rx) - z1 * Math.sin(rx)
|
||
const z2 = py * Math.sin(rx) + z1 * Math.cos(rx)
|
||
const x3 = x1 * Math.cos(rz) - y2 * Math.sin(rz)
|
||
const y3 = x1 * Math.sin(rz) + y2 * Math.cos(rz)
|
||
const depth = focal + z2
|
||
return [(focal * x3) / depth, (focal * y3) / depth] as const
|
||
})
|
||
const near = Math.min(...points.map(([x]) => x))
|
||
return points.map(([x, y]) => [x - near + shot.left, y + H / 2] as const)
|
||
}
|
||
|
||
type Matrix = [number, number, number, number, number, number, number, number, number]
|
||
|
||
// Heckbert's projective map from the unit square to a quad (top-left, top-right, bottom-right, bottom-left).
|
||
function squareToQuad(q: (readonly [number, number])[]): Matrix {
|
||
const [[x0, y0], [x1, y1], [x2, y2], [x3, y3]] = q as [[number, number], [number, number], [number, number], [number, number]]
|
||
const sx = x0 - x1 + x2 - x3
|
||
const sy = y0 - y1 + y2 - y3
|
||
const dx1 = x1 - x2
|
||
const dx2 = x3 - x2
|
||
const dy1 = y1 - y2
|
||
const dy2 = y3 - y2
|
||
const den = dx1 * dy2 - dx2 * dy1
|
||
const g = (sx * dy2 - dx2 * sy) / den
|
||
const hh = (dx1 * sy - sx * dy1) / den
|
||
return [x1 - x0 + g * x1, x3 - x0 + hh * x3, x0, y1 - y0 + g * y1, y3 - y0 + hh * y3, y0, g, hh, 1]
|
||
}
|
||
|
||
function invert(m: Matrix): Matrix {
|
||
const [a, b, c, d, e, f, g, h, i] = m
|
||
const A = e * i - f * h
|
||
const B = c * h - b * i
|
||
const C = b * f - c * e
|
||
const D = f * g - d * i
|
||
const E = a * i - c * g
|
||
const F = c * d - a * f
|
||
const G = d * h - e * g
|
||
const H2 = b * g - a * h
|
||
const I = a * e - b * d
|
||
return [A, B, C, D, E, F, G, H2, I]
|
||
}
|
||
|
||
function apply(m: Matrix, x: number, y: number) {
|
||
const w = m[6] * x + m[7] * y + m[8]
|
||
return [(m[0] * x + m[1] * y + m[2]) / w, (m[3] * x + m[4] * y + m[5]) / w] as const
|
||
}
|
||
|
||
/** Mobile's card: the voice orb from the mobile card cover, large and cut by the image area. */
|
||
async function orb() {
|
||
const sphere = await sharp(path.join(root, `${covers}/mobile-orb-100.webp`))
|
||
.extract({ left: 182, top: 308, width: 700, height: 700 })
|
||
.resize(560, 560)
|
||
.toBuffer()
|
||
const data = await sharp({ create: { width: IMAGE.width, height: H, channels: 3, background: "#0c0c0c" } })
|
||
.composite([{ input: await sharp(sphere).extract({ left: 40, top: 0, width: 300, height: 560 }).toBuffer(), left: 0, top: 35 }])
|
||
.png()
|
||
.toBuffer()
|
||
return `data:image/png;base64,${data.toString("base64")}`
|
||
}
|
||
|
||
function slug(page: string) {
|
||
if (page === "/") return "home"
|
||
return page.slice(1).replace("[tag]", "release").replaceAll("/", "-")
|
||
}
|
||
|
||
async function read(file: string) {
|
||
return new Uint8Array(await Bun.file(path.join(root, file)).arrayBuffer())
|
||
}
|
||
|
||
function exact(bytes: Uint8Array) {
|
||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||
}
|
||
|
||
function svgUri(svg: string) {
|
||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`
|
||
}
|
||
|
||
// Satori takes React-shaped elements; these plain objects are that shape.
|
||
type Node = Parameters<typeof satori>[0]
|
||
|
||
function el(type: string, style: Record<string, unknown>, ...children: (Node | string)[]): Node {
|
||
return { type, props: { style, children: children.length === 1 ? children[0] : children } } as unknown as Node
|
||
}
|
||
|
||
function img(style: Record<string, unknown>, src: string, width: number, height: number): Node {
|
||
return {
|
||
type: "img",
|
||
props: { style: { position: "absolute", ...style, width, height }, src, width, height },
|
||
} as unknown as Node
|
||
}
|
||
|