Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .gitignore +7 -0
- script/analyze_hand_span.py +215 -0
- web_demo/app.py +80 -3
- web_demo/static/mobile/mobile.css +843 -0
- web_demo/static/mobile/mobile.js +35 -0
- web_demo/static/mobile/session.js +46 -0
- web_demo/static/mobile/steps.js +74 -0
- web_demo/static/mobile/steps/capture.js +498 -0
- web_demo/static/mobile/steps/confirm.js +117 -0
- web_demo/static/mobile/steps/form.js +68 -0
- web_demo/static/mobile/steps/guide.js +100 -0
- web_demo/static/mobile/steps/intro.js +24 -0
- web_demo/static/mobile/steps/result.js +171 -0
- web_demo/static/preview/hands.js +90 -0
- web_demo/static/preview/orientation.js +77 -0
- web_demo/static/preview/preview.js +127 -0
- web_demo/static/preview/thresholds.js +58 -0
- web_demo/static/shared/fail-reasons.js +72 -0
- web_demo/static/shared/measure-api.js +53 -0
- web_demo/static/shared/tokens.css +16 -0
- web_demo/static/styles.css +25 -14
- web_demo/templates/mobile.html +31 -0
.gitignore
CHANGED
|
@@ -38,3 +38,10 @@ input/*.heic
|
|
| 38 |
.DS_Store
|
| 39 |
Thumbs.db
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
.DS_Store
|
| 39 |
Thumbs.db
|
| 40 |
|
| 41 |
+
# Claude Code session metadata
|
| 42 |
+
.claude/
|
| 43 |
+
|
| 44 |
+
# Local TLS certs (mkcert) — private key MUST NOT be committed.
|
| 45 |
+
web_demo/certs/
|
| 46 |
+
*.pem
|
| 47 |
+
|
script/analyze_hand_span.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Hand-span ratio distribution analysis for the web-preview distance gate.
|
| 3 |
+
|
| 4 |
+
For each image in `input/kol_total` ∪ `input/kol_success`:
|
| 5 |
+
1. Run MediaPipe Hands once on the original frame; compute
|
| 6 |
+
hand_span_ratio = ||landmark[5] - landmark[17]|| / min(W, H).
|
| 7 |
+
2. Run the full measurement pipeline once (cached) to get the authoritative
|
| 8 |
+
fail_reason. This is the only way to cleanly separate `card_too_small`
|
| 9 |
+
from other failure modes (`hand_not_detected`, `card_not_parallel`, etc.).
|
| 10 |
+
3. Bucket ratios by fail_reason and print percentile stats so we can pick
|
| 11 |
+
the preview "close enough" threshold (target: P10 of the success bucket
|
| 12 |
+
to be conservative for small-handed users).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import subprocess
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import cv2
|
| 22 |
+
import mediapipe as mp
|
| 23 |
+
import numpy as np
|
| 24 |
+
from mediapipe.tasks import python as mp_python
|
| 25 |
+
from mediapipe.tasks.python import vision as mp_vision
|
| 26 |
+
|
| 27 |
+
# Landmarks for the palm-MCP span (index MCP and pinky MCP).
|
| 28 |
+
LM_INDEX_MCP = 5
|
| 29 |
+
LM_PINKY_MCP = 17
|
| 30 |
+
|
| 31 |
+
IMG_EXTS = {".jpg", ".jpeg", ".png"}
|
| 32 |
+
|
| 33 |
+
# Reuse the same MediaPipe Tasks model the pipeline already downloads.
|
| 34 |
+
MODEL_PATH = (
|
| 35 |
+
Path(__file__).resolve().parent.parent / ".model" / "hand_landmarker.task"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
_detector = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _get_detector():
|
| 42 |
+
global _detector
|
| 43 |
+
if _detector is None:
|
| 44 |
+
if not MODEL_PATH.exists():
|
| 45 |
+
raise FileNotFoundError(
|
| 46 |
+
f"hand_landmarker.task missing at {MODEL_PATH} — run measure_finger.py once to trigger the auto-download."
|
| 47 |
+
)
|
| 48 |
+
opts = mp_vision.HandLandmarkerOptions(
|
| 49 |
+
base_options=mp_python.BaseOptions(model_asset_path=str(MODEL_PATH)),
|
| 50 |
+
num_hands=1,
|
| 51 |
+
min_hand_detection_confidence=0.3,
|
| 52 |
+
min_tracking_confidence=0.3,
|
| 53 |
+
)
|
| 54 |
+
_detector = mp_vision.HandLandmarker.create_from_options(opts)
|
| 55 |
+
return _detector
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def hand_span_ratio(image_path: Path):
|
| 59 |
+
"""Return (ratio, h, w, detected). Try 4 rotations like the pipeline does;
|
| 60 |
+
the ratio is rotation-invariant so we just take whichever rotation detects
|
| 61 |
+
a hand with highest confidence."""
|
| 62 |
+
img = cv2.imread(str(image_path))
|
| 63 |
+
if img is None:
|
| 64 |
+
return None, None, None, False
|
| 65 |
+
h0, w0 = img.shape[:2]
|
| 66 |
+
|
| 67 |
+
rotations = [
|
| 68 |
+
(img, 0),
|
| 69 |
+
(cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE), 1),
|
| 70 |
+
(cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE), 3),
|
| 71 |
+
(cv2.rotate(img, cv2.ROTATE_180), 2),
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
detector = _get_detector()
|
| 75 |
+
best_score = -1.0
|
| 76 |
+
best_lm = None
|
| 77 |
+
best_hw = None
|
| 78 |
+
|
| 79 |
+
for rotated, _code in rotations:
|
| 80 |
+
rgb = cv2.cvtColor(rotated, cv2.COLOR_BGR2RGB)
|
| 81 |
+
rgb = np.ascontiguousarray(rgb)
|
| 82 |
+
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
|
| 83 |
+
res = detector.detect(mp_image)
|
| 84 |
+
if not res.hand_landmarks:
|
| 85 |
+
continue
|
| 86 |
+
score = res.handedness[0][0].score if res.handedness else 0.0
|
| 87 |
+
if score > best_score:
|
| 88 |
+
best_score = score
|
| 89 |
+
best_lm = res.hand_landmarks[0]
|
| 90 |
+
best_hw = rotated.shape[:2]
|
| 91 |
+
|
| 92 |
+
if best_lm is None:
|
| 93 |
+
return None, h0, w0, False
|
| 94 |
+
|
| 95 |
+
rh, rw = best_hw
|
| 96 |
+
p5 = np.array([best_lm[LM_INDEX_MCP].x * rw, best_lm[LM_INDEX_MCP].y * rh])
|
| 97 |
+
p17 = np.array([best_lm[LM_PINKY_MCP].x * rw, best_lm[LM_PINKY_MCP].y * rh])
|
| 98 |
+
span_px = float(np.linalg.norm(p5 - p17))
|
| 99 |
+
short_side = min(rh, rw) # rotation-invariant
|
| 100 |
+
return span_px / short_side, h0, w0, True
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def measure_fail_reason(image_path: Path, cache_dir: Path, base: Path) -> dict:
|
| 104 |
+
"""Run measure_finger.py once (cached) and return its result dict."""
|
| 105 |
+
out_json = cache_dir / f"{image_path.stem}.json"
|
| 106 |
+
if not out_json.exists():
|
| 107 |
+
cmd = [
|
| 108 |
+
sys.executable, "measure_finger.py",
|
| 109 |
+
"--input", str(image_path),
|
| 110 |
+
"--output", str(out_json),
|
| 111 |
+
"--finger-index", "index",
|
| 112 |
+
"--card-method", "sam",
|
| 113 |
+
"--no-calibration",
|
| 114 |
+
]
|
| 115 |
+
try:
|
| 116 |
+
subprocess.run(
|
| 117 |
+
cmd, capture_output=True, text=True, timeout=180, cwd=base,
|
| 118 |
+
)
|
| 119 |
+
except subprocess.TimeoutExpired:
|
| 120 |
+
return {"fail_reason": "timeout"}
|
| 121 |
+
if out_json.exists():
|
| 122 |
+
with open(out_json) as f:
|
| 123 |
+
return json.load(f)
|
| 124 |
+
return {"fail_reason": "no_output"}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def percentiles(values, ps=(10, 25, 50, 75, 90)):
|
| 128 |
+
if not values:
|
| 129 |
+
return {p: None for p in ps}
|
| 130 |
+
arr = np.array(values)
|
| 131 |
+
return {p: float(np.percentile(arr, p)) for p in ps}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def main():
|
| 135 |
+
base = Path(__file__).resolve().parent.parent
|
| 136 |
+
os.chdir(base)
|
| 137 |
+
|
| 138 |
+
# Union of kol_total and kol_success, dedup by filename.
|
| 139 |
+
by_name = {}
|
| 140 |
+
for d in ("kol_total", "kol_success"):
|
| 141 |
+
folder = base / "input" / d
|
| 142 |
+
if not folder.is_dir():
|
| 143 |
+
continue
|
| 144 |
+
for p in folder.iterdir():
|
| 145 |
+
if p.suffix.lower() in IMG_EXTS:
|
| 146 |
+
by_name.setdefault(p.name, p)
|
| 147 |
+
images = sorted(by_name.values(), key=lambda p: p.name)
|
| 148 |
+
print(f"Found {len(images)} unique images across kol_total ∪ kol_success")
|
| 149 |
+
|
| 150 |
+
cache_dir = base / "output" / "hand_span_analysis"
|
| 151 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 152 |
+
|
| 153 |
+
results = []
|
| 154 |
+
for i, img_path in enumerate(images, 1):
|
| 155 |
+
print(f"[{i}/{len(images)}] {img_path.name}", end=" ", flush=True)
|
| 156 |
+
|
| 157 |
+
ratio, h, w, detected = hand_span_ratio(img_path)
|
| 158 |
+
meas = measure_fail_reason(img_path, cache_dir, base)
|
| 159 |
+
fail = meas.get("fail_reason")
|
| 160 |
+
|
| 161 |
+
results.append({
|
| 162 |
+
"image": img_path.name,
|
| 163 |
+
"h": h, "w": w,
|
| 164 |
+
"hand_detected_mediapipe": detected,
|
| 165 |
+
"hand_span_ratio": ratio,
|
| 166 |
+
"fail_reason": fail,
|
| 167 |
+
"scale_px_per_cm": meas.get("scale_px_per_cm"),
|
| 168 |
+
})
|
| 169 |
+
print(f"ratio={ratio if ratio is None else f'{ratio:.3f}'} fail={fail}")
|
| 170 |
+
|
| 171 |
+
out_json = cache_dir / "hand_span_results.json"
|
| 172 |
+
with open(out_json, "w") as f:
|
| 173 |
+
json.dump(results, f, indent=2)
|
| 174 |
+
print(f"\nSaved {out_json}")
|
| 175 |
+
|
| 176 |
+
# Bucket by fail_reason.
|
| 177 |
+
buckets: dict[str, list[float]] = {}
|
| 178 |
+
for r in results:
|
| 179 |
+
if r["hand_span_ratio"] is None:
|
| 180 |
+
key = "_mediapipe_no_hand"
|
| 181 |
+
else:
|
| 182 |
+
key = r["fail_reason"] or "success"
|
| 183 |
+
buckets.setdefault(key, []).append(r["hand_span_ratio"])
|
| 184 |
+
|
| 185 |
+
print("\n=== hand_span_ratio distribution by fail_reason ===")
|
| 186 |
+
print(f"{'bucket':<30} {'n':>4} {'P10':>6} {'P25':>6} {'P50':>6} {'P75':>6} {'P90':>6} mean")
|
| 187 |
+
for key in sorted(buckets, key=lambda k: (-len(buckets[k]), k)):
|
| 188 |
+
vals = buckets[key]
|
| 189 |
+
ps = percentiles(vals)
|
| 190 |
+
mean = float(np.mean(vals))
|
| 191 |
+
line = (
|
| 192 |
+
f"{key:<30} {len(vals):>4} "
|
| 193 |
+
f"{ps[10]:.3f} {ps[25]:.3f} {ps[50]:.3f} {ps[75]:.3f} {ps[90]:.3f} {mean:.3f}"
|
| 194 |
+
)
|
| 195 |
+
print(line)
|
| 196 |
+
|
| 197 |
+
no_hand = sum(1 for r in results if not r["hand_detected_mediapipe"])
|
| 198 |
+
print(f"\nMediaPipe failed to detect a hand on {no_hand}/{len(results)} images.")
|
| 199 |
+
|
| 200 |
+
# Threshold suggestion.
|
| 201 |
+
success = buckets.get("success", [])
|
| 202 |
+
too_small = buckets.get("card_too_small", [])
|
| 203 |
+
if success:
|
| 204 |
+
p10_success = float(np.percentile(success, 10))
|
| 205 |
+
print(f"\nSuggested preview gate: hand_span_ratio >= {p10_success:.3f}")
|
| 206 |
+
print(f" (P10 of success cohort, conservative for small-handed users)")
|
| 207 |
+
if too_small:
|
| 208 |
+
below = sum(1 for v in too_small if v < p10_success)
|
| 209 |
+
print(f" {below}/{len(too_small)} card_too_small samples fall below this gate.")
|
| 210 |
+
above = sum(1 for v in success if v >= p10_success)
|
| 211 |
+
print(f" {above}/{len(success)} success samples pass this gate (= 90% by construction).")
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
main()
|
web_demo/app.py
CHANGED
|
@@ -22,7 +22,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
| 22 |
|
| 23 |
import cv2
|
| 24 |
import numpy as np
|
| 25 |
-
from flask import Flask, Response, jsonify, render_template, request, send_from_directory
|
| 26 |
from werkzeug.utils import secure_filename
|
| 27 |
|
| 28 |
ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
@@ -55,6 +55,8 @@ DEFAULT_SAMPLE_URL = "/static/examples/default_sample.jpg"
|
|
| 55 |
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
| 56 |
DEMO_EDGE_METHOD = "mask"
|
| 57 |
DEMO_CARD_METHOD = "sam"
|
|
|
|
|
|
|
| 58 |
|
| 59 |
app = Flask(__name__)
|
| 60 |
|
|
@@ -145,25 +147,79 @@ def _save_json(path: Path, data: Dict[str, Any]) -> None:
|
|
| 145 |
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 146 |
|
| 147 |
|
| 148 |
-
def _read_form_settings() -> Dict[str,
|
| 149 |
"""Parse the measurement-request form fields shared by /api/measure and
|
| 150 |
/api/measure-default."""
|
| 151 |
ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
|
| 152 |
if ring_model not in VALID_RING_MODELS:
|
| 153 |
ring_model = DEFAULT_RING_MODEL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return {
|
| 155 |
"finger_index": request.form.get("finger_index", "index"),
|
| 156 |
"mode": request.form.get("mode", "single"),
|
| 157 |
"kol_name": request.form.get("kol_name", "").strip(),
|
| 158 |
"ring_model": ring_model,
|
|
|
|
|
|
|
| 159 |
}
|
| 160 |
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
@app.route("/")
|
| 163 |
def index():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=False)
|
| 165 |
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
@app.route("/dev")
|
| 168 |
@app.route("/debug")
|
| 169 |
def index_dev():
|
|
@@ -209,6 +265,8 @@ def api_measure():
|
|
| 209 |
input_image_url=f"/uploads/{upload_name}",
|
| 210 |
ring_model=settings["ring_model"],
|
| 211 |
kol_name=settings["kol_name"],
|
|
|
|
|
|
|
| 212 |
upload_path=upload_path,
|
| 213 |
upload_name=upload_name,
|
| 214 |
base_name=base_name,
|
|
@@ -236,6 +294,8 @@ def api_measure_default():
|
|
| 236 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 237 |
ring_model=settings["ring_model"],
|
| 238 |
kol_name=settings["kol_name"],
|
|
|
|
|
|
|
| 239 |
base_name=base_name,
|
| 240 |
run_id=run_id,
|
| 241 |
)
|
|
@@ -250,6 +310,8 @@ def _run_measurement(
|
|
| 250 |
input_image_url: str,
|
| 251 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 252 |
kol_name: str = "",
|
|
|
|
|
|
|
| 253 |
upload_path: Optional[Path] = None,
|
| 254 |
upload_name: str = "",
|
| 255 |
base_name: str = "",
|
|
@@ -279,6 +341,9 @@ def _run_measurement(
|
|
| 279 |
if rec:
|
| 280 |
result["ring_size"] = rec
|
| 281 |
|
|
|
|
|
|
|
|
|
|
| 282 |
result = _numpy_safe(result)
|
| 283 |
|
| 284 |
result_json_name = f"{base_name}_result.json"
|
|
@@ -325,6 +390,8 @@ def _run_multi_measurement(
|
|
| 325 |
input_image_url: str,
|
| 326 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 327 |
kol_name: str = "",
|
|
|
|
|
|
|
| 328 |
upload_path: Optional[Path] = None,
|
| 329 |
upload_name: str = "",
|
| 330 |
base_name: str = "",
|
|
@@ -344,6 +411,9 @@ def _run_multi_measurement(
|
|
| 344 |
ring_model=ring_model,
|
| 345 |
)
|
| 346 |
|
|
|
|
|
|
|
|
|
|
| 347 |
result = _numpy_safe(result)
|
| 348 |
|
| 349 |
# Collect finger widths for AI recommendation
|
|
@@ -744,4 +814,11 @@ def api_admin_export_csv():
|
|
| 744 |
|
| 745 |
|
| 746 |
if __name__ == "__main__":
|
| 747 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
import cv2
|
| 24 |
import numpy as np
|
| 25 |
+
from flask import Flask, Response, jsonify, redirect, render_template, request, send_from_directory
|
| 26 |
from werkzeug.utils import secure_filename
|
| 27 |
|
| 28 |
ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
|
|
| 55 |
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
| 56 |
DEMO_EDGE_METHOD = "mask"
|
| 57 |
DEMO_CARD_METHOD = "sam"
|
| 58 |
+
VALID_CAPTURE_METHODS = {"upload", "camera"}
|
| 59 |
+
DEFAULT_CAPTURE_METHOD = "upload"
|
| 60 |
|
| 61 |
app = Flask(__name__)
|
| 62 |
|
|
|
|
| 147 |
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 148 |
|
| 149 |
|
| 150 |
+
def _read_form_settings() -> Dict[str, Any]:
|
| 151 |
"""Parse the measurement-request form fields shared by /api/measure and
|
| 152 |
/api/measure-default."""
|
| 153 |
ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
|
| 154 |
if ring_model not in VALID_RING_MODELS:
|
| 155 |
ring_model = DEFAULT_RING_MODEL
|
| 156 |
+
capture_method = request.form.get("capture_method", DEFAULT_CAPTURE_METHOD)
|
| 157 |
+
if capture_method not in VALID_CAPTURE_METHODS:
|
| 158 |
+
capture_method = DEFAULT_CAPTURE_METHOD
|
| 159 |
+
|
| 160 |
+
# gate_telemetry is a JSON-encoded blob from the v5 capture coach. Schema
|
| 161 |
+
# is intentionally fluid — server stamps it into result_json verbatim so
|
| 162 |
+
# frontend can evolve the payload without server changes. Hard-cap the
|
| 163 |
+
# raw size at 10 KB so a malicious client can't pad result_json (and
|
| 164 |
+
# Supabase rows) with arbitrary bytes; the legitimate payload is on the
|
| 165 |
+
# order of a few hundred bytes.
|
| 166 |
+
gate_telemetry: Optional[Dict[str, Any]] = None
|
| 167 |
+
raw = request.form.get("gate_telemetry")
|
| 168 |
+
if raw:
|
| 169 |
+
if len(raw) > 10_000:
|
| 170 |
+
logger.warning("dropping oversized gate_telemetry payload (%d bytes)", len(raw))
|
| 171 |
+
else:
|
| 172 |
+
try:
|
| 173 |
+
parsed = json.loads(raw)
|
| 174 |
+
if isinstance(parsed, dict):
|
| 175 |
+
gate_telemetry = parsed
|
| 176 |
+
except json.JSONDecodeError:
|
| 177 |
+
logger.warning("dropping malformed gate_telemetry payload")
|
| 178 |
+
|
| 179 |
return {
|
| 180 |
"finger_index": request.form.get("finger_index", "index"),
|
| 181 |
"mode": request.form.get("mode", "single"),
|
| 182 |
"kol_name": request.form.get("kol_name", "").strip(),
|
| 183 |
"ring_model": ring_model,
|
| 184 |
+
"capture_method": capture_method,
|
| 185 |
+
"gate_telemetry": gate_telemetry,
|
| 186 |
}
|
| 187 |
|
| 188 |
|
| 189 |
+
# Coarse UA sniff — only used to decide which surface (`index.html` vs
|
| 190 |
+
# `mobile.html`) to render at `/`. We deliberately use a simple regex
|
| 191 |
+
# rather than a UA-parsing library: false negatives just mean a phone
|
| 192 |
+
# user sees the desktop page, which they can recover from with the
|
| 193 |
+
# `?desktop=1` override; false positives are harmless because the
|
| 194 |
+
# desktop user just sees the mobile page (and can use `?desktop=1`
|
| 195 |
+
# from there). Not worth a heavy dependency.
|
| 196 |
+
_MOBILE_UA_RE = re.compile(
|
| 197 |
+
r"iphone|ipod|android.+mobile|blackberry|iemobile|opera mini|windows phone",
|
| 198 |
+
re.IGNORECASE,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _is_mobile_ua(user_agent_str: str) -> bool:
|
| 203 |
+
return bool(user_agent_str and _MOBILE_UA_RE.search(user_agent_str))
|
| 204 |
+
|
| 205 |
+
|
| 206 |
@app.route("/")
|
| 207 |
def index():
|
| 208 |
+
# Phone visitors get auto-redirected to the mobile flow unless they
|
| 209 |
+
# opt out with `?desktop=1`. The override lets the dev demo from a
|
| 210 |
+
# phone in desktop mode and lets us share desktop links that hold
|
| 211 |
+
# up regardless of the recipient's device.
|
| 212 |
+
force_desktop = request.args.get("desktop") is not None
|
| 213 |
+
if not force_desktop and _is_mobile_ua(request.headers.get("User-Agent", "")):
|
| 214 |
+
return redirect("/m", code=302)
|
| 215 |
return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=False)
|
| 216 |
|
| 217 |
|
| 218 |
+
@app.route("/m")
|
| 219 |
+
def index_mobile():
|
| 220 |
+
return render_template("mobile.html")
|
| 221 |
+
|
| 222 |
+
|
| 223 |
@app.route("/dev")
|
| 224 |
@app.route("/debug")
|
| 225 |
def index_dev():
|
|
|
|
| 265 |
input_image_url=f"/uploads/{upload_name}",
|
| 266 |
ring_model=settings["ring_model"],
|
| 267 |
kol_name=settings["kol_name"],
|
| 268 |
+
capture_method=settings["capture_method"],
|
| 269 |
+
gate_telemetry=settings["gate_telemetry"],
|
| 270 |
upload_path=upload_path,
|
| 271 |
upload_name=upload_name,
|
| 272 |
base_name=base_name,
|
|
|
|
| 294 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 295 |
ring_model=settings["ring_model"],
|
| 296 |
kol_name=settings["kol_name"],
|
| 297 |
+
capture_method=settings["capture_method"],
|
| 298 |
+
gate_telemetry=settings["gate_telemetry"],
|
| 299 |
base_name=base_name,
|
| 300 |
run_id=run_id,
|
| 301 |
)
|
|
|
|
| 310 |
input_image_url: str,
|
| 311 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 312 |
kol_name: str = "",
|
| 313 |
+
capture_method: str = DEFAULT_CAPTURE_METHOD,
|
| 314 |
+
gate_telemetry: Optional[Dict[str, Any]] = None,
|
| 315 |
upload_path: Optional[Path] = None,
|
| 316 |
upload_name: str = "",
|
| 317 |
base_name: str = "",
|
|
|
|
| 341 |
if rec:
|
| 342 |
result["ring_size"] = rec
|
| 343 |
|
| 344 |
+
result["capture_method"] = capture_method
|
| 345 |
+
if gate_telemetry is not None:
|
| 346 |
+
result["gate_telemetry"] = gate_telemetry
|
| 347 |
result = _numpy_safe(result)
|
| 348 |
|
| 349 |
result_json_name = f"{base_name}_result.json"
|
|
|
|
| 390 |
input_image_url: str,
|
| 391 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 392 |
kol_name: str = "",
|
| 393 |
+
capture_method: str = DEFAULT_CAPTURE_METHOD,
|
| 394 |
+
gate_telemetry: Optional[Dict[str, Any]] = None,
|
| 395 |
upload_path: Optional[Path] = None,
|
| 396 |
upload_name: str = "",
|
| 397 |
base_name: str = "",
|
|
|
|
| 411 |
ring_model=ring_model,
|
| 412 |
)
|
| 413 |
|
| 414 |
+
result["capture_method"] = capture_method
|
| 415 |
+
if gate_telemetry is not None:
|
| 416 |
+
result["gate_telemetry"] = gate_telemetry
|
| 417 |
result = _numpy_safe(result)
|
| 418 |
|
| 419 |
# Collect finger widths for AI recommendation
|
|
|
|
| 814 |
|
| 815 |
|
| 816 |
if __name__ == "__main__":
|
| 817 |
+
# Local-dev HTTPS via mkcert: set RING_DEV_TLS_CERT and RING_DEV_TLS_KEY
|
| 818 |
+
# to enable HTTPS on the dev server (required for getUserMedia on a
|
| 819 |
+
# phone over LAN). HF Space deploys leave these unset and serve HTTP
|
| 820 |
+
# behind HF's platform-level TLS terminator.
|
| 821 |
+
cert = os.environ.get("RING_DEV_TLS_CERT")
|
| 822 |
+
key = os.environ.get("RING_DEV_TLS_KEY")
|
| 823 |
+
ssl_context = (cert, key) if cert and key else None
|
| 824 |
+
app.run(host="0.0.0.0", port=8000, debug=True, ssl_context=ssl_context)
|
web_demo/static/mobile/mobile.css
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Mobile flow stylesheet. Pulls design tokens from shared/tokens.css
|
| 2 |
+
so the palette matches the desktop. Visual treatments here mirror
|
| 3 |
+
the desktop's hero copy, hero-card panel, file-drop, capture-tips,
|
| 4 |
+
primary button, image-frame, finger-cards, and size-ref-table —
|
| 5 |
+
the mobile flow is the desktop page paginated, not a different
|
| 6 |
+
visual identity. */
|
| 7 |
+
|
| 8 |
+
@import url("../shared/tokens.css");
|
| 9 |
+
|
| 10 |
+
* {
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
[hidden] {
|
| 15 |
+
display: none !important;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
html,
|
| 19 |
+
body {
|
| 20 |
+
margin: 0;
|
| 21 |
+
padding: 0;
|
| 22 |
+
width: 100%;
|
| 23 |
+
height: 100%;
|
| 24 |
+
background: var(--bg-1);
|
| 25 |
+
color: var(--ink);
|
| 26 |
+
font-family: "Iowan Old Style", "Palatino", "Book Antiqua", "Times New Roman", serif;
|
| 27 |
+
-webkit-font-smoothing: antialiased;
|
| 28 |
+
overflow: hidden;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#mobileRoot {
|
| 32 |
+
width: 100%;
|
| 33 |
+
/* Cascade order: each later rule overrides on browsers supporting
|
| 34 |
+
that unit. Old browsers stop at vh; modern iOS Safari reaches
|
| 35 |
+
dvh which tracks URL-bar transitions, so per-step footers stay
|
| 36 |
+
visible. */
|
| 37 |
+
height: 100vh;
|
| 38 |
+
height: 100svh;
|
| 39 |
+
height: 100dvh;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* --- Step base layout -------------------------------------------- */
|
| 43 |
+
|
| 44 |
+
.step {
|
| 45 |
+
display: flex;
|
| 46 |
+
flex-direction: column;
|
| 47 |
+
width: 100%;
|
| 48 |
+
height: 100%;
|
| 49 |
+
padding: max(16px, env(safe-area-inset-top, 0px)) 20px
|
| 50 |
+
max(16px, env(safe-area-inset-bottom, 0px));
|
| 51 |
+
background:
|
| 52 |
+
radial-gradient(circle at 10% 10%, var(--bg-3), transparent 55%),
|
| 53 |
+
radial-gradient(circle at 90% 0%, var(--bg-2), transparent 55%),
|
| 54 |
+
linear-gradient(140deg, var(--bg-1), #fff8f2 60%, #f0e2d8 100%);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.step-head {
|
| 58 |
+
display: flex;
|
| 59 |
+
align-items: center;
|
| 60 |
+
gap: 12px;
|
| 61 |
+
margin-bottom: 12px;
|
| 62 |
+
min-height: 40px;
|
| 63 |
+
flex: 0 0 auto;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.step-back {
|
| 67 |
+
width: 40px;
|
| 68 |
+
height: 40px;
|
| 69 |
+
flex: 0 0 40px;
|
| 70 |
+
border: 1px solid var(--border);
|
| 71 |
+
border-radius: 50%;
|
| 72 |
+
background: white;
|
| 73 |
+
color: var(--ink);
|
| 74 |
+
font-size: 22px;
|
| 75 |
+
cursor: pointer;
|
| 76 |
+
display: inline-flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
justify-content: center;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.step-back:active {
|
| 82 |
+
background: var(--sand);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.step-body {
|
| 86 |
+
flex: 1 1 auto;
|
| 87 |
+
overflow-y: auto;
|
| 88 |
+
-webkit-overflow-scrolling: touch;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.step-foot {
|
| 92 |
+
flex: 0 0 auto;
|
| 93 |
+
padding-top: 14px;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* Secondary action under a primary button — text-link weight so it
|
| 97 |
+
reads as an "escape hatch" rather than an equally-weighted choice.
|
| 98 |
+
Padding is sized to clear the WCAG 2.5.5 / Apple HIG ~44px minimum
|
| 99 |
+
touch target. */
|
| 100 |
+
.step-link {
|
| 101 |
+
display: block;
|
| 102 |
+
width: 100%;
|
| 103 |
+
margin-top: 8px;
|
| 104 |
+
padding: 12px 16px;
|
| 105 |
+
background: transparent;
|
| 106 |
+
border: none;
|
| 107 |
+
border-radius: 8px;
|
| 108 |
+
color: var(--ink-soft);
|
| 109 |
+
font-size: 0.95rem;
|
| 110 |
+
font-weight: 500;
|
| 111 |
+
text-decoration: underline;
|
| 112 |
+
text-underline-offset: 3px;
|
| 113 |
+
cursor: pointer;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.step-link:hover {
|
| 117 |
+
color: var(--ink);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.step-link:focus-visible {
|
| 121 |
+
color: var(--ink);
|
| 122 |
+
outline: 2px solid var(--accent);
|
| 123 |
+
outline-offset: 2px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.guide-upload-error {
|
| 127 |
+
margin: 8px 4px 0;
|
| 128 |
+
font-size: 0.85rem;
|
| 129 |
+
color: var(--accent);
|
| 130 |
+
text-align: center;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* --- Typography (matches desktop hero/panel) --------------------- */
|
| 134 |
+
|
| 135 |
+
.hero-eyebrow {
|
| 136 |
+
text-transform: uppercase;
|
| 137 |
+
letter-spacing: 0.18em;
|
| 138 |
+
font-size: 0.75rem;
|
| 139 |
+
font-weight: 600;
|
| 140 |
+
color: var(--accent-dark);
|
| 141 |
+
margin: 0 0 24px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.hero-headline {
|
| 145 |
+
font-family: "Futura", "Gill Sans", "Optima", "Trebuchet MS", sans-serif;
|
| 146 |
+
font-size: clamp(2rem, 8vw, 2.4rem);
|
| 147 |
+
letter-spacing: 0.02em;
|
| 148 |
+
line-height: 1.15;
|
| 149 |
+
margin: 0 0 28px;
|
| 150 |
+
color: var(--ink);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.hero-sub {
|
| 154 |
+
font-size: 1.05rem;
|
| 155 |
+
line-height: 1.7;
|
| 156 |
+
color: var(--ink-soft);
|
| 157 |
+
margin: 0;
|
| 158 |
+
max-width: 36ch;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/* Step 1 leans into a softly centered hero: vertically nudge the copy
|
| 162 |
+
toward the middle so the eye lands on the headline, not the very
|
| 163 |
+
top of the page. */
|
| 164 |
+
.step-intro .step-body {
|
| 165 |
+
display: flex;
|
| 166 |
+
flex-direction: column;
|
| 167 |
+
justify-content: center;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* --- Panel (matches desktop .hero-card / .panel) ----------------- */
|
| 171 |
+
|
| 172 |
+
.panel {
|
| 173 |
+
background: rgba(255, 255, 255, 0.78);
|
| 174 |
+
border: 1px solid var(--border);
|
| 175 |
+
border-radius: 20px;
|
| 176 |
+
padding: 22px;
|
| 177 |
+
box-shadow: 0 18px 40px rgba(43, 31, 31, 0.08);
|
| 178 |
+
-webkit-backdrop-filter: blur(6px);
|
| 179 |
+
backdrop-filter: blur(6px);
|
| 180 |
+
margin-bottom: 16px;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.panel:last-child {
|
| 184 |
+
margin-bottom: 0;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.panel-title {
|
| 188 |
+
font-family: "Futura", "Gill Sans", "Optima", "Trebuchet MS", sans-serif;
|
| 189 |
+
font-size: 1.4rem;
|
| 190 |
+
margin: 0 0 14px;
|
| 191 |
+
color: var(--ink);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/* --- Form controls (matches desktop) ----------------------------- */
|
| 195 |
+
|
| 196 |
+
.controls {
|
| 197 |
+
display: flex;
|
| 198 |
+
flex-direction: column;
|
| 199 |
+
gap: 14px;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.controls label {
|
| 203 |
+
display: flex;
|
| 204 |
+
flex-direction: column;
|
| 205 |
+
gap: 6px;
|
| 206 |
+
font-size: 0.9rem;
|
| 207 |
+
color: var(--ink-soft);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.controls input[type="text"],
|
| 211 |
+
.controls select {
|
| 212 |
+
/* Lock both controls to the same height. iOS Safari's native
|
| 213 |
+
<select> renders at a slightly shorter intrinsic height than
|
| 214 |
+
<input>, so identical padding alone doesn't match — an explicit
|
| 215 |
+
height (with box-sizing: border-box, set globally) does. */
|
| 216 |
+
height: 48px;
|
| 217 |
+
width: 100%;
|
| 218 |
+
border: 1px solid var(--border);
|
| 219 |
+
border-radius: 12px;
|
| 220 |
+
padding: 0 14px;
|
| 221 |
+
/* iOS auto-zoom guard — must be ≥ 16px. */
|
| 222 |
+
font-size: 1rem;
|
| 223 |
+
line-height: 1.4;
|
| 224 |
+
background: white;
|
| 225 |
+
color: var(--ink);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/* Strip the native <select> chrome (Safari's tiny up/down chevrons,
|
| 229 |
+
inset shadows, etc.) so the visual treatment matches the text
|
| 230 |
+
input. We restore a single chevron via a background-image so the
|
| 231 |
+
dropdown affordance is still visible. */
|
| 232 |
+
.controls select {
|
| 233 |
+
-webkit-appearance: none;
|
| 234 |
+
-moz-appearance: none;
|
| 235 |
+
appearance: none;
|
| 236 |
+
padding-right: 40px;
|
| 237 |
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%234b3d3d' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1,2 6,7 11,2'/></svg>");
|
| 238 |
+
background-repeat: no-repeat;
|
| 239 |
+
background-position: right 14px center;
|
| 240 |
+
background-size: 12px 8px;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.form-error {
|
| 244 |
+
margin: 12px 0 0;
|
| 245 |
+
font-size: 0.85rem;
|
| 246 |
+
color: var(--accent);
|
| 247 |
+
font-weight: 600;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* --- File drop + camera trigger + tips (matches desktop) -------- */
|
| 251 |
+
|
| 252 |
+
.file-drop {
|
| 253 |
+
display: flex;
|
| 254 |
+
flex-direction: column;
|
| 255 |
+
gap: 6px;
|
| 256 |
+
padding: 22px;
|
| 257 |
+
border: 1.5px dashed var(--accent);
|
| 258 |
+
border-radius: 18px;
|
| 259 |
+
background: var(--sand);
|
| 260 |
+
cursor: pointer;
|
| 261 |
+
margin-bottom: 12px;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.file-drop input {
|
| 265 |
+
display: none;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.file-title {
|
| 269 |
+
font-size: 1.05rem;
|
| 270 |
+
font-weight: 600;
|
| 271 |
+
color: var(--ink);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.file-hint {
|
| 275 |
+
font-size: 0.9rem;
|
| 276 |
+
color: var(--ink-soft);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.camera-trigger {
|
| 280 |
+
width: 100%;
|
| 281 |
+
margin-bottom: 14px;
|
| 282 |
+
padding: 12px 14px;
|
| 283 |
+
font-size: 0.95rem;
|
| 284 |
+
font-weight: 600;
|
| 285 |
+
color: var(--accent-dark);
|
| 286 |
+
background: rgba(255, 255, 255, 0.85);
|
| 287 |
+
border: 1px dashed var(--accent);
|
| 288 |
+
border-radius: 14px;
|
| 289 |
+
cursor: pointer;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.capture-tips {
|
| 293 |
+
margin: 0;
|
| 294 |
+
padding: 14px 18px 14px 32px;
|
| 295 |
+
list-style: disc;
|
| 296 |
+
background: rgba(191, 58, 43, 0.06);
|
| 297 |
+
border-left: 3px solid rgba(191, 58, 43, 0.55);
|
| 298 |
+
border-radius: 8px;
|
| 299 |
+
font-size: 0.9rem;
|
| 300 |
+
color: var(--ink-soft);
|
| 301 |
+
line-height: 1.55;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.capture-tips li + li {
|
| 305 |
+
margin-top: 6px;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.capture-tips strong {
|
| 309 |
+
color: var(--ink);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/* "Like this" sample below the tips on the guide step. Same rounded
|
| 313 |
+
image-frame chrome as the confirm preview / result overlay so the
|
| 314 |
+
visual treatment is consistent across the three places we show a
|
| 315 |
+
photo. */
|
| 316 |
+
.guide-example {
|
| 317 |
+
margin: 16px 0 0;
|
| 318 |
+
padding: 0;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.guide-example img {
|
| 322 |
+
width: 100%;
|
| 323 |
+
height: auto;
|
| 324 |
+
display: block;
|
| 325 |
+
border-radius: 14px;
|
| 326 |
+
background: #f6efea;
|
| 327 |
+
box-shadow: 0 6px 18px var(--shadow);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.guide-example figcaption {
|
| 331 |
+
margin-bottom: 8px;
|
| 332 |
+
font-size: 0.85rem;
|
| 333 |
+
color: var(--ink-soft);
|
| 334 |
+
text-align: center;
|
| 335 |
+
font-style: italic;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
/* --- Primary button --------------------------------------------- */
|
| 339 |
+
|
| 340 |
+
.primary {
|
| 341 |
+
width: 100%;
|
| 342 |
+
border: none;
|
| 343 |
+
border-radius: 14px;
|
| 344 |
+
padding: 14px 16px;
|
| 345 |
+
font-size: 1rem;
|
| 346 |
+
font-weight: 600;
|
| 347 |
+
color: white;
|
| 348 |
+
background: linear-gradient(120deg, var(--accent), #e25f4f);
|
| 349 |
+
box-shadow: 0 12px 30px var(--shadow);
|
| 350 |
+
cursor: pointer;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.primary:active {
|
| 354 |
+
transform: translateY(1px);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.primary:disabled {
|
| 358 |
+
/* Distinct solid color rather than an opacity fade. Opacity makes
|
| 359 |
+
a disabled button look like a "weak" version of the live one,
|
| 360 |
+
which is visually noisy on top of a busy video preview. A solid
|
| 361 |
+
warm beige-gray with no shadow reads unambiguously as inactive
|
| 362 |
+
while staying inside the cream palette. */
|
| 363 |
+
background: #a89e8f;
|
| 364 |
+
box-shadow: none;
|
| 365 |
+
cursor: not-allowed;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
/* --- Image frame (matches desktop input/result image panels) ---- */
|
| 369 |
+
|
| 370 |
+
.image-frame {
|
| 371 |
+
position: relative;
|
| 372 |
+
border-radius: 16px;
|
| 373 |
+
overflow: hidden;
|
| 374 |
+
background: #f6efea;
|
| 375 |
+
min-height: 180px;
|
| 376 |
+
display: grid;
|
| 377 |
+
place-items: center;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.image-frame img {
|
| 381 |
+
width: 100%;
|
| 382 |
+
height: auto;
|
| 383 |
+
display: none;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.image-frame.show img {
|
| 387 |
+
display: block;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
/* --- Confirm step status text ----------------------------------- */
|
| 391 |
+
|
| 392 |
+
/* Lives in the step-foot (not the panel) so it's always visible —
|
| 393 |
+
tall photos can push panel content out of the visible step-body
|
| 394 |
+
area, which would otherwise hide the live "Measuring… Xs" timer. */
|
| 395 |
+
.confirm-status {
|
| 396 |
+
margin: 0 0 10px;
|
| 397 |
+
font-size: 0.95rem;
|
| 398 |
+
color: var(--ink-soft);
|
| 399 |
+
text-align: center;
|
| 400 |
+
/* Keep the timer line single-line so the layout doesn't jump as the
|
| 401 |
+
seconds counter ticks; the message is short by design. */
|
| 402 |
+
white-space: nowrap;
|
| 403 |
+
overflow: hidden;
|
| 404 |
+
text-overflow: ellipsis;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.confirm-status.error {
|
| 408 |
+
color: var(--accent);
|
| 409 |
+
font-weight: 600;
|
| 410 |
+
/* Errors can be longer than the live timer; allow wrapping there. */
|
| 411 |
+
white-space: normal;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
/* --- Step 4 — Capture stage (full-bleed camera) ----------------- */
|
| 415 |
+
|
| 416 |
+
.step-capture {
|
| 417 |
+
--capture-url-bar-inset: 0px;
|
| 418 |
+
--capture-bottom-inset: max(
|
| 419 |
+
28px,
|
| 420 |
+
calc(var(--capture-url-bar-inset) + env(safe-area-inset-bottom, 0px) + 16px)
|
| 421 |
+
);
|
| 422 |
+
position: relative;
|
| 423 |
+
padding: 0;
|
| 424 |
+
background: #000;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.capture-video {
|
| 428 |
+
width: 100%;
|
| 429 |
+
height: 100%;
|
| 430 |
+
object-fit: cover;
|
| 431 |
+
background: #000;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.capture-back,
|
| 435 |
+
.capture-flash {
|
| 436 |
+
position: absolute;
|
| 437 |
+
top: max(12px, env(safe-area-inset-top, 12px));
|
| 438 |
+
width: 40px;
|
| 439 |
+
height: 40px;
|
| 440 |
+
padding: 0;
|
| 441 |
+
border: none;
|
| 442 |
+
border-radius: 50%;
|
| 443 |
+
background: rgba(0, 0, 0, 0.45);
|
| 444 |
+
color: #fff;
|
| 445 |
+
font-size: 22px;
|
| 446 |
+
cursor: pointer;
|
| 447 |
+
z-index: 2;
|
| 448 |
+
display: inline-flex;
|
| 449 |
+
align-items: center;
|
| 450 |
+
justify-content: center;
|
| 451 |
+
-webkit-backdrop-filter: blur(8px);
|
| 452 |
+
backdrop-filter: blur(8px);
|
| 453 |
+
transition: background 0.15s ease, color 0.15s ease;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.capture-back {
|
| 457 |
+
left: 12px;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.capture-flash {
|
| 461 |
+
right: 12px;
|
| 462 |
+
font-size: 18px;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
.capture-back:hover,
|
| 466 |
+
.capture-back:focus-visible,
|
| 467 |
+
.capture-flash:hover,
|
| 468 |
+
.capture-flash:focus-visible {
|
| 469 |
+
background: rgba(0, 0, 0, 0.65);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.capture-flash[aria-pressed="true"] {
|
| 473 |
+
background: #fff8d4;
|
| 474 |
+
color: #8a5b0a;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.capture-chips {
|
| 478 |
+
position: absolute;
|
| 479 |
+
bottom: calc(var(--capture-bottom-inset) + 64px);
|
| 480 |
+
left: 16px;
|
| 481 |
+
right: 16px;
|
| 482 |
+
display: flex;
|
| 483 |
+
flex-wrap: wrap;
|
| 484 |
+
gap: 8px;
|
| 485 |
+
justify-content: center;
|
| 486 |
+
z-index: 2;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.capture-status {
|
| 490 |
+
position: absolute;
|
| 491 |
+
bottom: calc(var(--capture-bottom-inset) + 110px);
|
| 492 |
+
left: 16px;
|
| 493 |
+
right: 16px;
|
| 494 |
+
margin: 0;
|
| 495 |
+
text-align: center;
|
| 496 |
+
font-size: 0.85rem;
|
| 497 |
+
color: rgba(255, 255, 255, 0.85);
|
| 498 |
+
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
|
| 499 |
+
z-index: 2;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.capture-status.error {
|
| 503 |
+
color: #ffd5d0;
|
| 504 |
+
font-weight: 600;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.capture-controls {
|
| 508 |
+
position: absolute;
|
| 509 |
+
bottom: var(--capture-bottom-inset);
|
| 510 |
+
left: 16px;
|
| 511 |
+
right: 16px;
|
| 512 |
+
z-index: 2;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.capture-controls .capture-shutter {
|
| 516 |
+
width: 100%;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/* --- Status chips (distance / level) ---------------------------- */
|
| 520 |
+
|
| 521 |
+
.status-chip {
|
| 522 |
+
display: inline-flex;
|
| 523 |
+
align-items: center;
|
| 524 |
+
gap: 6px;
|
| 525 |
+
padding: 4px 10px;
|
| 526 |
+
border-radius: 999px;
|
| 527 |
+
font-size: 0.82rem;
|
| 528 |
+
font-weight: 600;
|
| 529 |
+
border: 1px solid transparent;
|
| 530 |
+
background: rgba(255, 255, 255, 0.85);
|
| 531 |
+
color: var(--ink-soft);
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
.status-chip .chip-dot {
|
| 535 |
+
display: inline-block;
|
| 536 |
+
width: 8px;
|
| 537 |
+
height: 8px;
|
| 538 |
+
border-radius: 50%;
|
| 539 |
+
background: currentColor;
|
| 540 |
+
opacity: 0.8;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.status-chip.pending {
|
| 544 |
+
color: var(--ink-soft);
|
| 545 |
+
border-color: var(--border);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
.status-chip.skipped {
|
| 549 |
+
color: var(--ink-soft);
|
| 550 |
+
border-color: var(--border);
|
| 551 |
+
opacity: 0.6;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.status-chip.red {
|
| 555 |
+
color: #b1271b;
|
| 556 |
+
background: #fde7e3;
|
| 557 |
+
border-color: rgba(177, 39, 27, 0.4);
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.status-chip.amber {
|
| 561 |
+
color: #8a5b0a;
|
| 562 |
+
background: #fbeed1;
|
| 563 |
+
border-color: rgba(138, 91, 10, 0.4);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.status-chip.green {
|
| 567 |
+
color: #1f6b34;
|
| 568 |
+
background: #d8f1de;
|
| 569 |
+
border-color: rgba(31, 107, 52, 0.4);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
/* --- Bubble level (capture stage) ------------------------------- */
|
| 573 |
+
/* iPhone-style two-cross level. A static white cross marks the center
|
| 574 |
+
target; a second cross of the same shape drifts toward the high side
|
| 575 |
+
of the device. When the device is in tolerance the moving cross snaps
|
| 576 |
+
to the center and turns green (the two crosses visually merge). When
|
| 577 |
+
out of tolerance it floats out, clamped to a max radius, in red. The
|
| 578 |
+
±5° tolerance ring is decorative — it gives the user a sense of how
|
| 579 |
+
far "in tolerance" is — and stays neutral white. */
|
| 580 |
+
|
| 581 |
+
.capture-level-bubble {
|
| 582 |
+
position: absolute;
|
| 583 |
+
top: 50%;
|
| 584 |
+
left: 50%;
|
| 585 |
+
width: 128px;
|
| 586 |
+
height: 128px;
|
| 587 |
+
transform: translate(-50%, -50%);
|
| 588 |
+
pointer-events: none;
|
| 589 |
+
z-index: 2;
|
| 590 |
+
--neutral-color: rgba(255, 255, 255, 0.85);
|
| 591 |
+
--indicator-color: rgba(255, 255, 255, 0.85);
|
| 592 |
+
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.capture-level-bubble[data-state="green"] {
|
| 596 |
+
--indicator-color: #6ce39a;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.capture-level-bubble[data-state="red"] {
|
| 600 |
+
--indicator-color: #ff8a7e;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
/* Tolerance ring + center crosshair stay neutral so only the moving
|
| 604 |
+
cross signals state (per design feedback). */
|
| 605 |
+
.capture-level-bubble .bubble-tolerance {
|
| 606 |
+
position: absolute;
|
| 607 |
+
top: 50%;
|
| 608 |
+
left: 50%;
|
| 609 |
+
width: 38px;
|
| 610 |
+
height: 38px;
|
| 611 |
+
border-radius: 50%;
|
| 612 |
+
transform: translate(-50%, -50%);
|
| 613 |
+
border: 1.5px solid var(--neutral-color);
|
| 614 |
+
opacity: 0.85;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.capture-level-bubble .bubble-crosshair-h,
|
| 618 |
+
.capture-level-bubble .bubble-crosshair-v {
|
| 619 |
+
position: absolute;
|
| 620 |
+
top: 50%;
|
| 621 |
+
left: 50%;
|
| 622 |
+
background: var(--neutral-color);
|
| 623 |
+
opacity: 0.6;
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
.capture-level-bubble .bubble-crosshair-h {
|
| 627 |
+
width: 14px;
|
| 628 |
+
height: 1.5px;
|
| 629 |
+
transform: translate(-50%, -50%);
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
.capture-level-bubble .bubble-crosshair-v {
|
| 633 |
+
width: 1.5px;
|
| 634 |
+
height: 14px;
|
| 635 |
+
transform: translate(-50%, -50%);
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
/* Moving cross. The wrapper is the element JS translates every tick;
|
| 639 |
+
the two arms inside form the cross relative to the wrapper's origin. */
|
| 640 |
+
.capture-level-bubble .bubble-indicator {
|
| 641 |
+
position: absolute;
|
| 642 |
+
top: 50%;
|
| 643 |
+
left: 50%;
|
| 644 |
+
width: 14px;
|
| 645 |
+
height: 14px;
|
| 646 |
+
transform: translate(-50%, -50%);
|
| 647 |
+
/* Linear transform transition smooths the 100 ms detection tick;
|
| 648 |
+
also gives the snap-to-center on green a brief settle motion. */
|
| 649 |
+
transition: transform 0.08s linear;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.capture-level-bubble .bubble-indicator-h,
|
| 653 |
+
.capture-level-bubble .bubble-indicator-v {
|
| 654 |
+
position: absolute;
|
| 655 |
+
top: 50%;
|
| 656 |
+
left: 50%;
|
| 657 |
+
background: var(--indicator-color);
|
| 658 |
+
transition: background 0.18s ease;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.capture-level-bubble .bubble-indicator-h {
|
| 662 |
+
width: 18px;
|
| 663 |
+
height: 2px;
|
| 664 |
+
transform: translate(-50%, -50%);
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.capture-level-bubble .bubble-indicator-v {
|
| 668 |
+
width: 2px;
|
| 669 |
+
height: 18px;
|
| 670 |
+
transform: translate(-50%, -50%);
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.capture-level-bubble[data-state="pending"] .bubble-indicator {
|
| 674 |
+
opacity: 0;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/* --- Step 6 — Result -------------------------------------------- */
|
| 678 |
+
|
| 679 |
+
/* Top-of-result feedback banner. Mirrors the desktop's status text
|
| 680 |
+
semantically (single line of feedback after measurement) but is
|
| 681 |
+
visually a small panel-shaped card so it reads as a result rather
|
| 682 |
+
than form chrome. Color-coded: green = success, red = failure. */
|
| 683 |
+
.result-status {
|
| 684 |
+
margin: 0 0 16px;
|
| 685 |
+
padding: 14px 16px;
|
| 686 |
+
border-radius: 14px;
|
| 687 |
+
background: white;
|
| 688 |
+
border: 1px solid var(--border);
|
| 689 |
+
border-left: 4px solid var(--ink-soft);
|
| 690 |
+
box-shadow: 0 6px 18px var(--shadow);
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.result-status p {
|
| 694 |
+
margin: 0;
|
| 695 |
+
font-size: 0.95rem;
|
| 696 |
+
font-weight: 600;
|
| 697 |
+
line-height: 1.5;
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
.result-status-success {
|
| 701 |
+
background: #e8f4ec;
|
| 702 |
+
border-color: rgba(31, 107, 52, 0.4);
|
| 703 |
+
border-left-color: #1f6b34;
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
.result-status-success p {
|
| 707 |
+
color: #1f6b34;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.result-status-error {
|
| 711 |
+
background: #fde7e3;
|
| 712 |
+
border-color: rgba(177, 39, 27, 0.4);
|
| 713 |
+
border-left-color: var(--accent);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.result-status-error p {
|
| 717 |
+
color: #b1271b;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
/* Three finger cards stacked vertically (instead of the desktop's
|
| 721 |
+
3-column grid) — narrower mobile width fits a single column more
|
| 722 |
+
comfortably without shrinking the size number. */
|
| 723 |
+
.finger-cards {
|
| 724 |
+
display: flex;
|
| 725 |
+
flex-direction: column;
|
| 726 |
+
gap: 10px;
|
| 727 |
+
margin-bottom: 12px;
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.finger-card {
|
| 731 |
+
background: var(--sand);
|
| 732 |
+
border-radius: 10px;
|
| 733 |
+
padding: 14px 16px 16px;
|
| 734 |
+
text-align: center;
|
| 735 |
+
box-shadow: 0 1px 4px var(--shadow);
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
.finger-card .finger-name {
|
| 739 |
+
font-weight: 600;
|
| 740 |
+
font-size: 0.95rem;
|
| 741 |
+
margin-bottom: 6px;
|
| 742 |
+
text-transform: uppercase;
|
| 743 |
+
letter-spacing: 0.04em;
|
| 744 |
+
color: var(--ink);
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
.finger-card .finger-size-label {
|
| 748 |
+
font-size: 0.75rem;
|
| 749 |
+
text-transform: uppercase;
|
| 750 |
+
letter-spacing: 0.06em;
|
| 751 |
+
color: var(--ink-soft);
|
| 752 |
+
margin-top: 4px;
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
.finger-card .finger-size {
|
| 756 |
+
font-size: 2rem;
|
| 757 |
+
font-weight: 700;
|
| 758 |
+
color: var(--accent);
|
| 759 |
+
line-height: 1.1;
|
| 760 |
+
margin: 2px 0;
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
.finger-card .finger-range {
|
| 764 |
+
font-size: 0.9rem;
|
| 765 |
+
color: var(--ink-soft);
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
.finger-card .finger-width {
|
| 769 |
+
font-size: 0.85rem;
|
| 770 |
+
color: var(--ink-soft);
|
| 771 |
+
margin-top: 4px;
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
.finger-card-failed {
|
| 775 |
+
opacity: 0.85;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
.finger-card .finger-failed {
|
| 779 |
+
font-size: 1rem;
|
| 780 |
+
font-weight: 600;
|
| 781 |
+
color: #721c24;
|
| 782 |
+
margin: 8px 0 4px;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
.finger-card .finger-fail-reason {
|
| 786 |
+
font-size: 0.78rem;
|
| 787 |
+
color: var(--ink-soft);
|
| 788 |
+
word-break: break-word;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.finger-count {
|
| 792 |
+
text-align: center;
|
| 793 |
+
font-size: 0.85rem;
|
| 794 |
+
color: var(--ink-soft);
|
| 795 |
+
margin-bottom: 12px;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
/* --- Size reference table (mirrors desktop) --------------------- */
|
| 799 |
+
|
| 800 |
+
.size-ref-table {
|
| 801 |
+
margin-top: 14px;
|
| 802 |
+
padding-top: 12px;
|
| 803 |
+
border-top: 1px solid var(--border);
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
.size-ref-title {
|
| 807 |
+
font-family: "Futura", "Gill Sans", "Optima", "Trebuchet MS", sans-serif;
|
| 808 |
+
font-size: 1rem;
|
| 809 |
+
margin: 0 0 8px;
|
| 810 |
+
color: var(--ink);
|
| 811 |
+
/* Center the model-label heading so it lines up with the centered
|
| 812 |
+
SIZE / Inner ⌀ (mm) columns below — same alignment story as
|
| 813 |
+
the desktop. */
|
| 814 |
+
/*text-align: center;*/
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.size-ref-table table {
|
| 818 |
+
width: 100%;
|
| 819 |
+
border-collapse: collapse;
|
| 820 |
+
font-size: 0.9rem;
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
.size-ref-table th,
|
| 824 |
+
.size-ref-table td {
|
| 825 |
+
padding: 8px 10px;
|
| 826 |
+
/* Match the desktop alignment — both headers and values are
|
| 827 |
+
centered, which is more comfortable to scan when the columns
|
| 828 |
+
are short numeric values. */
|
| 829 |
+
text-align: center;
|
| 830 |
+
border-bottom: 1px solid var(--border);
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
.size-ref-table th {
|
| 834 |
+
font-weight: 600;
|
| 835 |
+
color: var(--ink-soft);
|
| 836 |
+
font-size: 0.8rem;
|
| 837 |
+
text-transform: uppercase;
|
| 838 |
+
letter-spacing: 0.06em;
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
.size-ref-table tbody tr:nth-child(even) {
|
| 842 |
+
background: rgba(245, 241, 231, 0.5);
|
| 843 |
+
}
|
web_demo/static/mobile/mobile.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Mobile flow entrypoint. Registers six steps that mirror the
|
| 2 |
+
// desktop page broken into discrete screens:
|
| 3 |
+
//
|
| 4 |
+
// 1. intro — value prop (desktop hero copy)
|
| 5 |
+
// 2. form — name + ring model (desktop hero-card top)
|
| 6 |
+
// 3. guide — photo tips + "like this" sample, opens the camera
|
| 7 |
+
// 4. capture — fullscreen camera coach
|
| 8 |
+
// 5. confirm — preview the captured image, fire measurement
|
| 9 |
+
// 6. result — overlay image + ring-size cards + size reference
|
| 10 |
+
//
|
| 11 |
+
// Cross-step state lives in `session.js` so back-navigation preserves
|
| 12 |
+
// what the user has already entered. Step 3 also exposes an "Upload
|
| 13 |
+
// from photos" escape hatch that skips the capture step and hands the
|
| 14 |
+
// picked file straight to confirm — same downstream path as a camera
|
| 15 |
+
// capture, just with `imageSource = "upload"`.
|
| 16 |
+
|
| 17 |
+
import { registerStep, setOrder, start } from "./steps.js";
|
| 18 |
+
import intro from "./steps/intro.js";
|
| 19 |
+
import form from "./steps/form.js";
|
| 20 |
+
import guide from "./steps/guide.js";
|
| 21 |
+
import capture from "./steps/capture.js";
|
| 22 |
+
import confirm from "./steps/confirm.js";
|
| 23 |
+
import result from "./steps/result.js";
|
| 24 |
+
|
| 25 |
+
registerStep("intro", intro);
|
| 26 |
+
registerStep("form", form);
|
| 27 |
+
registerStep("guide", guide);
|
| 28 |
+
registerStep("capture", capture);
|
| 29 |
+
registerStep("confirm", confirm);
|
| 30 |
+
registerStep("result", result);
|
| 31 |
+
|
| 32 |
+
setOrder(["intro", "form", "guide", "capture", "confirm", "result"]);
|
| 33 |
+
|
| 34 |
+
const root = document.getElementById("mobileRoot");
|
| 35 |
+
if (root) start(root);
|
web_demo/static/mobile/session.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shared state that persists across step transitions.
|
| 2 |
+
//
|
| 3 |
+
// `goTo(name, data)` would force every step to re-thread the full
|
| 4 |
+
// payload to the next; with six steps and a back-nav that needs to
|
| 5 |
+
// remember what was already entered, an implicit session module is
|
| 6 |
+
// simpler. Lifecycle is bounded by the page (cleared on `reset()`,
|
| 7 |
+
// invoked by the result step's "Measure again" button to ensure the
|
| 8 |
+
// next run starts fresh).
|
| 9 |
+
|
| 10 |
+
export const session = {
|
| 11 |
+
kolName: "",
|
| 12 |
+
ringModel: "gen",
|
| 13 |
+
// Either an uploaded File or a Blob captured from the camera step.
|
| 14 |
+
imageBlob: null,
|
| 15 |
+
// Object URL pointing at imageBlob — created by whichever step
|
| 16 |
+
// produced the blob, revoked by reset() to avoid leaks.
|
| 17 |
+
imageUrl: "",
|
| 18 |
+
// Source of the blob, used by confirm.js to label the preview.
|
| 19 |
+
imageSource: "", // "upload" | "camera"
|
| 20 |
+
// Capture-coach telemetry snapshot. Forwarded to /api/measure with
|
| 21 |
+
// the camera path; null for the upload path.
|
| 22 |
+
gateTelemetry: null,
|
| 23 |
+
// /api/measure response payload, populated by confirm.js before
|
| 24 |
+
// navigating to the result step.
|
| 25 |
+
result: null,
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
// Wipe just the photo + result — used by "Measure again" so the user
|
| 29 |
+
// keeps their entered name + ring model on the next capture without
|
| 30 |
+
// re-typing. A full reset isn't a separate function: a page refresh
|
| 31 |
+
// reloads this module and reinitializes `session` to the defaults.
|
| 32 |
+
export function resetForRetake() {
|
| 33 |
+
if (session.imageUrl && session.imageUrl.startsWith("blob:")) {
|
| 34 |
+
try {
|
| 35 |
+
URL.revokeObjectURL(session.imageUrl);
|
| 36 |
+
} catch (err) {
|
| 37 |
+
// URL.revokeObjectURL never throws on a valid blob URL; this is
|
| 38 |
+
// pure defensive guarding for malformed values.
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
session.imageBlob = null;
|
| 42 |
+
session.imageUrl = "";
|
| 43 |
+
session.imageSource = "";
|
| 44 |
+
session.gateTelemetry = null;
|
| 45 |
+
session.result = null;
|
| 46 |
+
}
|
web_demo/static/mobile/steps.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tiny linear step controller for the mobile flow.
|
| 2 |
+
//
|
| 3 |
+
// Each step registers a `mount(container)` function that paints itself
|
| 4 |
+
// into the supplied DOM container, plus an optional `unmount()` for
|
| 5 |
+
// cleanup (e.g. tearing down a camera stream when leaving the capture
|
| 6 |
+
// step). The controller maintains an index into a fixed step order and
|
| 7 |
+
// exposes `next()` / `back()` / `goTo(name)` for transitions.
|
| 8 |
+
//
|
| 9 |
+
// Deliberately minimal: no transitions, no history-API integration, no
|
| 10 |
+
// deep-linking. Five linear steps with back/forward — anything fancier
|
| 11 |
+
// can be added when the UX calls for it.
|
| 12 |
+
|
| 13 |
+
const stepRegistry = new Map();
|
| 14 |
+
let order = [];
|
| 15 |
+
let currentIndex = -1;
|
| 16 |
+
let currentStep = null;
|
| 17 |
+
let rootContainer = null;
|
| 18 |
+
|
| 19 |
+
export function registerStep(name, step) {
|
| 20 |
+
if (typeof step.mount !== "function") {
|
| 21 |
+
throw new Error(`Step "${name}" must define mount(container)`);
|
| 22 |
+
}
|
| 23 |
+
stepRegistry.set(name, step);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function setOrder(names) {
|
| 27 |
+
for (const name of names) {
|
| 28 |
+
if (!stepRegistry.has(name)) {
|
| 29 |
+
throw new Error(`Step "${name}" is not registered`);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
order = [...names];
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function start(container) {
|
| 36 |
+
rootContainer = container;
|
| 37 |
+
if (!order.length) throw new Error("setOrder() must be called before start()");
|
| 38 |
+
goTo(order[0]);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// `data` is an optional payload handed to the destination step's
|
| 42 |
+
// mount() — used by the capture step to forward the measurement result
|
| 43 |
+
// to the result step. Steps that don't need data can ignore the third
|
| 44 |
+
// argument; steps that need persistent state should still maintain it
|
| 45 |
+
// themselves (the controller doesn't survive a hard reload).
|
| 46 |
+
export function goTo(name, data) {
|
| 47 |
+
const idx = order.indexOf(name);
|
| 48 |
+
if (idx === -1) throw new Error(`Step "${name}" is not in the active order`);
|
| 49 |
+
if (currentStep && typeof currentStep.unmount === "function") {
|
| 50 |
+
try {
|
| 51 |
+
currentStep.unmount();
|
| 52 |
+
} catch (err) {
|
| 53 |
+
console.error(`Step "${order[currentIndex]}" unmount failed:`, err);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
rootContainer.innerHTML = "";
|
| 57 |
+
currentIndex = idx;
|
| 58 |
+
currentStep = stepRegistry.get(name);
|
| 59 |
+
// Pass a small `nav` API down so steps don't need to import this
|
| 60 |
+
// module — keeps each step file standalone and easy to test.
|
| 61 |
+
const nav = {
|
| 62 |
+
next: (payload) => {
|
| 63 |
+
if (currentIndex < order.length - 1) goTo(order[currentIndex + 1], payload);
|
| 64 |
+
},
|
| 65 |
+
back: (payload) => {
|
| 66 |
+
if (currentIndex > 0) goTo(order[currentIndex - 1], payload);
|
| 67 |
+
},
|
| 68 |
+
goTo,
|
| 69 |
+
isFirst: () => currentIndex === 0,
|
| 70 |
+
isLast: () => currentIndex === order.length - 1,
|
| 71 |
+
progress: () => ({ index: currentIndex, total: order.length }),
|
| 72 |
+
};
|
| 73 |
+
currentStep.mount(rootContainer, nav, data);
|
| 74 |
+
}
|
web_demo/static/mobile/steps/capture.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Step 4 — Live camera + capture coach.
|
| 2 |
+
//
|
| 3 |
+
// Drives the existing `static/preview/*` modules (camera lifecycle,
|
| 4 |
+
// MediaPipe Hands, DeviceOrientation, torch) inside the step
|
| 5 |
+
// lifecycle: mount() opens the camera and starts the gate loop,
|
| 6 |
+
// unmount() tears everything down. On shutter, captures a frame and
|
| 7 |
+
// hands the blob to the confirm step (step 5) — the actual
|
| 8 |
+
// /api/measure POST happens there.
|
| 9 |
+
//
|
| 10 |
+
// Most of the gate-loop logic mirrors the desktop implementation in
|
| 11 |
+
// `web_demo/static/app.js` (anti-jitter counters, telemetry shape,
|
| 12 |
+
// MediaPipe-not-loaded fallback). The desktop and mobile copies will
|
| 13 |
+
// converge into a shared module in a later phase once the mobile flow
|
| 14 |
+
// is validated end-to-end.
|
| 15 |
+
|
| 16 |
+
import { session } from "../session.js";
|
| 17 |
+
|
| 18 |
+
const LEVEL_NO_DATA_TIMEOUT_MS = 2000;
|
| 19 |
+
|
| 20 |
+
let detectionTimer = null;
|
| 21 |
+
let consecutiveDistanceGreen = 0;
|
| 22 |
+
let consecutiveLevelGreen = 0;
|
| 23 |
+
let mediaPipeReady = false;
|
| 24 |
+
let orientationStatus = "unsupported";
|
| 25 |
+
let latestOrientation = null;
|
| 26 |
+
let levelStartedAtMs = null;
|
| 27 |
+
let levelMarkedSkipped = false;
|
| 28 |
+
let gateTelemetry = createTelemetry();
|
| 29 |
+
let videoEl = null;
|
| 30 |
+
let distanceChip = null;
|
| 31 |
+
let levelChip = null;
|
| 32 |
+
let levelBubble = null;
|
| 33 |
+
let levelDot = null;
|
| 34 |
+
let shutterBtn = null;
|
| 35 |
+
let flashBtn = null;
|
| 36 |
+
let statusEl = null;
|
| 37 |
+
|
| 38 |
+
function createTelemetry() {
|
| 39 |
+
return {
|
| 40 |
+
started_at_ms: null,
|
| 41 |
+
distance_first_green_ms: null,
|
| 42 |
+
distance_green_frames: 0,
|
| 43 |
+
distance_red_frames: 0,
|
| 44 |
+
distance_amber_frames: 0,
|
| 45 |
+
distance_no_hand_frames: 0,
|
| 46 |
+
level_first_green_ms: null,
|
| 47 |
+
level_green_frames: 0,
|
| 48 |
+
level_red_frames: 0,
|
| 49 |
+
level_amber_frames: 0,
|
| 50 |
+
level_no_data_frames: 0,
|
| 51 |
+
orientation_status: "unsupported",
|
| 52 |
+
threshold_used: null,
|
| 53 |
+
};
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function setChip(chipEl, state, label) {
|
| 57 |
+
if (!chipEl) return;
|
| 58 |
+
chipEl.className = `status-chip ${state}`;
|
| 59 |
+
const labelEl = chipEl.querySelector(".chip-label");
|
| 60 |
+
if (labelEl) labelEl.textContent = label;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function setStatus(text, isError = false) {
|
| 64 |
+
if (!statusEl) return;
|
| 65 |
+
statusEl.textContent = text || "";
|
| 66 |
+
statusEl.classList.toggle("error", !!isError);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function evaluateDistance(T) {
|
| 70 |
+
// Mobile collapses the desktop's 5-band gauge (red / amber /
|
| 71 |
+
// green / amber / red) to a strict 3-state read: too far / OK /
|
| 72 |
+
// too close. The amber soft-zone is gone — clearer single-action
|
| 73 |
+
// feedback at the cost of fewer intermediate hints.
|
| 74 |
+
const minRatio = T.HAND_SPAN_RATIO_MIN ?? 0.28;
|
| 75 |
+
const maxRatio = T.HAND_SPAN_RATIO_MAX ?? 0.55;
|
| 76 |
+
|
| 77 |
+
let result = null;
|
| 78 |
+
try {
|
| 79 |
+
result = window.HandsDetector ? window.HandsDetector.detect(videoEl) : null;
|
| 80 |
+
} catch (err) {
|
| 81 |
+
return { state: "skipped", label: "Detection error", isGreen: false };
|
| 82 |
+
}
|
| 83 |
+
const ratio = result ? result.ratio : null;
|
| 84 |
+
if (ratio === null) {
|
| 85 |
+
return { state: "red", label: "Hand not detected", isGreen: false };
|
| 86 |
+
}
|
| 87 |
+
if (ratio < minRatio) {
|
| 88 |
+
return { state: "red", label: "Move closer", isGreen: false };
|
| 89 |
+
}
|
| 90 |
+
if (ratio > maxRatio) {
|
| 91 |
+
return { state: "red", label: "Pull back", isGreen: false };
|
| 92 |
+
}
|
| 93 |
+
return { state: "green", label: "Distance OK", isGreen: true };
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function evaluateLevel(T) {
|
| 97 |
+
if (orientationStatus !== "granted") {
|
| 98 |
+
return { included: false, state: "skipped", label: "" };
|
| 99 |
+
}
|
| 100 |
+
if (!latestOrientation) {
|
| 101 |
+
if (levelStartedAtMs !== null && Date.now() - levelStartedAtMs > LEVEL_NO_DATA_TIMEOUT_MS) {
|
| 102 |
+
return { included: false, state: "skipped", label: "Level: no sensor" };
|
| 103 |
+
}
|
| 104 |
+
return { included: true, state: "pending", label: "Level: detecting…", isGreen: false };
|
| 105 |
+
}
|
| 106 |
+
// Same red/green collapse as the distance gate — drop the desktop's
|
| 107 |
+
// amber "Hold steady" middle band. Tighter ±5° green threshold (set
|
| 108 |
+
// in thresholds.js) gets the user closer to a clean top-down shot.
|
| 109 |
+
const beta = Math.abs(latestOrientation.beta);
|
| 110 |
+
const gamma = Math.abs(latestOrientation.gamma);
|
| 111 |
+
const bmax = T.LEVEL_BETA_MAX_DEG ?? 5;
|
| 112 |
+
const gmax = T.LEVEL_GAMMA_MAX_DEG ?? 5;
|
| 113 |
+
if (beta < bmax && gamma < gmax) {
|
| 114 |
+
return { included: true, state: "green", label: "Level OK", isGreen: true };
|
| 115 |
+
}
|
| 116 |
+
return { included: true, state: "red", label: "Camera not level", isGreen: false };
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Bubble-level visualization. Dot moves toward the *high* side of the
|
| 120 |
+
// device (matches a real spirit level): tilting the right edge down
|
| 121 |
+
// (γ > 0) leaves the left edge high, so the dot drifts left. Saturates
|
| 122 |
+
// at ±15° so wild tilts don't fly off the rim. Color states are derived
|
| 123 |
+
// from raw degrees independent of the gate's anti-jitter counter — the
|
| 124 |
+
// bubble is purely visual feedback; the gate still owns shutter unlock.
|
| 125 |
+
function updateLevelBubble(lvl, T) {
|
| 126 |
+
if (!levelBubble || !levelDot) return;
|
| 127 |
+
if (!lvl.included) {
|
| 128 |
+
levelBubble.hidden = true;
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
levelBubble.hidden = false;
|
| 132 |
+
// Belt-and-braces guard: orientation.js already filters non-finite
|
| 133 |
+
// readings, but treat anything we can't read as pending so a bad
|
| 134 |
+
// sample never propagates into the CSS transform as NaN.
|
| 135 |
+
if (
|
| 136 |
+
lvl.state === "pending" ||
|
| 137 |
+
!latestOrientation ||
|
| 138 |
+
!Number.isFinite(latestOrientation.beta) ||
|
| 139 |
+
!Number.isFinite(latestOrientation.gamma)
|
| 140 |
+
) {
|
| 141 |
+
levelBubble.dataset.state = "pending";
|
| 142 |
+
levelDot.style.transform = "translate(-50%, -50%)";
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
// Two-state behavior:
|
| 146 |
+
// green → indicator snaps to the center cross, so the user sees a
|
| 147 |
+
// single merged cross flash green when they're in tolerance.
|
| 148 |
+
// red → indicator floats out toward the high side of the device,
|
| 149 |
+
// clamped to a max radius so wild tilts don't fly off-canvas.
|
| 150 |
+
if (lvl.isGreen) {
|
| 151 |
+
levelDot.style.transform = "translate(-50%, -50%)";
|
| 152 |
+
levelBubble.dataset.state = "green";
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
const SATURATION_DEG = 15;
|
| 156 |
+
const MAX_RADIUS_PX = 52;
|
| 157 |
+
const clamp = (v) => Math.max(-1, Math.min(1, v / SATURATION_DEG));
|
| 158 |
+
const dx = -clamp(latestOrientation.gamma) * MAX_RADIUS_PX;
|
| 159 |
+
const dy = -clamp(latestOrientation.beta) * MAX_RADIUS_PX;
|
| 160 |
+
levelDot.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
|
| 161 |
+
levelBubble.dataset.state = "red";
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function runDetectionTick() {
|
| 165 |
+
if (!videoEl || videoEl.paused || videoEl.readyState < 2) return;
|
| 166 |
+
const T = window.PreviewThresholds || {};
|
| 167 |
+
const requiredFrames = T.GATE_CONSECUTIVE_FRAMES ?? 3;
|
| 168 |
+
|
| 169 |
+
const dist = evaluateDistance(T);
|
| 170 |
+
setChip(distanceChip, dist.state, dist.label);
|
| 171 |
+
if (dist.isGreen) {
|
| 172 |
+
consecutiveDistanceGreen++;
|
| 173 |
+
gateTelemetry.distance_green_frames++;
|
| 174 |
+
if (
|
| 175 |
+
consecutiveDistanceGreen === requiredFrames &&
|
| 176 |
+
gateTelemetry.distance_first_green_ms === null &&
|
| 177 |
+
gateTelemetry.started_at_ms !== null
|
| 178 |
+
) {
|
| 179 |
+
gateTelemetry.distance_first_green_ms = Date.now() - gateTelemetry.started_at_ms;
|
| 180 |
+
}
|
| 181 |
+
} else {
|
| 182 |
+
consecutiveDistanceGreen = 0;
|
| 183 |
+
if (dist.state === "red") gateTelemetry.distance_red_frames++;
|
| 184 |
+
else if (dist.state === "amber") gateTelemetry.distance_amber_frames++;
|
| 185 |
+
else gateTelemetry.distance_no_hand_frames++;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const lvl = evaluateLevel(T);
|
| 189 |
+
updateLevelBubble(lvl, T);
|
| 190 |
+
if (lvl.included) {
|
| 191 |
+
if (levelChip) {
|
| 192 |
+
levelChip.hidden = false;
|
| 193 |
+
setChip(levelChip, lvl.state, lvl.label);
|
| 194 |
+
}
|
| 195 |
+
if (lvl.isGreen) {
|
| 196 |
+
consecutiveLevelGreen++;
|
| 197 |
+
gateTelemetry.level_green_frames++;
|
| 198 |
+
if (
|
| 199 |
+
consecutiveLevelGreen === requiredFrames &&
|
| 200 |
+
gateTelemetry.level_first_green_ms === null &&
|
| 201 |
+
gateTelemetry.started_at_ms !== null
|
| 202 |
+
) {
|
| 203 |
+
gateTelemetry.level_first_green_ms = Date.now() - gateTelemetry.started_at_ms;
|
| 204 |
+
}
|
| 205 |
+
} else {
|
| 206 |
+
consecutiveLevelGreen = 0;
|
| 207 |
+
if (lvl.state === "red") gateTelemetry.level_red_frames++;
|
| 208 |
+
else if (lvl.state === "amber") gateTelemetry.level_amber_frames++;
|
| 209 |
+
else gateTelemetry.level_no_data_frames++;
|
| 210 |
+
}
|
| 211 |
+
} else if (lvl.state === "skipped" && lvl.label) {
|
| 212 |
+
if (levelChip && !levelMarkedSkipped) {
|
| 213 |
+
levelChip.hidden = false;
|
| 214 |
+
setChip(levelChip, "skipped", lvl.label);
|
| 215 |
+
levelMarkedSkipped = true;
|
| 216 |
+
}
|
| 217 |
+
} else if (levelChip) {
|
| 218 |
+
levelChip.hidden = true;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
const distancePass = consecutiveDistanceGreen >= requiredFrames;
|
| 222 |
+
const levelPass = !lvl.included || consecutiveLevelGreen >= requiredFrames;
|
| 223 |
+
if (shutterBtn) shutterBtn.disabled = !(distancePass && levelPass);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
function abandonGate(distanceLabel) {
|
| 227 |
+
if (window.OrientationDetector) window.OrientationDetector.stop();
|
| 228 |
+
if (levelChip) levelChip.hidden = true;
|
| 229 |
+
if (levelBubble) {
|
| 230 |
+
levelBubble.hidden = true;
|
| 231 |
+
levelBubble.dataset.state = "pending";
|
| 232 |
+
if (levelDot) levelDot.style.transform = "translate(-50%, -50%)";
|
| 233 |
+
}
|
| 234 |
+
setChip(distanceChip, "skipped", distanceLabel);
|
| 235 |
+
if (shutterBtn) shutterBtn.disabled = false;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
async function startGateLoop() {
|
| 239 |
+
gateTelemetry = createTelemetry();
|
| 240 |
+
gateTelemetry.started_at_ms = Date.now();
|
| 241 |
+
gateTelemetry.threshold_used =
|
| 242 |
+
(window.PreviewThresholds && window.PreviewThresholds.HAND_SPAN_RATIO_MIN) || null;
|
| 243 |
+
gateTelemetry.orientation_status = orientationStatus;
|
| 244 |
+
consecutiveDistanceGreen = 0;
|
| 245 |
+
consecutiveLevelGreen = 0;
|
| 246 |
+
latestOrientation = null;
|
| 247 |
+
levelStartedAtMs = Date.now();
|
| 248 |
+
levelMarkedSkipped = false;
|
| 249 |
+
mediaPipeReady = false;
|
| 250 |
+
if (shutterBtn) shutterBtn.disabled = true;
|
| 251 |
+
setChip(distanceChip, "pending", "Distance: starting…");
|
| 252 |
+
|
| 253 |
+
if (orientationStatus === "granted" && window.OrientationDetector) {
|
| 254 |
+
if (levelChip) {
|
| 255 |
+
levelChip.hidden = false;
|
| 256 |
+
setChip(levelChip, "pending", "Level: starting…");
|
| 257 |
+
}
|
| 258 |
+
if (levelBubble) {
|
| 259 |
+
levelBubble.hidden = false;
|
| 260 |
+
levelBubble.dataset.state = "pending";
|
| 261 |
+
if (levelDot) levelDot.style.transform = "translate(-50%, -50%)";
|
| 262 |
+
}
|
| 263 |
+
window.OrientationDetector.start((data) => {
|
| 264 |
+
latestOrientation = data;
|
| 265 |
+
});
|
| 266 |
+
} else {
|
| 267 |
+
if (levelChip) levelChip.hidden = true;
|
| 268 |
+
if (levelBubble) {
|
| 269 |
+
levelBubble.hidden = true;
|
| 270 |
+
levelBubble.dataset.state = "pending";
|
| 271 |
+
if (levelDot) levelDot.style.transform = "translate(-50%, -50%)";
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
if (!window.HandsDetector) {
|
| 276 |
+
abandonGate("Hands module didn't load");
|
| 277 |
+
return;
|
| 278 |
+
}
|
| 279 |
+
try {
|
| 280 |
+
await window.HandsDetector.init();
|
| 281 |
+
mediaPipeReady = true;
|
| 282 |
+
} catch (err) {
|
| 283 |
+
console.warn("MediaPipe init failed; distance gate disabled", err);
|
| 284 |
+
const detail = err && err.message ? String(err.message).slice(0, 60) : "init failed";
|
| 285 |
+
abandonGate(`Distance check off: ${detail}`);
|
| 286 |
+
return;
|
| 287 |
+
}
|
| 288 |
+
detectionTimer = setInterval(runDetectionTick, 100);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function stopGateLoop() {
|
| 292 |
+
if (detectionTimer) {
|
| 293 |
+
clearInterval(detectionTimer);
|
| 294 |
+
detectionTimer = null;
|
| 295 |
+
}
|
| 296 |
+
if (window.OrientationDetector) window.OrientationDetector.stop();
|
| 297 |
+
consecutiveDistanceGreen = 0;
|
| 298 |
+
consecutiveLevelGreen = 0;
|
| 299 |
+
latestOrientation = null;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// Same pattern as desktop: stage anchored to top:0/bottom:0 with the
|
| 303 |
+
// URL-bar height pushed into a CSS variable so the bottom controls
|
| 304 |
+
// stay above iOS Safari's URL bar without leaving a gap below.
|
| 305 |
+
function syncCaptureInsets(stageEl) {
|
| 306 |
+
const layoutH = window.innerHeight;
|
| 307 |
+
const vv = window.visualViewport;
|
| 308 |
+
const visualH = vv ? vv.height : layoutH;
|
| 309 |
+
const offsetTop = vv ? vv.offsetTop : 0;
|
| 310 |
+
const urlBarH = Math.max(0, layoutH - visualH - offsetTop);
|
| 311 |
+
stageEl.style.setProperty("--capture-url-bar-inset", `${urlBarH}px`);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
async function refreshFlash() {
|
| 315 |
+
if (!flashBtn || !window.CapturePreview) return;
|
| 316 |
+
if (!window.CapturePreview.isTorchSupported()) {
|
| 317 |
+
flashBtn.hidden = true;
|
| 318 |
+
return;
|
| 319 |
+
}
|
| 320 |
+
flashBtn.hidden = false;
|
| 321 |
+
flashBtn.setAttribute("aria-pressed", "true");
|
| 322 |
+
flashBtn.setAttribute("aria-label", "Turn flash off");
|
| 323 |
+
try {
|
| 324 |
+
await window.CapturePreview.setTorch(true);
|
| 325 |
+
} catch (err) {
|
| 326 |
+
flashBtn.setAttribute("aria-pressed", "false");
|
| 327 |
+
flashBtn.setAttribute("aria-label", "Turn flash on");
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
async function toggleFlash() {
|
| 332 |
+
if (!flashBtn || !window.CapturePreview) return;
|
| 333 |
+
const wasOn = flashBtn.getAttribute("aria-pressed") === "true";
|
| 334 |
+
const next = !wasOn;
|
| 335 |
+
flashBtn.setAttribute("aria-pressed", next ? "true" : "false");
|
| 336 |
+
flashBtn.setAttribute("aria-label", next ? "Turn flash off" : "Turn flash on");
|
| 337 |
+
try {
|
| 338 |
+
await window.CapturePreview.setTorch(next);
|
| 339 |
+
} catch (err) {
|
| 340 |
+
flashBtn.setAttribute("aria-pressed", wasOn ? "true" : "false");
|
| 341 |
+
flashBtn.setAttribute("aria-label", wasOn ? "Turn flash off" : "Turn flash on");
|
| 342 |
+
setStatus("Could not toggle flash on this device.", true);
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
async function shoot(nav) {
|
| 347 |
+
if (!shutterBtn || !window.CapturePreview) return;
|
| 348 |
+
shutterBtn.disabled = true;
|
| 349 |
+
setStatus("Capturing…");
|
| 350 |
+
let blob;
|
| 351 |
+
try {
|
| 352 |
+
blob = await window.CapturePreview.captureFrame(videoEl);
|
| 353 |
+
} catch (err) {
|
| 354 |
+
setStatus(`Capture failed: ${err.message || err}`, true);
|
| 355 |
+
shutterBtn.disabled = false;
|
| 356 |
+
return;
|
| 357 |
+
}
|
| 358 |
+
// Hand the captured frame off to the confirm step. The /api/measure
|
| 359 |
+
// POST happens there so the user can review the photo (and the
|
| 360 |
+
// measuring timer has its own dedicated screen).
|
| 361 |
+
if (session.imageUrl && session.imageUrl.startsWith("blob:")) {
|
| 362 |
+
URL.revokeObjectURL(session.imageUrl);
|
| 363 |
+
}
|
| 364 |
+
session.imageBlob = blob;
|
| 365 |
+
session.imageUrl = URL.createObjectURL(blob);
|
| 366 |
+
session.imageSource = "camera";
|
| 367 |
+
session.gateTelemetry = {
|
| 368 |
+
...gateTelemetry,
|
| 369 |
+
captured_at_ms: gateTelemetry.started_at_ms ? Date.now() - gateTelemetry.started_at_ms : null,
|
| 370 |
+
media_pipe_ready: mediaPipeReady,
|
| 371 |
+
capture_width: videoEl.videoWidth || null,
|
| 372 |
+
capture_height: videoEl.videoHeight || null,
|
| 373 |
+
surface: "mobile",
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
stopGateLoop();
|
| 377 |
+
window.CapturePreview.stop();
|
| 378 |
+
nav.next();
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
export default {
|
| 382 |
+
mount(container, nav) {
|
| 383 |
+
container.innerHTML = `
|
| 384 |
+
<section class="step step-capture">
|
| 385 |
+
<video id="captureVideo" class="capture-video" autoplay playsinline muted></video>
|
| 386 |
+
|
| 387 |
+
<button type="button" class="capture-back" aria-label="Back">←</button>
|
| 388 |
+
<button type="button" class="capture-flash" hidden aria-label="Turn flash on" aria-pressed="false">
|
| 389 |
+
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
| 390 |
+
<path d="M13 2 L4 14 L11 14 L9 22 L20 9 L13 9 Z" fill="currentColor" />
|
| 391 |
+
</svg>
|
| 392 |
+
</button>
|
| 393 |
+
|
| 394 |
+
<div class="capture-level-bubble" id="captureLevelBubble" data-state="pending" hidden aria-hidden="true">
|
| 395 |
+
<div class="bubble-tolerance"></div>
|
| 396 |
+
<div class="bubble-crosshair-h"></div>
|
| 397 |
+
<div class="bubble-crosshair-v"></div>
|
| 398 |
+
<div class="bubble-indicator" id="captureLevelDot">
|
| 399 |
+
<div class="bubble-indicator-h"></div>
|
| 400 |
+
<div class="bubble-indicator-v"></div>
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
|
| 404 |
+
<div class="capture-chips">
|
| 405 |
+
<div class="status-chip pending" id="captureDistanceChip">
|
| 406 |
+
<span class="chip-dot" aria-hidden="true"></span>
|
| 407 |
+
<span class="chip-label">Distance: starting…</span>
|
| 408 |
+
</div>
|
| 409 |
+
<div class="status-chip pending" id="captureLevelChip" hidden>
|
| 410 |
+
<span class="chip-dot" aria-hidden="true"></span>
|
| 411 |
+
<span class="chip-label">Level: starting…</span>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
|
| 415 |
+
<p class="capture-status" id="captureStatus">Requesting camera…</p>
|
| 416 |
+
|
| 417 |
+
<div class="capture-controls">
|
| 418 |
+
<button id="captureShutterBtn" type="button" class="primary capture-shutter" disabled>
|
| 419 |
+
Take Photo
|
| 420 |
+
</button>
|
| 421 |
+
</div>
|
| 422 |
+
</section>
|
| 423 |
+
`;
|
| 424 |
+
|
| 425 |
+
const stage = container.querySelector(".step-capture");
|
| 426 |
+
videoEl = container.querySelector("#captureVideo");
|
| 427 |
+
distanceChip = container.querySelector("#captureDistanceChip");
|
| 428 |
+
levelChip = container.querySelector("#captureLevelChip");
|
| 429 |
+
levelBubble = container.querySelector("#captureLevelBubble");
|
| 430 |
+
levelDot = container.querySelector("#captureLevelDot");
|
| 431 |
+
shutterBtn = container.querySelector("#captureShutterBtn");
|
| 432 |
+
flashBtn = container.querySelector(".capture-flash");
|
| 433 |
+
statusEl = container.querySelector("#captureStatus");
|
| 434 |
+
|
| 435 |
+
container.querySelector(".capture-back").addEventListener("click", () => {
|
| 436 |
+
// Tear-down happens in unmount() when the step controller swaps
|
| 437 |
+
// us out; just trigger the back transition.
|
| 438 |
+
nav.back();
|
| 439 |
+
});
|
| 440 |
+
shutterBtn.addEventListener("click", () => shoot(nav));
|
| 441 |
+
flashBtn.addEventListener("click", toggleFlash);
|
| 442 |
+
|
| 443 |
+
// URL-bar inset sync. Same pattern as v5 capture stage.
|
| 444 |
+
const sync = () => syncCaptureInsets(stage);
|
| 445 |
+
sync();
|
| 446 |
+
requestAnimationFrame(sync);
|
| 447 |
+
if (window.visualViewport) {
|
| 448 |
+
window.visualViewport.addEventListener("resize", sync);
|
| 449 |
+
window.visualViewport.addEventListener("scroll", sync);
|
| 450 |
+
}
|
| 451 |
+
// Stash the listener on the stage so unmount() can detach it.
|
| 452 |
+
stage._syncInsetsListener = sync;
|
| 453 |
+
|
| 454 |
+
(async () => {
|
| 455 |
+
// Orientation permission must be requested in response to the
|
| 456 |
+
// user gesture that triggered this step (the prep step's "Open
|
| 457 |
+
// camera" button click bubbles into this navigation, so we are
|
| 458 |
+
// still inside the gesture's task).
|
| 459 |
+
if (window.OrientationDetector) {
|
| 460 |
+
try {
|
| 461 |
+
orientationStatus = await window.OrientationDetector.requestPermission();
|
| 462 |
+
} catch (err) {
|
| 463 |
+
orientationStatus = "denied";
|
| 464 |
+
}
|
| 465 |
+
} else {
|
| 466 |
+
orientationStatus = "unsupported";
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
try {
|
| 470 |
+
await window.CapturePreview.start(videoEl);
|
| 471 |
+
} catch (err) {
|
| 472 |
+
setStatus(`Camera unavailable: ${err.message || err}`, true);
|
| 473 |
+
return;
|
| 474 |
+
}
|
| 475 |
+
setStatus("Keep the camera parallel to the table, at the right distance from your hand.");
|
| 476 |
+
await refreshFlash();
|
| 477 |
+
await startGateLoop();
|
| 478 |
+
})();
|
| 479 |
+
},
|
| 480 |
+
|
| 481 |
+
unmount() {
|
| 482 |
+
stopGateLoop();
|
| 483 |
+
if (window.CapturePreview) window.CapturePreview.stop();
|
| 484 |
+
const stage = document.querySelector(".step-capture");
|
| 485 |
+
if (stage && window.visualViewport && stage._syncInsetsListener) {
|
| 486 |
+
window.visualViewport.removeEventListener("resize", stage._syncInsetsListener);
|
| 487 |
+
window.visualViewport.removeEventListener("scroll", stage._syncInsetsListener);
|
| 488 |
+
}
|
| 489 |
+
videoEl = null;
|
| 490 |
+
distanceChip = null;
|
| 491 |
+
levelChip = null;
|
| 492 |
+
levelBubble = null;
|
| 493 |
+
levelDot = null;
|
| 494 |
+
shutterBtn = null;
|
| 495 |
+
flashBtn = null;
|
| 496 |
+
statusEl = null;
|
| 497 |
+
},
|
| 498 |
+
};
|
web_demo/static/mobile/steps/confirm.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Step 5 — Confirm the photo + run measurement.
|
| 2 |
+
//
|
| 3 |
+
// Receives `session.imageBlob` from either the upload step (step 3,
|
| 4 |
+
// file picked) or the capture step (step 4, camera shutter). Shows
|
| 5 |
+
// the photo so the user can decide whether to keep it, then on
|
| 6 |
+
// "Start Measurement" POSTs to /api/measure and shows a live
|
| 7 |
+
// elapsed-time counter — the algorithm can take 20-30 s and silence
|
| 8 |
+
// during that wait is the worst UX. When the response arrives, we
|
| 9 |
+
// store it on `session.result` and advance to the result step.
|
| 10 |
+
|
| 11 |
+
import { session } from "../session.js";
|
| 12 |
+
import { postMeasure } from "../../shared/measure-api.js";
|
| 13 |
+
|
| 14 |
+
let elapsedTimer = null;
|
| 15 |
+
|
| 16 |
+
function clearTimerFn() {
|
| 17 |
+
if (elapsedTimer) {
|
| 18 |
+
clearInterval(elapsedTimer);
|
| 19 |
+
elapsedTimer = null;
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export default {
|
| 24 |
+
mount(container, nav) {
|
| 25 |
+
if (!session.imageBlob) {
|
| 26 |
+
// Defensive: someone reached this step without an image.
|
| 27 |
+
// Send them back to the photo guide rather than leaving them
|
| 28 |
+
// staring at an empty preview.
|
| 29 |
+
nav.goTo("guide");
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const sourceLabel =
|
| 34 |
+
session.imageSource === "camera" ? "Captured Photo" : "Uploaded Photo";
|
| 35 |
+
|
| 36 |
+
container.innerHTML = `
|
| 37 |
+
<section class="step step-confirm">
|
| 38 |
+
<header class="step-head">
|
| 39 |
+
<button type="button" class="step-back" aria-label="Back">←</button>
|
| 40 |
+
</header>
|
| 41 |
+
<div class="step-body">
|
| 42 |
+
<div class="panel">
|
| 43 |
+
<p class="hero-eyebrow">Step 3 of 4</p>
|
| 44 |
+
<h2 class="panel-title">${sourceLabel}</h2>
|
| 45 |
+
<div class="image-frame show">
|
| 46 |
+
<img id="confirmPreview" alt="Photo to measure" />
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
<footer class="step-foot">
|
| 51 |
+
<!-- Status sits in the footer (which never scrolls) so the
|
| 52 |
+
live "Measuring…" feedback stays visible even when the
|
| 53 |
+
photo is tall enough to push the rest of the panel
|
| 54 |
+
content off-screen. Empty before the user taps Start
|
| 55 |
+
Measurement — no pre-measurement placeholder. -->
|
| 56 |
+
<p class="confirm-status" id="confirmStatus" hidden></p>
|
| 57 |
+
<button id="confirmStartBtn" type="button" class="primary">
|
| 58 |
+
Start Measurement
|
| 59 |
+
</button>
|
| 60 |
+
</footer>
|
| 61 |
+
</section>
|
| 62 |
+
`;
|
| 63 |
+
|
| 64 |
+
const previewImg = container.querySelector("#confirmPreview");
|
| 65 |
+
const statusEl = container.querySelector("#confirmStatus");
|
| 66 |
+
const startBtn = container.querySelector("#confirmStartBtn");
|
| 67 |
+
|
| 68 |
+
previewImg.src = session.imageUrl;
|
| 69 |
+
|
| 70 |
+
container.querySelector(".step-back").addEventListener("click", () => {
|
| 71 |
+
// Sending the user back to the photo-guide step is the sanest
|
| 72 |
+
// default. The capture step would re-open the camera and
|
| 73 |
+
// discard the photo we just captured, which is hardly ever
|
| 74 |
+
// what the user wants.
|
| 75 |
+
nav.goTo("guide");
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
startBtn.addEventListener("click", async () => {
|
| 79 |
+
startBtn.disabled = true;
|
| 80 |
+
statusEl.hidden = false;
|
| 81 |
+
|
| 82 |
+
// Live elapsed-time counter — copy mirrors the desktop's
|
| 83 |
+
// setStatus(`Measuring… Done in under a minute. (${secs}s)`)
|
| 84 |
+
// so both surfaces show identical feedback during the wait.
|
| 85 |
+
const startedAt = Date.now();
|
| 86 |
+
const renderTimer = () => {
|
| 87 |
+
const secs = Math.floor((Date.now() - startedAt) / 1000);
|
| 88 |
+
statusEl.textContent = `Measuring… Done in under a minute. (${secs}s)`;
|
| 89 |
+
statusEl.classList.remove("error");
|
| 90 |
+
};
|
| 91 |
+
renderTimer();
|
| 92 |
+
elapsedTimer = setInterval(renderTimer, 1000);
|
| 93 |
+
|
| 94 |
+
try {
|
| 95 |
+
const result = await postMeasure({
|
| 96 |
+
blob: session.imageBlob,
|
| 97 |
+
kol_name: session.kolName,
|
| 98 |
+
ring_model: session.ringModel,
|
| 99 |
+
gate_telemetry: session.gateTelemetry,
|
| 100 |
+
capture_method: session.imageSource === "camera" ? "camera" : "upload",
|
| 101 |
+
});
|
| 102 |
+
clearTimerFn();
|
| 103 |
+
session.result = result;
|
| 104 |
+
nav.next();
|
| 105 |
+
} catch (err) {
|
| 106 |
+
clearTimerFn();
|
| 107 |
+
statusEl.textContent = `Measurement failed: ${err.message || err}`;
|
| 108 |
+
statusEl.classList.add("error");
|
| 109 |
+
startBtn.disabled = false;
|
| 110 |
+
}
|
| 111 |
+
});
|
| 112 |
+
},
|
| 113 |
+
|
| 114 |
+
unmount() {
|
| 115 |
+
clearTimerFn();
|
| 116 |
+
},
|
| 117 |
+
};
|
web_demo/static/mobile/steps/form.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Step 2 — Name / ID + Ring Model.
|
| 2 |
+
// Mirrors the desktop hero-card's top "controls" block.
|
| 3 |
+
// Persists answers in session so back-nav preserves them.
|
| 4 |
+
|
| 5 |
+
import { session } from "../session.js";
|
| 6 |
+
|
| 7 |
+
export default {
|
| 8 |
+
mount(container, nav) {
|
| 9 |
+
container.innerHTML = `
|
| 10 |
+
<section class="step step-form">
|
| 11 |
+
<header class="step-head">
|
| 12 |
+
<button type="button" class="step-back" aria-label="Back">←</button>
|
| 13 |
+
</header>
|
| 14 |
+
<div class="step-body">
|
| 15 |
+
<div class="panel">
|
| 16 |
+
<p class="hero-eyebrow">Step 1 of 4</p>
|
| 17 |
+
<h2 class="panel-title">Select Ring Model</h2>
|
| 18 |
+
<div class="controls">
|
| 19 |
+
<label>
|
| 20 |
+
<span>Ring Model</span>
|
| 21 |
+
<select id="formRingModel">
|
| 22 |
+
<option value="gen">Gen1/Gen2</option>
|
| 23 |
+
<option value="air">Air</option>
|
| 24 |
+
</select>
|
| 25 |
+
</label>
|
| 26 |
+
<label>
|
| 27 |
+
<span>Name / ID</span>
|
| 28 |
+
<input
|
| 29 |
+
type="text"
|
| 30 |
+
id="formKolName"
|
| 31 |
+
placeholder="e.g. Your Name"
|
| 32 |
+
autocomplete="name"
|
| 33 |
+
/>
|
| 34 |
+
</label>
|
| 35 |
+
</div>
|
| 36 |
+
<p class="form-error" id="formError" hidden></p>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
<footer class="step-foot">
|
| 40 |
+
<button type="button" class="primary step-next">Continue</button>
|
| 41 |
+
</footer>
|
| 42 |
+
</section>
|
| 43 |
+
`;
|
| 44 |
+
|
| 45 |
+
const nameInput = container.querySelector("#formKolName");
|
| 46 |
+
const modelSelect = container.querySelector("#formRingModel");
|
| 47 |
+
const errorEl = container.querySelector("#formError");
|
| 48 |
+
|
| 49 |
+
// Pre-fill from session — back-nav from upload/camera should not
|
| 50 |
+
// ask the user to retype.
|
| 51 |
+
nameInput.value = session.kolName || "";
|
| 52 |
+
modelSelect.value = session.ringModel || "gen";
|
| 53 |
+
|
| 54 |
+
container.querySelector(".step-back").addEventListener("click", nav.back);
|
| 55 |
+
container.querySelector(".step-next").addEventListener("click", () => {
|
| 56 |
+
const name = nameInput.value.trim();
|
| 57 |
+
if (!name) {
|
| 58 |
+
errorEl.textContent = "Please enter a name or ID before continuing.";
|
| 59 |
+
errorEl.hidden = false;
|
| 60 |
+
nameInput.focus();
|
| 61 |
+
return;
|
| 62 |
+
}
|
| 63 |
+
session.kolName = name;
|
| 64 |
+
session.ringModel = modelSelect.value;
|
| 65 |
+
nav.next();
|
| 66 |
+
});
|
| 67 |
+
},
|
| 68 |
+
};
|
web_demo/static/mobile/steps/guide.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Step 3 — Photo guide.
|
| 2 |
+
//
|
| 3 |
+
// Shows the same five-bullet tips list the desktop displays, plus the
|
| 4 |
+
// pre-bundled sample photo as a concrete "like this" example so the
|
| 5 |
+
// user knows exactly what they're aiming for. Two actions at the
|
| 6 |
+
// bottom: a primary "Open Camera" (preferred path — feeds the live
|
| 7 |
+
// capture coach with level + distance gates) and a secondary ghost
|
| 8 |
+
// link "Upload from photos" for users with a usable shot already in
|
| 9 |
+
// the camera roll. Upload bypasses the capture step entirely and
|
| 10 |
+
// hands the file straight to the confirm step.
|
| 11 |
+
|
| 12 |
+
import { session, resetForRetake } from "../session.js";
|
| 13 |
+
|
| 14 |
+
export default {
|
| 15 |
+
mount(container, nav) {
|
| 16 |
+
container.innerHTML = `
|
| 17 |
+
<section class="step step-guide">
|
| 18 |
+
<header class="step-head">
|
| 19 |
+
<button type="button" class="step-back" aria-label="Back">←</button>
|
| 20 |
+
</header>
|
| 21 |
+
<div class="step-body">
|
| 22 |
+
<div class="panel">
|
| 23 |
+
<p class="hero-eyebrow">Step 2 of 4</p>
|
| 24 |
+
<h2 class="panel-title">Photo Guidance</h2>
|
| 25 |
+
|
| 26 |
+
<ul class="capture-tips">
|
| 27 |
+
<li>Place a card of <strong>standard credit card size</strong> beside your hand.</li>
|
| 28 |
+
<li>Hold phone <strong>directly above hand</strong>, parallel to table.</li>
|
| 29 |
+
<li><strong>Spread your fingers naturally</strong>.</li>
|
| 30 |
+
<li><strong>Use plain white background</strong>, a sheet of paper works great.</li>
|
| 31 |
+
<li><strong>Turn on your phone's flash</strong>, it sharpens finger edges.</li>
|
| 32 |
+
</ul>
|
| 33 |
+
|
| 34 |
+
<figure class="guide-example">
|
| 35 |
+
<figcaption>Like this — hand flat, card beside it.</figcaption>
|
| 36 |
+
<img
|
| 37 |
+
src="/static/examples/default_sample.jpg"
|
| 38 |
+
alt="Example photo: hand flat with a credit card placed beside it"
|
| 39 |
+
/>
|
| 40 |
+
</figure>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
<footer class="step-foot">
|
| 44 |
+
<button type="button" class="primary step-next">Open Camera</button>
|
| 45 |
+
<button type="button" class="step-link guide-upload">Upload from photos</button>
|
| 46 |
+
<p class="guide-upload-error" id="guideUploadError" hidden role="alert"></p>
|
| 47 |
+
<input
|
| 48 |
+
type="file"
|
| 49 |
+
id="guideFileInput"
|
| 50 |
+
class="guide-file-input"
|
| 51 |
+
accept="image/*"
|
| 52 |
+
hidden
|
| 53 |
+
/>
|
| 54 |
+
</footer>
|
| 55 |
+
</section>
|
| 56 |
+
`;
|
| 57 |
+
|
| 58 |
+
container.querySelector(".step-back").addEventListener("click", nav.back);
|
| 59 |
+
container.querySelector(".step-next").addEventListener("click", nav.next);
|
| 60 |
+
|
| 61 |
+
const fileInput = container.querySelector("#guideFileInput");
|
| 62 |
+
const errorEl = container.querySelector("#guideUploadError");
|
| 63 |
+
const showUploadError = (msg) => {
|
| 64 |
+
if (!errorEl) return;
|
| 65 |
+
errorEl.textContent = msg;
|
| 66 |
+
errorEl.hidden = false;
|
| 67 |
+
};
|
| 68 |
+
const clearUploadError = () => {
|
| 69 |
+
if (!errorEl) return;
|
| 70 |
+
errorEl.hidden = true;
|
| 71 |
+
errorEl.textContent = "";
|
| 72 |
+
};
|
| 73 |
+
container.querySelector(".guide-upload").addEventListener("click", () => {
|
| 74 |
+
clearUploadError();
|
| 75 |
+
// Reset value so picking the same file twice still fires `change`.
|
| 76 |
+
fileInput.value = "";
|
| 77 |
+
fileInput.click();
|
| 78 |
+
});
|
| 79 |
+
fileInput.addEventListener("change", (ev) => {
|
| 80 |
+
const file = ev.target.files && ev.target.files[0];
|
| 81 |
+
if (!file) return;
|
| 82 |
+
// `accept="image/*"` is a hint, not enforcement — Android's "Files"
|
| 83 |
+
// picker lets users pick anything. Validate at source so the
|
| 84 |
+
// downstream confirm/measure steps don't render a broken image
|
| 85 |
+
// or POST garbage and surface a backend 4xx.
|
| 86 |
+
if (!file.type.startsWith("image/") || file.size === 0) {
|
| 87 |
+
showUploadError("Please pick an image file (JPG or PNG).");
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
// Drop any prior session image (camera capture or earlier upload)
|
| 91 |
+
// so the confirm step sees a clean slate.
|
| 92 |
+
resetForRetake();
|
| 93 |
+
session.imageBlob = file;
|
| 94 |
+
session.imageUrl = URL.createObjectURL(file);
|
| 95 |
+
session.imageSource = "upload";
|
| 96 |
+
// Skip the capture step — uploads bypass the live coach by design.
|
| 97 |
+
nav.goTo("confirm");
|
| 98 |
+
});
|
| 99 |
+
},
|
| 100 |
+
};
|
web_demo/static/mobile/steps/intro.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Step 1 — Intro / value prop.
|
| 2 |
+
// Mirrors the desktop hero copy: red eyebrow, large display headline,
|
| 3 |
+
// muted sub-line. No panel — this lives directly on the gradient
|
| 4 |
+
// background, same as the desktop column-left treatment.
|
| 5 |
+
|
| 6 |
+
export default {
|
| 7 |
+
mount(container, nav) {
|
| 8 |
+
container.innerHTML = `
|
| 9 |
+
<section class="step step-intro">
|
| 10 |
+
<div class="step-body">
|
| 11 |
+
<p class="hero-eyebrow">Femometer Smart Ring Sizer</p>
|
| 12 |
+
<h1 class="hero-headline">Upload a photo to quickly measure ring size</h1>
|
| 13 |
+
<p class="hero-sub">
|
| 14 |
+
Using a card of standard credit card size for scale reference.
|
| 15 |
+
</p>
|
| 16 |
+
</div>
|
| 17 |
+
<footer class="step-foot">
|
| 18 |
+
<button type="button" class="primary step-next">Get Started</button>
|
| 19 |
+
</footer>
|
| 20 |
+
</section>
|
| 21 |
+
`;
|
| 22 |
+
container.querySelector(".step-next").addEventListener("click", nav.next);
|
| 23 |
+
},
|
| 24 |
+
};
|
web_demo/static/mobile/steps/result.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Step 6 — Result.
|
| 2 |
+
//
|
| 3 |
+
// Mirrors the desktop result section: a "Result Overlay" panel
|
| 4 |
+
// showing the algorithm's annotated image, then a "Ring Size
|
| 5 |
+
// Recommendation" panel with one card per measured finger and the
|
| 6 |
+
// ring-size reference table at the bottom. Reads
|
| 7 |
+
// `session.result` (the /api/measure response) and `session.ringModel`.
|
| 8 |
+
|
| 9 |
+
import { session, resetForRetake } from "../session.js";
|
| 10 |
+
import { formatFailReason } from "../../shared/fail-reasons.js";
|
| 11 |
+
|
| 12 |
+
const FINGER_LABEL = {
|
| 13 |
+
index: "Index (Recommended)",
|
| 14 |
+
middle: "Middle",
|
| 15 |
+
ring: "Ring",
|
| 16 |
+
};
|
| 17 |
+
const FINGER_COLOR = {
|
| 18 |
+
index: "#00dddd",
|
| 19 |
+
middle: "#00cc44",
|
| 20 |
+
ring: "#dd44dd",
|
| 21 |
+
};
|
| 22 |
+
const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
|
| 23 |
+
const RING_SIZE_TABLES = {
|
| 24 |
+
gen: { 6: 16.9, 7: 17.7, 8: 18.6, 9: 19.4, 10: 20.3, 11: 21.1, 12: 21.9, 13: 22.7 },
|
| 25 |
+
air: { 6: 16.6, 7: 17.4, 8: 18.2, 9: 19.0, 10: 19.9, 11: 20.7, 12: 21.5, 13: 22.3 },
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
function escape(s) {
|
| 29 |
+
return String(s == null ? "" : s)
|
| 30 |
+
.replace(/&/g, "&")
|
| 31 |
+
.replace(/</g, "<")
|
| 32 |
+
.replace(/>/g, ">");
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function fingerCardHtml(fn, pf) {
|
| 36 |
+
const label = FINGER_LABEL[fn] || fn;
|
| 37 |
+
const color = FINGER_COLOR[fn] || "#888";
|
| 38 |
+
if (pf.status !== "ok") {
|
| 39 |
+
return `
|
| 40 |
+
<div class="finger-card finger-card-failed" style="border-top: 3px solid ${color};">
|
| 41 |
+
<div class="finger-name">${escape(label)}</div>
|
| 42 |
+
<div class="finger-failed">Failed</div>
|
| 43 |
+
<div class="finger-fail-reason">${escape(pf.fail_reason || "unknown")}</div>
|
| 44 |
+
</div>
|
| 45 |
+
`;
|
| 46 |
+
}
|
| 47 |
+
const widthMm = pf.diameter_cm ? (pf.diameter_cm * 10).toFixed(1) : "—";
|
| 48 |
+
const range = pf.range ? `${pf.range[0]} – ${pf.range[1]}` : "";
|
| 49 |
+
return `
|
| 50 |
+
<div class="finger-card" style="border-top: 3px solid ${color};">
|
| 51 |
+
<div class="finger-name">${escape(label)}</div>
|
| 52 |
+
<div class="finger-size-label">Size</div>
|
| 53 |
+
<div class="finger-size">${escape(pf.best_match)}</div>
|
| 54 |
+
${range ? `<div class="finger-range">Range: ${escape(range)}</div>` : ""}
|
| 55 |
+
<div class="finger-width">Width: ${escape(widthMm)} mm</div>
|
| 56 |
+
</div>
|
| 57 |
+
`;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function buildSizeRefTable(ringModel) {
|
| 61 |
+
const sizeTable = RING_SIZE_TABLES[ringModel] || RING_SIZE_TABLES.gen;
|
| 62 |
+
const modelLabel = RING_MODEL_LABELS[ringModel] || ringModel;
|
| 63 |
+
const rows = Object.entries(sizeTable)
|
| 64 |
+
.map(([size, mm]) => `<tr><td>${size}</td><td>${mm.toFixed(1)}</td></tr>`)
|
| 65 |
+
.join("");
|
| 66 |
+
return `
|
| 67 |
+
<div class="size-ref-table">
|
| 68 |
+
<h3 class="size-ref-title">Size Reference (${escape(modelLabel)})</h3>
|
| 69 |
+
<table>
|
| 70 |
+
<thead><tr><th>Size</th><th>Inner ⌀ (mm)</th></tr></thead>
|
| 71 |
+
<tbody>${rows}</tbody>
|
| 72 |
+
</table>
|
| 73 |
+
</div>
|
| 74 |
+
`;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function renderRecommendation(payload, ringModel) {
|
| 78 |
+
// The status banner above this panel already explains the failure
|
| 79 |
+
// case (using the friendly desktop copy via formatFailReason), so
|
| 80 |
+
// we don't repeat a "Measurement Failed" hero here. Just render
|
| 81 |
+
// whatever finger data made it back, the count, and the size
|
| 82 |
+
// reference — useful even when nothing measured.
|
| 83 |
+
const perFinger = (payload && payload.per_finger) || {};
|
| 84 |
+
let cards = "";
|
| 85 |
+
for (const fn of ["index", "middle", "ring"]) {
|
| 86 |
+
const pf = perFinger[fn];
|
| 87 |
+
if (!pf) continue;
|
| 88 |
+
cards += fingerCardHtml(fn, pf);
|
| 89 |
+
}
|
| 90 |
+
const succeeded = (payload && payload.fingers_succeeded) ?? 0;
|
| 91 |
+
const total = (payload && payload.fingers_measured) ?? 0;
|
| 92 |
+
// Count above the cards (not below) so the user reads
|
| 93 |
+
// "X/Y fingers measured" first and immediately understands the
|
| 94 |
+
// cards are a per-finger breakdown rather than a single result.
|
| 95 |
+
return `
|
| 96 |
+
<div class="finger-count">${escape(`${succeeded}/${total} fingers measured`)}</div>
|
| 97 |
+
${cards ? `<div class="finger-cards">${cards}</div>` : ""}
|
| 98 |
+
${buildSizeRefTable(ringModel)}
|
| 99 |
+
`;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export default {
|
| 103 |
+
mount(container, nav) {
|
| 104 |
+
const data = session.result;
|
| 105 |
+
if (!data) {
|
| 106 |
+
// Defensive — nothing to show, kick back to start.
|
| 107 |
+
nav.goTo("intro");
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
const payload = data.result || data;
|
| 111 |
+
const overlayUrl = data.result_image_url;
|
| 112 |
+
const ringModel = session.ringModel || "gen";
|
| 113 |
+
|
| 114 |
+
// Same success/error rule the desktop uses (web_demo/app.py
|
| 115 |
+
// sets `data.success = result.fail_reason is None`). Status copy
|
| 116 |
+
// is sourced from shared/fail-reasons.js so the mobile and
|
| 117 |
+
// desktop surfaces stay in sync.
|
| 118 |
+
const succeeded = !!data.success && payload && payload.per_finger;
|
| 119 |
+
const statusBanner = succeeded
|
| 120 |
+
? `
|
| 121 |
+
<div class="result-status result-status-success">
|
| 122 |
+
<p>Measurement complete.</p>
|
| 123 |
+
</div>
|
| 124 |
+
`
|
| 125 |
+
: `
|
| 126 |
+
<div class="result-status result-status-error">
|
| 127 |
+
<p>${escape(formatFailReason(payload && payload.fail_reason))}</p>
|
| 128 |
+
</div>
|
| 129 |
+
`;
|
| 130 |
+
|
| 131 |
+
container.innerHTML = `
|
| 132 |
+
<section class="step step-result">
|
| 133 |
+
<div class="step-body">
|
| 134 |
+
${statusBanner}
|
| 135 |
+
${
|
| 136 |
+
overlayUrl
|
| 137 |
+
? `
|
| 138 |
+
<div class="panel">
|
| 139 |
+
<h2 class="panel-title">Result Overlay</h2>
|
| 140 |
+
<div class="image-frame show">
|
| 141 |
+
<img src="${escape(overlayUrl)}" alt="Measurement overlay" />
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
`
|
| 145 |
+
: ""
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
<div class="panel">
|
| 149 |
+
<h2 class="panel-title">Ring Size Recommendation</h2>
|
| 150 |
+
${renderRecommendation(payload, ringModel)}
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
<footer class="step-foot">
|
| 154 |
+
<button type="button" class="primary step-retake">Measure Again</button>
|
| 155 |
+
</footer>
|
| 156 |
+
</section>
|
| 157 |
+
`;
|
| 158 |
+
|
| 159 |
+
// "Measure again" returns to the photo-guide step so the user can
|
| 160 |
+
// pick between Open Camera and Upload from photos — the upload
|
| 161 |
+
// entry was added after this button originally jumped straight to
|
| 162 |
+
// the camera. Form fields (Name / Ring Model) are preserved; only
|
| 163 |
+
// the photo + result + telemetry are wiped. Users who want a full
|
| 164 |
+
// reset (e.g. handing the phone to a different person) can
|
| 165 |
+
// refresh the page.
|
| 166 |
+
container.querySelector(".step-retake").addEventListener("click", () => {
|
| 167 |
+
resetForRetake();
|
| 168 |
+
nav.goTo("guide");
|
| 169 |
+
});
|
| 170 |
+
},
|
| 171 |
+
};
|
web_demo/static/preview/hands.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// v5 Phase 2 — in-browser MediaPipe Hands for the distance gate.
|
| 2 |
+
//
|
| 3 |
+
// Loaded as `<script type="module">` so we can `import` directly from the
|
| 4 |
+
// jsdelivr CDN. The module attaches the public API to window.HandsDetector
|
| 5 |
+
// so the classic-script app.js can call it.
|
| 6 |
+
//
|
| 7 |
+
// CDN choice: jsdelivr-mirrored npm package (default for Phase 2). Future
|
| 8 |
+
// productization on Shopify may require self-hosting under
|
| 9 |
+
// `web_demo/static/vendor/mediapipe/` to satisfy strict CSP — see doc/v5/PRD.md
|
| 10 |
+
// "Risks and open questions".
|
| 11 |
+
|
| 12 |
+
// Pinned to a real published version (0.10.22 from an earlier draft did not
|
| 13 |
+
// exist on jsdelivr; latest at the time of pinning was 0.10.35). Bump only
|
| 14 |
+
// after re-validating that the entrypoint and `/wasm/` subpath both 200.
|
| 15 |
+
import {
|
| 16 |
+
HandLandmarker,
|
| 17 |
+
FilesetResolver,
|
| 18 |
+
} from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/vision_bundle.mjs";
|
| 19 |
+
|
| 20 |
+
// Same hand-landmarker model the Python pipeline uses (see
|
| 21 |
+
// src/finger_segmentation.py:55). Hosting it on Google's CDN keeps the
|
| 22 |
+
// frontend / backend symmetric and avoids us redistributing the weights.
|
| 23 |
+
const MODEL_URL =
|
| 24 |
+
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
|
| 25 |
+
const WASM_URL =
|
| 26 |
+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
|
| 27 |
+
|
| 28 |
+
// MediaPipe landmark indices for the palm-MCP span — matches
|
| 29 |
+
// LM_INDEX_MCP / LM_PINKY_MCP in script/analyze_hand_span.py.
|
| 30 |
+
const LM_INDEX_MCP = 5;
|
| 31 |
+
const LM_PINKY_MCP = 17;
|
| 32 |
+
|
| 33 |
+
let landmarker = null;
|
| 34 |
+
let initPromise = null;
|
| 35 |
+
|
| 36 |
+
async function init() {
|
| 37 |
+
if (landmarker) return landmarker;
|
| 38 |
+
if (initPromise) return initPromise;
|
| 39 |
+
initPromise = (async () => {
|
| 40 |
+
const fileset = await FilesetResolver.forVisionTasks(WASM_URL);
|
| 41 |
+
landmarker = await HandLandmarker.createFromOptions(fileset, {
|
| 42 |
+
baseOptions: {
|
| 43 |
+
modelAssetPath: MODEL_URL,
|
| 44 |
+
delegate: "GPU",
|
| 45 |
+
},
|
| 46 |
+
runningMode: "VIDEO",
|
| 47 |
+
numHands: 1,
|
| 48 |
+
minHandDetectionConfidence: 0.3,
|
| 49 |
+
minTrackingConfidence: 0.3,
|
| 50 |
+
});
|
| 51 |
+
return landmarker;
|
| 52 |
+
})();
|
| 53 |
+
try {
|
| 54 |
+
return await initPromise;
|
| 55 |
+
} catch (err) {
|
| 56 |
+
initPromise = null;
|
| 57 |
+
throw err;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Run a single detection on the current `<video>` frame. Returns
|
| 62 |
+
// `{ratio, span_px}` if a hand is detected, `null` otherwise. The ratio is
|
| 63 |
+
// computed against `min(videoWidth, videoHeight)` so it's invariant to
|
| 64 |
+
// portrait vs landscape framing.
|
| 65 |
+
function detect(videoEl) {
|
| 66 |
+
if (!landmarker) return null;
|
| 67 |
+
if (!videoEl || videoEl.readyState < 2) return null;
|
| 68 |
+
const w = videoEl.videoWidth;
|
| 69 |
+
const h = videoEl.videoHeight;
|
| 70 |
+
if (!w || !h) return null;
|
| 71 |
+
|
| 72 |
+
const result = landmarker.detectForVideo(videoEl, performance.now());
|
| 73 |
+
if (!result || !result.landmarks || result.landmarks.length === 0) {
|
| 74 |
+
return null;
|
| 75 |
+
}
|
| 76 |
+
const lm = result.landmarks[0];
|
| 77 |
+
const p5 = lm[LM_INDEX_MCP];
|
| 78 |
+
const p17 = lm[LM_PINKY_MCP];
|
| 79 |
+
if (!p5 || !p17) return null;
|
| 80 |
+
|
| 81 |
+
// Landmark coords from MediaPipe Tasks are normalized [0,1] over the
|
| 82 |
+
// image dimensions, so multiplying by w/h gets pixel coords.
|
| 83 |
+
const dx = (p5.x - p17.x) * w;
|
| 84 |
+
const dy = (p5.y - p17.y) * h;
|
| 85 |
+
const span_px = Math.sqrt(dx * dx + dy * dy);
|
| 86 |
+
const ratio = span_px / Math.min(w, h);
|
| 87 |
+
return { ratio, span_px };
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
window.HandsDetector = { init, detect };
|
web_demo/static/preview/orientation.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// v5 Phase 3 — DeviceOrientationEvent for the level gate.
|
| 2 |
+
//
|
| 3 |
+
// Encapsulates the iOS 13+ requestPermission() dance, falls back gracefully
|
| 4 |
+
// where the API is missing, and exposes a callback-based stream of
|
| 5 |
+
// {beta, gamma} readings. Loaded as a non-module IIFE attached to window so
|
| 6 |
+
// the classic-script app.js can call it directly (matching preview.js).
|
| 7 |
+
//
|
| 8 |
+
// Permission semantics:
|
| 9 |
+
// "granted" — events should fire; caller can attach the listener
|
| 10 |
+
// "denied" — user explicitly declined; respect that and skip the gate
|
| 11 |
+
// "unsupported" — browser doesn't expose DeviceOrientationEvent at all
|
| 12 |
+
//
|
| 13 |
+
// The desktop case (DeviceOrientationEvent exists but no sensor) is reported
|
| 14 |
+
// as "granted" — events will fire but with null beta/gamma. Caller is
|
| 15 |
+
// responsible for treating "no data after some time" as "skipped" and
|
| 16 |
+
// excluding the level signal from the gate.
|
| 17 |
+
|
| 18 |
+
window.OrientationDetector = (function () {
|
| 19 |
+
let activeHandler = null;
|
| 20 |
+
let receivedAny = false;
|
| 21 |
+
|
| 22 |
+
async function requestPermission() {
|
| 23 |
+
if (typeof DeviceOrientationEvent === "undefined") {
|
| 24 |
+
return "unsupported";
|
| 25 |
+
}
|
| 26 |
+
// Non-iOS browsers do not require an explicit grant — they fire events
|
| 27 |
+
// immediately when an attached listener is present (subject to policy).
|
| 28 |
+
if (typeof DeviceOrientationEvent.requestPermission !== "function") {
|
| 29 |
+
return "granted";
|
| 30 |
+
}
|
| 31 |
+
try {
|
| 32 |
+
const result = await DeviceOrientationEvent.requestPermission();
|
| 33 |
+
return result; // "granted" | "denied"
|
| 34 |
+
} catch (err) {
|
| 35 |
+
// Spec says the rejection is permanent for this origin within the
|
| 36 |
+
// session — same observable behaviour as a denial, so map to that.
|
| 37 |
+
return "denied";
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function start(callback) {
|
| 42 |
+
stop();
|
| 43 |
+
receivedAny = false;
|
| 44 |
+
activeHandler = function (event) {
|
| 45 |
+
// Desktop browsers fire `deviceorientation` with null beta/gamma
|
| 46 |
+
// (the API exists, the sensor doesn't). Drop those silently — the
|
| 47 |
+
// caller's no-data-after-timeout logic decides whether to skip.
|
| 48 |
+
// Also drop non-finite (NaN/Infinity) readings — rare but observed
|
| 49 |
+
// on misbehaving sensors; would otherwise propagate into the gate
|
| 50 |
+
// and CSS `calc()` as NaN, which silently freezes the indicator.
|
| 51 |
+
if (
|
| 52 |
+
event.beta === null ||
|
| 53 |
+
event.gamma === null ||
|
| 54 |
+
!Number.isFinite(event.beta) ||
|
| 55 |
+
!Number.isFinite(event.gamma)
|
| 56 |
+
) {
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
receivedAny = true;
|
| 60 |
+
callback({ beta: event.beta, gamma: event.gamma });
|
| 61 |
+
};
|
| 62 |
+
window.addEventListener("deviceorientation", activeHandler);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function stop() {
|
| 66 |
+
if (activeHandler) {
|
| 67 |
+
window.removeEventListener("deviceorientation", activeHandler);
|
| 68 |
+
activeHandler = null;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function hasReceivedData() {
|
| 73 |
+
return receivedAny;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return { requestPermission, start, stop, hasReceivedData };
|
| 77 |
+
})();
|
web_demo/static/preview/preview.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// v5 Phase 1 — camera lifecycle + frame capture.
|
| 2 |
+
// No gates yet; that's Phase 2-4. This module owns the MediaStream and
|
| 3 |
+
// exposes start/stop/captureFrame so app.js can wire UI events to it.
|
| 4 |
+
//
|
| 5 |
+
// Loaded as a non-module IIFE attached to window so it can coexist with
|
| 6 |
+
// the existing app.js (which is also non-module). Convert both to ES
|
| 7 |
+
// modules later if v5 adds a bundler.
|
| 8 |
+
|
| 9 |
+
window.CapturePreview = (function () {
|
| 10 |
+
let activeStream = null;
|
| 11 |
+
let activeVideo = null;
|
| 12 |
+
|
| 13 |
+
function isSupported() {
|
| 14 |
+
// getUserMedia requires a secure context (HTTPS or localhost). On HTTP
|
| 15 |
+
// LAN IPs, Chrome leaves navigator.mediaDevices undefined; Safari
|
| 16 |
+
// exposes the namespace but getUserMedia() rejects. Check secure
|
| 17 |
+
// context up-front so the "Use Camera" button stays hidden in
|
| 18 |
+
// insecure-test setups instead of opening a doomed flow.
|
| 19 |
+
return !!(
|
| 20 |
+
window.isSecureContext &&
|
| 21 |
+
navigator.mediaDevices &&
|
| 22 |
+
typeof navigator.mediaDevices.getUserMedia === "function"
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
async function start(videoEl) {
|
| 27 |
+
if (!window.isSecureContext) {
|
| 28 |
+
throw new Error(
|
| 29 |
+
"Camera requires HTTPS. Open the deployed (Hugging Face) URL, or set up HTTPS locally — http://<LAN-IP> won't work."
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
| 33 |
+
throw new Error("This browser does not expose getUserMedia.");
|
| 34 |
+
}
|
| 35 |
+
stop();
|
| 36 |
+
// facingMode 'environment' = rear camera. 'ideal' (not 'exact') so
|
| 37 |
+
// desktop laptops without a rear camera still open the front cam
|
| 38 |
+
// rather than rejecting outright.
|
| 39 |
+
//
|
| 40 |
+
// Width/height are also `ideal` hints — the UA picks the closest
|
| 41 |
+
// mode the camera actually supports. We ask for 4K because the
|
| 42 |
+
// kol_success training set is ~3024×4032 native iPhone captures and
|
| 43 |
+
// sub-1080 frames noticeably soften the finger boundary. Modern
|
| 44 |
+
// iPhones and high-end Android phones honor 3840×2160; lesser
|
| 45 |
+
// hardware quietly negotiates down (typically to 1920×1080), which
|
| 46 |
+
// is still our previous baseline. No outright rejection.
|
| 47 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 48 |
+
video: {
|
| 49 |
+
facingMode: { ideal: "environment" },
|
| 50 |
+
width: { ideal: 3840 },
|
| 51 |
+
height: { ideal: 2160 },
|
| 52 |
+
},
|
| 53 |
+
audio: false,
|
| 54 |
+
});
|
| 55 |
+
videoEl.srcObject = stream;
|
| 56 |
+
// iOS Safari needs muted + playsinline (set as HTML attrs) to autoplay.
|
| 57 |
+
await videoEl.play();
|
| 58 |
+
activeStream = stream;
|
| 59 |
+
activeVideo = videoEl;
|
| 60 |
+
return stream;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function stop() {
|
| 64 |
+
if (activeStream) {
|
| 65 |
+
activeStream.getTracks().forEach((t) => t.stop());
|
| 66 |
+
activeStream = null;
|
| 67 |
+
}
|
| 68 |
+
if (activeVideo) {
|
| 69 |
+
activeVideo.srcObject = null;
|
| 70 |
+
activeVideo = null;
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function getActiveVideoTrack() {
|
| 75 |
+
if (!activeStream) return null;
|
| 76 |
+
const tracks = activeStream.getVideoTracks();
|
| 77 |
+
return tracks.length ? tracks[0] : null;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Torch (camera flash) support is Chromium-on-Android only — iOS
|
| 81 |
+
// Safari/WebKit does not expose `torch` in MediaTrackCapabilities, and
|
| 82 |
+
// most front cameras lack an LED even on Android. Callers must hide
|
| 83 |
+
// the flash UI when this returns false.
|
| 84 |
+
function isTorchSupported() {
|
| 85 |
+
const track = getActiveVideoTrack();
|
| 86 |
+
if (!track || typeof track.getCapabilities !== "function") return false;
|
| 87 |
+
let caps;
|
| 88 |
+
try {
|
| 89 |
+
caps = track.getCapabilities();
|
| 90 |
+
} catch (err) {
|
| 91 |
+
return false;
|
| 92 |
+
}
|
| 93 |
+
return !!(caps && caps.torch);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
async function setTorch(on) {
|
| 97 |
+
const track = getActiveVideoTrack();
|
| 98 |
+
if (!track) throw new Error("No active video track");
|
| 99 |
+
await track.applyConstraints({ advanced: [{ torch: !!on }] });
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Draw the current video frame to an offscreen canvas at native sensor
|
| 103 |
+
// resolution and encode as a JPEG Blob suitable for FormData submission.
|
| 104 |
+
// The server's existing /api/measure path accepts this with no changes.
|
| 105 |
+
async function captureFrame(videoEl, mimeType, quality) {
|
| 106 |
+
const w = videoEl.videoWidth;
|
| 107 |
+
const h = videoEl.videoHeight;
|
| 108 |
+
if (!w || !h) {
|
| 109 |
+
throw new Error("Video stream has no dimensions yet");
|
| 110 |
+
}
|
| 111 |
+
const canvas = document.createElement("canvas");
|
| 112 |
+
canvas.width = w;
|
| 113 |
+
canvas.height = h;
|
| 114 |
+
const ctx = canvas.getContext("2d");
|
| 115 |
+
ctx.drawImage(videoEl, 0, 0, w, h);
|
| 116 |
+
return new Promise((resolve, reject) => {
|
| 117 |
+
canvas.toBlob(
|
| 118 |
+
(blob) =>
|
| 119 |
+
blob ? resolve(blob) : reject(new Error("toBlob returned null")),
|
| 120 |
+
mimeType || "image/jpeg",
|
| 121 |
+
typeof quality === "number" ? quality : 0.92,
|
| 122 |
+
);
|
| 123 |
+
});
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
return { isSupported, start, stop, captureFrame, isTorchSupported, setTorch };
|
| 127 |
+
})();
|
web_demo/static/preview/thresholds.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// v5 capture-coach gate thresholds.
|
| 2 |
+
// Source of truth: doc/v5/PRD.md and script/analyze_hand_span.py results
|
| 3 |
+
// (72-image kol corpus, 2026-05-06 fit).
|
| 4 |
+
//
|
| 5 |
+
// Loaded as a non-module IIFE attached to window so both hands.js (module)
|
| 6 |
+
// and app.js (classic) can read the same constants. Convert to ES module
|
| 7 |
+
// when v5 introduces a bundler.
|
| 8 |
+
|
| 9 |
+
window.PreviewThresholds = (function () {
|
| 10 |
+
// Distance: ‖landmark[5] - landmark[17]‖ / min(videoWidth, videoHeight).
|
| 11 |
+
// Originally 0.239 (P10 of the success cohort) — narrowed to 0.3 on
|
| 12 |
+
// 2026-05-07 after user feedback that the previous bound accepted
|
| 13 |
+
// too many "still a bit far" frames as green.
|
| 14 |
+
const HAND_SPAN_RATIO_MIN = 0.3;
|
| 15 |
+
|
| 16 |
+
// Soft amber threshold (desktop only — mobile collapsed to red/green
|
| 17 |
+
// on the same date). Between 80% of the hard threshold and the
|
| 18 |
+
// threshold itself, the desktop UI shows "almost there".
|
| 19 |
+
const HAND_SPAN_RATIO_AMBER = HAND_SPAN_RATIO_MIN * 0.8;
|
| 20 |
+
|
| 21 |
+
// Upper distance bound. MediaPipe still returns landmarks when the hand
|
| 22 |
+
// partially clips the frame, so without an upper threshold the gate
|
| 23 |
+
// reads "Distance OK" even when the camera is too close — at which
|
| 24 |
+
// point there is no room left for the credit card and lens distortion
|
| 25 |
+
// biases the edge measurement. Tightened from 0.55 → 0.5 on
|
| 26 |
+
// 2026-05-07 alongside the lower-bound bump; pending a P90 sweep of
|
| 27 |
+
// the success cohort via analyze_hand_span.py.
|
| 28 |
+
const HAND_SPAN_RATIO_MAX = 0.5;
|
| 29 |
+
const HAND_SPAN_RATIO_MAX_AMBER = HAND_SPAN_RATIO_MAX * 0.88;
|
| 30 |
+
|
| 31 |
+
// Level: DeviceOrientationEvent beta (front-back) and gamma (left-right) in
|
| 32 |
+
// degrees. Tightened from 10° to 5° on 2026-05-07 — the previous
|
| 33 |
+
// bound matched the card_not_parallel hard gate but felt too lax in
|
| 34 |
+
// practice; ±5° gets the user closer to a clean top-down shot.
|
| 35 |
+
const LEVEL_BETA_MAX_DEG = 5;
|
| 36 |
+
const LEVEL_GAMMA_MAX_DEG = 5;
|
| 37 |
+
|
| 38 |
+
// Brightness: mean luminance (0–255) of a 64×64 downsample of the preview
|
| 39 |
+
// frame, computed as 0.299R + 0.587G + 0.114B. Below 60 is visibly
|
| 40 |
+
// underexposed and likely to fail edge detection.
|
| 41 |
+
const BRIGHTNESS_MIN_MEAN_LUM = 60;
|
| 42 |
+
|
| 43 |
+
// Anti-jitter: number of consecutive frames a signal must hold green
|
| 44 |
+
// before the gate counts it as passing. Prevents the shutter from flashing
|
| 45 |
+
// on/off as the user wobbles between threshold and threshold-epsilon.
|
| 46 |
+
const GATE_CONSECUTIVE_FRAMES = 3;
|
| 47 |
+
|
| 48 |
+
return {
|
| 49 |
+
HAND_SPAN_RATIO_MIN,
|
| 50 |
+
HAND_SPAN_RATIO_AMBER,
|
| 51 |
+
HAND_SPAN_RATIO_MAX,
|
| 52 |
+
HAND_SPAN_RATIO_MAX_AMBER,
|
| 53 |
+
LEVEL_BETA_MAX_DEG,
|
| 54 |
+
LEVEL_GAMMA_MAX_DEG,
|
| 55 |
+
BRIGHTNESS_MIN_MEAN_LUM,
|
| 56 |
+
GATE_CONSECUTIVE_FRAMES,
|
| 57 |
+
};
|
| 58 |
+
})();
|
web_demo/static/shared/fail-reasons.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Friendly status messages for /api/measure failures.
|
| 2 |
+
//
|
| 3 |
+
// Currently consumed by the mobile result step. The desktop
|
| 4 |
+
// `static/app.js` keeps an inline duplicate of this map until the
|
| 5 |
+
// Phase 4 desktop refactor migrates it to import from here. Until
|
| 6 |
+
// then, edits to copy must land in BOTH places — that's tracked in
|
| 7 |
+
// doc/v6/Progress.md.
|
| 8 |
+
|
| 9 |
+
export const FAIL_REASON_MESSAGES = {
|
| 10 |
+
card_not_detected:
|
| 11 |
+
"Card not detected. A card of standard credit card dimensions (85.6 × 54 mm) is required as a scale reference to measure your finger width. Place the card beside your hand on a plain white background (e.g. a sheet of paper), and turn on your phone's flash.",
|
| 12 |
+
card_not_parallel:
|
| 13 |
+
"Card scale calibration failed. Keep your phone parallel to the card. Use a card of standard credit card dimensions (85.6 × 54 mm) as the reference.",
|
| 14 |
+
card_near_edge:
|
| 15 |
+
"Card appears cropped. Place the entire card within the photo frame.",
|
| 16 |
+
card_too_small:
|
| 17 |
+
"Card looks too small in the photo. Move your phone closer to the table so the card takes up a larger portion of the frame, then retake.",
|
| 18 |
+
hand_not_detected:
|
| 19 |
+
"Hand not detected. Place your hand flat on a plain white background (e.g. a sheet of paper), and spread your fingers naturally.",
|
| 20 |
+
finger_isolation_failed:
|
| 21 |
+
"Could not isolate the selected finger. Keep one target finger extended and separated.",
|
| 22 |
+
finger_not_fully_visible:
|
| 23 |
+
"Finger is partially out of frame. Move hand to center of photo.",
|
| 24 |
+
finger_mask_too_small:
|
| 25 |
+
"Finger region is too small. Move closer and use a higher-resolution photo.",
|
| 26 |
+
fingers_too_close:
|
| 27 |
+
"Fingers are too close together. Spread your fingers apart naturally.",
|
| 28 |
+
contour_extraction_failed:
|
| 29 |
+
"Finger contour extraction failed. Improve lighting and reduce background clutter.",
|
| 30 |
+
axis_estimation_failed:
|
| 31 |
+
"Finger axis estimation failed. Keep the finger straight and fully visible.",
|
| 32 |
+
zone_localization_failed:
|
| 33 |
+
"Ring zone localization failed. Keep more of the finger base visible.",
|
| 34 |
+
width_measurement_failed:
|
| 35 |
+
"Width measurement failed. Retake with phone parallel to the table and steady focus.",
|
| 36 |
+
sobel_edge_refinement_failed:
|
| 37 |
+
"Edge refinement failed. Turn on flash or use stronger, even lighting.",
|
| 38 |
+
width_unreasonable:
|
| 39 |
+
"Measured width is out of range. Retake with the phone parallel to the table.",
|
| 40 |
+
disagreement_with_contour:
|
| 41 |
+
"Edge methods disagree too much. Retake with cleaner edges and more even lighting.",
|
| 42 |
+
all_fingers_failed:
|
| 43 |
+
"Could not measure any fingers. Ensure hand is flat with fingers spread and well-lit.",
|
| 44 |
+
image_too_blurry:
|
| 45 |
+
"Photo is blurry. Hold your phone steady or use a tripod.",
|
| 46 |
+
image_underexposed:
|
| 47 |
+
"Photo is too dark. Turn on flash or improve lighting.",
|
| 48 |
+
image_overexposed:
|
| 49 |
+
"Photo is too bright. Avoid direct sunlight or strong overhead light.",
|
| 50 |
+
image_low_contrast:
|
| 51 |
+
"Photo has low contrast. Use a different background color.",
|
| 52 |
+
image_resolution_too_low:
|
| 53 |
+
"Photo resolution is too low. Use the rear camera at full resolution.",
|
| 54 |
+
image_quality_low_lighting:
|
| 55 |
+
"Lighting is uneven. Turn on flash and shoot from directly above.",
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
export function formatFailReason(failReason) {
|
| 59 |
+
if (!failReason) {
|
| 60 |
+
return "Measurement failed.";
|
| 61 |
+
}
|
| 62 |
+
if (failReason.startsWith("quality_score_low_")) {
|
| 63 |
+
return "Low edge quality detected. Turn on flash and retake.";
|
| 64 |
+
}
|
| 65 |
+
if (failReason.startsWith("consistency_low_")) {
|
| 66 |
+
return "Edge detection was inconsistent. Keep phone parallel to table and retry.";
|
| 67 |
+
}
|
| 68 |
+
return (
|
| 69 |
+
FAIL_REASON_MESSAGES[failReason] ||
|
| 70 |
+
"Measurement failed. Please retake the photo and try again."
|
| 71 |
+
);
|
| 72 |
+
}
|
web_demo/static/shared/measure-api.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// POST a captured frame to /api/measure. Returns the parsed response
|
| 2 |
+
// JSON unchanged so callers can render the same shape the desktop UI
|
| 3 |
+
// already understands.
|
| 4 |
+
//
|
| 5 |
+
// Currently consumed only by the mobile capture step. Phase 4 of the
|
| 6 |
+
// v6 plan migrates the desktop submit handler to this helper too;
|
| 7 |
+
// until then `web_demo/static/app.js` keeps its inline fetch call.
|
| 8 |
+
|
| 9 |
+
const DEFAULTS = {
|
| 10 |
+
finger_index: "index",
|
| 11 |
+
mode: "multi",
|
| 12 |
+
edge_method: "mask",
|
| 13 |
+
ring_model: "gen",
|
| 14 |
+
// Server reads "1" as truthy; anything else (including "on", "0",
|
| 15 |
+
// "") is treated as off. Mobile flow does not surface the AI toggle,
|
| 16 |
+
// so default off — opt-in lives on the desktop dev page.
|
| 17 |
+
ai_explain: "0",
|
| 18 |
+
capture_method: "camera",
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export async function postMeasure({
|
| 22 |
+
blob,
|
| 23 |
+
kol_name,
|
| 24 |
+
gate_telemetry = null,
|
| 25 |
+
...overrides
|
| 26 |
+
} = {}) {
|
| 27 |
+
if (!blob) throw new Error("postMeasure: blob is required");
|
| 28 |
+
if (!kol_name) throw new Error("postMeasure: kol_name is required");
|
| 29 |
+
|
| 30 |
+
const settings = { ...DEFAULTS, ...overrides };
|
| 31 |
+
const formData = new FormData();
|
| 32 |
+
formData.append("image", blob, "capture.jpg");
|
| 33 |
+
formData.append("kol_name", kol_name);
|
| 34 |
+
formData.append("finger_index", settings.finger_index);
|
| 35 |
+
formData.append("mode", settings.mode);
|
| 36 |
+
formData.append("edge_method", settings.edge_method);
|
| 37 |
+
formData.append("ring_model", settings.ring_model);
|
| 38 |
+
formData.append("ai_explain", settings.ai_explain);
|
| 39 |
+
formData.append("capture_method", settings.capture_method);
|
| 40 |
+
if (gate_telemetry) {
|
| 41 |
+
formData.append("gate_telemetry", JSON.stringify(gate_telemetry));
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const response = await fetch("/api/measure", {
|
| 45 |
+
method: "POST",
|
| 46 |
+
body: formData,
|
| 47 |
+
});
|
| 48 |
+
if (!response.ok) {
|
| 49 |
+
const text = await response.text().catch(() => "");
|
| 50 |
+
throw new Error(`Measurement request failed (${response.status}): ${text.slice(0, 120)}`);
|
| 51 |
+
}
|
| 52 |
+
return response.json();
|
| 53 |
+
}
|
web_demo/static/shared/tokens.css
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Design tokens shared by the desktop (`styles.css`) and mobile
|
| 2 |
+
(`mobile/mobile.css`) surfaces. Keep this file colors-and-radii only —
|
| 3 |
+
no layout, no per-component rules — so both surfaces can evolve
|
| 4 |
+
independently while staying visually coherent. */
|
| 5 |
+
:root {
|
| 6 |
+
--bg-1: #f5f1e7;
|
| 7 |
+
--bg-2: #eedad5;
|
| 8 |
+
--bg-3: #e7efe8;
|
| 9 |
+
--ink: #2b1f1f;
|
| 10 |
+
--ink-soft: #4b3d3d;
|
| 11 |
+
--accent: #bf3a2b;
|
| 12 |
+
--accent-dark: #8f2b22;
|
| 13 |
+
--sand: #f9f4ec;
|
| 14 |
+
--shadow: rgba(34, 26, 26, 0.12);
|
| 15 |
+
--border: rgba(45, 33, 33, 0.18);
|
| 16 |
+
}
|
web_demo/static/styles.css
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
--ink: #2b1f1f;
|
| 6 |
-
--ink-soft: #4b3d3d;
|
| 7 |
-
--accent: #bf3a2b;
|
| 8 |
-
--accent-dark: #8f2b22;
|
| 9 |
-
--sand: #f9f4ec;
|
| 10 |
-
--shadow: rgba(34, 26, 26, 0.12);
|
| 11 |
-
--border: rgba(45, 33, 33, 0.18);
|
| 12 |
-
}
|
| 13 |
|
| 14 |
* {
|
| 15 |
box-sizing: border-box;
|
| 16 |
}
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
body {
|
| 19 |
margin: 0;
|
| 20 |
min-height: 100vh;
|
|
@@ -140,7 +140,7 @@ body {
|
|
| 140 |
background: rgba(191, 58, 43, 0.06);
|
| 141 |
border-left: 3px solid rgba(191, 58, 43, 0.55);
|
| 142 |
border-radius: 8px;
|
| 143 |
-
font-size:
|
| 144 |
color: var(--ink-soft);
|
| 145 |
line-height: 1.5;
|
| 146 |
}
|
|
@@ -173,7 +173,11 @@ select,
|
|
| 173 |
border: 1px solid var(--border);
|
| 174 |
border-radius: 12px;
|
| 175 |
padding: 10px 12px;
|
| 176 |
-
font-size
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
background: white;
|
| 178 |
color: var(--ink);
|
| 179 |
width: 100%;
|
|
@@ -197,6 +201,13 @@ select,
|
|
| 197 |
box-shadow: 0 12px 24px rgba(191, 58, 43, 0.25);
|
| 198 |
}
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
.status {
|
| 201 |
margin-top: 12px;
|
| 202 |
font-size: 0.9rem;
|
|
|
|
| 1 |
+
/* Design tokens (CSS variables) live in shared/tokens.css so the mobile
|
| 2 |
+
surface can pick up the same palette. CSS @import must be the first
|
| 3 |
+
rule in a stylesheet, so it goes above everything else here. */
|
| 4 |
+
@import url("./shared/tokens.css");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
* {
|
| 7 |
box-sizing: border-box;
|
| 8 |
}
|
| 9 |
|
| 10 |
+
/* Force the HTML `hidden` attribute to win over class-level display rules.
|
| 11 |
+
The user-agent `[hidden]` rule is `display: none` at attribute specificity
|
| 12 |
+
(0,0,1,0), which loses to any class rule. We use `!important` because that's
|
| 13 |
+
the only way to beat author-level display declarations across the codebase. */
|
| 14 |
+
[hidden] {
|
| 15 |
+
display: none !important;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
body {
|
| 19 |
margin: 0;
|
| 20 |
min-height: 100vh;
|
|
|
|
| 140 |
background: rgba(191, 58, 43, 0.06);
|
| 141 |
border-left: 3px solid rgba(191, 58, 43, 0.55);
|
| 142 |
border-radius: 8px;
|
| 143 |
+
font-size: 1.0rem;
|
| 144 |
color: var(--ink-soft);
|
| 145 |
line-height: 1.5;
|
| 146 |
}
|
|
|
|
| 173 |
border: 1px solid var(--border);
|
| 174 |
border-radius: 12px;
|
| 175 |
padding: 10px 12px;
|
| 176 |
+
/* iOS Safari auto-zooms the page when an input's computed font-size
|
| 177 |
+
is below 16px. The zoom doesn't always restore cleanly when the
|
| 178 |
+
keyboard dismisses, leaving the layout viewport offset. 16px
|
| 179 |
+
(= 1rem at default html font-size) keeps Safari hands-off. */
|
| 180 |
+
font-size: 1rem;
|
| 181 |
background: white;
|
| 182 |
color: var(--ink);
|
| 183 |
width: 100%;
|
|
|
|
| 201 |
box-shadow: 0 12px 24px rgba(191, 58, 43, 0.25);
|
| 202 |
}
|
| 203 |
|
| 204 |
+
.primary:disabled {
|
| 205 |
+
opacity: 0.55;
|
| 206 |
+
cursor: not-allowed;
|
| 207 |
+
transform: none;
|
| 208 |
+
box-shadow: none;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
.status {
|
| 212 |
margin-top: 12px;
|
| 213 |
font-size: 0.9rem;
|
web_demo/templates/mobile.html
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, viewport-fit=cover" />
|
| 6 |
+
<meta name="theme-color" content="#f5f1e7" />
|
| 7 |
+
<title>Ring Size · Mobile</title>
|
| 8 |
+
<link rel="stylesheet" href="/static/mobile/mobile.css" />
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<!--
|
| 12 |
+
Step controller paints into this single root container — each step
|
| 13 |
+
fully owns the visible viewport, no nested scrolling. Phase 1 is
|
| 14 |
+
Next/Back navigation between five placeholder steps; later phases
|
| 15 |
+
fill them in with real content (intro copy, position guide,
|
| 16 |
+
camera capture, results).
|
| 17 |
+
-->
|
| 18 |
+
<main id="mobileRoot"></main>
|
| 19 |
+
|
| 20 |
+
<!-- Capture-coach modules. Loaded up front so they're ready by the
|
| 21 |
+
time the user reaches the capture step (step 4). They register
|
| 22 |
+
on window.* so the mobile capture step can reach them via
|
| 23 |
+
window.HandsDetector / CapturePreview / OrientationDetector. -->
|
| 24 |
+
<script src="/static/preview/thresholds.js"></script>
|
| 25 |
+
<script src="/static/preview/preview.js"></script>
|
| 26 |
+
<script src="/static/preview/orientation.js"></script>
|
| 27 |
+
<script type="module" src="/static/preview/hands.js"></script>
|
| 28 |
+
|
| 29 |
+
<script type="module" src="/static/mobile/mobile.js"></script>
|
| 30 |
+
</body>
|
| 31 |
+
</html>
|