Spaces:
Running on Zero
Running on Zero
feat(ui): add React app for form analysis
Browse files- app.py +124 -73
- web/app.js +330 -0
- web/index.html +20 -0
- 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"""##
|
| 43 |
|
| 44 |
{status}
|
| 45 |
|
|
@@ -104,13 +118,15 @@ def analyze_video(
|
|
| 104 |
|
| 105 |
markdown = f"""## Scan Summary
|
| 106 |
|
| 107 |
-
|
| 108 |
-
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
| 114 |
|
| 115 |
{mock_status}
|
| 116 |
|
|
@@ -139,72 +155,107 @@ def analyze_video(
|
|
| 139 |
)
|
| 140 |
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 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 |
-
|
|
|
|
| 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 |
+
}
|