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:
@@ -16,7 +16,11 @@
|
|||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@tts/shared": "workspace:*",
|
"@tts/shared": "workspace:*",
|
||||||
"@tts/tts": "workspace:*",
|
"@tts/tts": "workspace:*",
|
||||||
|
"@visioncortex/vtracer": "1.0.0-alpha.3",
|
||||||
|
"bson": "^6.10.4",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"svgpath": "^2.6.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import asset from './routes/asset.js';
|
|||||||
import health from './routes/health.js';
|
import health from './routes/health.js';
|
||||||
import items from './routes/items.js';
|
import items from './routes/items.js';
|
||||||
import search from './routes/search.js';
|
import search from './routes/search.js';
|
||||||
|
import trace from './routes/trace.js';
|
||||||
|
|
||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ app.route('/health', health);
|
|||||||
app.route('/search', search);
|
app.route('/search', search);
|
||||||
app.route('/items', items);
|
app.route('/items', items);
|
||||||
app.route('/asset', asset);
|
app.route('/asset', asset);
|
||||||
|
app.route('/trace', trace);
|
||||||
|
|
||||||
serve(
|
serve(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseSvgShape } from './svgShape.js';
|
||||||
|
|
||||||
|
describe('parseSvgShape', () => {
|
||||||
|
it('parses a simple closed outline', () => {
|
||||||
|
const svg =
|
||||||
|
'<svg><path d="M0,0L10,0L10,10L0,10Z" fill="#000"/></svg>';
|
||||||
|
const shape = parseSvgShape(svg);
|
||||||
|
expect(shape.outline.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(shape.holes).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns a clockwise subpath as a hole', () => {
|
||||||
|
// Outer ring CCW, inner ring CW (hole).
|
||||||
|
const svg =
|
||||||
|
'<svg><path d="M0,0L20,0L20,20L0,20ZM5,5L5,15L15,15L15,5Z" fill="#000"/></svg>';
|
||||||
|
const shape = parseSvgShape(svg);
|
||||||
|
expect(shape.outline.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(shape.holes).toBeDefined();
|
||||||
|
expect(shape.holes!.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens cubic curves into multiple points', () => {
|
||||||
|
const svg =
|
||||||
|
'<svg><path d="M0,0C10,0 10,10 20,10Z" fill="#000"/></svg>';
|
||||||
|
const shape = parseSvgShape(svg);
|
||||||
|
// Start point + 12 curve samples + closing point, minus duplicate.
|
||||||
|
expect(shape.outline.length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty outline for no paths', () => {
|
||||||
|
const shape = parseSvgShape('<svg></svg>');
|
||||||
|
expect(shape.outline).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import svgpath from 'svgpath';
|
||||||
|
import type { TracedShape } from '@tts/shared';
|
||||||
|
|
||||||
|
/** Number of samples per cubic bezier when flattening curves to polylines. */
|
||||||
|
const CURVE_STEPS = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an SVG produced by vtracer into a `TracedShape` (outline + holes),
|
||||||
|
* matching the `Shape` interface in `@tts/mesh` so the result can be extruded
|
||||||
|
* directly. vtracer emits one `<path>` per traced region; a region with holes
|
||||||
|
* is a single path containing multiple subpaths (one per `M` command).
|
||||||
|
*
|
||||||
|
* Subpaths are classified by winding: counter-clockwise rings become the
|
||||||
|
* outline, clockwise rings become holes. Holes are assigned to the smallest
|
||||||
|
* outline that contains them (point-in-polygon on the first vertex).
|
||||||
|
*/
|
||||||
|
export function parseSvgShape(svg: string): TracedShape {
|
||||||
|
const paths = [...svg.matchAll(/<path\s+d="([^"]*)"/g)].map((m) => m[1]!);
|
||||||
|
|
||||||
|
const rings = paths.flatMap(parseRings);
|
||||||
|
if (rings.length === 0) {
|
||||||
|
return { outline: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const outlines = rings.filter((r) => signedArea(r) > 0);
|
||||||
|
const holes = rings.filter((r) => signedArea(r) <= 0);
|
||||||
|
|
||||||
|
// Assign each hole to the smallest outline that contains its first vertex.
|
||||||
|
const assigned = new Map<number, number[][][]>();
|
||||||
|
for (const hole of holes) {
|
||||||
|
const p = hole[0]! as [number, number];
|
||||||
|
let best: number | null = null;
|
||||||
|
let bestArea = Infinity;
|
||||||
|
for (let i = 0; i < outlines.length; i++) {
|
||||||
|
const area = Math.abs(signedArea(outlines[i]!));
|
||||||
|
if (area < bestArea && pointInPolygon(p, outlines[i]!)) {
|
||||||
|
best = i;
|
||||||
|
bestArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best !== null) {
|
||||||
|
const list = assigned.get(best) ?? [];
|
||||||
|
list.push(hole);
|
||||||
|
assigned.set(best, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The largest outline is the outer boundary; any remaining outlines are
|
||||||
|
// treated as additional (disjoint) regions, which vtracer emits as separate
|
||||||
|
// paths anyway. Keep the first/largest as the primary outline.
|
||||||
|
const primary = outlines.reduce((a, b) =>
|
||||||
|
Math.abs(signedArea(a)) >= Math.abs(signedArea(b)) ? a : b,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outline: primary,
|
||||||
|
holes: assigned.get(outlines.indexOf(primary)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a path's `d` attribute into rings, one per `M` subpath. */
|
||||||
|
function parseRings(d: string): number[][][] {
|
||||||
|
const rings: number[][][] = [];
|
||||||
|
let cur: number[][] | null = null;
|
||||||
|
let pen: [number, number] = [0, 0];
|
||||||
|
let ctrl: [number, number] | null = null;
|
||||||
|
|
||||||
|
svgpath(d)
|
||||||
|
.abs()
|
||||||
|
.iterate((seg) => {
|
||||||
|
const cmd = seg[0];
|
||||||
|
switch (cmd) {
|
||||||
|
case 'M':
|
||||||
|
if (cur && cur.length) rings.push(cur);
|
||||||
|
cur = [[seg[1]!, seg[2]!]];
|
||||||
|
pen = [seg[1]!, seg[2]!];
|
||||||
|
ctrl = null;
|
||||||
|
break;
|
||||||
|
case 'L':
|
||||||
|
cur?.push([seg[1]!, seg[2]!]);
|
||||||
|
pen = [seg[1]!, seg[2]!];
|
||||||
|
ctrl = null;
|
||||||
|
break;
|
||||||
|
case 'C': {
|
||||||
|
const p1: [number, number] = [seg[1]!, seg[2]!];
|
||||||
|
const p2: [number, number] = [seg[3]!, seg[4]!];
|
||||||
|
const p3: [number, number] = [seg[5]!, seg[6]!];
|
||||||
|
flattenCubic(pen, p1, p2, p3, cur!);
|
||||||
|
pen = p3;
|
||||||
|
ctrl = p2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Q': {
|
||||||
|
// Approximate a quadratic bezier with a cubic.
|
||||||
|
const q1: [number, number] = [seg[1]!, seg[2]!];
|
||||||
|
const q2: [number, number] = [seg[3]!, seg[4]!];
|
||||||
|
const p1: [number, number] = [
|
||||||
|
pen[0] + (2 / 3) * (q1[0] - pen[0]),
|
||||||
|
pen[1] + (2 / 3) * (q1[1] - pen[1]),
|
||||||
|
];
|
||||||
|
const p2: [number, number] = [
|
||||||
|
q2[0] + (2 / 3) * (q1[0] - q2[0]),
|
||||||
|
q2[1] + (2 / 3) * (q1[1] - q2[1]),
|
||||||
|
];
|
||||||
|
flattenCubic(pen, p1, p2, q2, cur!);
|
||||||
|
pen = q2;
|
||||||
|
ctrl = q1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Z':
|
||||||
|
case 'z':
|
||||||
|
cur?.push([pen[0], pen[1]]);
|
||||||
|
ctrl = null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cur !== null) rings.push(cur);
|
||||||
|
|
||||||
|
// Drop the duplicate closing vertex (first == last).
|
||||||
|
for (const ring of rings) {
|
||||||
|
const first = ring[0]!;
|
||||||
|
const last = ring[ring.length - 1]!;
|
||||||
|
if (
|
||||||
|
Math.abs(first[0]! - last[0]!) < 1e-6 &&
|
||||||
|
Math.abs(first[1]! - last[1]!) < 1e-6
|
||||||
|
) {
|
||||||
|
ring.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample a cubic bezier into `CURVE_STEPS` points appended to `out`. */
|
||||||
|
function flattenCubic(
|
||||||
|
p0: [number, number],
|
||||||
|
p1: [number, number],
|
||||||
|
p2: [number, number],
|
||||||
|
p3: [number, number],
|
||||||
|
out: number[][],
|
||||||
|
): void {
|
||||||
|
for (let i = 1; i <= CURVE_STEPS; i++) {
|
||||||
|
const t = i / CURVE_STEPS;
|
||||||
|
const mt = 1 - t;
|
||||||
|
const x =
|
||||||
|
mt * mt * mt * p0[0] +
|
||||||
|
3 * mt * mt * t * p1[0] +
|
||||||
|
3 * mt * t * t * p2[0] +
|
||||||
|
t * t * t * p3[0];
|
||||||
|
const y =
|
||||||
|
mt * mt * mt * p0[1] +
|
||||||
|
3 * mt * mt * t * p1[1] +
|
||||||
|
3 * mt * t * t * p2[1] +
|
||||||
|
t * t * t * p3[1];
|
||||||
|
out.push([x, y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed area of a polygon; positive means counter-clockwise. */
|
||||||
|
function signedArea(points: number[][]): number {
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const [x1, y1] = points[i]!;
|
||||||
|
const [x2, y2] = points[(i + 1) % points.length]!;
|
||||||
|
area += x1! * y2! - x2! * y1!;
|
||||||
|
}
|
||||||
|
return area / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ray-casting point-in-polygon test. */
|
||||||
|
function pointInPolygon(p: [number, number], polygon: number[][]): boolean {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const [xi, yi] = polygon[i]!;
|
||||||
|
const [xj, yj] = polygon[j]!;
|
||||||
|
const intersects =
|
||||||
|
yi! > p[1] !== yj! > p[1] &&
|
||||||
|
p[0] < ((xj! - xi!) * (p[1] - yi!)) / (yj! - yi!) + xi!;
|
||||||
|
if (intersects) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { deserialize } from 'bson';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import trace from './trace.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Build a small PNG with an opaque circle in the center on a transparent bg. */
|
||||||
|
async function makePng(size = 20): Promise<Buffer> {
|
||||||
|
const rgba = Buffer.alloc(size * size * 4);
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
const r = size / 3;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
if (Math.hypot(x - cx, y - cy) <= r) {
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
rgba[i] = 255;
|
||||||
|
rgba[i + 1] = 255;
|
||||||
|
rgba[i + 2] = 255;
|
||||||
|
rgba[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sharp(rgba, { raw: { width: size, height: size, channels: 4 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch(body: Buffer) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(new Uint8Array(body), {
|
||||||
|
headers: { 'content-type': 'image/png' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('trace route', () => {
|
||||||
|
it('rejects a missing url', async () => {
|
||||||
|
const res = await trace.request('/');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: 'Required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-http url', async () => {
|
||||||
|
const res = await trace.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 502 when the upstream fetch fails', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
|
||||||
|
);
|
||||||
|
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a BSON shape by default', async () => {
|
||||||
|
stubFetch(await makePng());
|
||||||
|
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('application/octet-stream');
|
||||||
|
|
||||||
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||||
|
expect(result.format).toBe('shape');
|
||||||
|
expect(result.mode).toBe('alpha');
|
||||||
|
expect(result.width).toBe(20);
|
||||||
|
expect(result.height).toBe(20);
|
||||||
|
expect(typeof result.svg).toBe('string');
|
||||||
|
expect(result.svg).toContain('<svg');
|
||||||
|
expect(result.shape).toBeDefined();
|
||||||
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a raw SVG when format=svg', async () => {
|
||||||
|
stubFetch(await makePng());
|
||||||
|
const res = await trace.request(
|
||||||
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&format=svg',
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||||
|
expect(result.format).toBe('svg');
|
||||||
|
expect(result.shape).toBeUndefined();
|
||||||
|
expect(typeof result.svg).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports bw mode', async () => {
|
||||||
|
stubFetch(await makePng());
|
||||||
|
const res = await trace.request(
|
||||||
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&mode=bw',
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||||
|
expect(result.mode).toBe('bw');
|
||||||
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
itemIdSchema,
|
itemIdSchema,
|
||||||
searchQuerySchema,
|
searchQuerySchema,
|
||||||
searchResultSchema,
|
searchResultSchema,
|
||||||
|
traceRequestSchema,
|
||||||
workshopItemSchema,
|
workshopItemSchema,
|
||||||
} from './schemas.js';
|
} from './schemas.js';
|
||||||
|
|
||||||
@@ -133,3 +134,55 @@ describe('searchResultSchema', () => {
|
|||||||
expect(searchResultSchema.parse(result)).toEqual(result);
|
expect(searchResultSchema.parse(result)).toEqual(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('traceRequestSchema', () => {
|
||||||
|
it('defaults mode, threshold, and format', () => {
|
||||||
|
expect(traceRequestSchema.parse({ url: 'https://example.com/a.png' })).toEqual({
|
||||||
|
url: 'https://example.com/a.png',
|
||||||
|
mode: 'alpha',
|
||||||
|
threshold: 128,
|
||||||
|
format: 'shape',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces threshold and accepts optional params', () => {
|
||||||
|
expect(
|
||||||
|
traceRequestSchema.parse({
|
||||||
|
url: 'https://example.com/a.png',
|
||||||
|
mode: 'bw',
|
||||||
|
threshold: '200',
|
||||||
|
format: 'svg',
|
||||||
|
simplify: 1.5,
|
||||||
|
maxColors: '8',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
url: 'https://example.com/a.png',
|
||||||
|
mode: 'bw',
|
||||||
|
threshold: 200,
|
||||||
|
format: 'svg',
|
||||||
|
simplify: 1.5,
|
||||||
|
maxColors: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid url', () => {
|
||||||
|
expect(traceRequestSchema.safeParse({ url: 'not-a-url' }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown mode or format', () => {
|
||||||
|
expect(
|
||||||
|
traceRequestSchema.safeParse({
|
||||||
|
url: 'https://example.com/a.png',
|
||||||
|
mode: 'nope',
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
traceRequestSchema.safeParse({
|
||||||
|
url: 'https://example.com/a.png',
|
||||||
|
format: 'nope',
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,3 +52,17 @@ export const searchResultSchema = z.object({
|
|||||||
page: z.number(),
|
page: z.number(),
|
||||||
hasMore: z.boolean(),
|
hasMore: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const traceModeSchema = z.enum(['alpha', 'bw', 'color']);
|
||||||
|
|
||||||
|
export const traceFormatSchema = z.enum(['svg', 'shape']);
|
||||||
|
|
||||||
|
/** Query params for `GET /trace`. */
|
||||||
|
export const traceRequestSchema = z.object({
|
||||||
|
url: z.string().url('url must be a valid URL'),
|
||||||
|
mode: traceModeSchema.default('alpha'),
|
||||||
|
threshold: z.coerce.number().int().min(0).max(255).default(128),
|
||||||
|
format: traceFormatSchema.default('shape'),
|
||||||
|
simplify: z.coerce.number().min(0).optional(),
|
||||||
|
maxColors: z.coerce.number().int().min(1).optional(),
|
||||||
|
});
|
||||||
@@ -110,3 +110,32 @@ export interface ExtractedObject {
|
|||||||
childrenGuids: string[];
|
childrenGuids: string[];
|
||||||
refs: AssetRef[];
|
refs: AssetRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How to derive the traced region from the source image. */
|
||||||
|
export type TraceMode = 'alpha' | 'bw' | 'color';
|
||||||
|
|
||||||
|
/** Output format for a traced result. */
|
||||||
|
export type TraceFormat = 'svg' | 'shape';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A closed 2D polygon described by its outline, matching the `Shape`
|
||||||
|
* interface in `@tts/mesh` so a traced result can be extruded directly.
|
||||||
|
*/
|
||||||
|
export interface TracedShape {
|
||||||
|
/** Outline vertices in order, each `[x, y]`. */
|
||||||
|
outline: number[][];
|
||||||
|
/** Optional holes, each a list of `[x, y]`. */
|
||||||
|
holes?: number[][][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A traced image, BSON-serialized by the proxy. */
|
||||||
|
export interface TraceResult {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
mode: TraceMode;
|
||||||
|
format: TraceFormat;
|
||||||
|
/** Raw SVG produced by vtracer, kept as a reference. */
|
||||||
|
svg: string;
|
||||||
|
/** Parsed shape, present when `format` is `shape`. */
|
||||||
|
shape?: TracedShape;
|
||||||
|
}
|
||||||
Generated
+354
@@ -26,9 +26,21 @@ importers:
|
|||||||
'@tts/tts':
|
'@tts/tts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/tts
|
version: link:../../packages/tts
|
||||||
|
'@visioncortex/vtracer':
|
||||||
|
specifier: 1.0.0-alpha.3
|
||||||
|
version: 1.0.0-alpha.3
|
||||||
|
bson:
|
||||||
|
specifier: ^6.10.4
|
||||||
|
version: 6.10.4
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.6.14
|
specifier: ^4.6.14
|
||||||
version: 4.13.1
|
version: 4.13.1
|
||||||
|
sharp:
|
||||||
|
specifier: ^0.35.3
|
||||||
|
version: 0.35.3(@types/node@22.20.1)
|
||||||
|
svgpath:
|
||||||
|
specifier: ^2.6.0
|
||||||
|
version: 2.6.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.1
|
specifier: ^3.24.1
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -180,6 +192,9 @@ packages:
|
|||||||
'@dimforge/rapier3d-compat@0.12.0':
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.3':
|
||||||
|
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -356,6 +371,168 @@ packages:
|
|||||||
'@iconify/utils@3.1.4':
|
'@iconify/utils@3.1.4':
|
||||||
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
|
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
|
||||||
|
|
||||||
|
'@img/colour@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-freebsd-wasm32@0.35.3':
|
||||||
|
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.3.2':
|
||||||
|
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.3.2':
|
||||||
|
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.3.2':
|
||||||
|
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.3.2':
|
||||||
|
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.3.2':
|
||||||
|
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.3.2':
|
||||||
|
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.3.2':
|
||||||
|
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.3.2':
|
||||||
|
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
|
||||||
|
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
|
||||||
|
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.35.3':
|
||||||
|
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.35.3':
|
||||||
|
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.35.3':
|
||||||
|
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
|
||||||
|
'@img/sharp-webcontainers-wasm32@0.35.3':
|
||||||
|
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.35.3':
|
||||||
|
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
|
||||||
|
engines: {node: ^20.9.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.35.3':
|
||||||
|
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -670,6 +847,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>= 16.8.0'
|
react: '>= 16.8.0'
|
||||||
|
|
||||||
|
'@visioncortex/vtracer@1.0.0-alpha.3':
|
||||||
|
resolution: {integrity: sha512-82rIJylIXu6dPv4Z0+wjKZVkNsZEdFg4ycDCWytQNuSoYLp31S1iZ+VFJP41+iVUNKmS5wVehwby/brQ91Qikw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.5':
|
'@vitejs/plugin-react@6.0.5':
|
||||||
resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==}
|
resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1115,9 +1296,23 @@ packages:
|
|||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
|
semver@7.8.5:
|
||||||
|
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
set-cookie-parser@2.7.2:
|
set-cookie-parser@2.7.2:
|
||||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
|
sharp@0.35.3:
|
||||||
|
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1153,6 +1348,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=17.0'
|
react: '>=17.0'
|
||||||
|
|
||||||
|
svgpath@2.6.0:
|
||||||
|
resolution: {integrity: sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==}
|
||||||
|
|
||||||
tailwindcss@4.3.3:
|
tailwindcss@4.3.3:
|
||||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||||
|
|
||||||
@@ -1201,6 +1399,9 @@ packages:
|
|||||||
troika-worker-utils@0.52.0:
|
troika-worker-utils@0.52.0:
|
||||||
resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==}
|
resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
tsx@4.23.11:
|
tsx@4.23.11:
|
||||||
resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==}
|
resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -1373,6 +1574,11 @@ snapshots:
|
|||||||
|
|
||||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.3':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1472,6 +1678,112 @@ snapshots:
|
|||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
import-meta-resolve: 4.2.0
|
import-meta-resolve: 4.2.0
|
||||||
|
|
||||||
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-freebsd-wasm32@0.35.3':
|
||||||
|
dependencies:
|
||||||
|
'@img/sharp-wasm32': 0.35.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.35.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.35.3':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.11.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-webcontainers-wasm32@0.35.3':
|
||||||
|
dependencies:
|
||||||
|
'@img/sharp-wasm32': 0.35.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.35.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.35.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.35.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -1731,6 +2043,8 @@ snapshots:
|
|||||||
'@use-gesture/core': 10.3.1
|
'@use-gesture/core': 10.3.1
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|
||||||
|
'@visioncortex/vtracer@1.0.0-alpha.3': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11))':
|
'@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
@@ -2113,8 +2427,43 @@ snapshots:
|
|||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
|
semver@7.8.5: {}
|
||||||
|
|
||||||
set-cookie-parser@2.7.2: {}
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
|
sharp@0.35.3(@types/node@22.20.1):
|
||||||
|
dependencies:
|
||||||
|
'@img/colour': 1.1.0
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
semver: 7.8.5
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.35.3
|
||||||
|
'@img/sharp-darwin-x64': 0.35.3
|
||||||
|
'@img/sharp-freebsd-wasm32': 0.35.3
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.3.2
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.3.2
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.3.2
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.3.2
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.3.2
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.3.2
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.3.2
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.3.2
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
|
||||||
|
'@img/sharp-linux-arm': 0.35.3
|
||||||
|
'@img/sharp-linux-arm64': 0.35.3
|
||||||
|
'@img/sharp-linux-ppc64': 0.35.3
|
||||||
|
'@img/sharp-linux-riscv64': 0.35.3
|
||||||
|
'@img/sharp-linux-s390x': 0.35.3
|
||||||
|
'@img/sharp-linux-x64': 0.35.3
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.35.3
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.35.3
|
||||||
|
'@img/sharp-webcontainers-wasm32': 0.35.3
|
||||||
|
'@img/sharp-win32-arm64': 0.35.3
|
||||||
|
'@img/sharp-win32-ia32': 0.35.3
|
||||||
|
'@img/sharp-win32-x64': 0.35.3
|
||||||
|
'@types/node': 22.20.1
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -2140,6 +2489,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|
||||||
|
svgpath@2.6.0: {}
|
||||||
|
|
||||||
tailwindcss@4.3.3: {}
|
tailwindcss@4.3.3: {}
|
||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
@@ -2185,6 +2536,9 @@ snapshots:
|
|||||||
|
|
||||||
troika-worker-utils@0.52.0: {}
|
troika-worker-utils@0.52.0: {}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tsx@4.23.11:
|
tsx@4.23.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.28.1
|
esbuild: 0.28.1
|
||||||
|
|||||||
Reference in New Issue
Block a user