skating-coach-deploy commited on
Commit
43bbe2b
·
1 Parent(s): 51da90a

deploy: sync web demo ff7cc29

Browse files
Dockerfile CHANGED
@@ -39,9 +39,11 @@ WORKDIR /app/server
39
  # Resolve + install runtime deps into an in-project .venv.
40
  RUN uv sync --no-dev
41
 
42
- # HF Spaces injects $PORT (default 7860). Session scratch goes to a writable dir.
43
- ENV PORT=7860 \
44
- SKATING_COACH_DATA_DIR=/home/app/skating-coach-data
 
 
45
  EXPOSE 7860
46
 
47
  CMD ["sh", "-c", "uv run --no-dev uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
 
39
  # Resolve + install runtime deps into an in-project .venv.
40
  RUN uv sync --no-dev
41
 
42
+ # HF Spaces injects $PORT (default 7860). Data dir is auto-resolved in
43
+ # storage/sessions.py: HF persistent storage at /data when mounted (durable —
44
+ # survives Space restarts, for the F1 corpus + events), else an ephemeral /tmp
45
+ # dir. Set SKATING_COACH_DATA_DIR to override.
46
+ ENV PORT=7860
47
  EXPOSE 7860
48
 
49
  CMD ["sh", "-c", "uv run --no-dev uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
server/app/api/sessions.py CHANGED
@@ -19,7 +19,7 @@ from fastapi.responses import FileResponse
19
  from pydantic import BaseModel, Field
20
 
21
  from app.pipeline import PipelineError, run_pipeline
22
- from app.storage import Session, SessionStatus, get_store
23
 
24
  log = logging.getLogger(__name__)
25
  router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@@ -186,6 +186,35 @@ async def get_session(session_id: str) -> dict:
186
  return _serialize_session(session)
187
 
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  @router.get("/{session_id}/annotated.mp4")
190
  async def get_annotated_video(session_id: str) -> FileResponse:
191
  session = get_store().get(session_id)
 
19
  from pydantic import BaseModel, Field
20
 
21
  from app.pipeline import PipelineError, run_pipeline
22
+ from app.storage import Session, SessionStatus, corpus, get_store
23
 
24
  log = logging.getLogger(__name__)
25
  router = APIRouter(prefix="/api/sessions", tags=["sessions"])
 
186
  return _serialize_session(session)
187
 
188
 
189
+ class FeedbackIn(BaseModel):
190
+ """F1 trust-flywheel: the skater's one-tap verdict on a review."""
191
+
192
+ install_id: str = Field(min_length=8, max_length=64)
193
+ verdict: str = Field(pattern=r"^(right|wrong)$")
194
+ # What it actually was, when the skater corrects us ("wrong"). Free text.
195
+ said: str | None = Field(default=None, max_length=120)
196
+ # Explicit opt-in to keep this clip for the eval corpus (internal-only).
197
+ retain_clip: bool = False
198
+
199
+
200
+ @router.post("/{session_id}/feedback")
201
+ async def submit_feedback(session_id: str, fb: FeedbackIn) -> dict[str, bool]:
202
+ """Record a review verdict + optional correction, and (on opt-in) retain the
203
+ clip for the eval corpus. Tolerant of an expired session — the correction is
204
+ always logged; only the clip copy needs the session to still hold a file, so
205
+ this never 404s (the anonymous verdict already rode the M1 event stream)."""
206
+ session = get_store().get(session_id)
207
+ retained = corpus.record_feedback(
208
+ install_id=fb.install_id,
209
+ session_id=session_id,
210
+ verdict=fb.verdict,
211
+ said=fb.said,
212
+ retain_clip=fb.retain_clip,
213
+ session=session,
214
+ )
215
+ return {"recorded": True, "retained": retained}
216
+
217
+
218
  @router.get("/{session_id}/annotated.mp4")
219
  async def get_annotated_video(session_id: str) -> FileResponse:
220
  session = get_store().get(session_id)
server/app/legal/privacy.html ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html><html><head><meta charset='utf-8'><title>privacy_policy</title><style>
2
+ body{font:15px/1.6 -apple-system,Helvetica,sans-serif;color:#1b2330;max-width:960px;margin:40px auto;padding:0 24px;background:#faf7f2}
3
+ h1{color:#1b2330;border-bottom:3px solid #e8604c;padding-bottom:8px}
4
+ h2{color:#1b2330;margin-top:2em} h3{color:#e8604c;margin-top:1.8em}
5
+ table{border-collapse:collapse;width:100%;font-size:13.5px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08)}
6
+ th,td{border:1px solid #e3ded6;padding:7px 10px;text-align:left;vertical-align:top}
7
+ th{background:#1b2330;color:#fff} tr:nth-child(even){background:#f6f2ea}
8
+ code{background:#eee9e0;padding:1px 5px;border-radius:4px;font-size:12.5px}
9
+ strong{color:#b3402f} li{margin:3px 0}
10
+ blockquote{border-left:3px solid #e8604c;margin-left:0;padding-left:14px;color:#555}
11
+ </style></head><body><h1>Glide — Privacy Policy</h1>
12
+ <p><em>Last updated: 18 June 2026</em></p>
13
+ <p>Glide ("Glide", "we", "us") is a figure-skating coaching app that analyzes a
14
+ short video of your skating and shows you technique feedback. This policy
15
+ explains exactly what data the app handles, why, and the choices you have. It is
16
+ written to describe what the app <strong>actually does today</strong> — no more, no less.</p>
17
+ <h2>The short version</h2>
18
+ <ul>
19
+ <li>We do <strong>not</strong> ask you to create an account. There is no name, email, or login.</li>
20
+ <li>When you ask for a review, your <strong>video clip is uploaded to our server</strong> to be
21
+ analyzed, then the coaching result is sent back to your phone.</li>
22
+ <li>Your past reviews are stored <strong>on your device</strong>, not on our servers.</li>
23
+ <li>The only identifier we use is a <strong>random, anonymous install ID</strong> so we can
24
+ count app usage. It is not tied to you, your device, or any other service.</li>
25
+ <li>You can <strong>delete all your reviews from inside the app</strong> at any time.</li>
26
+ <li>We do <strong>not</strong> sell data, run third-party ad/analytics SDKs, or track you across
27
+ other apps or websites.</li>
28
+ </ul>
29
+ <h2>What we collect and why</h2>
30
+ <h3>1. The video you submit for a review</h3>
31
+ <p>When you record or import a clip and ask for a review, the app uploads that clip
32
+ to our coaching server (hosted on Hugging Face), which runs pose analysis and an
33
+ AI critique and returns the result.</p>
34
+ <ul>
35
+ <li><strong>What it may contain:</strong> you, and anyone else who happens to be in frame
36
+ (rinks are public — bystanders, other skaters, and <strong>possibly minors</strong> may
37
+ appear). Please only film where you are permitted to, and avoid submitting
38
+ clips that focus on other people, especially children, without their (or a
39
+ guardian's) permission.</li>
40
+ <li><strong>Retention:</strong> the uploaded source clip is processed and held only on the
41
+ server's temporary working storage. It is <strong>not</strong> placed in a durable
42
+ database, and it is cleared when the server restarts (which happens routinely).
43
+ Clips submitted via a pasted link are deleted from the server immediately after
44
+ processing. We are not building a permanent library of your clips.</li>
45
+ <li><strong>On your device:</strong> the finished review (the clip plus the coaching) is saved
46
+ locally on your phone so you can reopen it. This local copy never leaves your
47
+ device unless you explicitly share it.</li>
48
+ </ul>
49
+ <h3>2. Anonymous usage events</h3>
50
+ <p>To understand whether the app is useful (e.g. how long a review takes, how often
51
+ people come back), the app sends small, anonymous events to our server — for
52
+ example "app opened", "review ready (took N seconds)". These carry <strong>no personal
53
+ information</strong>. The only identifier is a random UUID generated once on your device
54
+ ("install ID"); it is not linked to your identity, Apple ID, advertising ID, or
55
+ any other app.</p>
56
+ <h3>3. "Help improve the coach" (opt-in only)</h3>
57
+ <p>If a review gets your move wrong, you can tell us — and you may <strong>optionally</strong>
58
+ choose to share that one clip so we can use it to improve our analysis. This
59
+ sharing is <strong>off by default</strong> and happens only when you tick the box. Shared
60
+ clips are used internally to test and improve the coaching, are reviewed by a
61
+ person, and are <strong>never</strong> used to train a public model or sold to anyone.</p>
62
+ <h3>4. Profile details you enter (optional, stays on your device)</h3>
63
+ <p>If you enter your height (to convert measurements into real-world units), it is
64
+ stored <strong>only on your device</strong> and is never uploaded.</p>
65
+ <h2>What we do NOT do</h2>
66
+ <ul>
67
+ <li>No accounts, no passwords, no email collection.</li>
68
+ <li>No third-party advertising or analytics SDKs.</li>
69
+ <li>No cross-app or cross-site tracking; we do not use the Advertising Identifier.</li>
70
+ <li>No selling or renting of any data.</li>
71
+ </ul>
72
+ <h2>Camera, microphone, and photo access</h2>
73
+ <ul>
74
+ <li><strong>Camera / microphone:</strong> used only while you are recording a clip to review.</li>
75
+ <li><strong>Photo library:</strong> used only when you pick an existing video to review.</li>
76
+ </ul>
77
+ <p>Audio is captured alongside video when you record, as part of the clip; it is
78
+ not analyzed separately or used for any other purpose.</p>
79
+ <h2>Your choices and controls</h2>
80
+ <ul>
81
+ <li><strong>Delete your data:</strong> open the <strong>Me</strong> tab → <strong>Clear all reviews</strong> to delete
82
+ every saved review (clip, thumbnail, and result) from your device. You can also
83
+ delete an individual review from its screen.</li>
84
+ <li><strong>Stop sharing clips:</strong> the "help improve the coach" option is per-review and
85
+ opt-in; simply leave it unticked.</li>
86
+ <li><strong>Uninstalling</strong> the app removes all locally stored reviews and your install ID.</li>
87
+ </ul>
88
+ <p>To request deletion of a clip you previously chose to share, contact us (below)
89
+ with the approximate date you submitted it.</p>
90
+ <h2>Children</h2>
91
+ <p>Glide is designed for <strong>adult</strong> recreational figure skaters and is not directed
92
+ at children. We do not knowingly collect personal information from children. If
93
+ you believe a clip you submitted contains a child and you would like it removed,
94
+ contact us and we will delete any retained copy.</p>
95
+ <h2>Data security &amp; location</h2>
96
+ <p>Uploads use HTTPS. Coaching analysis runs on Hugging Face and GPU compute on
97
+ Modal; these providers process clips transiently on our behalf to produce your
98
+ review. We do not grant them any other rights to your data.</p>
99
+ <h2>Changes to this policy</h2>
100
+ <p>If this policy changes materially, we will update the "Last updated" date and the
101
+ in-app link. Because the app currently has no account or contact list, we cannot
102
+ notify you individually — please check this page.</p>
103
+ <h2>Contact</h2>
104
+ <p>Questions, or a deletion request: <strong>emmanuel@mexkoy.com</strong></p></body></html>
server/app/legal/support.html ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html><html><head><meta charset='utf-8'><title>support</title><style>
2
+ body{font:15px/1.6 -apple-system,Helvetica,sans-serif;color:#1b2330;max-width:960px;margin:40px auto;padding:0 24px;background:#faf7f2}
3
+ h1{color:#1b2330;border-bottom:3px solid #e8604c;padding-bottom:8px}
4
+ h2{color:#1b2330;margin-top:2em} h3{color:#e8604c;margin-top:1.8em}
5
+ table{border-collapse:collapse;width:100%;font-size:13.5px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08)}
6
+ th,td{border:1px solid #e3ded6;padding:7px 10px;text-align:left;vertical-align:top}
7
+ th{background:#1b2330;color:#fff} tr:nth-child(even){background:#f6f2ea}
8
+ code{background:#eee9e0;padding:1px 5px;border-radius:4px;font-size:12.5px}
9
+ strong{color:#b3402f} li{margin:3px 0}
10
+ blockquote{border-left:3px solid #e8604c;margin-left:0;padding-left:14px;color:#555}
11
+ </style></head><body><h1>Glide — Support</h1>
12
+ <p><em>Glide is an AI figure-skating coach: film a field move, get the one fix drawn
13
+ on your body in seconds.</em></p>
14
+ <h2>Contact</h2>
15
+ <ul>
16
+ <li><strong>Email:</strong> emmanuel@mexkoy.com</li>
17
+ <li>We're a tiny team building this with early skaters — real replies, usually
18
+ within a couple of days.</li>
19
+ </ul>
20
+ <h2>What Glide does</h2>
21
+ <p>Record or import a short clip of a <strong>field move</strong> — a spiral, edge, turn, spin,
22
+ stroking, a glide hold — tap the skater that's you, and Glide analyzes your body
23
+ mechanics and shows you what to work on, traced onto your own video.</p>
24
+ <p>It coaches the things one phone camera can honestly see: extension, posture,
25
+ timing, steadiness, hold duration. When it can't be sure what a move was, it
26
+ shows you the <strong>measurements</strong> instead of guessing a name.</p>
27
+ <h2>What Glide does NOT do (yet)</h2>
28
+ <ul>
29
+ <li><strong>Jumps.</strong> A single phone angle can't reliably judge airborne rotation, so
30
+ Glide stays out of jumps on purpose rather than guess.</li>
31
+ <li><strong>Edge depth / blade tracing.</strong> Sub-blade detail isn't visible from a phone;
32
+ we coach the body, not the ice.</li>
33
+ </ul>
34
+ <h2>Tips for the best review</h2>
35
+ <ul>
36
+ <li>Film from roughly a <strong>45° angle</strong> to your path (not flat side-on or head-on),
37
+ with your <strong>whole body in frame</strong>.</li>
38
+ <li>Keep the clip short (a single element).</li>
39
+ <li>Good light helps the tracker follow you.</li>
40
+ </ul>
41
+ <h2>Common questions</h2>
42
+ <p><strong>Do I need an account?</strong> No. There's no sign-up — just record and review.</p>
43
+ <p><strong>Where do my videos go?</strong> Your finished reviews are stored on your phone. The
44
+ clip is briefly uploaded to our server to be analyzed and is not kept in a
45
+ permanent database. See the <a href="/privacy">Privacy Policy</a>.</p>
46
+ <p><strong>How do I delete my reviews?</strong> Me tab → <strong>Clear all reviews</strong>, or delete a
47
+ single review from its screen.</p>
48
+ <p><strong>It got my move wrong.</strong> Tap <strong>"Did we get your move right?" → No</strong> on the
49
+ review and tell us what it was — that's the most useful thing you can send us,
50
+ and it directly improves the coach. You can optionally share that clip to help.</p>
51
+ <p><strong>The review failed or said my session expired.</strong> Tap <strong>Try again</strong> — it
52
+ re-sends your clip. If it keeps failing, check your connection or try a shorter
53
+ clip, and email us if it persists.</p>
54
+ <h2>Privacy</h2>
55
+ <p>See the full <a href="/privacy">Privacy Policy</a>.</p></body></html>
server/app/main.py CHANGED
@@ -2,6 +2,8 @@
2
 
3
  Routes:
4
  / — single-page demo frontend
 
 
5
  /api/sessions — upload / poll / fetch annotated video
6
  /api/events — anonymous client instrumentation (batched, JSONL per day)
7
  /api/warmup — fire-and-forget pre-warm of the Modal GPU pose backend
@@ -32,6 +34,7 @@ app.include_router(sessions_router)
32
  app.include_router(events_router)
33
 
34
  WEB_DIR = Path(__file__).parent / "web"
 
35
 
36
 
37
  @app.get("/healthz")
@@ -39,6 +42,21 @@ def healthz() -> dict[str, str]:
39
  return {"status": "ok"}
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  # /api/warmup — pre-warm the Modal T4 pose backend. Cold start is ~30-90s vs
43
  # ~4s warm inference, so the iOS client fires this the moment the user starts
44
  # recording / picking a clip; by upload time the container is already up.
 
2
 
3
  Routes:
4
  / — single-page demo frontend
5
+ /privacy — privacy policy (also the App Store / TestFlight URL)
6
+ /support — support page
7
  /api/sessions — upload / poll / fetch annotated video
8
  /api/events — anonymous client instrumentation (batched, JSONL per day)
9
  /api/warmup — fire-and-forget pre-warm of the Modal GPU pose backend
 
34
  app.include_router(events_router)
35
 
36
  WEB_DIR = Path(__file__).parent / "web"
37
+ LEGAL_DIR = Path(__file__).parent / "legal"
38
 
39
 
40
  @app.get("/healthz")
 
42
  return {"status": "ok"}
43
 
44
 
45
+ # Legal pages — the privacy URL is required by App Store review + external
46
+ # TestFlight metadata once any data is collected. Served from the app server so
47
+ # the URL is stable HTTPS (the iOS GlideLinks point here). Source of truth is
48
+ # docs/legal/*.md; server/app/legal/*.html are the rendered, self-contained
49
+ # (inline-CSS, no external refs) copies.
50
+ @app.get("/privacy")
51
+ def privacy() -> FileResponse:
52
+ return FileResponse(LEGAL_DIR / "privacy.html", media_type="text/html")
53
+
54
+
55
+ @app.get("/support")
56
+ def support() -> FileResponse:
57
+ return FileResponse(LEGAL_DIR / "support.html", media_type="text/html")
58
+
59
+
60
  # /api/warmup — pre-warm the Modal T4 pose backend. Cold start is ~30-90s vs
61
  # ~4s warm inference, so the iOS client fires this the moment the user starts
62
  # recording / picking a clip; by upload time the container is already up.
server/app/storage/corpus.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Opt-in corpus inbox for the F1 trust flywheel.
2
+
3
+ When a beta skater says "you got my move wrong" and opts in to share the clip,
4
+ we record their correction and (best-effort) copy the session's source clip into
5
+ `DATA_DIR/corpus_inbox/` so it can be hand-verified and folded into the eval
6
+ corpus — the standing protocol (bug report -> eval clip -> fix -> rerun) gains a
7
+ UI handle instead of relying on chat messages.
8
+
9
+ Privacy: clip retention happens ONLY on explicit per-review opt-in, is
10
+ internal-only, and never trains a public model. The verdict itself (right/wrong
11
+ + what it actually was) also rides the anonymous M1 event stream — this module
12
+ is purely the clip-and-correction sidecar that M1 can't carry.
13
+
14
+ No database — JSONL append + a file copy, like the events log. Durable storage
15
+ lands with the rest of session persistence in U9.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import shutil
23
+ import threading
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from app.storage import sessions
29
+
30
+ if TYPE_CHECKING:
31
+ from app.storage.sessions import Session
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ # Line-level interleave guard for concurrent appends (mirrors events.py).
36
+ _append_lock = threading.Lock()
37
+
38
+
39
+ def corpus_dir() -> Path:
40
+ """`DATA_DIR/corpus_inbox` — resolved at call time so tests can repoint."""
41
+ d = sessions.DATA_DIR / "corpus_inbox"
42
+ d.mkdir(parents=True, exist_ok=True)
43
+ return d
44
+
45
+
46
+ def _source_clip(session: Session | None) -> Path | None:
47
+ """The best clip to retain for re-eval: the original the skater filmed,
48
+ falling back to the normalized then the zoom crop. None if the session is
49
+ gone (non-durable store) or no file survives on disk."""
50
+ if session is None:
51
+ return None
52
+ for path in (session.source_path, session.normalized_path, session.zoom_path):
53
+ if path is not None and Path(path).exists():
54
+ return Path(path)
55
+ return None
56
+
57
+
58
+ def record_feedback(
59
+ *,
60
+ install_id: str,
61
+ session_id: str,
62
+ verdict: str,
63
+ said: str | None,
64
+ retain_clip: bool,
65
+ session: Session | None,
66
+ ) -> bool:
67
+ """Append the correction to `corpus_inbox/feedback.jsonl` (always) and, when
68
+ `retain_clip` is set and a source clip survives on disk, copy it into
69
+ `corpus_inbox/clips/`. Returns whether a clip was actually retained.
70
+
71
+ The feedback line is written even when the session has expired so the
72
+ correction is never lost — only the clip copy depends on the session still
73
+ holding a file.
74
+ """
75
+ root = corpus_dir()
76
+ retained = False
77
+ clip_name: str | None = None
78
+
79
+ if retain_clip:
80
+ src = _source_clip(session)
81
+ if src is not None:
82
+ clips = root / "clips"
83
+ clips.mkdir(parents=True, exist_ok=True)
84
+ clip_name = f"{session_id}{src.suffix}"
85
+ try:
86
+ shutil.copy2(src, clips / clip_name)
87
+ retained = True
88
+ except OSError:
89
+ # Best-effort; the correction line still lands. Log it so a
90
+ # systemic failure (disk full) isn't silent in the corpus pipe.
91
+ log.warning("corpus clip retain failed for %s", session_id, exc_info=True)
92
+ retained = False
93
+ clip_name = None
94
+
95
+ record: dict[str, Any] = {
96
+ "ts": datetime.now(tz=UTC).isoformat(),
97
+ "install_id": install_id,
98
+ "session_id": session_id,
99
+ "verdict": verdict,
100
+ "said": said,
101
+ "declared_move": getattr(session, "declared_move", None),
102
+ "detected_move": _detected_move(session),
103
+ "clip": clip_name,
104
+ }
105
+ line = json.dumps(record, separators=(",", ":"))
106
+ with _append_lock:
107
+ with (root / "feedback.jsonl").open("a", encoding="utf-8") as f:
108
+ f.write(line + "\n")
109
+ return retained
110
+
111
+
112
+ def _detected_move(session: Session | None) -> str | None:
113
+ """The most specific move the pipeline named for this session, for the
114
+ sidecar label — best-effort, tolerant of partial/older scorecards."""
115
+ if session is None:
116
+ return None
117
+ moves = session.predicted_moves or []
118
+ for m in moves:
119
+ if isinstance(m, dict):
120
+ name = m.get("move") or m.get("name") or m.get("label")
121
+ if name:
122
+ return str(name)
123
+ return None
server/app/storage/sessions.py CHANGED
@@ -10,7 +10,25 @@ from enum import StrEnum
10
  from pathlib import Path
11
  from typing import Any
12
 
13
- DATA_DIR = Path(os.environ.get("SKATING_COACH_DATA_DIR", "/tmp/skating-coach-data"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  """Local directory for uploaded + annotated video files."""
15
 
16
 
 
10
  from pathlib import Path
11
  from typing import Any
12
 
13
+ def _resolve_data_dir(persistent_root: Path = Path("/data")) -> Path:
14
+ """Where uploaded clips, events, and the F1 corpus live.
15
+
16
+ Priority: an explicit ``SKATING_COACH_DATA_DIR`` override → HF persistent
17
+ storage (``persistent_root``, ``/data``) when it's actually mounted +
18
+ writable (durable across Space restarts, so the F1 corpus + events survive —
19
+ enable it in the Space settings) → an ephemeral ``/tmp`` dir (lost on
20
+ restart; fine pre-scale). ``persistent_root`` is a parameter only so tests
21
+ can stand in for ``/data``.
22
+ """
23
+ override = os.environ.get("SKATING_COACH_DATA_DIR")
24
+ if override:
25
+ return Path(override)
26
+ if persistent_root.is_dir() and os.access(persistent_root, os.W_OK):
27
+ return persistent_root / "skating-coach"
28
+ return Path("/tmp/skating-coach-data")
29
+
30
+
31
+ DATA_DIR = _resolve_data_dir()
32
  """Local directory for uploaded + annotated video files."""
33
 
34