import { 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({ at: "none" }); const [problem, setProblem] = useState(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(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(null); const paper = useRef(null); const stream = useRef(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

The catalog is still loading.

; } if (!manifest.artwork) { return (

This catalog was published without an artwork index, so there is nothing to scan against.

); } 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 (
{/* 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. */}
); } // 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(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 ; } // 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 ( <> {stack.quantity} ); } // How much is in the list, and the way into it. function Waiting({ list }: { list: Entry[] }) { const held = copies(list); return ( {held.toLocaleString()} card{held === 1 ? "" : "s"} scanned ); } // 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 (

No printing in this catalog carries that art.

); } return ( <> {!certain && (

Not by much. {!found.quad && " Its outline was not found, so the whole frame was read as the card."}

)} ); } // 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 (

No printing in this catalog carries that art.

); } // 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 ( <>

{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."}

{picks.map(({ card, print }) => onPick && several ? ( ) : ( ), )} {others.map((one) => one.prints[0] ? ( ) : null, )} {(more || hidden > 0) && ( )} ); } // 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 (

{named.text}
{describe(print)}

); } // 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 { 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); }