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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user