|
| 1 | +/** |
| 2 | + * Generates Open Graph cards for the catalog: one 1200x630 PNG per captured |
| 3 | + * screen/theme pair plus a default card, written to dist/og. Runs after the |
| 4 | + * production build so the images deploy as plain static assets. |
| 5 | + */ |
| 6 | +import { fileURLToPath } from "node:url" |
| 7 | +import { GlobalFonts, createCanvas } from "@napi-rs/canvas" |
| 8 | +import type { SKRSContext2D, Canvas } from "@napi-rs/canvas" |
| 9 | +import { |
| 10 | + CellHeight, |
| 11 | + CellWidth, |
| 12 | + DimAlpha, |
| 13 | + FontSize, |
| 14 | + StrikethroughOffset, |
| 15 | + TextStyle, |
| 16 | + UnderlineOffset, |
| 17 | + baselineOffset, |
| 18 | + drawBlockGlyph, |
| 19 | +} from "opencode-drive/frame" |
| 20 | + |
| 21 | +const CardWidth = 1200 |
| 22 | +const CardHeight = 630 |
| 23 | +const Pad = 56 |
| 24 | + |
| 25 | +const FontStack = `"OpenCode Mono", "OpenCode Symbols", "OpenCode Symbols 2", "OpenCode Math"` |
| 26 | + |
| 27 | +const driveRoot = new URL("../../", Bun.pathToFileURL(Bun.resolveSync("opencode-drive/frame", import.meta.dir)).href) |
| 28 | +for (const [file, family] of [ |
| 29 | + ["commit-mono/CommitMono-400-Regular.otf", "OpenCode Mono"], |
| 30 | + ["commit-mono/CommitMono-700-Regular.otf", "OpenCode Mono"], |
| 31 | + ["commit-mono/CommitMono-400-Italic.otf", "OpenCode Mono"], |
| 32 | + ["commit-mono/CommitMono-700-Italic.otf", "OpenCode Mono"], |
| 33 | + ["noto/NotoSansSymbols.ttf", "OpenCode Symbols"], |
| 34 | + ["noto/NotoSansSymbols2-Regular.ttf", "OpenCode Symbols 2"], |
| 35 | + ["noto/NotoSansMath-Regular.ttf", "OpenCode Math"], |
| 36 | +] as const) { |
| 37 | + const path = fileURLToPath(new URL(`assets/fonts/${file}`, driveRoot)) |
| 38 | + if (!GlobalFonts.registerFromPath(path, family)) throw new Error(`Failed to register OG font: ${path}`) |
| 39 | +} |
| 40 | + |
| 41 | +interface FrameSpan { |
| 42 | + readonly text: string |
| 43 | + readonly fg: readonly [number, number, number, number] |
| 44 | + readonly bg: readonly [number, number, number, number] |
| 45 | + readonly attributes: number |
| 46 | + readonly width: number |
| 47 | +} |
| 48 | + |
| 49 | +interface FrameArtifact { |
| 50 | + readonly cols: number |
| 51 | + readonly rows: number |
| 52 | + readonly lines: ReadonlyArray<{ readonly spans: ReadonlyArray<FrameSpan> }> |
| 53 | +} |
| 54 | + |
| 55 | +function color([red, green, blue, alpha]: FrameSpan["fg"], opacity = 1) { |
| 56 | + return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})` |
| 57 | +} |
| 58 | + |
| 59 | +/** Mirrors the browser canvas renderer in src/components/TerminalFrame.tsx. */ |
| 60 | +function renderFrame(frame: FrameArtifact) { |
| 61 | + const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight) |
| 62 | + const context = canvas.getContext("2d") |
| 63 | + context.fillStyle = "#080808" |
| 64 | + context.fillRect(0, 0, canvas.width, canvas.height) |
| 65 | + context.textBaseline = "alphabetic" |
| 66 | + context.textAlign = "center" |
| 67 | + |
| 68 | + frame.lines.forEach((line, row) => { |
| 69 | + let column = 0 |
| 70 | + line.spans.forEach((span) => { |
| 71 | + const attributes = span.attributes & 0xff |
| 72 | + const inverse = Boolean(attributes & TextStyle.inverse) |
| 73 | + const hidden = Boolean(attributes & TextStyle.invisible) |
| 74 | + const foreground = inverse ? span.bg : span.fg |
| 75 | + const background = inverse ? span.fg : span.bg |
| 76 | + const chars = [...span.text] |
| 77 | + let remaining = span.width |
| 78 | + |
| 79 | + chars.forEach((char, index) => { |
| 80 | + const cells = Math.max(1, remaining - (chars.length - index - 1)) |
| 81 | + const x = column * CellWidth |
| 82 | + const y = row * CellHeight |
| 83 | + if (background[3]) { |
| 84 | + context.fillStyle = color(background) |
| 85 | + context.fillRect(x, y, cells * CellWidth, CellHeight) |
| 86 | + } |
| 87 | + if (!hidden && char.codePointAt(0) !== 0x0a00) { |
| 88 | + context.fillStyle = color(foreground, attributes & TextStyle.dim ? DimAlpha : 1) |
| 89 | + if (!drawBlockGlyph(context, char, x, y, cells)) { |
| 90 | + const font = `${attributes & TextStyle.italic ? "italic " : ""}${attributes & TextStyle.bold ? "700 " : "400 "}${FontSize}px ${FontStack}` |
| 91 | + context.font = font |
| 92 | + context.fillText(char, x + (cells * CellWidth) / 2, y + baselineOffset(context, font), cells * CellWidth) |
| 93 | + } |
| 94 | + if (attributes & TextStyle.underline) context.fillRect(x, y + UnderlineOffset, cells * CellWidth, 1) |
| 95 | + if (attributes & TextStyle.strikethrough) context.fillRect(x, y + StrikethroughOffset, cells * CellWidth, 1) |
| 96 | + } |
| 97 | + column += cells |
| 98 | + remaining -= cells |
| 99 | + }) |
| 100 | + while (remaining-- > 0) { |
| 101 | + if (background[3]) { |
| 102 | + context.fillStyle = color(background) |
| 103 | + context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight) |
| 104 | + } |
| 105 | + column++ |
| 106 | + } |
| 107 | + }) |
| 108 | + }) |
| 109 | + return canvas |
| 110 | +} |
| 111 | + |
| 112 | +function drawChrome(context: SKRSContext2D, kicker: string, title: string, chip: string) { |
| 113 | + context.fillStyle = "#0a0a0a" |
| 114 | + context.fillRect(0, 0, CardWidth, CardHeight) |
| 115 | + |
| 116 | + context.textAlign = "left" |
| 117 | + context.textBaseline = "alphabetic" |
| 118 | + context.font = `700 20px ${FontStack}` |
| 119 | + context.fillStyle = "#6f6e69" |
| 120 | + context.fillText(kicker.toUpperCase(), Pad, Pad + 16) |
| 121 | + |
| 122 | + context.font = `700 46px ${FontStack}` |
| 123 | + context.fillStyle = "#ededed" |
| 124 | + context.fillText(title, Pad - 2, Pad + 74, CardWidth - Pad * 2 - 220) |
| 125 | + |
| 126 | + if (chip !== "") { |
| 127 | + context.font = `400 20px ${FontStack}` |
| 128 | + const width = context.measureText(chip).width + 36 |
| 129 | + const x = CardWidth - Pad - width |
| 130 | + context.strokeStyle = "#2c2c2c" |
| 131 | + context.lineWidth = 1 |
| 132 | + context.beginPath() |
| 133 | + context.roundRect(x, Pad + 38, width, 40, 20) |
| 134 | + context.stroke() |
| 135 | + context.fillStyle = "#9c9b96" |
| 136 | + context.fillText(chip, x + 18, Pad + 65) |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +/** Frame peeks from the bottom edge like a window, cropped by the card. */ |
| 141 | +function drawFramePeek(context: SKRSContext2D, frame: Canvas) { |
| 142 | + const top = 182 |
| 143 | + const width = CardWidth - Pad * 2 |
| 144 | + const scale = width / frame.width |
| 145 | + const visible = CardHeight - top |
| 146 | + const radius = 10 |
| 147 | + |
| 148 | + context.save() |
| 149 | + context.beginPath() |
| 150 | + context.roundRect(Pad, top, width, visible + radius, radius) |
| 151 | + context.clip() |
| 152 | + context.drawImage(frame, 0, 0, frame.width, Math.min(frame.height, (visible + radius) / scale), Pad, top, width, Math.min(frame.height * scale, visible + radius)) |
| 153 | + context.restore() |
| 154 | + |
| 155 | + context.strokeStyle = "#2c2c2c" |
| 156 | + context.lineWidth = 2 |
| 157 | + context.beginPath() |
| 158 | + context.roundRect(Pad + 1, top + 1, width - 2, visible + radius, radius) |
| 159 | + context.stroke() |
| 160 | +} |
| 161 | + |
| 162 | +const root = new URL("../", import.meta.url) |
| 163 | +const outDir = new URL("dist/og/", root) |
| 164 | +const catalog = await Bun.file(new URL("dist/catalog.json", root)).json() |
| 165 | + |
| 166 | +const variants = catalog.variants as ReadonlyArray<{ id: string; label: string }> |
| 167 | +const screens = catalog.screens as ReadonlyArray<{ |
| 168 | + id: string |
| 169 | + title: string |
| 170 | + frames: ReadonlyArray<{ variantId: string; src: string }> |
| 171 | +}> |
| 172 | + |
| 173 | +let generated = 0 |
| 174 | +for (const screen of screens) { |
| 175 | + for (const frame of screen.frames) { |
| 176 | + const variant = variants.find((candidate) => candidate.id === frame.variantId) |
| 177 | + if (!variant) continue |
| 178 | + const artifact = (await Bun.file(new URL(frame.src, new URL("dist/", root))).json()) as FrameArtifact |
| 179 | + const card = createCanvas(CardWidth, CardHeight) |
| 180 | + const context = card.getContext("2d") |
| 181 | + drawChrome(context, "OpenCode · Terminal Catalog", screen.title, variant.label) |
| 182 | + drawFramePeek(context, renderFrame(artifact)) |
| 183 | + await Bun.write(new URL(`${screen.id}--${variant.id}.png`, outDir), card.toBuffer("image/png")) |
| 184 | + generated++ |
| 185 | + } |
| 186 | +} |
| 187 | + |
| 188 | +const home = screens.find((screen) => screen.id === "home") |
| 189 | +const homeFrame = home?.frames.find((frame) => frame.variantId === variants[0]?.id) ?? home?.frames[0] |
| 190 | +const card = createCanvas(CardWidth, CardHeight) |
| 191 | +const context = card.getContext("2d") |
| 192 | +drawChrome(context, "OpenCode", "Terminal Catalog", `${screens.length} screens · ${variants.length} themes`) |
| 193 | +if (homeFrame) { |
| 194 | + const artifact = (await Bun.file(new URL(homeFrame.src, new URL("dist/", root))).json()) as FrameArtifact |
| 195 | + drawFramePeek(context, renderFrame(artifact)) |
| 196 | +} |
| 197 | +await Bun.write(new URL("default.png", outDir), card.toBuffer("image/png")) |
| 198 | + |
| 199 | +console.log(`generated ${generated + 1} Open Graph cards in dist/og`) |
0 commit comments