| 1 | import { readFileSync } from 'node:fs';
|
| 2 | import { createRequire } from 'node:module';
|
| 3 | import { dirname, join } from 'node:path';
|
| 4 | import { createElement, type CSSProperties, type ReactNode } from 'react';
|
| 5 | import satori from 'satori';
|
| 6 | import sharp from 'sharp';
|
| 7 | import type { PublicAgentCard } from '../../../packages/protocol/src/index.js';
|
| 8 |
|
| 9 | const shorten = (value: string, length: number) =>
|
| 10 | value.length > length ? `${value.slice(0, length - 1).trimEnd()}…` : value;
|
| 11 |
|
| 12 | interface CapabilityChip {
|
| 13 | label: string;
|
| 14 | width: number;
|
| 15 | x: number;
|
| 16 | }
|
| 17 |
|
| 18 | function capabilityChips(capabilities: string[]): CapabilityChip[] {
|
| 19 | let x = 64;
|
| 20 | return capabilities.slice(0, 4).map((capability) => {
|
| 21 | const label = shorten(capability, 25);
|
| 22 | const width = Math.min(250, Math.max(96, label.length * 10 + 32));
|
| 23 | const chip = { label, width, x };
|
| 24 | x += width + 12;
|
| 25 | return chip;
|
| 26 | });
|
| 27 | }
|
| 28 |
|
| 29 | function descriptionLines(value: string) {
|
| 30 | const words = value.trim().split(/\s+/);
|
| 31 | const lines: string[] = [];
|
| 32 | for (const word of words) {
|
| 33 | const current = lines.at(-1);
|
| 34 | if (!current || (current.length + word.length + 1 > 66 && lines.length < 2)) lines.push(word);
|
| 35 | else lines[lines.length - 1] = `${current} ${word}`;
|
| 36 | }
|
| 37 | if (lines.length > 2) lines.length = 2;
|
| 38 | if (lines[1] && lines[1].length > 66) lines[1] = shorten(lines[1], 66);
|
| 39 | return lines;
|
| 40 | }
|
| 41 |
|
| 42 | const absolute = (style: CSSProperties): CSSProperties => ({
|
| 43 | position: 'absolute',
|
| 44 | display: 'flex',
|
| 45 | ...style,
|
| 46 | });
|
| 47 |
|
| 48 | let geistFonts: Array<{
|
| 49 | name: string;
|
| 50 | data: Buffer;
|
| 51 | weight: 400 | 700;
|
| 52 | style: 'normal';
|
| 53 | }> | null = null;
|
| 54 |
|
| 55 | function loadGeistFonts() {
|
| 56 | if (geistFonts) return geistFonts;
|
| 57 | const require = createRequire(import.meta.url);
|
| 58 | const fontDirectory = join(dirname(require.resolve('geist/font/sans')), 'fonts/geist-sans');
|
| 59 | geistFonts = [
|
| 60 | {
|
| 61 | name: 'Geist',
|
| 62 | data: readFileSync(join(fontDirectory, 'Geist-Regular.ttf')),
|
| 63 | weight: 400,
|
| 64 | style: 'normal',
|
| 65 | },
|
| 66 | {
|
| 67 | name: 'Geist',
|
| 68 | data: readFileSync(join(fontDirectory, 'Geist-Bold.ttf')),
|
| 69 | weight: 700,
|
| 70 | style: 'normal',
|
| 71 | },
|
| 72 | ];
|
| 73 | return geistFonts;
|
| 74 | }
|
| 75 |
|
| 76 | async function renderTextLayer({
|
| 77 | card,
|
| 78 | chips,
|
| 79 | description,
|
| 80 | initials,
|
| 81 | name,
|
| 82 | nameSize,
|
| 83 | publicReference,
|
| 84 | }: {
|
| 85 | card: PublicAgentCard;
|
| 86 | chips: CapabilityChip[];
|
| 87 | description: string[];
|
| 88 | initials: string;
|
| 89 | name: string;
|
| 90 | nameSize: number;
|
| 91 | publicReference: string;
|
| 92 | }) {
|
| 93 | const children: ReactNode[] = [
|
| 94 | createElement(
|
| 95 | 'div',
|
| 96 | {
|
| 97 | key: 'brand',
|
| 98 | style: absolute({
|
| 99 | left: 108,
|
| 100 | top: 58,
|
| 101 | color: '#f04b2d',
|
| 102 | fontSize: 27,
|
| 103 | fontWeight: 700,
|
| 104 | letterSpacing: -1,
|
| 105 | lineHeight: 1,
|
| 106 | }),
|
| 107 | },
|
| 108 | 'OpenClasp',
|
| 109 | ),
|
| 110 | createElement(
|
| 111 | 'div',
|
| 112 | {
|
| 113 | key: 'verified',
|
| 114 | style: absolute({
|
| 115 | left: 978,
|
| 116 | top: 69,
|
| 117 | color: '#44d37e',
|
| 118 | fontSize: 16,
|
| 119 | lineHeight: 1,
|
| 120 | }),
|
| 121 | },
|
| 122 | 'Publisher verified',
|
| 123 | ),
|
| 124 | createElement(
|
| 125 | 'div',
|
| 126 | {
|
| 127 | key: 'initials',
|
| 128 | style: absolute({
|
| 129 | left: 64,
|
| 130 | top: 165,
|
| 131 | width: 122,
|
| 132 | height: 122,
|
| 133 | alignItems: 'center',
|
| 134 | justifyContent: 'center',
|
| 135 | color: '#ff8068',
|
| 136 | fontSize: 42,
|
| 137 | fontWeight: 700,
|
| 138 | }),
|
| 139 | },
|
| 140 | initials || 'AI',
|
| 141 | ),
|
| 142 | createElement(
|
| 143 | 'div',
|
| 144 | {
|
| 145 | key: 'framework',
|
| 146 | style: absolute({
|
| 147 | left: 220,
|
| 148 | top: 160,
|
| 149 | color: '#a99e99',
|
| 150 | fontSize: 18,
|
| 151 | letterSpacing: 2,
|
| 152 | lineHeight: 1,
|
| 153 | }),
|
| 154 | },
|
| 155 | `${shorten(card.framework, 44).toUpperCase()} · AGENT CARD`,
|
| 156 | ),
|
| 157 | createElement(
|
| 158 | 'div',
|
| 159 | {
|
| 160 | key: 'name',
|
| 161 | style: absolute({
|
| 162 | left: 220,
|
| 163 | top: 196,
|
| 164 | color: '#f7f2ef',
|
| 165 | fontSize: nameSize,
|
| 166 | fontWeight: 700,
|
| 167 | letterSpacing: -2,
|
| 168 | lineHeight: 1,
|
| 169 | }),
|
| 170 | },
|
| 171 | name,
|
| 172 | ),
|
| 173 | ...description.map((line, index) =>
|
| 174 | createElement(
|
| 175 | 'div',
|
| 176 | {
|
| 177 | key: `description-${index}`,
|
| 178 | style: absolute({
|
| 179 | left: 220,
|
| 180 | top: 287 + index * 34,
|
| 181 | color: '#c7bdb8',
|
| 182 | fontSize: 24,
|
| 183 | lineHeight: 1,
|
| 184 | }),
|
| 185 | },
|
| 186 | line,
|
| 187 | ),
|
| 188 | ),
|
| 189 | ...chips.map((chip) =>
|
| 190 | createElement(
|
| 191 | 'div',
|
| 192 | {
|
| 193 | key: `chip-${chip.x}`,
|
| 194 | style: absolute({
|
| 195 | left: chip.x + 16,
|
| 196 | top: 442,
|
| 197 | color: '#ddd4d0',
|
| 198 | fontSize: 17,
|
| 199 | lineHeight: 1,
|
| 200 | }),
|
| 201 | },
|
| 202 | chip.label,
|
| 203 | ),
|
| 204 | ),
|
| 205 | createElement(
|
| 206 | 'div',
|
| 207 | {
|
| 208 | key: 'reference',
|
| 209 | style: absolute({
|
| 210 | left: 64,
|
| 211 | top: 568,
|
| 212 | color: '#8f8580',
|
| 213 | fontSize: 16,
|
| 214 | lineHeight: 1,
|
| 215 | }),
|
| 216 | },
|
| 217 | publicReference,
|
| 218 | ),
|
| 219 | createElement(
|
| 220 | 'div',
|
| 221 | {
|
| 222 | key: 'disclaimer',
|
| 223 | style: absolute({
|
| 224 | right: 64,
|
| 225 | top: 568,
|
| 226 | color: '#8f8580',
|
| 227 | fontSize: 16,
|
| 228 | lineHeight: 1,
|
| 229 | }),
|
| 230 | },
|
| 231 | 'Capabilities are self-declared',
|
| 232 | ),
|
| 233 | ];
|
| 234 |
|
| 235 | return satori(
|
| 236 | createElement(
|
| 237 | 'div',
|
| 238 | {
|
| 239 | style: {
|
| 240 | position: 'relative',
|
| 241 | display: 'flex',
|
| 242 | width: 1200,
|
| 243 | height: 630,
|
| 244 | fontFamily: 'Geist',
|
| 245 | },
|
| 246 | },
|
| 247 | ...children,
|
| 248 | ),
|
| 249 | { width: 1200, height: 630, fonts: loadGeistFonts() },
|
| 250 | );
|
| 251 | }
|
| 252 |
|
| 253 | export async function renderAgentCardImage(card: PublicAgentCard) {
|
| 254 | const initials = card.name
|
| 255 | .split(/\s+/)
|
| 256 | .filter(Boolean)
|
| 257 | .slice(0, 2)
|
| 258 | .map((part) => part[0]?.toUpperCase())
|
| 259 | .join('');
|
| 260 | const publicReference = shorten(card.profileUrl ?? card.cardUrl, 88);
|
| 261 | const name = shorten(card.name, 42);
|
| 262 | const nameSize = name.length > 30 ? 50 : 62;
|
| 263 | const description = descriptionLines(card.description || 'Public agent identity on OpenClasp');
|
| 264 | const chips = capabilityChips(card.capabilities);
|
| 265 | const textLayer = await renderTextLayer({
|
| 266 | card,
|
| 267 | chips,
|
| 268 | description,
|
| 269 | initials,
|
| 270 | name,
|
| 271 | nameSize,
|
| 272 | publicReference,
|
| 273 | });
|
| 274 | const textContent = textLayer.slice(textLayer.indexOf('>') + 1, textLayer.lastIndexOf('</svg>'));
|
| 275 | const chipBackgrounds = chips
|
| 276 | .map(
|
| 277 | ({ width, x }) =>
|
| 278 | `<rect x="${x}" y="430" width="${width}" height="42" rx="8" fill="#ffffff0b" stroke="#ffffff28"/>`,
|
| 279 | )
|
| 280 | .join('');
|
| 281 | const svg = `<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
|
| 282 | <defs>
|
| 283 | <linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
| 284 | <stop offset="0" stop-color="#0c0a0a"/>
|
| 285 | <stop offset="0.62" stop-color="#17100e"/>
|
| 286 | <stop offset="1" stop-color="#2a120d"/>
|
| 287 | </linearGradient>
|
| 288 | <radialGradient id="glow" cx="0" cy="0" r="1" gradientTransform="translate(1030 120) rotate(140) scale(430 340)">
|
| 289 | <stop stop-color="#f04b2d" stop-opacity="0.2"/>
|
| 290 | <stop offset="1" stop-color="#f04b2d" stop-opacity="0"/>
|
| 291 | </radialGradient>
|
| 292 | </defs>
|
| 293 | <rect width="1200" height="630" fill="url(#background)"/>
|
| 294 | <rect width="1200" height="630" fill="url(#glow)"/>
|
| 295 | <path d="M64 66h18l10 10-10 10H64l-10-10 10-10Z" fill="none" stroke="#f04b2d" stroke-width="4"/>
|
| 296 | <rect x="936" y="58" width="200" height="42" rx="21" fill="#44d37e0d" stroke="#44d37e88"/>
|
| 297 | <path d="M954 79l5 5 9-11" fill="none" stroke="#44d37e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
| 298 | <rect x="64" y="165" width="122" height="122" rx="28" fill="#f04b2d1a" stroke="#f04b2d80"/>
|
| 299 | ${chipBackgrounds}
|
| 300 | <line x1="64" y1="540" x2="1136" y2="540" stroke="#ffffff21"/>
|
| 301 | ${textContent}
|
| 302 | </svg>`;
|
| 303 |
|
| 304 | return sharp(Buffer.from(svg)).png().toBuffer();
|
| 305 | }
|