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.
149 lines
4.2 KiB
TypeScript
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; |