Skip to content

Commit 90f0df6

Browse files
committed
Merge remote-tracking branch 'origin/master'
2 parents 959776e + 1bb8d25 commit 90f0df6

6 files changed

Lines changed: 123 additions & 11 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,20 @@ curl -X POST "http://localhost:18011/api/v1/jobs/" \
100100

101101
### 3. Visualize Results
102102

103-
No extra install needed — VideoAnnotator bundles [Video Annotation Viewer](https://github.com/InfantLab/video-annotation-viewer) and serves it directly:
103+
No extra install needed — VideoAnnotator bundles [Video Annotation Viewer](https://github.com/InfantLab/video-annotation-viewer) and serves it directly. `setup-db` (and `generate-token`) print a one-click link that logs the viewer in for you:
104104

105105
```
106-
Open http://localhost:18011/viewer
106+
Connect the viewer with one click: http://localhost:18011/viewer-connect?token=...
107107
```
108108

109-
It's pre-configured to talk to this server, so your job results are one click away. Set `VIDEOANNOTATOR_ENABLE_VIEWER=false` if you'd rather not serve it.
109+
Open that link (not `/viewer` directly) the first time — it stores your API key where the viewer expects it and
110+
redirects you there. After that, `http://localhost:18011/viewer` remembers you.
111+
112+
Without it, the viewer falls back to a built-in demo token that won't authenticate against your server, and the
113+
job list will show 401s — if that happens, open the viewer's **Settings** panel and paste in the key printed by
114+
`setup-db`/`generate-token` by hand instead.
115+
116+
Set `VIDEOANNOTATOR_ENABLE_VIEWER=false` if you'd rather not serve it.
110117

111118
**🎉 That's it!** You now have both automated video processing and interactive visualization.
112119

‎src/videoannotator/api/main.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from fastapi import FastAPI
1111
from fastapi.middleware.cors import CORSMiddleware
12+
from fastapi.responses import HTMLResponse
1213
from fastapi.staticfiles import StaticFiles
1314

1415
from ..utils.logging_config import get_logger
@@ -111,6 +112,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
111112
"Background job processing started", extra={"component": "background_tasks"}
112113
)
113114

115+
# Warm up torch/CUDA in the background so the first client request to
116+
# /api/v1/system/health (typically the viewer, right after startup)
117+
# doesn't pay the one-time import cost itself.
118+
import asyncio
119+
120+
from .startup import warm_up_torch
121+
122+
asyncio.get_running_loop().run_in_executor(None, warm_up_torch)
123+
114124
# TODO: Initialize pipeline cache
115125

116126
yield
@@ -223,6 +233,29 @@ async def health_check():
223233
"database": db_status,
224234
}
225235

236+
# One-click bridge for onboarding: stores an API token (from `setup-db` /
237+
# `generate-token`) into the browser's localStorage under the keys the
238+
# bundled viewer reads, then redirects to /viewer/. Avoids asking users to
239+
# manually copy the token into the viewer's Settings page.
240+
@app.get("/viewer-connect", response_class=HTMLResponse, include_in_schema=False)
241+
async def viewer_connect(token: str = "", url: str = ""):
242+
import json
243+
244+
html = f"""<!doctype html>
245+
<html><head><meta charset="utf-8"><title>Connecting Video Annotation Viewer…</title></head>
246+
<body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 4rem auto; text-align: center;">
247+
<p>Connecting the viewer to your VideoAnnotator server…</p>
248+
<p><a href="/viewer/">Click here</a> if you are not redirected automatically.</p>
249+
<script>
250+
var token = {json.dumps(token)};
251+
var url = {json.dumps(url)};
252+
if (token) {{ localStorage.setItem('videoannotator_api_token', token); }}
253+
if (url) {{ localStorage.setItem('videoannotator_api_url', url); }}
254+
window.location.replace('/viewer/');
255+
</script>
256+
</body></html>"""
257+
return HTMLResponse(content=html)
258+
226259
_mount_viewer(app)
227260

228261
return app

‎src/videoannotator/api/startup.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
import os
7+
import time
78

89
from videoannotator.utils.logging_config import get_logger
910

@@ -12,6 +13,27 @@
1213
logger = get_logger("api")
1314

1415

16+
def warm_up_torch() -> None:
17+
"""Import torch and probe CUDA once, ahead of the first client request.
18+
19+
``/api/v1/system/health`` lazily imports torch to report GPU info. On a
20+
fresh process that import (plus CUDA context init) can take tens of
21+
seconds, which the health endpoint would otherwise charge to whichever
22+
client happens to call it first (typically the viewer, right after
23+
startup). Doing it here, in a startup background thread, moves that cost
24+
off the request path.
25+
"""
26+
start = time.time()
27+
try:
28+
import torch
29+
30+
torch.cuda.is_available()
31+
except Exception as e:
32+
logger.debug(f"Torch warmup skipped: {e}")
33+
return
34+
logger.info(f"Torch warmup completed in {time.time() - start:.1f}s")
35+
36+
1537
def ensure_api_key_exists() -> tuple[str | None, bool]:
1638
"""Ensure at least one API key exists, generating one if needed.
1739

‎src/videoannotator/cli.py‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import typer
77
import uvicorn
88

9-
from .config_env import MAX_CONCURRENT_JOBS, WORKER_POLL_INTERVAL
9+
from .config_env import API_PORT, MAX_CONCURRENT_JOBS, WORKER_POLL_INTERVAL
1010
from .validation.emotion_validator import validate_emotion_file
1111
from .version import __version__
1212

@@ -1002,6 +1002,18 @@ def validate_emotion(
10021002
typer.echo(f"[OK] Emotion file valid: {file}")
10031003

10041004

1005+
def _viewer_connect_url(token: str, port: int = API_PORT) -> str:
1006+
"""Build a one-click link that logs the bundled viewer into this server.
1007+
1008+
Opening it stores `token` under the localStorage key the viewer reads and
1009+
redirects to /viewer/, so users don't have to paste the key into the
1010+
viewer's Settings page by hand. See the `/viewer-connect` route.
1011+
"""
1012+
from urllib.parse import quote
1013+
1014+
return f"http://localhost:{port}/viewer-connect?token={quote(token)}"
1015+
1016+
10051017
@app.command("generate-token")
10061018
def generate_token(
10071019
user: str = typer.Option(None, "--user", help="User email address"),
@@ -1122,6 +1134,8 @@ def generate_token(
11221134
' curl -H "Authorization: Bearer $API_KEY" http://localhost:18011/api/v1/jobs'
11231135
)
11241136
typer.echo("")
1137+
typer.echo(f"Connect the viewer with one click: {_viewer_connect_url(raw_key)}")
1138+
typer.echo("")
11251139

11261140
# Save to file if requested
11271141
if output_file:
@@ -1225,12 +1239,15 @@ def setup_db(
12251239
{key}
12261240
================================================
12271241
Save this key now; it will not be shown again.
1228-
""".strip().format(key=raw_key)
1242+
1243+
Connect the viewer with one click: {url}
1244+
""".strip().format(key=raw_key, url=_viewer_connect_url(raw_key))
12291245
)
12301246
else:
12311247
typer.echo(
1232-
"[INFO] Admin already existed; no new API key generated. "
1233-
"Use generate-token for additional keys."
1248+
"[INFO] Admin already existed; no new API key generated (existing keys "
1249+
"cannot be displayed again). Run `uv run videoannotator generate-token` "
1250+
"to create a new one — it will also print a one-click viewer-connect link."
12341251
)
12351252

12361253

‎src/videoannotator/pipelines/face_analysis/openface3_pipeline.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,18 @@ def process(
429429
"""
430430
if not self.is_initialized:
431431
self.initialize()
432+
if not self.is_initialized:
433+
reason = (
434+
self._model_info.get(
435+
"unavailable_reason", "unknown initialization failure"
436+
)
437+
if self._model_info
438+
else "unknown initialization failure"
439+
)
440+
raise RuntimeError(
441+
f"OpenFace 3.0 is not available ({reason}); see "
442+
"docs/installation/INSTALLATION.md for manual setup. Skipping this pipeline."
443+
)
432444

433445
self.logger.info(f"Processing video: {video_path}")
434446
start_processing_time = time.time()

‎src/videoannotator/registry/pipeline_loader.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,25 +29,46 @@ def load_all_pipelines(self) -> dict[str, type]:
2929
"""
3030
self._registry.load()
3131
pipeline_classes = {}
32+
# (stability_rank, name, class) candidates per family, used to pick which
33+
# pipeline the short family alias (e.g. 'face') should resolve to.
34+
stability_rank = {"stable": 0, "beta": 1, "experimental": 2}
35+
family_candidates: dict[str, list[tuple[int, str, type]]] = {}
3236

3337
for meta in self._registry.list():
3438
pipeline_class = self._load_pipeline_class(meta)
3539
if pipeline_class:
3640
# Add primary name
3741
pipeline_classes[meta.name] = pipeline_class
3842

39-
# Add common aliases based on pipeline_family
4043
if meta.pipeline_family:
41-
# Short family name (e.g., 'face' for 'face_analysis')
42-
if meta.pipeline_family not in pipeline_classes:
43-
pipeline_classes[meta.pipeline_family] = pipeline_class
44+
family_candidates.setdefault(meta.pipeline_family, []).append(
45+
(
46+
stability_rank.get(meta.stability or "", 3),
47+
meta.name,
48+
pipeline_class,
49+
)
50+
)
4451

4552
# Add variant-based alias if applicable
4653
if meta.variant:
4754
variant_name = f"{meta.pipeline_family}_{meta.variant}"
4855
if variant_name != meta.name:
4956
pipeline_classes[variant_name] = pipeline_class
5057

58+
# Assign each family's short alias (e.g. 'face') to its most-stable
59+
# loadable pipeline, rather than whichever happened to load first —
60+
# otherwise an experimental variant can silently shadow a stable one
61+
# just because metadata files are read in alphabetical order.
62+
for family, candidates in family_candidates.items():
63+
candidates.sort(key=lambda c: c[0])
64+
best_rank, best_name, best_class = candidates[0]
65+
pipeline_classes[family] = best_class
66+
if len(candidates) > 1:
67+
LOGGER.debug(
68+
f"Family alias '{family}' -> '{best_name}' "
69+
f"(candidates: {[c[1] for c in candidates]})"
70+
)
71+
5172
LOGGER.info(
5273
f"Loaded {len(set(pipeline_classes.values()))} unique pipeline classes "
5374
f"with {len(pipeline_classes)} total names/aliases"

0 commit comments

Comments
 (0)