import { Hono } from 'hono'; import { serialize } from 'bson'; import sharp from 'sharp'; import { createRequire } from 'module'; import { traceRequestSchema, type TraceResult } from '@tts/shared'; import { offsetShape, parseSvgShape } from './svgShape.js'; import { resolveAsset } from './resolveAsset.js'; import { GAMES_ROOT } from '../config.js'; // vtracer is a CommonJS package; load it via require so the wasm initializes // with the correct `__dirname`. const require = createRequire(import.meta.url); const vtracer = require('@visioncortex/vtracer') as { convertPixels( rgba: Uint8Array, width: number, height: number, options: Record, ): string; }; const app = new Hono(); /** * Trace an image into a vector shape and return it BSON-encoded. * * `mode` controls how the traced region is derived: * - `alpha` (default): the alpha channel (opaque -> shape) * - `bw`: luminance thresholding * - `color`: vtracer's native color clustering * * `format` selects the response shape: `shape` (default) returns a parsed * `{ outline, holes }` polygon ready for `@tts/mesh`; `svg` returns the raw * SVG string. */ app.get('/', async (c) => { const parsed = traceRequestSchema.safeParse(c.req.query()); if (!parsed.success) { return c.json({ error: parsed.error.issues[0]?.message }, 400); } const { url, mode, threshold, format, simplify, maxColors, offset } = parsed.data; // Resolve the image: a relative path is a local game asset under GAMES_ROOT; // otherwise it must be an http(s) URL. const resolved = await resolveAsset(url, GAMES_ROOT); if (!resolved.ok) { return c.json({ error: 'Invalid or missing image url' }, 400); } const asset = resolved.asset; let image: Buffer; try { if (asset.stream) { const chunks: Buffer[] = []; for await (const chunk of asset.stream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } image = Buffer.concat(chunks); } else { const res = await fetch(asset.url!); if (!res.ok) { return c.json({ error: `Image responded ${res.status}` }, 502); } image = Buffer.from(await res.arrayBuffer()); } } catch (err) { return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502); } let svg: string; let width: number; let height: number; try { const { data, info } = await sharp(image) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); width = info.width; height = info.height; const pixels = buildPixels(data, width, height, mode, threshold); const options: Record = {}; if (mode === 'color') { options.clustering = 'color-cluster'; if (maxColors !== undefined) options.maxColors = maxColors; } else { options.clustering = 'bw'; options.binaryThreshold = threshold; } if (simplify !== undefined) options.simplify = simplify; svg = vtracer.convertPixels( new Uint8Array(pixels), width, height, options, ); } catch (err) { return c.json({ error: `Failed to trace image: ${String(err)}` }, 500); } const result: TraceResult = { width, height, mode, format, svg, }; if (format === 'shape') { let shape = parseSvgShape(svg); if (offset !== undefined && offset !== 0) { shape = offsetShape(shape, offset); result.offset = offset; } result.shape = shape; } return new Response(Buffer.from(serialize(result)), { headers: { 'Content-Type': 'application/octet-stream' }, }); }); /** * Build the RGBA buffer fed to vtracer for the given mode. * `alpha` and `bw` produce a binary mask (black foreground, white background) * so vtracer's `bw` clustering traces the desired region. */ function buildPixels( rgba: Buffer, width: number, height: number, mode: 'alpha' | 'bw' | 'color', threshold: number, ): Buffer { if (mode === 'color') return rgba; const out = Buffer.alloc(width * height * 4); for (let i = 0; i < width * height; i++) { const r = rgba[i * 4]!; const g = rgba[i * 4 + 1]!; const b = rgba[i * 4 + 2]!; const a = rgba[i * 4 + 3]!; const isForeground = mode === 'alpha' ? a > threshold : 0.299 * r + 0.587 * g + 0.114 * b < threshold; // vtracer treats intensity below `binaryThreshold` as foreground, so the // foreground region is black and the background is white. const v = isForeground ? 0 : 255; out[i * 4] = v; out[i * 4 + 1] = v; out[i * 4 + 2] = v; out[i * 4 + 3] = 255; } return out; } export default app;