feat(proxy): add image tracing endpoint
Add GET /trace which fetches an image, traces it into a vector shape, and returns the result BSON-encoded. Tracing is configurable via mode (alpha, bw, color) and format (shape, svg). The shape format parses the vtracer SVG into an outline/holes polygon matching @tts/mesh's Shape interface.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serialize } from 'bson';
|
||||
import sharp from 'sharp';
|
||||
import { createRequire } from 'module';
|
||||
import { traceRequestSchema, type TraceResult } from '@tts/shared';
|
||||
import { parseSvgShape } from './svgShape.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, unknown>,
|
||||
): 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 } = parsed.data;
|
||||
|
||||
// Only allow http(s) to avoid SSRF via file://, etc.
|
||||
const parsedUrl = new URL(url);
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
|
||||
}
|
||||
|
||||
let image: Buffer;
|
||||
try {
|
||||
const res = await fetch(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<string, unknown> = {};
|
||||
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') {
|
||||
result.shape = parseSvgShape(svg);
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user