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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766import { lazy, Suspense, useCallback, useEffect, useRef, useState,} from "react";import { appLanguage, type Card, type Catalog, cardName, image, type Manifest, type Print,} from "../catalog";import { describe } from "../Printing";import { Link } from "../router";import { frame, open } from "./camera";import type { Quad } from "./engine";import { engine, index } from "./load";import { Outline } from "./Outline";import { type Picture, type Read, read } from "./read";import { copies, type Entry, minus, plus, reported, scanned, settled, sure,} from "./scratch";import type { Scratch } from "./store";
// Development only. The import sits inside the branch, so the module and// the route it posts to are dropped with it.const Told = import.meta.env.DEV ? lazy(() => import("./Report").then((held) => ({ default: held.Report }))) : null;
// Development only, the same way, and what `bun run sealed` reads the build// back for.const Timed = import.meta.env.DEV ? lazy(() => import("./Readout").then((held) => ({ default: held.Readout }))) : null;
export const SCAN = "/scan";export const REVIEW = `${SCAN}/review`;
const APP = appLanguage();
// Picked against a photograph rather than against the page, the way the// panel over the camera is — `docs/scanner.md`.const SURE = "#4ade80";const THIN = "#fbbf24";
// How many of one artwork's printings to put up before asking. The median// artwork carries two and the tail reaches fourteen — `docs/scanner.md`.const MOST = 4;
type Answer = | { at: "none" } | { at: "reading" } // `frame` names the frame held for this read, whether or not it made a // stack. A read that named nothing is the one most worth sending back. // `picture` is the pixels the engine saw, which is what the outline is // placed against. | { at: "nothing"; frame: string; picture: Picture } // `stack` names the entry once it is in the list. `offer` is one made and // not put there: a read the margin does not carry joins the list when it // is agreed with, not because the shutter was pressed. | { at: "found"; held: Read; frame: string; picture: Picture; stack: string | null; offer: Entry | null; };
// The tab is the viewfinder, and what came back is laid over it rather than// put above it: a run of scanning should not be a run of pages.export function Scan({ catalog, manifest, scratch,}: { catalog: Catalog | null; manifest: Manifest | null; scratch: Scratch;}) { const { change } = scratch; const [camera, setCamera] = useState<"off" | "opening" | "on">("off"); const [answer, setAnswer] = useState<Answer>({ at: "none" }); const [problem, setProblem] = useState<string | null>(null); // todo(settings): held for the page rather than the person, there being // nowhere yet for a preference to live — `docs/scanner.md`. const [solid, setSolid] = useState(false); // Development only: which held frame a report is being written about, and // what to say once one has gone. const [telling, setTelling] = useState<string | null>(null); const [told, setTold] = useState(false); // Development only: whether the stage timings are up. const [timing, setTiming] = useState(false); // One per camera, so each open lays a fresh element out against the stream // it is given. Not per scan: the ref has to outlive a read. const [session, setSession] = useState(0); // Whether the panel is showing everything it has. Shut, it is a line: the // camera is stopped behind the frame anyway, so what is wanted most of the // time is the name and the next press. const [expanded, setExpanded] = useState(false); // Back to the camera with the read still up. A frame is held so it can be // looked at, and aiming the next card is what ends that. const [live, setLive] = useState(false);
const video = useRef<HTMLVideoElement>(null); const paper = useRef<HTMLCanvasElement | null>(null); const stream = useRef<MediaStream | null>(null); // Which attempt is the live one. A flag could not tell them apart, so // cancelling and opening again left the first camera arriving to a `true` // it did not set, taking the ref, and then being overwritten by the second // with nothing left holding it. const run = useRef(0);
// A camera left running is a light left on, so it goes out with the page as // well as with the button. const stop = useCallback(() => { run.current += 1; for (const track of stream.current?.getTracks() ?? []) track.stop(); stream.current = null; if (video.current) video.current.srcObject = null; setCamera("off"); setAnswer({ at: "none" }); setProblem(null); }, []);
useEffect(() => stop, [stop]);
const start = useCallback(async () => { run.current += 1; const mine = run.current; setSession((was) => was + 1); setCamera("opening"); setProblem(null); try { // Both before the camera opens, so the wait is one rather than two and // the first scan is not the one that pays for the index. if (manifest) await Promise.all([engine(), index(manifest)]); const held = await open(); // Opening a camera takes long enough to leave the page in, or to // cancel and ask again, and whatever ran meanwhile had nothing to stop. if (run.current !== mine) { for (const track of held.getTracks()) track.stop(); return; } stream.current = held; if (video.current) { video.current.srcObject = held; await video.current.play(); } if (run.current !== mine) { stop(); return; } setCamera("on"); } catch (failed: unknown) { // Cancelling clears `srcObject`, which rejects a pending `play`. That // is the cancel working, not something to report. if (run.current !== mine) return; const why = said(failed); stop(); setProblem(why); } }, [manifest, stop]);
const take = useCallback(async () => { if (!catalog || !manifest || !video.current) return; setAnswer({ at: "reading" }); setProblem(null); setExpanded(false); setLive(false); try { paper.current ??= document.createElement("canvas"); const picture = frame(video.current, paper.current); const [held, artworks] = await Promise.all([engine(), index(manifest)]); if (!picture) { setAnswer({ at: "none" }); setProblem("The camera has not given a frame yet"); return; } if (!artworks) { setAnswer({ at: "none" }); setProblem("No artwork index to scan against"); return; } const found = read(held, catalog, artworks, picture); const at = new Date().toISOString(); const id = crypto.randomUUID(); const made = found ? scanned(found, id, at) : null; // Below the floor it waits: a card read twice because the first look // was poor is one card, and the count has to say so while scanning // rather than after. const agreed = made !== null && sure(made); if (agreed) change((list) => [...list, made]); // A new read has had nothing said about it, whatever was said about // the last one. setTold(false); setAnswer( found ? { at: "found", held: found, frame: id, picture, stack: agreed ? made.id : null, offer: agreed ? null : made, } : { at: "nothing", frame: id, picture }, ); if (import.meta.env.DEV && paper.current) { // Every read, not only the ones that made a stack: a phantom card and // a frame nothing was found in are both worth sending. await remember(id, paper.current).catch((failed: unknown) => setProblem(said(failed)), ); } } catch (failed: unknown) { setAnswer({ at: "none" }); setProblem(said(failed)); } }, [catalog, change, manifest]);
if (!catalog || !manifest) { return <p className="quiet">The catalog is still loading.</p>; } if (!manifest.artwork) { return ( <p className="quiet"> This catalog was published without an artwork index, so there is nothing to scan against. </p> ); }
const answered = answer.at === "found" || answer.at === "nothing" || Boolean(problem) || Boolean(scratch.problem);
// Null once a minus has taken it away, which is what leaves nothing to // press. const stack = answer.at === "found" && answer.stack ? (scratch.list.find((one) => one.id === answer.stack) ?? null) : null;
// What the panel describes, whether or not the list holds it yet. const offer = answer.at === "found" ? answer.offer : null;
// Every read holds one, including the read that named nothing. const shot = answer.at === "found" || answer.at === "nothing" ? answer.frame : null;
// The frame the read was taken from. A read that found nothing still has // one, and it is the one most worth looking at. const seen = answer.at === "found" ? { picture: answer.picture, quad: answer.held.quad, sure: answer.held.sure, } : answer.at === "nothing" ? { picture: answer.picture, quad: null, sure: false } : null;
// Picking among the printings one artwork carries, which is why the read // was not kept in the first place. function pick(print: Print, finishes: string[]) { setAnswer((was) => was.at === "found" && was.offer ? { ...was, offer: { ...was.offer, scryfallId: print.id, picked: true, finish: settled(finishes), }, } : was, ); }
// Kept, not affirmed: keeping is also how a wrong read reaches the list to // be put right, so it cannot stand as naming the card. function agree(one: Entry) { change((list) => [...list, one]); setAnswer((was) => was.at === "found" ? { ...was, stack: one.id, offer: null } : was, ); }
// The stack goes with its last copy, and so does the panel describing it. function fewer(one: Entry) { change((list) => minus(list, one.id)); if (one.quantity <= 1) setAnswer({ at: "none" }); }
return ( <div className="scan"> {/* Mounted whatever the state, so the ref outlives a read, and keyed per camera: reattaching a stream to the element a closed one left behind paints it against that element's old box. */} <video key={session} ref={video} playsInline muted hidden={camera !== "on"} />
{camera !== "on" && ( <div className="scan-idle"> {camera === "opening" ? ( <> <p>Opening the camera…</p> <button type="button" onClick={stop}> Cancel </button> </> ) : ( <button type="button" onClick={() => void start()}> Open the camera </button> )} {scratch.list.length > 0 && <Waiting list={scratch.list} />} {problem && <p className="warn">{problem}</p>} </div> )}
{camera === "on" && (!seen || live) && !telling && ( <Outline video={video} /> )}
{camera === "on" && seen && !live && !telling && ( <div className="scan-frozen"> <Shot {...seen} /> <button type="button" className="scan-live" onClick={() => setLive(true)} > <span>Back to the camera</span> </button> </div> )}
{camera === "on" && ( <div className="scan-foot"> {Timed && timing && manifest && ( <Suspense fallback={<p className="quiet">Opening…</p>}> <Timed video={video} catalog={catalog} manifest={manifest} /> </Suspense> )}
{Told && telling && ( <div className={solid ? "scan-said solid" : "scan-said"}> <div className="scan-panel"> <Suspense fallback={<p className="quiet">Opening…</p>}> <Told catalog={catalog} frame={telling} found={answer.at === "found" ? answer.held : null} entry={stack ?? offer} onDone={(sent) => { setTelling(null); setTold(sent); // The stack keeps it, where the read made one. An // offer keeps it too, so keeping it afterwards does // not lose that it went. if (sent) { const at = new Date().toISOString(); if (stack) { change((list) => reported(list, stack.id, at)); } else if (offer) { setAnswer((was) => was.at === "found" && was.offer ? { ...was, offer: { ...was.offer, reported: at }, } : was, ); } } }} /> </Suspense> </div> </div> )}
{answered && !telling && ( <div className={solid ? "scan-said solid" : "scan-said"}> <div className="scan-panel"> {expanded && ( <button type="button" className="scan-veil" onClick={() => setSolid(!solid)} aria-pressed={solid} > {solid ? "See through" : "Solid"} </button> )} {problem && <p className="warn">{problem}</p>} {scratch.problem && <p className="warn">{scratch.problem}</p>} {answer.at === "nothing" && ( <p>Nothing in that frame looked like a card.</p> )} {answer.at === "found" && (expanded ? ( <Answered found={answer.held} stack={stack ?? offer} onPick={offer ? pick : null} /> ) : ( <Said found={answer.held} stack={stack ?? offer} /> ))} </div> {offer && ( <p className="scan-count"> <button type="button" onClick={() => agree(offer)}> Keep it </button> <span className="quiet">or scan again</span> </p> )} {import.meta.env.DEV && expanded && shot && ( <p className="scan-count"> <button type="button" onClick={() => { setTold(false); setTelling(shot); }} > {told ? "Report again" : "Report"} </button> <span className="quiet"> {told ? "sent, thank you" : "something is wrong with it"} </span> </p> )} {seen && ( <p className="scan-count"> <button type="button" className="link" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} > {expanded ? "Less" : "More"} </button> </p> )} {scratch.list.length > 0 && ( <p className="scan-count"> {stack && ( <Counted stack={stack} onFewer={() => fewer(stack)} onMore={() => change((list) => plus(list, stack.id))} /> )} <Waiting list={scratch.list} /> </p> )} </div> )}
<div className="scan-controls"> <button type="button" onClick={() => void take()} disabled={answer.at === "reading"} > {answer.at === "reading" ? "Reading…" : answer.at === "none" ? "Scan" : "Scan again"} </button> {Timed && manifest && ( <button type="button" onClick={() => setTiming(!timing)} aria-pressed={timing} > {timing ? "Timing" : "Time"} </button> )} <button type="button" onClick={stop}> Close </button> </div> </div> )} </div> );}
// The frame the engine read, with the card's own corners on it. Drawn at the// picture's size and sized down by CSS, so enlarging it costs no second copy// and no second read.function Shot({ picture, quad, sure,}: { picture: Picture; quad: Quad | null; sure: boolean;}) { const onto = useRef<HTMLCanvasElement>(null);
useEffect(() => { const held = onto.current; const paper = held?.getContext("2d"); if (!held || !paper) return; held.width = picture.width; held.height = picture.height; paper.putImageData(picture as ImageData, 0, 0); if (!quad) return;
paper.beginPath(); for (const [at, { x, y }] of quad.entries()) { if (at === 0) paper.moveTo(x, y); else paper.lineTo(x, y); } paper.closePath(); // Against the picture's own width, the canvas being drawn at whatever // the camera gave rather than at the size it is shown. const wide = Math.max(2, picture.width / 200); paper.lineWidth = wide; paper.setLineDash(sure ? [] : [wide * 3, wide * 2]); paper.strokeStyle = sure ? SURE : THIN; paper.stroke(); }, [picture, quad, sure]);
return <canvas ref={onto} />;}
// How many of it were seen, kept out of the scroll above so the press that// undoes a scan is always in the same place.function Counted({ stack, onFewer, onMore,}: { stack: Entry; onFewer: () => void; onMore: () => void;}) { return ( <> <button type="button" onClick={onFewer} aria-label="One fewer"> − </button> <span className="tally">{stack.quantity}</span> <button type="button" onClick={onMore} aria-label="One more"> + </button> </> );}
// How much is in the list, and the way into it.function Waiting({ list }: { list: Entry[] }) { const held = copies(list);
return ( <Link className="link" to={REVIEW}> {held.toLocaleString()} card{held === 1 ? "" : "s"} scanned </Link> );}
// A shut panel: the printing it settled on and nothing to choose between.// Opening it is what offers the runners-up and the presses.function Said({ found, stack }: { found: Read; stack: Entry | null }) { // What the stack settled on rather than what the read led with: a // printing picked while it was open is the one `Keep it` would add. const prints = found.found.flatMap((one) => one.prints); const lead = prints.find(({ print }) => print.id === stack?.scryfallId) ?? prints[0]; const certain = stack ? sure(stack) : found.sure; if (!lead) { return ( <p className="quiet">No printing in this catalog carries that art.</p> ); }
return ( <> <Named card={lead.card} print={lead.print} /> {!certain && ( <p className="warn"> Not by much. {!found.quad && " Its outline was not found, so the whole frame was read as the card."} </p> )} </> );}
// What came back, and how much to believe it. The runners-up show only where// the margin is too thin to take, which is the whole of what keeping them is// for.function Answered({ found, stack, onPick,}: { found: Read; stack: Entry | null; onPick: ((print: Print, finishes: string[]) => void) | null;}) { // The matches the list could do nothing with are left out here too, or the // panel names one card while the stack below it holds another. const [first, ...rest] = found.found.filter((one) => one.prints.length > 0); const certain = stack ? sure(stack) : found.sure; const [more, setMore] = useState(false);
if (!first) { return ( <p className="quiet">No printing in this catalog carries that art.</p> ); }
// The art match narrowed to these. What picks among them is the collector // number on the card, which nothing reads yet — `docs/scanner.md`. const several = first.prints.length > 1; const chosen = stack?.scryfallId; const picks = more ? first.prints : first.prints.slice(0, MOST); // A thin margin is the whole reason for keeping the runners-up, so they // stand there on their own. Judged by the same reading the verdict is, // not by the read's own margin, which counts artworks this cannot name. const thin = !certain && first.prints.length === 1; const others = thin || more ? rest : []; // What pressing it would still add, so it never offers nothing and never // strands itself open. const hidden = first.prints.length - picks.length + (others.length === 0 ? rest.length : 0);
return ( <> <p className={certain ? "tally" : "warn"}> {certain ? "This is the card." : several ? "This art, on more than one printing." : "Nearest, but not by much."} {/* Both can be true at once: the framings guessed at when no outline was found still answered, and still answered clear of the floor. Said as a caveat rather than as a second verdict. */} {!found.quad && " Its outline was not found, so the whole frame was read as the card."} </p>
{picks.map(({ card, print }) => onPick && several ? ( <button key={print.id} type="button" className={print.id === chosen ? "scan-pick chosen" : "scan-pick"} onClick={() => onPick(print, print.finishes)} > <Named card={card} print={print} /> </button> ) : ( <Named key={print.id} card={card} print={print} /> ), )}
{others.map((one) => one.prints[0] ? ( <Named key={one.artwork} card={one.prints[0].card} print={one.prints[0].print} quiet /> ) : null, )}
{(more || hidden > 0) && ( <button type="button" className="link" onClick={() => setMore(!more)} aria-expanded={more} > {more ? "Fewer" : holding(first.prints.length - picks.length, rest)} </button> )} </> );}
// What the fold is still holding back, named as what it is: printings of// this artwork, other artworks, or both.function holding(printings: number, rest: unknown[]): string { const more = printings > 0 ? `${printings} more printing${printings === 1 ? "" : "s"}` : ""; const other = rest.length > 0 ? "what else it could be" : ""; return [more, other].filter(Boolean).join(", and ");}
// Enough of a printing to tell whether it is the card in your hand: the art// as it was scanned, the name, and what tells two printings apart.function Named({ card, print, quiet = false,}: { card: Card; print: Print; quiet?: boolean;}) { const named = cardName(card.name, print, APP);
return ( <p className={quiet ? "scan-named quiet" : "scan-named"}> <img src={image(print.id, "small")} alt="" width="48" height="67" /> <span> <span lang={named.lang}>{named.text}</span> <br /> <small>{describe(print)}</small> </span> </p> );}
// The frame a scan read, put by in case the read turns out to be wrong.// Imported here rather than at the top: the branch goes with the build, and// the module goes with the branch.async function remember(id: string, canvas: HTMLCanvasElement): Promise<void> { const { hold, snapshot } = await import("./frames"); await hold(id, await snapshot(canvas));}
function said(failed: unknown): string { return failed instanceof Error ? failed.message : String(failed);}