Spaces:
Running on Zero
Running on Zero
feat(report): show analysis progress timeline
Browse files- web/app.js +135 -0
- web/styles.css +60 -0
web/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import React, {
|
| 2 |
useEffect,
|
| 3 |
useMemo,
|
|
|
|
| 4 |
useState,
|
| 5 |
} from "https://esm.sh/react@18.2.0";
|
| 6 |
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
|
|
@@ -62,6 +63,103 @@ function percent(value) {
|
|
| 62 |
return typeof value === "number" ? `${Math.round(value * 100)}%` : "n/a";
|
| 63 |
}
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
function SummaryTab({ result }) {
|
| 66 |
if (!result) {
|
| 67 |
return h(
|
|
@@ -536,6 +634,8 @@ function App() {
|
|
| 536 |
const [activeTab, setActiveTab] = useState("summary");
|
| 537 |
const [status, setStatus] = useState("idle");
|
| 538 |
const [error, setError] = useState("");
|
|
|
|
|
|
|
| 539 |
|
| 540 |
const previewUrl = useMemo(
|
| 541 |
() => (file ? URL.createObjectURL(file) : ""),
|
|
@@ -555,6 +655,29 @@ function App() {
|
|
| 555 |
};
|
| 556 |
}, [previewUrl]);
|
| 557 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
function toggleLimitation(value) {
|
| 559 |
setLimitations((current) =>
|
| 560 |
current.includes(value)
|
|
@@ -568,6 +691,7 @@ function App() {
|
|
| 568 |
setError("");
|
| 569 |
setStatus("running");
|
| 570 |
setResult(null);
|
|
|
|
| 571 |
|
| 572 |
const payload = new FormData();
|
| 573 |
if (file) payload.append("video", file);
|
|
@@ -585,10 +709,20 @@ function App() {
|
|
| 585 |
});
|
| 586 |
const body = await response.json();
|
| 587 |
if (!response.ok) throw new Error(body.detail || "Analysis failed.");
|
|
|
|
| 588 |
setResult(body);
|
|
|
|
| 589 |
setStatus("complete");
|
| 590 |
} catch (caught) {
|
|
|
|
| 591 |
setError(caught.message || "Analysis failed.");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
setStatus("idle");
|
| 593 |
}
|
| 594 |
}
|
|
@@ -741,6 +875,7 @@ function App() {
|
|
| 741 |
status === "running" ? "Analyzing..." : "Analyze Form",
|
| 742 |
),
|
| 743 |
error ? h("div", { className: "error" }, error) : null,
|
|
|
|
| 744 |
),
|
| 745 |
h(
|
| 746 |
"section",
|
|
|
|
| 1 |
import React, {
|
| 2 |
useEffect,
|
| 3 |
useMemo,
|
| 4 |
+
useRef,
|
| 5 |
useState,
|
| 6 |
} from "https://esm.sh/react@18.2.0";
|
| 7 |
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
|
|
|
|
| 63 |
return typeof value === "number" ? `${Math.round(value * 100)}%` : "n/a";
|
| 64 |
}
|
| 65 |
|
| 66 |
+
const runningProgressSteps = [
|
| 67 |
+
{
|
| 68 |
+
id: "quality",
|
| 69 |
+
text: "Tôi đang kiểm tra chất lượng video của bạn.",
|
| 70 |
+
delayMs: 0,
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
id: "pose",
|
| 74 |
+
text: "Tôi đang tiến hành phân tích dáng tập của bạn.",
|
| 75 |
+
delayMs: 900,
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
id: "exercise",
|
| 79 |
+
text: "Tôi đang nhận diện bài tập bạn đang thực hiện.",
|
| 80 |
+
delayMs: 1800,
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
id: "reps",
|
| 84 |
+
text: "Tôi đang đếm số reps trong video.",
|
| 85 |
+
delayMs: 2800,
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
id: "issues",
|
| 89 |
+
text: "Tôi đang phân tích các lỗi sai trong khi tập của bạn.",
|
| 90 |
+
delayMs: 3900,
|
| 91 |
+
},
|
| 92 |
+
];
|
| 93 |
+
|
| 94 |
+
function runningProgressState(activeIndex = 0) {
|
| 95 |
+
return runningProgressSteps.map((step, index) => ({
|
| 96 |
+
id: step.id,
|
| 97 |
+
text: step.text,
|
| 98 |
+
status:
|
| 99 |
+
index < activeIndex ? "done" : index === activeIndex ? "active" : "pending",
|
| 100 |
+
}));
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function finalProgressState(result) {
|
| 104 |
+
const report = result.report;
|
| 105 |
+
const warnings = report.video_manifest?.quality_warnings || [];
|
| 106 |
+
const exercise = label(report.exercise?.exercise || "movement");
|
| 107 |
+
const repCount = report.reps?.reps?.length || 0;
|
| 108 |
+
const issues = report.issue_markers?.issues || [];
|
| 109 |
+
return [
|
| 110 |
+
{
|
| 111 |
+
id: "quality",
|
| 112 |
+
status: "done",
|
| 113 |
+
text: warnings.length
|
| 114 |
+
? `Video có một vài điểm cần lưu ý: ${warnings.map(label).join(", ")}.`
|
| 115 |
+
: "Ồ, video của bạn có chất lượng quay tốt đó.",
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
id: "pose",
|
| 119 |
+
status: "done",
|
| 120 |
+
text: "Tôi đã phân tích xong dáng tập và các điểm landmark chính.",
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
id: "exercise",
|
| 124 |
+
status: "done",
|
| 125 |
+
text: `Chúng tôi nhận thấy bạn đang thực hiện ${exercise}.`,
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
id: "reps",
|
| 129 |
+
status: "done",
|
| 130 |
+
text: `Tôi đếm được bạn đã thực hiện ${repCount} reps ${exercise}.`,
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
id: "issues",
|
| 134 |
+
status: "done",
|
| 135 |
+
text: issues.length
|
| 136 |
+
? `Tôi tìm thấy ${issues.length} điểm cần chú ý trong khi tập của bạn.`
|
| 137 |
+
: "Tôi không thấy lỗi kỹ thuật rõ ràng trong bài tập này.",
|
| 138 |
+
},
|
| 139 |
+
];
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function ProgressPanel({ steps }) {
|
| 143 |
+
if (!steps.length) return null;
|
| 144 |
+
return h(
|
| 145 |
+
"section",
|
| 146 |
+
{ className: "progress-panel", "aria-live": "polite" },
|
| 147 |
+
h("h3", null, "Analysis progress"),
|
| 148 |
+
h(
|
| 149 |
+
"ol",
|
| 150 |
+
{ className: "progress-list" },
|
| 151 |
+
steps.map((step) =>
|
| 152 |
+
h(
|
| 153 |
+
"li",
|
| 154 |
+
{ className: `progress-step ${step.status}`, key: step.id },
|
| 155 |
+
h("span", { className: "progress-dot", "aria-hidden": "true" }),
|
| 156 |
+
h("span", null, step.text),
|
| 157 |
+
),
|
| 158 |
+
),
|
| 159 |
+
),
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
function SummaryTab({ result }) {
|
| 164 |
if (!result) {
|
| 165 |
return h(
|
|
|
|
| 634 |
const [activeTab, setActiveTab] = useState("summary");
|
| 635 |
const [status, setStatus] = useState("idle");
|
| 636 |
const [error, setError] = useState("");
|
| 637 |
+
const [progressSteps, setProgressSteps] = useState([]);
|
| 638 |
+
const progressTimers = useRef([]);
|
| 639 |
|
| 640 |
const previewUrl = useMemo(
|
| 641 |
() => (file ? URL.createObjectURL(file) : ""),
|
|
|
|
| 655 |
};
|
| 656 |
}, [previewUrl]);
|
| 657 |
|
| 658 |
+
useEffect(() => {
|
| 659 |
+
return () => {
|
| 660 |
+
progressTimers.current.forEach((timer) => clearTimeout(timer));
|
| 661 |
+
};
|
| 662 |
+
}, []);
|
| 663 |
+
|
| 664 |
+
function clearProgressTimers() {
|
| 665 |
+
progressTimers.current.forEach((timer) => clearTimeout(timer));
|
| 666 |
+
progressTimers.current = [];
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
function startProgressTimeline() {
|
| 670 |
+
clearProgressTimers();
|
| 671 |
+
setProgressSteps(runningProgressState(0));
|
| 672 |
+
progressTimers.current = runningProgressSteps
|
| 673 |
+
.slice(1)
|
| 674 |
+
.map((step, index) =>
|
| 675 |
+
setTimeout(() => {
|
| 676 |
+
setProgressSteps(runningProgressState(index + 1));
|
| 677 |
+
}, step.delayMs),
|
| 678 |
+
);
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
function toggleLimitation(value) {
|
| 682 |
setLimitations((current) =>
|
| 683 |
current.includes(value)
|
|
|
|
| 691 |
setError("");
|
| 692 |
setStatus("running");
|
| 693 |
setResult(null);
|
| 694 |
+
startProgressTimeline();
|
| 695 |
|
| 696 |
const payload = new FormData();
|
| 697 |
if (file) payload.append("video", file);
|
|
|
|
| 709 |
});
|
| 710 |
const body = await response.json();
|
| 711 |
if (!response.ok) throw new Error(body.detail || "Analysis failed.");
|
| 712 |
+
clearProgressTimers();
|
| 713 |
setResult(body);
|
| 714 |
+
setProgressSteps(finalProgressState(body));
|
| 715 |
setStatus("complete");
|
| 716 |
} catch (caught) {
|
| 717 |
+
clearProgressTimers();
|
| 718 |
setError(caught.message || "Analysis failed.");
|
| 719 |
+
setProgressSteps([
|
| 720 |
+
{
|
| 721 |
+
id: "error",
|
| 722 |
+
status: "active",
|
| 723 |
+
text: "Phân tích chưa hoàn tất. Vui lòng thử lại với video khác hoặc kiểm tra kết nối.",
|
| 724 |
+
},
|
| 725 |
+
]);
|
| 726 |
setStatus("idle");
|
| 727 |
}
|
| 728 |
}
|
|
|
|
| 875 |
status === "running" ? "Analyzing..." : "Analyze Form",
|
| 876 |
),
|
| 877 |
error ? h("div", { className: "error" }, error) : null,
|
| 878 |
+
h(ProgressPanel, { steps: progressSteps }),
|
| 879 |
),
|
| 880 |
h(
|
| 881 |
"section",
|
web/styles.css
CHANGED
|
@@ -339,6 +339,66 @@ input[type="text"]:focus,
|
|
| 339 |
opacity: 0.72;
|
| 340 |
}
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
.result-stage {
|
| 343 |
display: grid;
|
| 344 |
min-height: 470px;
|
|
|
|
| 339 |
opacity: 0.72;
|
| 340 |
}
|
| 341 |
|
| 342 |
+
.progress-panel {
|
| 343 |
+
border: 1px solid rgba(37, 44, 31, 0.12);
|
| 344 |
+
border-radius: 8px;
|
| 345 |
+
margin-top: 14px;
|
| 346 |
+
padding: 14px;
|
| 347 |
+
background: rgba(255, 253, 247, 0.66);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.progress-panel h3 {
|
| 351 |
+
margin: 0 0 12px;
|
| 352 |
+
color: var(--forest);
|
| 353 |
+
font-size: 0.96rem;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.progress-list {
|
| 357 |
+
display: grid;
|
| 358 |
+
gap: 10px;
|
| 359 |
+
margin: 0;
|
| 360 |
+
padding: 0;
|
| 361 |
+
list-style: none;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.progress-step {
|
| 365 |
+
display: grid;
|
| 366 |
+
grid-template-columns: 18px minmax(0, 1fr);
|
| 367 |
+
gap: 10px;
|
| 368 |
+
align-items: start;
|
| 369 |
+
color: var(--muted);
|
| 370 |
+
line-height: 1.35;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.progress-step.done {
|
| 374 |
+
color: #304133;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.progress-step.active {
|
| 378 |
+
color: var(--forest-deep);
|
| 379 |
+
font-weight: 600;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.progress-dot {
|
| 383 |
+
width: 10px;
|
| 384 |
+
height: 10px;
|
| 385 |
+
border: 2px solid rgba(33, 79, 58, 0.3);
|
| 386 |
+
border-radius: 999px;
|
| 387 |
+
margin-top: 4px;
|
| 388 |
+
background: rgba(255, 253, 247, 0.9);
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.progress-step.done .progress-dot {
|
| 392 |
+
border-color: var(--forest);
|
| 393 |
+
background: var(--forest);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.progress-step.active .progress-dot {
|
| 397 |
+
border-color: var(--clay);
|
| 398 |
+
background: var(--clay);
|
| 399 |
+
box-shadow: 0 0 0 5px rgba(190, 104, 75, 0.14);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
.result-stage {
|
| 403 |
display: grid;
|
| 404 |
min-height: 470px;
|