Something went wrong. Try again.
An atproto AppView for tracking a Magic: The Gathering collection github.com/eth0net/manaweb
appview atproto mtg rust typescript
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327// The hashing, as the browser reaches it: `crates/scanner` compiled to wasm// and called over its C ABI. No bindings generator, so what arrives is a// pointer and a length and this module owns both sides of that.
// One word each, little-endian, as every integer in the catalog is.const WORD = 8;
// Four bytes, which is both a float and the pointer a wasm32 module counts in.const HALF = 4;
// A corner each way, four of them.const CORNERS = 8;
// What a call answers for a frame the buffer does not hold.const REFUSED = -1;
// And where it wrote nothing: no card in the frame, or no room for one.const NOTHING = 1;
// A point of a frame, in its own pixels and not necessarily on a whole one.export interface Point { x: number; y: number;}
// A card's four corners, clockwise from one end of a short side, so that// rectifying one stands it portrait whichever way round it lay.export type Quad = [Point, Point, Point, Point];
// A card read back as a rectangle of its own proportions.export interface Card { levels: Uint8Array; width: number; height: number;}
// What one frame asks the index, and what framed the asking.export interface Query { hashes: bigint[]; // The card that was found, or null where the framing was guessed at. quad: Quad | null;}
interface Exports { memory: WebAssembly.Memory; scan_alloc(bytes: number): number; scan_free(at: number, bytes: number): void; scan_hashes(): number; scan_fingerprint(): bigint; scan_entry( at: number, width: number, height: number, stride: number, out: number, ): number; scan_luma(at: number, pixels: number, step: number, out: number): number; scan_detect( at: number, width: number, height: number, stride: number, out: number, ): number; scan_rectify( at: number, width: number, height: number, stride: number, quad: number, out: number, room: number, size: number, ): number; scan_query_words(): number; scan_query( at: number, width: number, height: number, stride: number, out: number, quad: number, ): number;}
export class Engine { // The build's own name for its hashing, which an index has to match. readonly fingerprint: bigint; // Hashes one artwork carries, and what `entry` answers with. readonly hashes: number; // The most `query` can answer with, a frame it found no card in being // asked at more framings than one it did. readonly words: number;
#wasm: Exports;
private constructor(wasm: Exports) { this.#wasm = wasm; this.hashes = wasm.scan_hashes(); this.words = wasm.scan_query_words(); this.fingerprint = wasm.scan_fingerprint(); }
// The fingerprint as the artwork index spells it in its own header. get hasher(): string { return this.fingerprint.toString(16).padStart(16, "0"); }
// A Response streams and compiles at once; bytes are for a caller that // already has them, which is every check and no browser. static async load( source: Uint8Array | Response | Promise<Response>, ): Promise<Engine> { const held = source instanceof Uint8Array ? await WebAssembly.instantiate(source, {}) : await WebAssembly.instantiateStreaming(source, {}); return new Engine(held.instance.exports as unknown as Exports); }
// Room for each of `sizes`, the call, then the room given back however // that ends. Every view of the memory is taken inside the call, because an // allocation may have moved what one taken before it points at. #room<const N extends readonly number[], T>( sizes: N, run: (at: { [K in keyof N]: number }) => T, ): T { const at: number[] = sizes.map(() => 0); try { for (const [which, bytes] of sizes.entries()) { const held = bytes === 0 ? 0 : this.#wasm.scan_alloc(bytes); if (bytes !== 0 && held === 0) { throw new Error(`the engine has no room for ${bytes} bytes`); } at[which] = held; } return run(at as unknown as { [K in keyof N]: number }); } finally { for (const [which, bytes] of sizes.entries()) { const held = at[which]; if (held) this.#wasm.scan_free(held, bytes); } } }
// Here rather than in a loop over the pixels: the builder converts through // the same arithmetic, and the fingerprint covers it. luma(color: Uint8Array, step = 4): Uint8Array { const pixels = Math.floor(color.length / step); if (step < 3 || pixels === 0) { throw new Error(`${color.length} bytes of ${step} is no picture`); }
return this.#room([pixels * step, pixels], ([at, out]) => { new Uint8Array(this.#wasm.memory.buffer).set( color.subarray(0, pixels * step), at, ); if (this.#wasm.scan_luma(at, pixels, step, out) === REFUSED) { throw new Error(`the engine refused ${step} bytes a pixel`); } // A copy, because the next allocation may move what this points at. return new Uint8Array(this.#wasm.memory.buffer).slice(out, out + pixels); }); }
// A camera's rows carry padding past their pixels, so the stride is asked // for rather than taken from the width. entry( luma: Uint8Array, width: number, height: number, stride: number = width, ): bigint[] { const span = this.#span(luma, width, height, stride); return this.#room([span, this.hashes * WORD], ([at, out]) => { this.#plane(luma, span, at); if (this.#wasm.scan_entry(at, width, height, stride, out) === REFUSED) { throw new Error(`the engine refused ${width}x${height} at ${stride}`); } return this.#words(out, this.hashes); }); }
// The card in a frame, or null where nothing in it is shaped like one. detect( luma: Uint8Array, width: number, height: number, stride: number = width, ): Quad | null { const span = this.#span(luma, width, height, stride); return this.#room([span, CORNERS * HALF], ([at, out]) => { this.#plane(luma, span, at); const found = this.#wasm.scan_detect(at, width, height, stride, out); if (found === REFUSED) { throw new Error(`the engine refused ${width}x${height} at ${stride}`); } return found === NOTHING ? null : this.#corners(out); }); }
// A card standing upright, its foreshortening undone, or null where the // corners enclose nothing. rectify( luma: Uint8Array, width: number, height: number, quad: Quad, stride: number = width, ): Card | null { const span = this.#span(luma, width, height, stride); // No room at all on the first call, so that it answers with the size and // the room is made once. return this.#room( [span, CORNERS * HALF, 2 * HALF], ([at, corners, size]) => { this.#plane(luma, span, at); const held = new DataView(this.#wasm.memory.buffer); quad.forEach((point, corner) => { held.setFloat32(corners + corner * 2 * HALF, point.x, true); held.setFloat32(corners + (corner * 2 + 1) * HALF, point.y, true); });
const cut = (out: number, room: number) => this.#wasm.scan_rectify( at, width, height, stride, corners, out, room, size, ); if (cut(0, 0) === REFUSED) return null;
const read = new DataView(this.#wasm.memory.buffer); const wide = read.getUint32(size, true); const tall = read.getUint32(size + HALF, true); return this.#room([wide * tall], ([out]) => { if (cut(out, wide * tall) !== 0) return null; return { levels: new Uint8Array(this.#wasm.memory.buffer).slice( out, out + wide * tall, ), width: wide, height: tall, }; }); }, ); }
// Everything a frame asks the index: the card in it read back both ways up, // or, where there is none, the framings it might have had. query( luma: Uint8Array, width: number, height: number, stride: number = width, ): Query { const span = this.#span(luma, width, height, stride); return this.#room( [span, this.words * WORD, CORNERS * HALF], ([at, out, corners]) => { this.#plane(luma, span, at); const asked = this.#wasm.scan_query( at, width, height, stride, out, corners, ); if (asked === REFUSED) { throw new Error( `the engine refused ${width}x${height} at ${stride}`, ); } const quad = this.#corners(corners); // All zero is the engine saying it found no card and guessed at the // framing instead; a real one encloses something. const found = quad.some((point) => point.x !== 0 || point.y !== 0); return { hashes: this.#words(out, asked), quad: found ? quad : null }; }, ); }
// The bytes a plane covers, which is what every call below is handed. #span( luma: Uint8Array, width: number, height: number, stride: number, ): number { const span = (height - 1) * stride + width; if (width <= 0 || height <= 0 || stride < width || luma.length < span) { throw new Error( `${width}x${height} at ${stride} is not in ${luma.length}`, ); } return span; }
#plane(luma: Uint8Array, span: number, at: number): void { new Uint8Array(this.#wasm.memory.buffer).set(luma.subarray(0, span), at); }
#words(at: number, count: number): bigint[] { const read = new DataView(this.#wasm.memory.buffer); return Array.from({ length: count }, (_, which) => read.getBigUint64(at + which * WORD, true), ); }
#corners(at: number): Quad { const read = new DataView(this.#wasm.memory.buffer); const point = (corner: number) => ({ x: read.getFloat32(at + corner * 2 * HALF, true), y: read.getFloat32(at + (corner * 2 + 1) * HALF, true), }); return [point(0), point(1), point(2), point(3)]; }}