cetusian commited on
Commit
6b6430f
·
verified ·
1 Parent(s): ed73739

Deploy doodle-decoder

Browse files
Files changed (9) hide show
  1. Dockerfile +9 -0
  2. README.md +19 -3
  3. app.py +19 -0
  4. live.py +49 -0
  5. requirements.txt +3 -0
  6. rune.py +120 -0
  7. static/client.js +28 -0
  8. static/embed.js +37 -0
  9. static/index.html +202 -0
Dockerfile ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ RUN useradd -m -u 1000 user
3
+ WORKDIR /home/user/app
4
+ COPY --chown=user requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+ COPY --chown=user . .
7
+ USER user
8
+ EXPOSE 7860
9
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--no-proxy-headers"]
README.md CHANGED
@@ -1,10 +1,26 @@
1
  ---
2
  title: Doodle Decoder
3
- emoji: 🐨
4
  colorFrom: yellow
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
1
  ---
2
  title: Doodle Decoder
3
+ emoji: ✏️
4
  colorFrom: yellow
5
+ colorTo: pink
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: apache-2.0
10
+ short_description: You draw, Rune guesses from the pixels, live.
11
+ models:
12
+ - surogate/rune-26b-a4b-GGUF
13
+ tags:
14
+ - surogate
15
+ - rune
16
+ - decision-model
17
+ - vision
18
  ---
19
 
20
+ # Doodle Decoder
21
+
22
+ Draw the word you're given. Every time you lift the pen, [Surogate Rune](https://huggingface.co/surogate/rune-26b-a4b-GGUF),
23
+ an open decision model, looks at the canvas and gives a probability for each of 50 words, so you watch it change its
24
+ mind stroke by stroke.
25
+
26
+ Nothing you draw is stored.
app.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import live
4
+
5
+ HERE = Path(__file__).parent
6
+ WORDS = ["cat", "dog", "fish", "bird", "house", "tree", "flower", "sun", "moon", "star", "car", "bicycle",
7
+ "boat", "airplane", "train", "rocket", "cup", "chair", "table", "lamp", "clock", "key", "umbrella",
8
+ "hat", "shoe", "glasses", "guitar", "pizza", "apple", "banana", "ice cream", "cake", "heart",
9
+ "smiley face", "snake", "spider", "butterfly", "mountain", "cloud", "lightning", "snowman",
10
+ "ghost", "crown", "sword", "light bulb", "phone", "envelope", "scissors", "pencil", "eye"]
11
+
12
+
13
+ def questions(context):
14
+ return ("A sketch drawn by a person in a guessing game.", {"guess": {
15
+ "type": "choice", "instructions": "What is this sketch meant to be?",
16
+ "criteria": {w: w for w in WORDS}}})
17
+
18
+
19
+ app = live.make_app(questions, HERE / "static")
live.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI app for the live Rune Labs demos: a static page plus /decide.
2
+
3
+ deploy.py copies this file into the Docker Spaces. Edit it here, never in a Space.
4
+ """
5
+ from fastapi import FastAPI, HTTPException, Request
6
+ from fastapi.responses import JSONResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+ from pydantic import BaseModel
9
+
10
+ import rune
11
+
12
+ MAX_BODY = rune.MAX_DATA_URL + 64 * 1024
13
+
14
+
15
+ class Ask(BaseModel):
16
+ image: str
17
+ context: dict = {}
18
+
19
+
20
+ def make_app(build_questions, static_dir):
21
+ """build_questions(context) -> (state, questions) for one decision about the posted image."""
22
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
23
+
24
+ @app.middleware("http")
25
+ async def refuse_oversized(request: Request, call_next):
26
+ # Checked before FastAPI reads and parses the body; a data URL plus the JSON around it.
27
+ size = request.headers.get("content-length")
28
+ if request.method == "POST" and (size is None or not size.isdigit() or int(size) > MAX_BODY):
29
+ return JSONResponse({"detail": "Request too large."}, status_code=413 if size else 411)
30
+ return await call_next(request)
31
+
32
+ @app.post("/decide")
33
+ def ask(a: Ask):
34
+ try:
35
+ img = rune.from_data_url(a.image)
36
+ except ValueError as e:
37
+ raise HTTPException(400, str(e))
38
+ state, questions = build_questions(a.context)
39
+ body = rune.request_body(state, questions, [rune.image_data_url(img)])
40
+ try:
41
+ answers = rune.decide(body)
42
+ except rune.Busy as e:
43
+ raise HTTPException(503, str(e))
44
+ except rune.RuneError as e:
45
+ raise HTTPException(502, str(e))
46
+ return {"labels": {k: rune.as_label(v) for k, v in answers.items()}}
47
+
48
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
49
+ return app
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastapi==0.141.1
2
+ uvicorn==0.54.0
3
+ pillow==12.3.0
rune.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared by every Rune Labs Space: the Rune client, its concurrency cap, and image handling.
2
+
3
+ deploy.py copies this file into each Space. Edit it here, never in a Space.
4
+ """
5
+ import base64
6
+ import binascii
7
+ import http.client
8
+ import io
9
+ import json
10
+ import logging
11
+ import os
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+
17
+ from PIL import Image
18
+
19
+ MODEL = "rune-v3"
20
+ MAX_SIDE = 1024
21
+ MAX_TEXT = 4000
22
+ MAX_DATA_URL = 6 * 1024 * 1024
23
+ MAX_PIXELS = 25_000_000 # checked from the header, before any pixel is decoded
24
+ BUSY = "Rune is busy right now. Try again in a few seconds."
25
+ FAILED = "Rune could not answer that one. Try again in a moment."
26
+
27
+ log = logging.getLogger("rune")
28
+
29
+ # One cap for every caller in the process, Gradio handler or FastAPI route alike.
30
+ _slots = threading.BoundedSemaphore(2)
31
+
32
+
33
+ class RuneError(Exception):
34
+ """Rune did not answer. str() is safe to show to a user."""
35
+
36
+
37
+ class Busy(RuneError):
38
+ pass
39
+
40
+
41
+ def request_body(state, questions, images=None):
42
+ if isinstance(state, str):
43
+ state = state[:MAX_TEXT]
44
+ body = {"model": MODEL, "state": state, "questions": questions}
45
+ if images:
46
+ body["images"] = images
47
+ return body
48
+
49
+
50
+ def decide(body, *, opener=urllib.request.urlopen, sleep=time.sleep):
51
+ req = urllib.request.Request(
52
+ os.environ["RUNE_URL"], json.dumps(body).encode(),
53
+ {"Authorization": "Bearer " + os.environ["RUNE_API_KEY"], "Content-Type": "application/json"})
54
+ with _slots:
55
+ for attempt in (0, 1):
56
+ try:
57
+ with opener(req, timeout=60) as r:
58
+ reply = json.load(r)
59
+ if not isinstance(reply, dict) or not isinstance(reply.get("answers"), dict):
60
+ raise ValueError("reply has no answers")
61
+ return reply["answers"]
62
+ except urllib.error.HTTPError as e:
63
+ e.close()
64
+ if e.code not in (429, 503):
65
+ log.warning("rune call failed: HTTP %s", e.code)
66
+ raise RuneError(FAILED) from None
67
+ if attempt == 0:
68
+ sleep(1.5)
69
+ except (OSError, http.client.HTTPException, ValueError) as e:
70
+ # Logged by type only: the request carries user content, the log never does.
71
+ log.warning("rune call failed: %s", type(e).__name__)
72
+ raise RuneError(FAILED) from None
73
+ log.warning("rune busy after one retry")
74
+ raise Busy(BUSY)
75
+
76
+
77
+ def image_data_url(img):
78
+ img = img.copy()
79
+ img.thumbnail((MAX_SIDE, MAX_SIDE))
80
+ png = img.mode in ("RGBA", "LA", "P")
81
+ buf = io.BytesIO()
82
+ if png:
83
+ img.save(buf, "PNG")
84
+ else:
85
+ img.convert("RGB").save(buf, "JPEG", quality=90)
86
+ return f"data:image/{'png' if png else 'jpeg'};base64," + base64.b64encode(buf.getvalue()).decode()
87
+
88
+
89
+ def from_data_url(url):
90
+ """Decode an untrusted data URL from a browser into a resized image. Raises ValueError."""
91
+ if len(url) > MAX_DATA_URL or not url.startswith(("data:image/png;base64,", "data:image/jpeg;base64,")):
92
+ raise ValueError("expected a PNG or JPEG data URL under 6 MB")
93
+ try:
94
+ img = Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1], validate=True)))
95
+ if img.width * img.height > MAX_PIXELS:
96
+ raise ValueError("image too large")
97
+ img.load()
98
+ except (binascii.Error, OSError, Image.DecompressionBombError) as e:
99
+ raise ValueError("not a readable image") from e
100
+ img.thumbnail((MAX_SIDE, MAX_SIDE))
101
+ return img
102
+
103
+
104
+ def as_label(answer, names=None):
105
+ """Rune answer -> {label: probability}, the shape gr.Label and the live pages draw.
106
+
107
+ names maps a choice's option keys to what to show instead, usually its criteria.
108
+ """
109
+ if "noul" in answer:
110
+ return {"yes": answer["noul"], "no": 1 - answer["noul"]}
111
+ names = names or answer.get("legend") or {}
112
+ return {names.get(k, k): p for k, p in answer["probabilities"].items()}
113
+
114
+
115
+ def curl(body):
116
+ shown = dict(body)
117
+ if "images" in shown:
118
+ shown["images"] = [u[:40] + "…" for u in shown["images"]]
119
+ return (f"curl {os.environ.get('RUNE_URL', '')} \\\n -H 'Authorization: Bearer $RUNE_API_KEY' \\\n"
120
+ f" -H 'Content-Type: application/json' \\\n -d '{json.dumps(shown, ensure_ascii=False)}'")
static/client.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Rune Labs: the two helpers both static Spaces use. Plain globals, loaded before the page's own script. */
2
+
3
+ /* POST JSON to the Space and return the reply, or throw with the server's message. */
4
+ async function post(path, body) {
5
+ const r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
6
+ const data = await r.json().catch(() => ({}));
7
+ if (!r.ok) throw new Error(data.detail || "Something went wrong. Try again in a moment.");
8
+ return data;
9
+ }
10
+
11
+ /* One probability row: label, bar, percentage. Built with textContent, never HTML. */
12
+ function bar(label, p, top) {
13
+ const row = document.createElement("div");
14
+ row.className = top ? "bar top" : "bar";
15
+ const name = document.createElement("span");
16
+ name.textContent = label;
17
+ const track = document.createElement("div");
18
+ track.className = "track";
19
+ const fill = document.createElement("div");
20
+ fill.className = "fill";
21
+ fill.style.width = `${(p * 100).toFixed(1)}%`;
22
+ track.append(fill);
23
+ const pct = document.createElement("span");
24
+ pct.className = "pct";
25
+ pct.textContent = `${Math.round(p * 100)}%`;
26
+ row.append(name, track, pct);
27
+ return row;
28
+ }
static/embed.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Rune Labs: inside surogate.ai a demo drops its own header (the page row already names it) and
2
+ reports its height, so the page sizes the frame to fit and nothing scrolls inside it.
3
+
4
+ The height is the bottom of main's children, not of main itself: Gradio stretches <main> to fill
5
+ the frame, so measuring it would grow the frame, which grows main, forever.
6
+
7
+ surogate.ai asks for this with ?embed=surogate. Being in a frame is not enough: huggingface.co
8
+ shows every Space in one too, and there the header and footer should stay. */
9
+ (() => {
10
+ if (window.self === window.top || !new URLSearchParams(location.search).has("embed")) return;
11
+ document.documentElement.classList.add("rune-embedded");
12
+ let last = 0;
13
+ const contentBottom = (main) => {
14
+ let bottom = 0;
15
+ for (const child of main.children) bottom = Math.max(bottom, child.getBoundingClientRect().bottom);
16
+ return bottom + window.scrollY + parseFloat(getComputedStyle(main).paddingBottom || "0");
17
+ };
18
+ const send = () => {
19
+ const main = document.querySelector("main");
20
+ if (!main) return;
21
+ const height = Math.ceil(contentBottom(main) + 28);
22
+ if (Math.abs(height - last) < 2) return;
23
+ last = height;
24
+ window.parent.postMessage({ type: "rune-labs:height", height }, "*");
25
+ };
26
+ const start = () => {
27
+ const main = document.querySelector("main"); // Gradio mounts it after the script runs
28
+ if (!main || !main.children.length) return setTimeout(start, 150);
29
+ const watch = new ResizeObserver(send);
30
+ for (const child of main.children) watch.observe(child);
31
+ new MutationObserver(() => { for (const child of main.children) watch.observe(child); send(); })
32
+ .observe(main, { childList: true });
33
+ send();
34
+ };
35
+ if (document.readyState === "loading") addEventListener("DOMContentLoaded", start);
36
+ else start();
37
+ })();
static/index.html ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Doodle Decoder</title>
7
+ <meta name="description" content="Draw the word you're given. Surogate Rune looks at your pixels and guesses, live.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --ink: #2a102d; --ink-2: #52504c; --ink-3: #8c847a; --paper: #ffffff; --card: #ffffff;
13
+ --amber: #ffaf10; --amber-soft: rgba(255, 175, 16, 0.14); --line: #ece6da; --good: #22c55e;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ body { margin: 0; font: 16px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
17
+ background: var(--paper); color: var(--ink); -webkit-font-smoothing: antialiased; }
18
+ main { max-width: 1040px; margin: 0 auto; padding: 24px 20px 40px; display: grid; gap: 24px;
19
+ grid-template-columns: minmax(0, 1fr) 320px; }
20
+ @media (max-width: 800px) { main { grid-template-columns: minmax(0, 1fr); } }
21
+ header { grid-column: 1 / -1; }
22
+ h1 { margin: 0; font-family: Fraunces, Georgia, serif; font-weight: 600; font-size: clamp(30px, 5vw, 44px); letter-spacing: -0.02em; line-height: 1.05; }
23
+ .rune-embedded header, .rune-embedded footer { display: none; }
24
+ .rune-embedded main { padding-top: 8px; }
25
+ .sub { margin: 6px 0 0; color: var(--ink-2); }
26
+ .prompt { display: flex; align-items: baseline; gap: 10px; margin: 0 0 12px; font-size: 20px; color: var(--ink-2); }
27
+ .prompt b { font-size: 30px; color: var(--ink); background: var(--amber-soft); padding: 0 10px; border-radius: 8px; }
28
+ canvas { display: block; width: 100%; aspect-ratio: 1; background: #fff; border: 1px solid var(--line);
29
+ border-radius: 16px; touch-action: none; cursor: crosshair; box-shadow: 0 24px 50px -30px rgba(42, 16, 45, 0.35); }
30
+ .tools { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
31
+ button { font: inherit; font-weight: 600; padding: 9px 16px; border-radius: 10px; border: 2px solid var(--ink);
32
+ background: var(--card); color: var(--ink); cursor: pointer; }
33
+ button.primary { background: var(--amber); border-color: var(--amber); }
34
+ button:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
35
+ aside { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 18px 18px 14px;
36
+ align-self: start; position: sticky; top: 16px; }
37
+ .head { margin: 0 0 12px; font-family: Fraunces, Georgia, serif; font-weight: 600; font-size: 26px; min-height: 1.4em; }
38
+ .bar { display: grid; grid-template-columns: 96px 1fr 42px; gap: 8px; align-items: center; margin: 7px 0; font-size: 15px; }
39
+ .bar .track { height: 12px; background: var(--line); border-radius: 6px; overflow: hidden; }
40
+ .bar .fill { height: 100%; background: var(--amber); border-radius: 6px; transition: width 0.3s ease; }
41
+ .bar.top .fill { background: var(--ink); }
42
+ .bar .pct { text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2); }
43
+ .status { color: var(--ink-3); font-size: 14px; min-height: 1.5em; margin: 10px 0 0; }
44
+ .win { display: none; margin-top: 14px; padding: 14px; border-radius: 12px; background: var(--ink); color: #fff; }
45
+ .win.show { display: block; animation: pop 0.35s cubic-bezier(.2, 1.4, .4, 1); }
46
+ .win p { margin: 0 0 10px; }
47
+ .win button { border-color: var(--amber); background: var(--amber); color: var(--ink); }
48
+ @keyframes pop { from { transform: scale(0.92); opacity: 0; } to { transform: none; opacity: 1; } }
49
+ footer { grid-column: 1 / -1; color: var(--ink-3); font-size: 14px; }
50
+ footer a { color: var(--ink); }
51
+ @media (prefers-reduced-motion: reduce) { .bar .fill { transition: none; } .win.show { animation: none; } }
52
+ </style>
53
+ <script src="embed.js"></script>
54
+ <script src="client.js"></script>
55
+ </head>
56
+ <body>
57
+ <main>
58
+ <header>
59
+ <h1>Doodle Decoder</h1>
60
+ <p class="sub">Draw the word. Every time you lift the pen, Rune looks at your pixels and guesses.</p>
61
+ </header>
62
+
63
+ <section>
64
+ <p class="prompt">Draw <b id="word"></b></p>
65
+ <canvas id="canvas" width="512" height="512" aria-label="Drawing canvas"></canvas>
66
+ <div class="tools">
67
+ <button id="clear" type="button">Clear</button>
68
+ <button id="next" type="button" class="primary">New word</button>
69
+ </div>
70
+ </section>
71
+
72
+ <aside aria-live="polite">
73
+ <h2 class="head" id="head">Rune is watching…</h2>
74
+ <div id="bars"></div>
75
+ <p class="status" id="status">Start drawing.</p>
76
+ <div class="win" id="win">
77
+ <p id="win-text"></p>
78
+ <button id="share" type="button">Share</button>
79
+ </div>
80
+ </aside>
81
+
82
+ <footer>
83
+ Powered by <a href="https://huggingface.co/surogate/rune-26b-a4b-GGUF" target="_blank" rel="noopener">Surogate Rune</a>,
84
+ an open decision model. <a href="https://surogate.ai/labs/rune-examples/" target="_blank" rel="noopener">More demos</a>.
85
+ Nothing you draw is stored.
86
+ </footer>
87
+ </main>
88
+
89
+ <script>
90
+ const WORDS = ["cat", "dog", "fish", "bird", "house", "tree", "flower", "sun", "moon", "star", "car", "bicycle",
91
+ "boat", "airplane", "train", "rocket", "cup", "chair", "table", "lamp", "clock", "key", "umbrella",
92
+ "hat", "shoe", "glasses", "guitar", "pizza", "apple", "banana", "ice cream", "cake", "heart",
93
+ "smiley face", "snake", "spider", "butterfly", "mountain", "cloud", "lightning", "snowman",
94
+ "ghost", "crown", "sword", "light bulb", "phone", "envelope", "scissors", "pencil", "eye"];
95
+
96
+ const canvas = document.getElementById("canvas");
97
+ const ctx = canvas.getContext("2d");
98
+ ctx.lineWidth = 8; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#111";
99
+ const $ = (id) => document.getElementById(id);
100
+ let strokes = 0, drawing = false, busy = false, pending = false, target = "", won = false;
101
+ let round = 0;
102
+ let last = null; // the previous pointer position; each move draws only the new segment // bumped by Clear and New word; an answer from an earlier round is dropped
103
+
104
+ function clearCanvas() {
105
+ ctx.fillStyle = "#fff";
106
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
107
+ strokes = 0; won = false; pending = false; round++;
108
+ $("bars").replaceChildren();
109
+ $("win").classList.remove("show");
110
+ $("head").textContent = "Rune is watching…";
111
+ $("status").textContent = "Start drawing.";
112
+ }
113
+
114
+ function newWord(asked) {
115
+ let next = WORDS.includes(asked) ? asked : null;
116
+ while (!next || next === target) next = WORDS[Math.floor(Math.random() * WORDS.length)];
117
+ target = next;
118
+ $("word").textContent = target;
119
+ clearCanvas();
120
+ }
121
+
122
+ function point(e) {
123
+ const r = canvas.getBoundingClientRect();
124
+ return [(e.clientX - r.left) * canvas.width / r.width, (e.clientY - r.top) * canvas.height / r.height];
125
+ }
126
+
127
+ canvas.addEventListener("pointerdown", (e) => {
128
+ drawing = true;
129
+ canvas.setPointerCapture(e.pointerId);
130
+ last = point(e);
131
+ });
132
+ canvas.addEventListener("pointermove", (e) => {
133
+ if (!drawing) return;
134
+ const p = point(e);
135
+ ctx.beginPath();
136
+ ctx.moveTo(...last);
137
+ ctx.lineTo(...p);
138
+ ctx.stroke();
139
+ last = p;
140
+ });
141
+ function endStroke() {
142
+ if (!drawing) return;
143
+ drawing = false;
144
+ strokes++;
145
+ guess();
146
+ }
147
+ canvas.addEventListener("pointerup", endStroke);
148
+ canvas.addEventListener("pointercancel", endStroke);
149
+
150
+
151
+
152
+ function setBusy(on) {
153
+ busy = on;
154
+ if (on) document.body.dataset.busy = "1"; else delete document.body.dataset.busy;
155
+ }
156
+
157
+ async function guess() {
158
+ if (won) return;
159
+ if (busy) { pending = true; return; } // one request at a time; the latest drawing is sent next
160
+ setBusy(true);
161
+ const mine = round;
162
+ try {
163
+ const t = performance.now();
164
+ const out = await post("decide", { image: canvas.toDataURL("image/png"), context: {} });
165
+ if (mine !== round) return;
166
+ const probs = Object.entries(out.labels.guess).sort((a, b) => b[1] - a[1]);
167
+ const [top, p] = probs[0];
168
+ $("head").textContent = p < 0.5 ? `Hmm… a ${top}?` : `It's a ${top}!`;
169
+ $("bars").replaceChildren(...probs.slice(0, 5).map(([w, q], i) => bar(w, q, i === 0)));
170
+ $("status").textContent = `${Math.round(performance.now() - t)} ms · stroke ${strokes}`;
171
+ if (top === target && p >= 0.5) {
172
+ won = true;
173
+ $("win-text").textContent = `Rune got it in ${strokes} stroke${strokes === 1 ? "" : "s"}, ${Math.round(p * 100)}% sure it's a ${target}.`;
174
+ $("win").classList.add("show");
175
+ }
176
+ } catch (err) {
177
+ if (mine === round) $("status").textContent = err.message;
178
+ } finally {
179
+ setBusy(false);
180
+ if (pending && !won) { pending = false; guess(); }
181
+ }
182
+ }
183
+
184
+ $("share").addEventListener("click", async () => {
185
+ const url = new URL(location.href);
186
+ url.searchParams.set("word", target); // the link challenges a friend to draw the same word
187
+ const text = `${$("win-text").textContent} Can you beat it? ${url}`;
188
+ const copy = async () => { await navigator.clipboard.writeText(text); $("share").textContent = "Copied!"; };
189
+ try {
190
+ if (navigator.share) await navigator.share({ text }); else await copy();
191
+ } catch (err) {
192
+ // AbortError: the visitor closed the share sheet. Anything else (a frame without web-share): copy instead.
193
+ if (err.name !== "AbortError") await copy().catch(() => { $("share").textContent = "Copy the address bar to share"; });
194
+ }
195
+ });
196
+ $("clear").addEventListener("click", clearCanvas);
197
+ $("next").addEventListener("click", () => newWord());
198
+ const asked = new URLSearchParams(location.search).get("word");
199
+ newWord(asked);
200
+ </script>
201
+ </body>
202
+ </html>