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