Files
tts-workshop/apps/proxy/src/routes/trace.ts
T
hypercross 9094ad58e0 feat(proxy): add offset param to inset or outset traced shapes
Trace results can now be inset (negative) or outset (positive) by a
pixel amount. Offset the outline and holes in opposite directions with
clipper-lib's miter joins, then recombine with a boolean difference so
holes grow on inset and shrink on outset. Collapsed shapes return an
empty outline; a split outline keeps the largest ring. Document the
parameter and the new dependency.
2026-08-08 15:37:27 +08:00

149 lines
4.2 KiB
TypeScript

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';
// 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, offset } =
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') {
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;