nvti commited on
Commit
1df1423
·
1 Parent(s): 4108569

feat(ui): add React app for form analysis

Browse files
Files changed (4) hide show
  1. app.py +124 -73
  2. web/app.js +330 -0
  3. web/index.html +20 -0
  4. web/styles.css +535 -0
app.py CHANGED
@@ -1,17 +1,31 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  import sys
 
5
  from pathlib import Path
6
  from typing import Any
7
 
8
  import gradio as gr
 
 
 
9
 
10
  sys.path.insert(0, str(Path(__file__).parent / "src"))
11
 
12
  from pozify.exercise_catalog import USER_SELECTABLE_EXERCISES
13
  from pozify.pipeline import run_pipeline
14
 
 
 
 
 
 
 
 
 
 
15
 
16
  QUALITY_GUIDANCE = {
17
  "too_short": "Record at least 10 seconds so the set contains enough movement context.",
@@ -39,7 +53,7 @@ def _quality_markdown(video_manifest: dict[str, Any]) -> str:
39
  if not video_manifest["analysis_allowed"]
40
  else "Analysis completed, but capture quality may affect downstream feedback."
41
  )
42
- return f"""## Video Quality
43
 
44
  {status}
45
 
@@ -104,13 +118,15 @@ def analyze_video(
104
 
105
  markdown = f"""## Scan Summary
106
 
107
- - **Exercise router output:** {report["exercise"]["exercise"]} (mock confidence placeholder: {report["exercise"]["confidence"]:.0%})
108
- - **Variation label:** {report["variation"]["detected_variation"]} (mock confidence placeholder: {report["variation"]["variation_confidence"]:.0%})
109
- - **Reps:** {len(report["reps"]["reps"])}
110
- - **Analysis mode:** {report["artifacts"].get("analysis_mode", "unknown")}
111
- - **Pose source:** {report["artifacts"].get("pose_source", "unknown")}
112
- - **Mock finding:** {summary["main_findings"][0] if summary["main_findings"] else "No mock finding emitted"}
113
- - **Run ID:** `{report["run_id"]}`
 
 
114
 
115
  {mock_status}
116
 
@@ -139,72 +155,107 @@ def analyze_video(
139
  )
140
 
141
 
142
- with gr.Blocks(title="Pozify") as demo:
143
- gr.Markdown("# Pozify")
144
- gr.Markdown(
145
- "Upload a short workout video and run the full mocked review pipeline. "
146
- "All steps produce structured JSON artifacts that can be replaced with real models later."
147
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- with gr.Row():
150
- with gr.Column(scale=1):
151
- video = gr.Video(label="Workout video", sources=["upload", "webcam"])
152
- goal = gr.Dropdown(
153
- label="Goal",
154
- choices=["strength", "hypertrophy", "endurance", "mobility", "beginner_practice"],
155
- value="beginner_practice",
156
- )
157
- experience_level = gr.Dropdown(
158
- label="Experience level",
159
- choices=["beginner", "intermediate"],
160
- value="beginner",
161
- )
162
- intended_exercise = gr.Dropdown(
163
- label="Intended exercise",
164
- choices=["auto", *USER_SELECTABLE_EXERCISES],
165
- value="auto",
166
- )
167
- intended_variation = gr.Textbox(
168
- label="Intended variation",
169
- placeholder="Optional, e.g. wide_grip_push_up",
170
- )
171
- limitations = gr.CheckboxGroup(
172
- label="Known limitations",
173
- choices=["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
174
- value=[],
175
- )
176
- equipment = gr.Dropdown(
177
- label="Equipment",
178
- choices=["bodyweight", "dumbbell", "barbell", "unknown"],
179
- value="bodyweight",
180
- )
181
- run_button = gr.Button("Analyze", variant="primary")
182
-
183
- with gr.Column(scale=1):
184
- annotated_video = gr.Video(label="Annotated video")
185
-
186
- summary_md = gr.Markdown(label="Summary")
187
-
188
- with gr.Tab("Final Report JSON"):
189
- report_json = gr.Code(label="final_report.json", language="json", lines=28)
190
-
191
- with gr.Tab("Artifact Path"):
192
- artifact_path = gr.Textbox(label="Saved report")
193
-
194
- run_button.click(
195
- fn=analyze_video,
196
- inputs=[
197
- video,
198
- goal,
199
- experience_level,
200
- intended_exercise,
201
- intended_variation,
202
- limitations,
203
- equipment,
204
- ],
205
- outputs=[annotated_video, summary_md, report_json, artifact_path],
206
- )
207
 
208
 
209
  if __name__ == "__main__":
210
- demo.launch()
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import shutil
5
  import sys
6
+ import tempfile
7
  from pathlib import Path
8
  from typing import Any
9
 
10
  import gradio as gr
11
+ from fastapi import File, Form, HTTPException, UploadFile
12
+ from fastapi.responses import FileResponse, HTMLResponse
13
+ from fastapi.staticfiles import StaticFiles
14
 
15
  sys.path.insert(0, str(Path(__file__).parent / "src"))
16
 
17
  from pozify.exercise_catalog import USER_SELECTABLE_EXERCISES
18
  from pozify.pipeline import run_pipeline
19
 
20
+ BASE_DIR = Path(__file__).parent
21
+ WEB_DIR = BASE_DIR / "web"
22
+ RUNS_ROOT = BASE_DIR / "runs"
23
+
24
+ APP_DESCRIPTION = (
25
+ "Upload a short workout clip, tune the athlete context, and generate an annotated "
26
+ "form-review report with structured artifacts."
27
+ )
28
+
29
 
30
  QUALITY_GUIDANCE = {
31
  "too_short": "Record at least 10 seconds so the set contains enough movement context.",
 
53
  if not video_manifest["analysis_allowed"]
54
  else "Analysis completed, but capture quality may affect downstream feedback."
55
  )
56
+ return f"""## Capture Quality
57
 
58
  {status}
59
 
 
118
 
119
  markdown = f"""## Scan Summary
120
 
121
+ | Signal | Result |
122
+ | --- | --- |
123
+ | Exercise router | {report["exercise"]["exercise"]} ({report["exercise"]["confidence"]:.0%}) |
124
+ | Variation | {report["variation"]["detected_variation"]} ({report["variation"]["variation_confidence"]:.0%}) |
125
+ | Reps | {len(report["reps"]["reps"])} |
126
+ | Analysis mode | {report["artifacts"].get("analysis_mode", "unknown")} |
127
+ | Pose source | {report["artifacts"].get("pose_source", "unknown")} |
128
+ | Primary finding | {summary["main_findings"][0] if summary["main_findings"] else "No finding emitted"} |
129
+ | Run ID | `{report["run_id"]}` |
130
 
131
  {mock_status}
132
 
 
155
  )
156
 
157
 
158
+ server = gr.Server(
159
+ title="Pozify",
160
+ summary="Video-based workout form review API",
161
+ description=APP_DESCRIPTION,
162
+ )
163
+ server.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
164
+
165
+
166
+ @server.get("/healthz", include_in_schema=False)
167
+ def healthz() -> dict[str, str]:
168
+ return {"status": "ok", "service": "pozify"}
169
+
170
+
171
+ @server.get("/", response_class=HTMLResponse, include_in_schema=False)
172
+ def index() -> FileResponse:
173
+ return FileResponse(WEB_DIR / "index.html")
174
+
175
+
176
+ @server.get("/api/config")
177
+ def config() -> dict[str, Any]:
178
+ return {
179
+ "description": APP_DESCRIPTION,
180
+ "goals": ["strength", "hypertrophy", "endurance", "mobility", "beginner_practice"],
181
+ "experience_levels": ["beginner", "intermediate"],
182
+ "exercises": ["auto", *USER_SELECTABLE_EXERCISES],
183
+ "limitations": ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
184
+ "equipment": ["bodyweight", "dumbbell", "barbell", "unknown"],
185
+ }
186
+
187
+
188
+ @server.get("/api/artifacts/{run_id}/{filename}", include_in_schema=False)
189
+ def artifact(run_id: str, filename: str) -> FileResponse:
190
+ artifact_path = (RUNS_ROOT / run_id / filename).resolve()
191
+ runs_root = RUNS_ROOT.resolve()
192
+ if runs_root not in artifact_path.parents or not artifact_path.is_file():
193
+ raise HTTPException(status_code=404, detail="Artifact not found.")
194
+ return FileResponse(artifact_path)
195
+
196
+
197
+ def _artifact_url(run_id: str, path: str | None) -> str | None:
198
+ if not path:
199
+ return None
200
+
201
+ artifact_path = Path(path).resolve()
202
+ run_root = (RUNS_ROOT / run_id).resolve()
203
+ if run_root not in artifact_path.parents or not artifact_path.is_file():
204
+ return None
205
+ return f"/api/artifacts/{run_id}/{artifact_path.name}"
206
+
207
+
208
+ @server.post("/api/analyze")
209
+ async def analyze_api(
210
+ video: UploadFile | None = File(default=None),
211
+ goal: str = Form(default="beginner_practice"),
212
+ experience_level: str = Form(default="beginner"),
213
+ intended_exercise: str = Form(default="auto"),
214
+ intended_variation: str = Form(default=""),
215
+ limitations: str = Form(default="[]"),
216
+ equipment: str = Form(default="bodyweight"),
217
+ ) -> dict[str, Any]:
218
+ try:
219
+ parsed_limitations = json.loads(limitations)
220
+ except json.JSONDecodeError as exc:
221
+ raise HTTPException(status_code=400, detail="Limitations must be valid JSON.") from exc
222
+
223
+ if not isinstance(parsed_limitations, list) or not all(
224
+ isinstance(item, str) for item in parsed_limitations
225
+ ):
226
+ raise HTTPException(status_code=400, detail="Limitations must be a JSON list of strings.")
227
+
228
+ video_path: str | None = None
229
+ if video is not None and video.filename:
230
+ suffix = Path(video.filename).suffix or ".mp4"
231
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_video:
232
+ shutil.copyfileobj(video.file, temp_video)
233
+ video_path = temp_video.name
234
+
235
+ try:
236
+ result = run_pipeline(
237
+ video_path=video_path,
238
+ profile_input={
239
+ "goal": goal,
240
+ "experience_level": experience_level,
241
+ "intended_exercise": intended_exercise,
242
+ "intended_variation": intended_variation or None,
243
+ "known_limitations": parsed_limitations,
244
+ "equipment": equipment,
245
+ },
246
+ )
247
+ finally:
248
+ if video_path is not None:
249
+ Path(video_path).unlink(missing_ok=True)
250
 
251
+ return {
252
+ "run_id": result["run_id"],
253
+ "run_dir": result["run_dir"],
254
+ "annotated_video_url": _artifact_url(result["run_id"], result["annotated_video_path"]),
255
+ "final_report_url": f"/api/artifacts/{result['run_id']}/final_report.json",
256
+ "report": result["final_report"],
257
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
 
260
  if __name__ == "__main__":
261
+ server.launch(_frontend=False)
web/app.js ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useState } from "https://esm.sh/react@18.2.0";
2
+ import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
3
+
4
+ const h = React.createElement;
5
+
6
+ const defaults = {
7
+ description:
8
+ "Upload a short workout clip, tune the athlete context, and generate an annotated form-review report with structured artifacts.",
9
+ goals: ["strength", "hypertrophy", "endurance", "mobility", "beginner_practice"],
10
+ experience_levels: ["beginner", "intermediate"],
11
+ exercises: ["auto"],
12
+ limitations: ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
13
+ equipment: ["bodyweight", "dumbbell", "barbell", "unknown"],
14
+ };
15
+
16
+ function label(value) {
17
+ return value.replaceAll("_", " ");
18
+ }
19
+
20
+ function Field({ labelText, children, full = false }) {
21
+ return h(
22
+ "label",
23
+ { className: `field${full ? " full" : ""}` },
24
+ h("span", { className: "label" }, labelText),
25
+ children,
26
+ );
27
+ }
28
+
29
+ function SelectField({ labelText, value, onChange, options }) {
30
+ return h(
31
+ Field,
32
+ { labelText },
33
+ h(
34
+ "select",
35
+ { value, onChange: (event) => onChange(event.target.value) },
36
+ options.map((option) => h("option", { key: option, value: option }, label(option))),
37
+ ),
38
+ );
39
+ }
40
+
41
+ function Summary({ result }) {
42
+ if (!result) {
43
+ return h(
44
+ "section",
45
+ { className: "summary" },
46
+ h("h2", null, "Ready for review"),
47
+ h("p", null, "Choose the movement context and start the analyzer."),
48
+ );
49
+ }
50
+
51
+ const report = result.report;
52
+ const summary = report.coach_summary;
53
+ const warnings = report.video_manifest.quality_warnings || [];
54
+ const stats = [
55
+ ["Exercise", report.exercise.exercise],
56
+ ["Variation", report.variation.detected_variation],
57
+ ["Reps", String(report.reps.reps.length)],
58
+ ["Mode", report.artifacts.analysis_mode],
59
+ ];
60
+
61
+ return h(
62
+ "section",
63
+ { className: "summary" },
64
+ h("h2", null, "Scan summary"),
65
+ h("p", null, summary.summary),
66
+ h(
67
+ "div",
68
+ { className: "stat-grid" },
69
+ stats.map(([name, value]) =>
70
+ h("div", { className: "stat", key: name }, h("span", null, name), h("strong", null, value)),
71
+ ),
72
+ ),
73
+ h(
74
+ "div",
75
+ { className: "note-grid" },
76
+ h(NoteList, { title: "What went well", items: summary.what_went_well }),
77
+ h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
78
+ h(NoteList, { title: "Next session", items: summary.next_session_plan }),
79
+ ),
80
+ warnings.length
81
+ ? h(
82
+ "div",
83
+ { className: "quality-list" },
84
+ warnings.map((warning) => h("span", { key: warning }, label(warning))),
85
+ )
86
+ : h("div", { className: "quality-list" }, h("span", null, "No quality warnings")),
87
+ );
88
+ }
89
+
90
+ function NoteList({ title, items }) {
91
+ return h(
92
+ "article",
93
+ { className: "note" },
94
+ h("h3", null, title),
95
+ h("ul", null, items.map((item) => h("li", { key: item }, item))),
96
+ );
97
+ }
98
+
99
+ function App() {
100
+ const [config, setConfig] = useState(defaults);
101
+ const [file, setFile] = useState(null);
102
+ const [goal, setGoal] = useState("beginner_practice");
103
+ const [experience, setExperience] = useState("beginner");
104
+ const [exercise, setExercise] = useState("auto");
105
+ const [variation, setVariation] = useState("");
106
+ const [equipment, setEquipment] = useState("bodyweight");
107
+ const [limitations, setLimitations] = useState([]);
108
+ const [result, setResult] = useState(null);
109
+ const [activeTab, setActiveTab] = useState("json");
110
+ const [status, setStatus] = useState("idle");
111
+ const [error, setError] = useState("");
112
+
113
+ const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
114
+
115
+ useEffect(() => {
116
+ fetch("/api/config")
117
+ .then((response) => response.json())
118
+ .then(setConfig)
119
+ .catch(() => setConfig(defaults));
120
+ }, []);
121
+
122
+ useEffect(() => {
123
+ return () => {
124
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
125
+ };
126
+ }, [previewUrl]);
127
+
128
+ function toggleLimitation(value) {
129
+ setLimitations((current) =>
130
+ current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
131
+ );
132
+ }
133
+
134
+ async function analyze(event) {
135
+ event.preventDefault();
136
+ setError("");
137
+ setStatus("running");
138
+ setResult(null);
139
+
140
+ const payload = new FormData();
141
+ if (file) payload.append("video", file);
142
+ payload.append("goal", goal);
143
+ payload.append("experience_level", experience);
144
+ payload.append("intended_exercise", exercise);
145
+ payload.append("intended_variation", variation);
146
+ payload.append("limitations", JSON.stringify(limitations));
147
+ payload.append("equipment", equipment);
148
+
149
+ try {
150
+ const response = await fetch("/api/analyze", { method: "POST", body: payload });
151
+ const body = await response.json();
152
+ if (!response.ok) throw new Error(body.detail || "Analysis failed.");
153
+ setResult(body);
154
+ setStatus("complete");
155
+ } catch (caught) {
156
+ setError(caught.message || "Analysis failed.");
157
+ setStatus("idle");
158
+ }
159
+ }
160
+
161
+ return h(
162
+ "main",
163
+ { className: "app" },
164
+ h(
165
+ "section",
166
+ { className: "hero" },
167
+ h(
168
+ "div",
169
+ { className: "hero-content" },
170
+ h("p", { className: "eyebrow" }, "Pose intelligence for coached training"),
171
+ h("h1", null, "Pozify"),
172
+ h("p", null, config.description),
173
+ ),
174
+ h(
175
+ "div",
176
+ { className: "hero-metrics", "aria-label": "Pipeline highlights" },
177
+ h("div", { className: "metric" }, h("strong", null, "33"), h("span", null, "pose landmarks")),
178
+ h("div", { className: "metric" }, h("strong", null, "60s"), h("span", null, "clip ceiling")),
179
+ h("div", { className: "metric" }, h("strong", null, "JSON"), h("span", null, "audit trail")),
180
+ ),
181
+ ),
182
+ h(
183
+ "form",
184
+ { className: "workspace", onSubmit: analyze },
185
+ h(
186
+ "section",
187
+ { className: "panel" },
188
+ h(
189
+ "div",
190
+ { className: "panel-head" },
191
+ h("div", null, h("h2", null, "Session setup"), h("p", null, "Movement context")),
192
+ h("span", { className: "status-pill" }, status === "running" ? "Analyzing" : "Ready"),
193
+ ),
194
+ h(
195
+ "label",
196
+ { className: "dropzone" },
197
+ h("input", {
198
+ type: "file",
199
+ accept: "video/*",
200
+ onChange: (event) => setFile(event.target.files?.[0] || null),
201
+ }),
202
+ previewUrl
203
+ ? h("video", { className: "dropzone-preview", src: previewUrl, controls: true })
204
+ : h(
205
+ "span",
206
+ { className: "dropzone-empty" },
207
+ h("span", { className: "upload-icon" }, "↑"),
208
+ h("strong", null, "Drop video here"),
209
+ h("span", null, "or click to upload"),
210
+ ),
211
+ ),
212
+ h(
213
+ "div",
214
+ { className: "form-grid" },
215
+ h(SelectField, { labelText: "Goal", value: goal, onChange: setGoal, options: config.goals }),
216
+ h(SelectField, {
217
+ labelText: "Experience",
218
+ value: experience,
219
+ onChange: setExperience,
220
+ options: config.experience_levels,
221
+ }),
222
+ h(SelectField, {
223
+ labelText: "Exercise",
224
+ value: exercise,
225
+ onChange: setExercise,
226
+ options: config.exercises,
227
+ }),
228
+ h(SelectField, {
229
+ labelText: "Equipment",
230
+ value: equipment,
231
+ onChange: setEquipment,
232
+ options: config.equipment,
233
+ }),
234
+ h(
235
+ Field,
236
+ { labelText: "Variation", full: true },
237
+ h("input", {
238
+ type: "text",
239
+ value: variation,
240
+ placeholder: "Optional, e.g. wide_grip_push_up",
241
+ onChange: (event) => setVariation(event.target.value),
242
+ }),
243
+ ),
244
+ h(
245
+ "div",
246
+ { className: "field full" },
247
+ h("span", { className: "label" }, "Known limitations"),
248
+ h(
249
+ "div",
250
+ { className: "check-grid" },
251
+ config.limitations.map((item) =>
252
+ h(
253
+ "label",
254
+ { className: "check-chip", key: item },
255
+ h("input", {
256
+ type: "checkbox",
257
+ checked: limitations.includes(item),
258
+ onChange: () => toggleLimitation(item),
259
+ }),
260
+ h("span", null, label(item)),
261
+ ),
262
+ ),
263
+ ),
264
+ ),
265
+ ),
266
+ h("button", { className: "primary", disabled: status === "running" }, status === "running" ? "Analyzing..." : "Analyze Form"),
267
+ error ? h("div", { className: "error" }, error) : null,
268
+ ),
269
+ h(
270
+ "section",
271
+ { className: "panel" },
272
+ h(
273
+ "div",
274
+ { className: "panel-head" },
275
+ h("div", null, h("h2", null, "Review output"), h("p", null, "Annotated movement")),
276
+ result ? h("span", { className: "status-pill" }, result.run_id) : null,
277
+ ),
278
+ h(
279
+ "div",
280
+ { className: "result-stage" },
281
+ result?.annotated_video_url
282
+ ? h("video", { className: "result-video", src: result.annotated_video_url, controls: true })
283
+ : h(
284
+ "div",
285
+ { className: "result-empty" },
286
+ h("strong", null, "Awaiting scan"),
287
+ h("span", null, "The annotated video appears after analysis."),
288
+ ),
289
+ ),
290
+ ),
291
+ ),
292
+ h(Summary, { result }),
293
+ h(
294
+ "div",
295
+ { className: "tabs", role: "tablist", "aria-label": "Artifacts" },
296
+ ["json", "artifacts"].map((tab) =>
297
+ h(
298
+ "button",
299
+ {
300
+ className: `tab${activeTab === tab ? " active" : ""}`,
301
+ key: tab,
302
+ onClick: () => setActiveTab(tab),
303
+ type: "button",
304
+ },
305
+ tab === "json" ? "Final report JSON" : "Artifact links",
306
+ ),
307
+ ),
308
+ ),
309
+ h(
310
+ "section",
311
+ { className: "artifact-panel" },
312
+ activeTab === "json"
313
+ ? h("pre", { className: "json-block" }, result ? JSON.stringify(result.report, null, 2) : "{}")
314
+ : h(
315
+ "div",
316
+ { className: "artifact-links" },
317
+ result
318
+ ? [
319
+ h("a", { className: "artifact-link", key: "report", href: result.final_report_url }, "final_report.json", h("span", null, "open")),
320
+ result.annotated_video_url
321
+ ? h("a", { className: "artifact-link", key: "video", href: result.annotated_video_url }, "annotated_video.mp4", h("span", null, "open"))
322
+ : null,
323
+ ]
324
+ : h("p", null, "Artifacts appear after analysis."),
325
+ ),
326
+ ),
327
+ );
328
+ }
329
+
330
+ createRoot(document.getElementById("root")).render(h(App));
web/index.html ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="theme-color" content="#214f3a" />
7
+ <title>Pozify</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link
11
+ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
12
+ rel="stylesheet"
13
+ />
14
+ <link rel="stylesheet" href="/static/styles.css" />
15
+ </head>
16
+ <body>
17
+ <div id="root"></div>
18
+ <script type="module" src="/static/app.js"></script>
19
+ </body>
20
+ </html>
web/styles.css ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ --ink: #202016;
4
+ --muted: #6b6d5b;
5
+ --paper: #fff9ec;
6
+ --panel: rgba(255, 252, 243, 0.78);
7
+ --forest: #214f3a;
8
+ --forest-deep: #132f22;
9
+ --clay: #be684b;
10
+ --blue: #314b63;
11
+ --line: rgba(37, 44, 31, 0.14);
12
+ --shadow: 0 20px 58px rgba(32, 32, 22, 0.12);
13
+ font-family: "IBM Plex Sans", ui-sans-serif, sans-serif;
14
+ }
15
+
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ body {
21
+ min-width: 320px;
22
+ margin: 0;
23
+ color: var(--ink);
24
+ background:
25
+ radial-gradient(circle at 8% 4%, rgba(190, 104, 75, 0.18), transparent 28rem),
26
+ radial-gradient(circle at 94% 18%, rgba(49, 75, 99, 0.18), transparent 34rem),
27
+ linear-gradient(135deg, #f4efe3 0%, #edf3e4 48%, #f8f2e6 100%);
28
+ }
29
+
30
+ button,
31
+ input,
32
+ select,
33
+ textarea {
34
+ font: inherit;
35
+ }
36
+
37
+ button {
38
+ cursor: pointer;
39
+ }
40
+
41
+ .app {
42
+ width: min(1480px, 100%);
43
+ min-height: 100vh;
44
+ margin: 0 auto;
45
+ padding: 22px clamp(12px, 3vw, 36px) 38px;
46
+ }
47
+
48
+ .hero {
49
+ position: relative;
50
+ min-height: 260px;
51
+ overflow: hidden;
52
+ border: 1px solid rgba(255, 255, 255, 0.36);
53
+ border-radius: 8px;
54
+ padding: clamp(26px, 5vw, 58px);
55
+ color: var(--paper);
56
+ background:
57
+ linear-gradient(110deg, rgba(20, 44, 33, 0.94), rgba(30, 63, 47, 0.82) 54%, rgba(190, 104, 75, 0.74)),
58
+ url("https://images.unsplash.com/photo-1518611012118-696072aa579a?auto=format&fit=crop&w=1800&q=80");
59
+ background-position: center;
60
+ background-size: cover;
61
+ box-shadow: 0 26px 80px rgba(32, 32, 22, 0.18);
62
+ }
63
+
64
+ .hero::after {
65
+ content: "";
66
+ position: absolute;
67
+ inset: auto 0 0 0;
68
+ height: 44%;
69
+ background: linear-gradient(0deg, rgba(13, 26, 20, 0.56), transparent);
70
+ pointer-events: none;
71
+ }
72
+
73
+ .hero-content,
74
+ .hero-metrics {
75
+ position: relative;
76
+ z-index: 1;
77
+ }
78
+
79
+ .eyebrow,
80
+ .metric span,
81
+ .status-pill,
82
+ .label,
83
+ .tab,
84
+ .artifact-link,
85
+ .json-block {
86
+ font-family: "IBM Plex Mono", ui-monospace, monospace;
87
+ }
88
+
89
+ .eyebrow {
90
+ margin: 0 0 14px;
91
+ color: #dfead3;
92
+ font-size: 0.78rem;
93
+ letter-spacing: 0;
94
+ text-transform: uppercase;
95
+ }
96
+
97
+ .hero h1 {
98
+ margin: 0;
99
+ color: var(--paper);
100
+ font-size: clamp(2.7rem, 8vw, 6.7rem);
101
+ line-height: 0.9;
102
+ letter-spacing: 0;
103
+ }
104
+
105
+ .hero p {
106
+ max-width: 660px;
107
+ margin: 22px 0 0;
108
+ color: rgba(255, 249, 236, 0.86);
109
+ font-size: clamp(1rem, 1.7vw, 1.22rem);
110
+ line-height: 1.55;
111
+ }
112
+
113
+ .hero-metrics {
114
+ display: grid;
115
+ grid-template-columns: repeat(3, minmax(0, 1fr));
116
+ gap: 1px;
117
+ max-width: 620px;
118
+ margin-top: clamp(26px, 4vw, 46px);
119
+ overflow: hidden;
120
+ border: 1px solid rgba(255, 249, 236, 0.22);
121
+ border-radius: 8px;
122
+ background: rgba(255, 249, 236, 0.16);
123
+ backdrop-filter: blur(18px);
124
+ }
125
+
126
+ .metric {
127
+ padding: 18px;
128
+ background: rgba(10, 22, 16, 0.32);
129
+ }
130
+
131
+ .metric strong {
132
+ display: block;
133
+ color: var(--paper);
134
+ font-size: clamp(1.35rem, 2.6vw, 2rem);
135
+ line-height: 1;
136
+ }
137
+
138
+ .metric span {
139
+ display: block;
140
+ margin-top: 7px;
141
+ color: rgba(255, 249, 236, 0.72);
142
+ font-size: 0.72rem;
143
+ letter-spacing: 0;
144
+ text-transform: uppercase;
145
+ }
146
+
147
+ .workspace {
148
+ display: grid;
149
+ grid-template-columns: minmax(360px, 0.84fr) minmax(440px, 1.16fr);
150
+ gap: 18px;
151
+ margin-top: 18px;
152
+ align-items: stretch;
153
+ }
154
+
155
+ .panel,
156
+ .summary,
157
+ .artifact-panel {
158
+ border: 1px solid var(--line);
159
+ border-radius: 8px;
160
+ background: var(--panel);
161
+ box-shadow: var(--shadow);
162
+ backdrop-filter: blur(18px);
163
+ }
164
+
165
+ .panel {
166
+ padding: 16px;
167
+ }
168
+
169
+ .panel-head {
170
+ display: flex;
171
+ justify-content: space-between;
172
+ gap: 14px;
173
+ align-items: flex-start;
174
+ margin-bottom: 14px;
175
+ }
176
+
177
+ .panel h2,
178
+ .summary h2 {
179
+ margin: 0;
180
+ color: var(--forest);
181
+ font-size: 1.12rem;
182
+ line-height: 1.2;
183
+ }
184
+
185
+ .panel p,
186
+ .summary p {
187
+ margin: 6px 0 0;
188
+ color: var(--muted);
189
+ line-height: 1.45;
190
+ }
191
+
192
+ .status-pill {
193
+ flex: 0 0 auto;
194
+ border: 1px solid rgba(33, 79, 58, 0.18);
195
+ border-radius: 999px;
196
+ padding: 7px 10px;
197
+ color: var(--forest);
198
+ background: rgba(33, 79, 58, 0.08);
199
+ font-size: 0.72rem;
200
+ font-weight: 600;
201
+ text-transform: uppercase;
202
+ }
203
+
204
+ .dropzone {
205
+ display: grid;
206
+ min-height: 346px;
207
+ overflow: hidden;
208
+ border: 1px dashed rgba(33, 79, 58, 0.28);
209
+ border-radius: 8px;
210
+ background: #171815;
211
+ color: var(--paper);
212
+ place-items: center;
213
+ }
214
+
215
+ .dropzone input {
216
+ position: absolute;
217
+ width: 1px;
218
+ height: 1px;
219
+ opacity: 0;
220
+ pointer-events: none;
221
+ }
222
+
223
+ .dropzone-preview,
224
+ .result-video {
225
+ width: 100%;
226
+ height: 100%;
227
+ min-height: 346px;
228
+ object-fit: contain;
229
+ background: #171815;
230
+ }
231
+
232
+ .dropzone-empty {
233
+ display: grid;
234
+ gap: 10px;
235
+ justify-items: center;
236
+ padding: 28px;
237
+ text-align: center;
238
+ }
239
+
240
+ .upload-icon {
241
+ display: grid;
242
+ width: 52px;
243
+ height: 52px;
244
+ border: 1px solid rgba(255, 249, 236, 0.24);
245
+ border-radius: 999px;
246
+ color: #dce9d3;
247
+ place-items: center;
248
+ }
249
+
250
+ .dropzone-empty strong {
251
+ font-size: 1.06rem;
252
+ }
253
+
254
+ .dropzone-empty span {
255
+ color: rgba(255, 249, 236, 0.62);
256
+ }
257
+
258
+ .form-grid {
259
+ display: grid;
260
+ grid-template-columns: repeat(2, minmax(0, 1fr));
261
+ gap: 14px;
262
+ margin-top: 16px;
263
+ }
264
+
265
+ .field {
266
+ display: grid;
267
+ gap: 7px;
268
+ }
269
+
270
+ .field.full {
271
+ grid-column: 1 / -1;
272
+ }
273
+
274
+ .label {
275
+ color: #4d5244;
276
+ font-size: 0.76rem;
277
+ font-weight: 600;
278
+ text-transform: uppercase;
279
+ }
280
+
281
+ select,
282
+ input[type="text"] {
283
+ width: 100%;
284
+ min-height: 44px;
285
+ border: 1px solid rgba(37, 44, 31, 0.16);
286
+ border-radius: 8px;
287
+ padding: 0 12px;
288
+ color: var(--ink);
289
+ background: rgba(255, 253, 247, 0.92);
290
+ }
291
+
292
+ select:focus,
293
+ input[type="text"]:focus,
294
+ .tab:focus,
295
+ .primary:focus {
296
+ outline: 3px solid rgba(33, 79, 58, 0.24);
297
+ outline-offset: 2px;
298
+ }
299
+
300
+ .check-grid {
301
+ display: flex;
302
+ flex-wrap: wrap;
303
+ gap: 8px;
304
+ }
305
+
306
+ .check-chip {
307
+ display: inline-flex;
308
+ gap: 8px;
309
+ align-items: center;
310
+ min-height: 40px;
311
+ border: 1px solid rgba(37, 44, 31, 0.13);
312
+ border-radius: 8px;
313
+ padding: 0 12px;
314
+ background: rgba(255, 253, 247, 0.74);
315
+ }
316
+
317
+ .check-chip input {
318
+ accent-color: var(--forest);
319
+ }
320
+
321
+ .primary {
322
+ width: 100%;
323
+ min-height: 52px;
324
+ margin-top: 16px;
325
+ border: 0;
326
+ border-radius: 8px;
327
+ color: var(--paper);
328
+ background: var(--forest);
329
+ box-shadow: 0 12px 26px rgba(33, 79, 58, 0.22);
330
+ font-weight: 700;
331
+ }
332
+
333
+ .primary:hover {
334
+ background: var(--forest-deep);
335
+ }
336
+
337
+ .primary:disabled {
338
+ cursor: wait;
339
+ opacity: 0.72;
340
+ }
341
+
342
+ .result-stage {
343
+ display: grid;
344
+ min-height: 470px;
345
+ overflow: hidden;
346
+ border-radius: 8px;
347
+ background: #171815;
348
+ color: var(--paper);
349
+ place-items: center;
350
+ }
351
+
352
+ .result-empty {
353
+ max-width: 360px;
354
+ padding: 24px;
355
+ text-align: center;
356
+ }
357
+
358
+ .result-empty strong {
359
+ display: block;
360
+ margin-bottom: 8px;
361
+ font-size: 1.08rem;
362
+ }
363
+
364
+ .result-empty span {
365
+ color: rgba(255, 249, 236, 0.62);
366
+ line-height: 1.45;
367
+ }
368
+
369
+ .summary {
370
+ margin-top: 18px;
371
+ padding: clamp(16px, 2vw, 24px);
372
+ }
373
+
374
+ .stat-grid {
375
+ display: grid;
376
+ grid-template-columns: repeat(4, minmax(0, 1fr));
377
+ gap: 10px;
378
+ margin: 16px 0;
379
+ }
380
+
381
+ .stat {
382
+ border: 1px solid rgba(37, 44, 31, 0.12);
383
+ border-radius: 8px;
384
+ padding: 14px;
385
+ background: rgba(255, 253, 247, 0.66);
386
+ }
387
+
388
+ .stat span {
389
+ display: block;
390
+ color: var(--muted);
391
+ font-size: 0.82rem;
392
+ }
393
+
394
+ .stat strong {
395
+ display: block;
396
+ margin-top: 5px;
397
+ color: var(--forest);
398
+ font-size: 1.12rem;
399
+ }
400
+
401
+ .note-grid {
402
+ display: grid;
403
+ grid-template-columns: repeat(3, minmax(0, 1fr));
404
+ gap: 12px;
405
+ }
406
+
407
+ .note {
408
+ border: 1px solid rgba(37, 44, 31, 0.12);
409
+ border-radius: 8px;
410
+ padding: 14px;
411
+ background: rgba(255, 253, 247, 0.58);
412
+ }
413
+
414
+ .note h3 {
415
+ margin: 0 0 10px;
416
+ color: var(--forest);
417
+ font-size: 0.96rem;
418
+ }
419
+
420
+ .note ul {
421
+ display: grid;
422
+ gap: 8px;
423
+ margin: 0;
424
+ padding-left: 18px;
425
+ color: #3e4438;
426
+ }
427
+
428
+ .quality-list {
429
+ display: flex;
430
+ flex-wrap: wrap;
431
+ gap: 8px;
432
+ margin-top: 14px;
433
+ }
434
+
435
+ .quality-list span {
436
+ border-radius: 999px;
437
+ padding: 7px 10px;
438
+ color: #5a2d20;
439
+ background: rgba(190, 104, 75, 0.12);
440
+ font-size: 0.84rem;
441
+ }
442
+
443
+ .tabs {
444
+ display: flex;
445
+ gap: 8px;
446
+ margin-top: 18px;
447
+ }
448
+
449
+ .tab {
450
+ border: 0;
451
+ border-bottom: 2px solid transparent;
452
+ padding: 11px 14px;
453
+ color: var(--muted);
454
+ background: transparent;
455
+ font-size: 0.78rem;
456
+ font-weight: 600;
457
+ text-transform: uppercase;
458
+ }
459
+
460
+ .tab.active {
461
+ border-color: var(--forest);
462
+ color: var(--forest);
463
+ }
464
+
465
+ .artifact-panel {
466
+ overflow: hidden;
467
+ }
468
+
469
+ .json-block {
470
+ min-height: 360px;
471
+ max-height: 620px;
472
+ margin: 0;
473
+ overflow: auto;
474
+ padding: 18px;
475
+ color: #f4efe3;
476
+ background: #171815;
477
+ font-size: 0.82rem;
478
+ line-height: 1.55;
479
+ }
480
+
481
+ .artifact-links {
482
+ display: grid;
483
+ gap: 12px;
484
+ padding: 18px;
485
+ }
486
+
487
+ .artifact-link {
488
+ display: flex;
489
+ justify-content: space-between;
490
+ gap: 12px;
491
+ align-items: center;
492
+ border: 1px solid rgba(37, 44, 31, 0.12);
493
+ border-radius: 8px;
494
+ padding: 14px;
495
+ color: var(--forest);
496
+ background: rgba(255, 253, 247, 0.72);
497
+ text-decoration: none;
498
+ }
499
+
500
+ .error {
501
+ border: 1px solid rgba(190, 104, 75, 0.28);
502
+ border-radius: 8px;
503
+ margin-top: 14px;
504
+ padding: 12px;
505
+ color: #6d2f20;
506
+ background: rgba(190, 104, 75, 0.12);
507
+ }
508
+
509
+ @media (max-width: 980px) {
510
+ .workspace,
511
+ .note-grid,
512
+ .stat-grid {
513
+ grid-template-columns: 1fr;
514
+ }
515
+ }
516
+
517
+ @media (max-width: 640px) {
518
+ .app {
519
+ padding: 12px 10px 28px;
520
+ }
521
+
522
+ .hero {
523
+ min-height: 380px;
524
+ padding: 26px 20px;
525
+ }
526
+
527
+ .hero-metrics,
528
+ .form-grid {
529
+ grid-template-columns: 1fr;
530
+ }
531
+
532
+ .panel-head {
533
+ display: grid;
534
+ }
535
+ }