Pozify / web /app.js
tiena2cva's picture
feat(coach): disable verifier by default
8780d53
Raw
History Blame Contribute Delete
15.5 kB
import React, {
useEffect,
useMemo,
useState,
} from "https://esm.sh/react@18.2.0";
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
import { defaults, h, label } from "./common.js?v=20260614-modular-app";
import {
ReportPanel,
ReplayReviewPanel,
ReviewInsights,
} from "./report.js?v=20260614-modular-app";
function Field({ labelText, children, full = false }) {
return h(
"label",
{ className: `field${full ? " full" : ""}` },
h("span", { className: "label" }, labelText),
children,
);
}
function SelectField({ labelText, value, onChange, options, name }) {
return h(
Field,
{ labelText },
h(
"select",
{ name, value, onChange: (event) => onChange(event.target.value) },
options.map((option) =>
h("option", { key: option, value: option }, label(option)),
),
),
);
}
function KineticFigure({ compact = false }) {
const imageUrl = compact
? "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?auto=format&fit=crop&fm=jpg&w=900&q=84"
: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?auto=format&fit=crop&fm=jpg&w=1100&q=84";
return h(
"div",
{ className: `kinetic-photo${compact ? " compact" : ""}`, "aria-hidden": "true" },
h("img", {
alt: "",
className: "motion-photo",
draggable: false,
src: imageUrl,
}),
h("span", { className: "photo-vignette" }),
h("span", { className: "photo-scanline" }),
);
}
function SignalStack() {
return h(
"div",
{ className: "signal-stack", "aria-label": "Analysis pipeline" },
["Video QC", "Pose Map", "Rep Count", "Coach Notes"].map((item, index) =>
h(
"div",
{ className: "signal-row", key: item },
h("span", null, `0${index + 1}`),
h("strong", null, item),
),
),
);
}
const runningProgressSteps = [
{
id: "quality",
text: "First up, I am checking if the video is clear enough to coach from.",
delayMs: 0,
},
{
id: "pose",
text: "Now I am mapping your posture and tracking the key body landmarks.",
delayMs: 900,
},
{
id: "exercise",
text: "Let me figure out which exercise you are doing.",
delayMs: 1800,
},
{
id: "reps",
text: "Counting your reps now. One clean rep at a time.",
delayMs: 2800,
},
{
id: "issues",
text: "Almost there. I am checking the moments that may need a small fix.",
delayMs: 3900,
},
{
id: "render",
text: "I am preparing your annotated video and issue clips.",
delayMs: 4800,
},
{
id: "coach",
text: "I am turning the scan into coaching notes you can use right away.",
delayMs: 5600,
},
];
function pendingProgressState() {
return runningProgressSteps.map((step, index) => ({
id: step.id,
text: step.text,
status: index === 0 ? "active" : "pending",
}));
}
function finalProgressState(result) {
const report = result.report;
const warnings = report.video_manifest?.quality_warnings || [];
const exercise = label(report.exercise?.exercise || "movement");
const repCount = report.reps?.reps?.length || 0;
const issues = report.issue_markers?.issues || [];
return [
{
id: "quality",
status: "done",
text: warnings.length
? `Quick note: the video has a few things to watch, like ${warnings.map(label).join(", ")}.`
: "Nice, your video quality looks solid.",
},
{
id: "pose",
status: "done",
text: "Posture tracking is done. I found the key landmarks I need.",
},
{
id: "exercise",
status: "done",
text: `Looks like you are doing ${exercise}.`,
},
{
id: "reps",
status: "done",
text: `I counted ${repCount} ${exercise} reps in this set.`,
},
{
id: "issues",
status: "done",
text: issues.length
? `I found ${issues.length} coaching point${issues.length === 1 ? "" : "s"} worth reviewing.`
: "Good news, I did not spot any clear form issues in this set.",
},
{
id: "render",
status: "done",
text: result.annotated_video_url
? "Your annotated video is ready."
: "I could not render an annotated video, but the report is ready.",
},
{
id: "coach",
status: "done",
text: "Coach notes are ready.",
},
];
}
function applyProgressEvent(currentSteps, event) {
const baseSteps = currentSteps.length ? currentSteps : pendingProgressState();
const knownStepIds = new Set(runningProgressSteps.map((step) => step.id));
if (!knownStepIds.has(event.step)) return baseSteps;
return baseSteps.map((step) =>
step.id === event.step
? {
...step,
status: event.status || "active",
text: event.text || step.text,
}
: step,
);
}
async function readAnalysisStream(response, onEvent) {
if (!response.body)
throw new Error("Streaming is not available in this browser.");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === "progress") onEvent(event);
if (event.type === "complete") result = event.result;
if (event.type === "error")
throw new Error(event.detail || "Analysis failed.");
}
}
if (buffer.trim()) {
const event = JSON.parse(buffer);
if (event.type === "progress") onEvent(event);
if (event.type === "complete") result = event.result;
if (event.type === "error")
throw new Error(event.detail || "Analysis failed.");
}
if (!result) throw new Error("Analysis finished without a report.");
return result;
}
function ProgressPanel({ steps }) {
if (!steps.length) return null;
const isComplete = steps.every((step) => step.status === "done");
return h(
"section",
{ className: "progress-panel", "aria-live": "polite" },
h(
"h3",
null,
isComplete ? "Scan results are ready" : "Your scan is moving",
),
h(
"ol",
{ className: "progress-list" },
steps.map((step) =>
h(
"li",
{ className: `progress-step ${step.status}`, key: step.id },
h("span", { className: "progress-dot", "aria-hidden": "true" }),
h("span", null, step.text),
),
),
),
);
}
function StageEmpty() {
return h(
"div",
{ className: "result-empty" },
h(KineticFigure, { compact: true }),
h("strong", null, "Your annotated replay will land here"),
h(
"span",
null,
"Upload a set and Pozify will paint the movement path, rep timing, and coaching moments on top of the video.",
),
);
}
function App() {
const [config, setConfig] = useState(defaults);
const [file, setFile] = useState(null);
const [goal, setGoal] = useState("beginner_practice");
const [experience, setExperience] = useState("beginner");
const [exercise, setExercise] = useState("auto");
const [equipment, setEquipment] = useState("bodyweight");
const [limitations, setLimitations] = useState([]);
const [result, setResult] = useState(null);
const [activeTab, setActiveTab] = useState("summary");
const [status, setStatus] = useState("idle");
const [error, setError] = useState("");
const [progressSteps, setProgressSteps] = useState([]);
const previewUrl = useMemo(
() => (file ? URL.createObjectURL(file) : ""),
[file],
);
useEffect(() => {
fetch("/api/config")
.then((response) => response.json())
.then(setConfig)
.catch(() => setConfig(defaults));
}, []);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
function toggleLimitation(value) {
setLimitations((current) =>
current.includes(value)
? current.filter((item) => item !== value)
: [...current, value],
);
}
async function analyze(event) {
event.preventDefault();
setError("");
setStatus("running");
setResult(null);
setProgressSteps(pendingProgressState());
const payload = new FormData();
if (file) payload.append("video", file);
payload.append("goal", goal);
payload.append("experience_level", experience);
payload.append("intended_exercise", exercise);
payload.append("intended_variation", "");
payload.append("limitations", JSON.stringify(limitations));
payload.append("equipment", equipment);
payload.append("bypass_verifier", "true");
try {
const response = await fetch("/api/analyze/stream", {
method: "POST",
body: payload,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail || "Analysis failed.");
}
const body = await readAnalysisStream(response, (progressEvent) => {
setProgressSteps((currentSteps) =>
applyProgressEvent(currentSteps, progressEvent),
);
});
setResult(body);
setProgressSteps(finalProgressState(body));
setStatus("complete");
} catch (caught) {
setError(caught.message || "Analysis failed.");
setProgressSteps([
{
id: "error",
status: "active",
text: "The scan did not finish. Try another video or check the connection, and we can run it again.",
},
]);
setStatus("idle");
}
}
return h(
"main",
{ className: "app" },
h(
"header",
{ className: "hero" },
h(
"div",
{ className: "hero-content" },
h(
"p",
{ className: "eyebrow" },
"Pose intelligence for coached training",
),
h("h1", null, "Pozify"),
h("p", null, config.description),
h(
"div",
{ className: "hero-actions", "aria-label": "Demo strengths" },
h("span", null, "Realtime stream"),
h("span", null, "Annotated replay"),
h("span", null, "Grounded coach notes"),
),
),
h(
"aside",
{ className: "hero-lab", "aria-label": "Motion analysis preview" },
h(KineticFigure, null),
h(SignalStack, null),
h(
"div",
{ className: "hero-metrics", "aria-label": "Pipeline highlights" },
h(
"div",
{ className: "metric" },
h("strong", null, "17"),
h("span", null, "pose landmarks"),
),
h(
"div",
{ className: "metric" },
h("strong", null, "60s"),
h("span", null, "clip ceiling"),
),
h(
"div",
{ className: "metric" },
h("strong", null, "JSON"),
h("span", null, "audit trail"),
),
),
),
),
h(
"form",
{ className: "workspace", onSubmit: analyze },
h(
"section",
{ className: "panel" },
h(
"div",
{ className: "panel-head" },
h(
"div",
null,
h("h2", null, "Session setup"),
h("p", null, "Movement context"),
),
h(
"span",
{ className: "status-pill" },
status === "running" ? "Analyzing" : result ? "Complete" : "Ready",
),
),
h(
"label",
{ className: "dropzone" },
h("input", {
name: "video",
type: "file",
accept: "video/*",
onChange: (event) => setFile(event.target.files?.[0] || null),
}),
previewUrl
? h("video", {
className: "dropzone-preview",
src: previewUrl,
controls: true,
})
: h(
"span",
{ className: "dropzone-empty" },
h("span", { className: "upload-icon" }, "↑"),
h("strong", null, "Drop a workout clip"),
h("span", null, "or click to upload an MP4, MOV, or WebM file"),
),
),
h(
"div",
{ className: "form-grid" },
h(SelectField, {
labelText: "Goal",
name: "goal",
value: goal,
onChange: setGoal,
options: config.goals,
}),
h(SelectField, {
labelText: "Experience",
name: "experience_level",
value: experience,
onChange: setExperience,
options: config.experience_levels,
}),
h(SelectField, {
labelText: "Exercise",
name: "intended_exercise",
value: exercise,
onChange: setExercise,
options: config.exercises,
}),
h(SelectField, {
labelText: "Equipment",
name: "equipment",
value: equipment,
onChange: setEquipment,
options: config.equipment,
}),
h(
"div",
{ className: "field full" },
h("span", { className: "label" }, "Known limitations"),
h(
"div",
{ className: "check-grid" },
config.limitations.map((item) =>
h(
"label",
{ className: "check-chip", key: item },
h("input", {
name: "limitations",
type: "checkbox",
checked: limitations.includes(item),
onChange: () => toggleLimitation(item),
}),
h("span", null, label(item)),
),
),
),
),
),
h(
"button",
{ className: "primary", disabled: status === "running", type: "submit" },
status === "running" ? "Analyzing…" : "Analyze Form",
),
error ? h("div", { className: "error" }, error) : null,
),
h(
"section",
{ className: "panel" },
h(
"div",
{ className: "panel-head" },
h(
"div",
null,
h("h2", null, "Review output"),
h("p", null, "Annotated movement"),
),
result
? h("span", { className: "status-pill" }, result.run_id)
: null,
),
result
? h(ReplayReviewPanel, {
result,
videoSrc: result?.annotated_video_url || previewUrl,
className: "review-output-replay",
})
: h(
React.Fragment,
null,
h(
"div",
{ className: "result-stage" },
h(StageEmpty, null),
),
h(ProgressPanel, { steps: progressSteps }),
),
h(ReviewInsights, { result }),
),
),
h(ReportPanel, {
result,
activeTab,
onTabChange: setActiveTab,
}),
);
}
createRoot(document.getElementById("root")).render(h(App));