Files
openclaw-control-center/scripts/export-staff-avatars.ts
T

404 lines
12 KiB
TypeScript

import { promises as fs } from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { deflateSync } from "node:zlib";
type Sprite = string[][];
type SpriteRuntime = {
spriteSize: number;
spriteFactory: Record<string, () => Sprite>;
spriteVisualSpanCompensation: Record<string, number>;
};
type StaffAvatarExport = {
agentId: string;
animal: string;
label: string;
labelZh: string;
};
type SpriteBounds = {
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number;
height: number;
span: number;
};
export const STAFF_AVATARS: StaffAvatarExport[] = [
{ agentId: "codex", animal: "robot", label: "Codex", labelZh: "机器人" },
{ agentId: "coq", animal: "rooster", label: "Coq", labelZh: "公鸡" },
{ agentId: "dolphin", animal: "dolphin", label: "Dolphin", labelZh: "海豚" },
{ agentId: "main", animal: "lion", label: "Main", labelZh: "狮子" },
{ agentId: "monkey", animal: "monkey", label: "Monkey", labelZh: "猴子" },
{ agentId: "otter", animal: "otter", label: "Otter", labelZh: "水獭" },
{ agentId: "pandas", animal: "panda", label: "Pandas", labelZh: "熊猫" },
{ agentId: "tiger", animal: "tiger", label: "Tiger", labelZh: "老虎" },
];
export function extractSpriteRuntime(source: string): SpriteRuntime {
const start = source.indexOf("const spriteSize = 44;");
const end = source.indexOf("const motionActors = [];", start);
if (start < 0 || end < 0 || end <= start) {
throw new Error("Unable to locate embedded staff avatar sprite runtime in src/ui/server.ts");
}
const block = source.slice(start, end);
const runtime = vm.runInNewContext(
`
(() => {
${block}
return { spriteSize, spriteFactory, spriteVisualSpanCompensation };
})()
`,
{ Math },
) as SpriteRuntime;
if (!runtime || typeof runtime.spriteSize !== "number" || !runtime.spriteFactory || !runtime.spriteVisualSpanCompensation) {
throw new Error("Failed to evaluate embedded avatar sprite runtime");
}
return runtime;
}
export async function loadSpriteRuntime(serverPath: string): Promise<SpriteRuntime> {
const source = await fs.readFile(serverPath, "utf8");
return extractSpriteRuntime(source);
}
export function getSpriteBounds(sprite: Sprite): SpriteBounds {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (let y = 0; y < sprite.length; y += 1) {
for (let x = 0; x < sprite[y].length; x += 1) {
if (!sprite[y][x]) continue;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 1, height: 1, span: 1 };
}
const width = maxX - minX + 1;
const height = maxY - minY + 1;
return { minX, minY, maxX, maxY, width, height, span: Math.max(width, height) };
}
export function centerSprite(sprite: Sprite, padding = 2): Sprite {
const bounds = getSpriteBounds(sprite);
const size = bounds.span + padding * 2;
const centered = Array.from({ length: size }, () => Array(size).fill(""));
const offsetX = Math.floor((size - bounds.width) / 2);
const offsetY = Math.floor((size - bounds.height) / 2);
for (let y = bounds.minY; y <= bounds.maxY; y += 1) {
for (let x = bounds.minX; x <= bounds.maxX; x += 1) {
const color = sprite[y]?.[x];
if (!color) continue;
centered[offsetY + (y - bounds.minY)][offsetX + (x - bounds.minX)] = color;
}
}
return centered;
}
export function renderSpriteSvg(sprite: Sprite, pixelScale = 8): string {
const centered = centerSprite(sprite);
const size = centered.length;
const width = size * pixelScale;
const rects: string[] = [];
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const color = centered[y]?.[x];
if (!color) continue;
rects.push(
`<rect x="${x * pixelScale}" y="${y * pixelScale}" width="${pixelScale}" height="${pixelScale}" fill="${color}" />`,
);
}
}
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${width}" viewBox="0 0 ${width} ${width}" shape-rendering="crispEdges">`,
`<rect width="${width}" height="${width}" fill="transparent" />`,
...rects,
`</svg>`,
].join("\n");
}
export function renderSpriteRgba(sprite: Sprite, pixelScale = 8): { width: number; height: number; data: Buffer } {
const centered = centerSprite(sprite);
const size = centered.length;
const width = size * pixelScale;
const height = size * pixelScale;
const data = Buffer.alloc(width * height * 4);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const color = centered[y]?.[x];
for (let py = 0; py < pixelScale; py += 1) {
for (let px = 0; px < pixelScale; px += 1) {
const idx = ((y * pixelScale + py) * width + (x * pixelScale + px)) * 4;
if (!color) {
data[idx + 0] = 0;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = 0;
continue;
}
const rgb = hexToRgb(color);
data[idx + 0] = rgb.r;
data[idx + 1] = rgb.g;
data[idx + 2] = rgb.b;
data[idx + 3] = 255;
}
}
}
}
return { width, height, data };
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const safe = hex.trim().replace(/^#/, "");
if (safe.length !== 6) {
throw new Error(`Unsupported color "${hex}"`);
}
return {
r: Number.parseInt(safe.slice(0, 2), 16),
g: Number.parseInt(safe.slice(2, 4), 16),
b: Number.parseInt(safe.slice(4, 6), 16),
};
}
const crcTable = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let k = 0; k < 8; k += 1) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c >>> 0;
}
return table;
})();
function crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buffer.length; i += 1) {
crc = crcTable[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer): Buffer {
const typeBuffer = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const crcBuffer = Buffer.concat([typeBuffer, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(crcBuffer), 0);
return Buffer.concat([length, typeBuffer, data, crc]);
}
export function renderSpritePng(sprite: Sprite, pixelScale = 8): Buffer {
const rgba = renderSpriteRgba(sprite, pixelScale);
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(rgba.width, 0);
ihdr.writeUInt32BE(rgba.height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const stride = rgba.width * 4;
const raw = Buffer.alloc((stride + 1) * rgba.height);
for (let y = 0; y < rgba.height; y += 1) {
const rowStart = y * (stride + 1);
raw[rowStart] = 0;
rgba.data.copy(raw, rowStart + 1, y * stride, (y + 1) * stride);
}
const idat = deflateSync(raw, { level: 9 });
return Buffer.concat([
signature,
pngChunk("IHDR", ihdr),
pngChunk("IDAT", idat),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
export function renderPreviewHtml(items: StaffAvatarExport[]): string {
const cards = items
.map(
(item) => `<article class="card">
<img src="./${item.agentId}.png" alt="${item.label}" width="176" height="176" />
<div class="meta">
<h2>${item.label}</h2>
<p>${item.labelZh}</p>
<code>${item.agentId}</code>
</div>
</article>`,
)
.join("\n");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Staff Avatar Exports</title>
<style>
:root {
color-scheme: light;
--bg: #f5f5f7;
--panel: #ffffff;
--border: rgba(17, 24, 39, 0.08);
--text: #1d1d1f;
--muted: #6e6e73;
--shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
font-family: "SF Pro Display", "SF Pro Text", "Helvetica Neue", sans-serif;
}
body {
margin: 0;
background: linear-gradient(180deg, #fbfbfd 0%, var(--bg) 100%);
color: var(--text);
padding: 40px;
}
h1 {
margin: 0 0 12px;
font-size: 34px;
line-height: 1.05;
}
p.lead {
margin: 0 0 28px;
color: var(--muted);
font-size: 17px;
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
}
.card {
background: rgba(255,255,255,0.88);
border: 1px solid var(--border);
border-radius: 24px;
padding: 18px;
box-shadow: var(--shadow);
backdrop-filter: blur(20px);
}
img {
display: block;
width: 176px;
height: 176px;
margin: 0 auto 12px;
image-rendering: pixelated;
}
h2 {
margin: 0;
font-size: 22px;
}
.meta p,
.meta code {
margin: 6px 0 0;
color: var(--muted);
font-size: 14px;
display: block;
}
</style>
</head>
<body>
<h1>Staff Avatar Exports</h1>
<p class="lead">Exported from the current pixel avatar definitions embedded in <code>src/ui/server.ts</code>.</p>
<section class="grid">
${cards}
</section>
</body>
</html>`;
}
export async function exportStaffAvatars(outputDir: string, serverPath: string): Promise<string[]> {
const runtime = await loadSpriteRuntime(serverPath);
await fs.mkdir(outputDir, { recursive: true });
const written: string[] = [];
for (const item of STAFF_AVATARS) {
const renderer = runtime.spriteFactory[item.animal];
if (!renderer) {
throw new Error(`Missing renderer for animal "${item.animal}"`);
}
const sprite = renderer();
const svg = renderSpriteSvg(sprite);
const svgPath = path.join(outputDir, `${item.agentId}.svg`);
await fs.writeFile(svgPath, svg, "utf8");
written.push(svgPath);
const png = renderSpritePng(sprite);
const pngPath = path.join(outputDir, `${item.agentId}.png`);
await fs.writeFile(pngPath, png);
written.push(pngPath);
}
const manifestPath = path.join(outputDir, "manifest.json");
await fs.writeFile(
manifestPath,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
source: "src/ui/server.ts",
count: STAFF_AVATARS.length,
avatars: STAFF_AVATARS.map((item) => ({
agentId: item.agentId,
animal: item.animal,
label: item.label,
labelZh: item.labelZh,
svg: `${item.agentId}.svg`,
png: `${item.agentId}.png`,
})),
},
null,
2,
),
"utf8",
);
written.push(manifestPath);
const previewPath = path.join(outputDir, "index.html");
await fs.writeFile(previewPath, renderPreviewHtml(STAFF_AVATARS), "utf8");
written.push(previewPath);
return written;
}
async function main(): Promise<void> {
const repoRoot = process.cwd();
const serverPath = path.join(repoRoot, "src/ui/server.ts");
const outputDir = path.join(repoRoot, "runtime/exports/staff-avatars");
const written = await exportStaffAvatars(outputDir, serverPath);
process.stdout.write(`Exported ${written.length} staff avatar files to ${outputDir}\n`);
}
if (require.main === module) {
main().catch((error: unknown) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}