import { useEffect, useRef, useState } from "react"; import { api, ApiError } from "../api"; import type { BreedEstimateResult, PhotoSearchMatch, PhotoSearchResult } from "../types"; import { estimatedBreeds } from "./MatchCard"; import { prepareImage } from "../lib/prepareImage"; import BreedResults from "./BreedResults"; import ComparePhotosModal, { photosFromUrls } from "./ComparePhotosModal"; import SearchProgress, { MIN_PROGRESS_MS } from "./SearchProgress"; import TabButton from "./TabButton"; import { BreedNote, MatchingNote } from "./ToolNotes"; type Mode = "match" | "breed"; type Pool = "found" | "lost"; // "found": I lost my dog, search found dogs; "lost": I found a dog interface PickedImage { file: File; /** Preview URL, or null when this browser couldn't decode the file (still uploadable). */ url: string | null; } const MAX_IMAGES = 6; /** Turn a failed request into something a visitor can act on. */ function describeRequestError(err: unknown): string { if (err instanceof ApiError) { // 400s carry a specific, already human-readable reason (unreadable format, too large, ...). if (err.status === 400) return err.message || "That image couldn't be read."; if (err.status === 413) return "That image is too large to upload. Try a smaller one."; if (err.status === 429) return "Too many requests just now. Give it a few seconds and retry."; if (err.status >= 500) return "The server had a problem running the model. Please try again."; return err.message; } // fetch() rejects (Safari says "Load failed") on a dropped or timed-out connection. return "The upload didn't complete. Check your connection and try again."; } // Public home-page tool: upload up to 6 dog images, then run image matching, breed estimation, or // both. More images (different angles/lighting) only help matching — the server scores every query // image against every candidate image and keeps the best pair. Breed estimation uses the first // image. Read-only — stores nothing and creates no case. Match results carry their own photos so a // viewer can open without an authenticated endpoint. // `lockedPool` forces the search direction and hides the toggle — used by the demo, whose haystack is // a single found/unclaimed pool (there is no registered-lost pool to search). `haystackHint` is the // searchable-dog count shown in the progress indicator while a match runs. export default function PhotoTools({ lockedPool, haystackHint, }: { lockedPool?: Pool; haystackHint?: number } = {}) { const [images, setImages] = useState([]); const BREED_COUNT = 10; // always show the model's full top-10; no picker to fiddle with const [pool, setPool] = useState(lockedPool ?? "found"); const [dragging, setDragging] = useState(false); const [preparing, setPreparing] = useState(false); const [tab, setTab] = useState("match"); const [running, setRunning] = useState(null); const [error, setError] = useState(null); const [matchData, setMatchData] = useState(null); const [breedData, setBreedData] = useState(null); const [active, setActive] = useState(null); // Release every preview blob URL when the component unmounts (removeImage handles the rest). const imagesRef = useRef(images); imagesRef.current = images; useEffect( () => () => imagesRef.current.forEach((i) => i.url && URL.revokeObjectURL(i.url)), [] ); // NOTE: we deliberately don't filter on `file.type` — iOS often reports an empty type for HEIC // and for files picked out of the Files app, which silently dropped valid photos. Instead every // file is decoded and re-encoded here (see prepareImage), so what reaches the server is always a // modest JPEG and anything genuinely unreadable is reported by name. async function addImages(list: FileList | File[] | null) { if (!list) return; const incoming = Array.from(list); if (incoming.length === 0) return; setError(null); setPreparing(true); try { const room = MAX_IMAGES - images.length; const picked: PickedImage[] = []; let fellBack = 0; for (const file of incoming.slice(0, room)) { try { const prepared = await prepareImage(file); picked.push({ file: prepared, url: URL.createObjectURL(prepared) }); } catch { // This browser can't decode it (e.g. HEIC outside Safari). Send the original anyway — // the server reads HEIC too — and skip the preview rather than showing a broken image. picked.push({ file, url: null }); fellBack++; } } if (picked.length > 0) { setImages((prev) => [...prev, ...picked]); setMatchData(null); setBreedData(null); } if (fellBack > 0) { setError( `${fellBack === 1 ? "One image" : `${fellBack} images`} couldn't be previewed in this ` + "browser, but will still be searched. If it fails, try a JPEG or PNG." ); } } finally { setPreparing(false); } } function removeImage(idx: number) { setImages((prev) => { const gone = prev[idx].url; if (gone) URL.revokeObjectURL(gone); return prev.filter((_, i) => i !== idx); }); setMatchData(null); setBreedData(null); } async function run(mode: Mode) { if (images.length === 0) return; setRunning(mode); setError(null); const start = Date.now(); try { if (mode === "match") { const d = await api.searchByPhoto( images.map((i) => i.file), undefined, pool ); const wait = MIN_PROGRESS_MS - (Date.now() - start); if (wait > 0) await new Promise((r) => setTimeout(r, wait)); setMatchData(d); } else { const d = await api.estimateBreed( images.map((i) => i.file), BREED_COUNT ); const wait = MIN_PROGRESS_MS - (Date.now() - start); if (wait > 0) await new Promise((r) => setTimeout(r, wait)); setBreedData(d); } } catch (err) { setError(describeRequestError(err)); } finally { setRunning(null); } } const busy = running !== null || preparing; const hasImages = images.length > 0; return (

Try it with an image

Upload up to 6 images of a dog, and search for look-alikes in the found-dog database or estimate its breed. Pictures from more angles help the matching process. Use clear photos of a single dog. Both models only know dogs, giving them anything else will return a meaningless answer.

addImages(e.target.files)} /> {!hasImages ? ( // Big dropzone before anything is picked. ) : ( // Thumbnail grid + an add-more tile once at least one image is picked.
{images.map((img, i) => (
{img.url ? ( {`Your ) : (
No preview {img.file.name}
)} {running ? ( <>
) : ( )}
))} {images.length < MAX_IMAGES && !running && ( )}

{preparing ? "Preparing images…" : `${images.length} of ${MAX_IMAGES} images · nothing will be saved to the database`}

)} {/* Two tabs under the images: image matching | breed estimation (both use the same images) */}
{ setTab("match"); setError(null); }} > Image matching { setTab("breed"); setError(null); }} > Breed estimation
{tab === "match" ? (
{!lockedPool && (
What are you searching?

{pool === "found" ? "Searches found & unclaimed dogs in the system for a look-alike." : "Searches currently-lost registered dogs to help find the owner."}

)} {running === "match" && (
)} {matchData && }
) : (
{images.length > 1 && (

Averaged across all {images.length} images, since a single photo can easily flip the top breed.

)} {running === "breed" && (
)} {breedData && }
)}
{error &&

{error}

} {active && ( i.url).filter((u): u is string => !!u))} queryLabel={`Your image${images.length > 1 ? "s" : ""}`} onClose={() => setActive(null)} /> )}
); } function pct(score: number): string { return `${(score * 100).toFixed(1)}%`; } function MatchResults({ data, onOpen, }: { data: PhotoSearchResult; onOpen: (m: PhotoSearchMatch) => void; }) { const noun = data.pool === "lost" ? "lost dog" : "found dog"; return (

Closest {noun}s

{data.results.length === 0 ? (

No {noun}s to compare against yet. {" "} {data.pool === "lost" ? "Report the dog as found so we keep watching for the owner." : "Report your dog as lost so we keep watching for new matches."}

) : ( <>

Closest of {data.candidate_count} {noun}(s) {data.zip ? ` near ${data.zip}` : ""}. Tap a card to see all its images. The scores measure image similarity only, so always confirm with a person.

{/* Ten, to match the "top 10" figure quoted under the button. */} {data.results.slice(0, 10).map((m) => ( onOpen(m)} /> ))}
)}
); } function ResultCard({ match, onOpen }: { match: PhotoSearchMatch; onOpen: () => void }) { const { dog } = match; const thumb = match.photos[0]?.url || dog.thumb_url || "/paw-placeholder.svg"; return ( ); }