stoi-analyzer / index.html
YeeThree's picture
Upload index.html
52ba5fa verified
Raw
History Blame Contribute Delete
35.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stoi · Skill Analyzer</title>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose@0.5.1675469404/pose.js" crossorigin="anonymous"></script>
<script type="module">
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
window.GradioClient = Client;
</script>
<style>
:root{
--bg:#0a0a0b;--panel:#141416;--panel-2:#1c1c1f;--line:#2a2a2e;
--txt:#f2f2f0;--muted:#8a8a8f;--accent:#3da9fc;--good:#46d39a;--warn:#f5a623;--bad:#ff5a5a;--gold:#ffd24a;
--mono:ui-monospace,'SF Mono','Cascadia Mono','Roboto Mono',Menlo,monospace;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Roboto,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg);color:var(--txt);font-family:var(--sans);-webkit-font-smoothing:antialiased}
body{min-height:100vh;padding:24px;max-width:1280px;margin:0 auto}
header{display:flex;align-items:baseline;gap:14px;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:20px;flex-wrap:wrap}
header h1{font-size:18px;font-weight:600;letter-spacing:-0.02em}
header .tag{font-family:var(--mono);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em}
header .status{margin-left:auto;font-family:var(--mono);font-size:12px;color:var(--muted)}
header .status b{color:var(--accent)}
.grid{display:grid;grid-template-columns:1fr 330px;gap:20px;align-items:start}
@media(max-width:880px){.grid{grid-template-columns:1fr}}
.stage{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;position:relative}
.canvas-wrap{position:relative;width:100%;aspect-ratio:16/9;background:#000;display:flex;align-items:center;justify-content:center}
video{display:none}
canvas{max-width:100%;max-height:100%;display:block}
.placeholder{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;color:var(--muted);text-align:center;padding:30px}
.placeholder p{font-size:13px;max-width:340px;line-height:1.5}
.prog{position:absolute;left:0;right:0;bottom:0;height:4px;background:transparent}
.prog>div{height:100%;width:0;background:var(--accent);transition:width .1s}
.controls{display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--line);background:var(--panel-2);flex-wrap:wrap}
.btn{font-family:var(--sans);font-size:13px;font-weight:500;color:var(--txt);background:#262629;border:1px solid var(--line);border-radius:7px;padding:8px 14px;cursor:pointer;transition:.12s}
.btn:hover:not(:disabled){background:#303034;border-color:#3a3a3e}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#04121f}
.btn.primary:hover:not(:disabled){filter:brightness(1.08)}
.btn.go{background:var(--good);border-color:var(--good);color:#04201a}
.scrub{flex:1;min-width:140px;display:flex;align-items:center;gap:10px}
.scrub input[type=range]{flex:1;accent-color:var(--accent);height:4px}
.scrub .time{font-family:var(--mono);font-size:11px;color:var(--muted);min-width:88px;text-align:right}
.file-row{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
input[type=file]{display:none}
select{background:var(--panel-2);color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:8px 10px;font-family:var(--sans);font-size:13px}
.model-sel{font-family:var(--mono);font-size:11px;color:var(--muted);display:flex;align-items:center;gap:6px}
.model-sel select{font-family:var(--mono);font-size:11px;padding:5px 8px}
.side{display:flex;flex-direction:column;gap:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px}
.card h2{font-size:11px;text-transform:uppercase;letter-spacing:0.09em;color:var(--muted);font-weight:600;margin-bottom:14px;font-family:var(--mono)}
.verdict{padding:14px;border-radius:8px;text-align:center;border:1px solid var(--line);background:var(--panel-2)}
.verdict .big{font-family:var(--mono);font-size:26px;font-weight:800;line-height:1}
.verdict .sub{font-size:11px;color:var(--muted);margin-top:6px;text-transform:uppercase;letter-spacing:.07em}
.verdict.ok{border-color:rgba(70,211,154,.5)}.verdict.ok .big{color:var(--good)}
.verdict.mid{border-color:rgba(245,166,35,.5)}.verdict.mid .big{color:var(--warn)}
.verdict.bad{border-color:rgba(255,90,90,.5)}.verdict.bad .big{color:var(--bad)}
.lines{margin-top:12px;display:flex;flex-direction:column;gap:8px}
.lrow{display:flex;align-items:center;justify-content:space-between;font-size:13px;padding:8px 10px;background:var(--panel-2);border:1px solid var(--line);border-radius:7px}
.lrow .k{color:var(--muted)}
.lrow .v{font-family:var(--mono);font-weight:700}
.lrow .v.g{color:var(--good)}.lrow .v.w{color:var(--warn)}.lrow .v.b{color:var(--bad)}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}
.mini{padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;text-align:center}
.mini .v{font-family:var(--mono);font-size:15px;font-weight:700}
.mini .l{font-size:10px;color:var(--muted);margin-top:3px;text-transform:uppercase;letter-spacing:.05em}
.tune{margin-top:14px}
.tune label{font-size:11px;color:var(--muted);display:flex;justify-content:space-between;font-family:var(--mono)}
.tune input[type=range]{width:100%;accent-color:var(--accent);margin-top:6px}
.empty-note{font-size:12px;color:var(--muted);line-height:1.55;margin-top:10px}
.legend{font-size:11px;color:var(--muted);line-height:1.6}
.legend .sw{display:inline-block;width:18px;height:0;border-top:2px solid;vertical-align:middle;margin-right:6px}
.log{margin-top:18px;background:#0c0c0d;border:1px solid var(--line);border-radius:8px;font-family:var(--mono);font-size:11px;color:var(--muted);padding:10px 12px;max-height:130px;overflow:auto;white-space:pre-wrap;line-height:1.5}
.log .ok{color:var(--good)}.log .err{color:var(--bad)}.log .warn{color:var(--warn)}
</style>
</head>
<body>
<header>
<h1>Stoi · Skill Analyzer</h1>
<span class="tag">v4.0 / server (HF Space)</span>
<div class="status" id="status">starting…</div>
</header>
<div class="file-row">
<label class="btn primary" for="file">Load video</label>
<input type="file" id="file" accept="video/*">
<label class="btn" for="kpFile">Load keypoints</label>
<input type="file" id="kpFile" accept="application/json,.json">
<button class="btn primary" id="serverBtn">Run on server</button>
<input type="text" id="spaceId" value="YeeThree/stoi-judge" title="your Hugging Face Space" style="width:160px;background:#0e1320;border:1px solid #2a3550;color:#cfe0ff;border-radius:8px;padding:6px 8px;font-size:12px">
<select id="skillSel">
<option value="cross">Cross (iron cross)</option>
<option value="planche">Planche</option>
</select>
<button class="btn go" id="analyzeBtn" disabled>Analyze Skill</button>
<div class="model-sel">model
<select id="modelSel"><option value="2" selected>heavy</option><option value="1">full</option><option value="0">lite</option></select>
</div>
</div>
<div class="grid">
<div class="stage">
<div class="canvas-wrap">
<video id="video" playsinline muted></video>
<canvas id="canvas"></canvas>
<div class="placeholder" id="placeholder"><p>Pick the skill, load a clip, hit Analyze Skill. You get a scrubable skeleton replay, then the list of deductions it finds.</p></div>
<div class="prog"><div id="progBar"></div></div>
</div>
<div class="controls">
<button class="btn" id="playBtn" disabled>Play</button>
<button class="btn" id="stepBack" disabled>‹ frame</button>
<button class="btn" id="stepFwd" disabled>frame ›</button>
<div class="scrub"><input type="range" id="scrub" min="0" max="1000" value="0" disabled><span class="time" id="time">0.00 / 0.00</span></div>
</div>
</div>
<div class="side">
<div class="card">
<h2>Deductions</h2>
<div id="dedEmpty" class="empty-note">Load a skill, then hit Analyze Skill.</div>
<div id="dedResult" style="display:none">
<div class="verdict" id="verdict"><div class="big" id="vBig"></div><div class="sub" id="vSub"></div></div>
<div class="lines" id="dedList"></div>
</div>
<div class="tune">
<label>hold band (max wobble) <span id="stillVal"></span></label>
<input type="range" id="stillSlider" min="1" max="10" value="3">
<div class="empty-note">Held = the key angle stays inside this many degrees for the window. Re-run after changing.</div>
</div>
</div>
<div class="card">
<h2>Live frame</h2>
<div class="row2">
<div class="mini"><div class="v" id="mA"></div><div class="l" id="lA"></div></div>
<div class="mini"><div class="v" id="mB"></div><div class="l" id="lB"></div></div>
</div>
<div class="empty-note">Scrub anywhere to read one frame.</div>
</div>
<div class="card">
<h2>Overlay key</h2>
<div class="legend">
<span class="sw" style="border-color:var(--gold)"></span>reference line<br>
<span class="sw" style="border-color:#fff"></span>your body / arm line<br>
<span class="sw" style="border-color:var(--bad)"></span>measured angle arc
</div>
</div>
</div>
</div>
<div class="log" id="log"></div>
<script>
(function(){
"use strict";
var logEl=document.getElementById('log');
function log(m,cls){var d=document.createElement('div');if(cls)d.className=cls;d.textContent=m;logEl.appendChild(d);logEl.scrollTop=logEl.scrollHeight;}
window.onerror=function(m,s,l){log('JS ERROR: '+m+' (line '+l+')','err');};
log('script started','ok');
var el={};['status','file','kpFile','serverBtn','spaceId','skillSel','analyzeBtn','modelSel','video','canvas','placeholder','playBtn','stepBack','stepFwd','scrub','time','progBar','vBig','vSub','verdict','dedEmpty','dedResult','dedList','stillSlider','stillVal','mA','mB','lA','lB'].forEach(function(id){el[id]=document.getElementById(id);});
el.play=el.playBtn;el.analyze=el.analyzeBtn;
var ctx=el.canvas.getContext('2d');
var IDX={lsh:11,rsh:12,lel:13,rel:14,lwr:15,rwr:16,lhip:23,rhip:24,lkne:25,rkne:26,lank:27,rank:28};
var BONES=[[11,12],[11,13],[13,15],[12,14],[14,16],[11,23],[12,24],[23,24],[23,25],[25,27],[24,26],[26,28]];
var SNAP_PTS=[11,12,13,14,15,16,23,24,25,26,27,28];
var HOLD_REQ=2.0, MIN_STOP=0.15;
function holdBand(){return parseFloat(el.stillSlider.value);}
// smooth (EMA) then only commit the shown number ~2x/sec so it holds steady and is readable
var smooth={},disp={},lastCommit=0;
function feed(id,raw,a){a=a||0.16;if(raw!=null&&!isNaN(raw)){if(smooth[id]==null)smooth[id]=raw;else smooth[id]+=a*(raw-smooth[id]);}return smooth[id];}
function commitNumbers(){var now=(window.performance&&performance.now)?performance.now():Date.now();if(now-lastCommit>=450){lastCommit=now;for(var k in smooth)disp[k]=smooth[k];}}
function shown(id){return disp[id]!=null?disp[id]:null;}
function resetSmooth(){smooth={};disp={};lastCommit=0;}
// bridge dropout frames: keep the last confident position of each joint for a few frames
var frameCounter=0,holdStore={},HOLD_FRAMES=8;
function fillLandmarks(lm){
if(!lm)return lm;frameCounter++;
for(var i=0;i<lm.length;i++){
var v=lm[i]&&lm[i].visibility;
if(v>0.5){holdStore[i]={x:lm[i].x,y:lm[i].y,f:frameCounter};}
else if(holdStore[i]&&frameCounter-holdStore[i].f<=HOLD_FRAMES){lm[i]={x:holdStore[i].x,y:holdStore[i].y,visibility:0.55};}
}
return lm;
}
function resetHold(){holdStore={};}
// ---- precomputed keypoints (from the YOLO11 Colab export) ----
var kpData=null,useKP=false;
function kpFrameToLm(f){var a=new Array(33),lm=f.lm;for(var i=0;i<33;i++){var p=lm[i];a[i]=p?{x:p[0],y:p[1],visibility:p[2]}:undefined;}return a;}
// temporal smoothing of joint POSITIONS so the dense data stops strobing live
var lmEma=null;
function smoothLm(lm,a){a=a||0.25;if(!lm)return lm;
if(!lmEma){lmEma=lm.map(function(p){return p?{x:p.x,y:p.y,visibility:p.visibility}:undefined;});return lmEma;}
for(var i=0;i<lm.length;i++){
if(lm[i]){if(lmEma[i]){lmEma[i].x+=a*(lm[i].x-lmEma[i].x);lmEma[i].y+=a*(lm[i].y-lmEma[i].y);lmEma[i].visibility=lm[i].visibility;}else lmEma[i]={x:lm[i].x,y:lm[i].y,visibility:lm[i].visibility};}
else lmEma[i]=undefined;
}
return lmEma;
}
function resetLmEma(){lmEma=null;}
// one-time centered moving-average over the whole clip — clean, lag-free, kills per-frame jitter
function smoothSequence(frames,win){
win=win||4;var n=frames.length,out=new Array(n);
for(var i=0;i<n;i++){
var lm=new Array(33);
for(var j=0;j<33;j++){
var sx=0,sy=0,sv=0,c=0;
for(var k=-win;k<=win;k++){var idx=i+k;if(idx<0||idx>=n)continue;var p=frames[idx].lm[j];if(p){sx+=p[0];sy+=p[1];sv+=p[2];c++;}}
lm[j]=c?[sx/c,sy/c,sv/c]:null;
}
out[i]={t:frames[i].t,lm:lm};
}
return out;
}
function kpAt(t){var fr=kpData.frames,n=fr.length;if(!n)return null;var lo=0,hi=n-1;while(lo<hi){var m=(lo+hi)>>1;if(fr[m].t<t)lo=m+1;else hi=m;}var i=lo;if(i>0&&Math.abs(fr[i-1].t-t)<Math.abs(fr[i].t-t))i--;return fr[i];}
function renderFrame(){
if(useKP&&kpData){
if(el.video.videoWidth)resize();else{el.canvas.width=kpData.width;el.canvas.height=kpData.height;}
var f=kpAt(el.video.currentTime||0),lm=f?kpFrameToLm(f):null;draw(lm);liveReadout(lm);
} else { process(); }
}
function V(o,i){return o&&o[i]&&((o[i].v!=null?o[i].v:o[i].visibility)>0.4);}
function pxAngle(vtx,p1,p2,W,H){
var ax=(p1.x-vtx.x)*W,ay=(p1.y-vtx.y)*H,bx=(p2.x-vtx.x)*W,by=(p2.y-vtx.y)*H;
var ma=Math.hypot(ax,ay),mb=Math.hypot(bx,by);if(!ma||!mb)return null;
return Math.acos(Math.max(-1,Math.min(1,(ax*bx+ay*by)/(ma*mb))))*180/Math.PI;
}
// signed tilt of line a->b vs horizontal (+ = b lower than a)
function tiltH(a,b,W,H){return Math.atan2((b.y-a.y)*H,Math.abs((b.x-a.x)*W))*180/Math.PI;}
function mid(o,i,j){return {x:(o[i].x+o[j].x)/2,y:(o[i].y+o[j].y)/2};}
// best far-leg point: ankle only if it's anatomically attached to the knee, else fall back to knee
function legEnd(o,kneeI,ankI,hipI,W,H){
if(V(o,ankI)&&V(o,kneeI)&&V(o,hipI)){
var thigh=Math.hypot((o[kneeI].x-o[hipI].x)*W,(o[kneeI].y-o[hipI].y)*H);
var shin=Math.hypot((o[ankI].x-o[kneeI].x)*W,(o[ankI].y-o[kneeI].y)*H);
if(thigh>0&&shin>0.4*thigh&&shin<2.2*thigh)return o[ankI];
return o[kneeI];
}
if(V(o,kneeI))return o[kneeI];
if(V(o,ankI))return o[ankI];
return null;
}
function feetPt(o,W,H){var le=legEnd(o,25,27,23,W,H),re=legEnd(o,26,28,24,W,H);if(!le&&!re)return null;return (le&&re)?{x:(le.x+re.x)/2,y:(le.y+re.y)/2}:(le||re);}
function shoulderW(o,W,H){return V(o,11)&&V(o,12)?Math.hypot((o[12].x-o[11].x)*W,(o[12].y-o[11].y)*H):0;}
function drawArc(wx,wy,aFrom,aTo,r,color,W){
var d=aTo-aFrom;while(d>Math.PI)d-=2*Math.PI;while(d<-Math.PI)d+=2*Math.PI;
ctx.strokeStyle=color;ctx.lineWidth=Math.max(2.4,W/360);ctx.beginPath();
for(var t=0;t<=1.0001;t+=0.04){var a=aFrom+d*t,px=wx+r*Math.cos(a),py=wy+r*Math.sin(a);if(t===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);}
ctx.stroke();return aFrom+d*0.5;
}
function label(txt,x,y){ctx.fillStyle='#fff';ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(txt,x,y);ctx.textAlign='start';ctx.textBaseline='alphabetic';}
// ===================== SKILLS =====================
var SKILLS={
cross:{
label:'Cross', angleLabel:'Cross angle', holdMax:55, liveA:'left arm', liveB:'right arm', dedFn:angleDed, devWord:'off',
gate:function(o,W,H){
if(!V(o,11)||!V(o,12)||!V(o,15)||!V(o,16))return false;
var sw=shoulderW(o,W,H);if(!sw)return false;
var shY=(o[11].y+o[12].y)/2,cx=(o[11].x+o[12].x)/2;
var lH=Math.abs(o[15].y-shY)*H<0.7*sw,rH=Math.abs(o[16].y-shY)*H<0.7*sw;
var lA=Math.abs(o[15].x-cx)>Math.abs(o[11].x-cx)*0.9,rA=Math.abs(o[16].x-cx)>Math.abs(o[12].x-cx)*0.9;
return lH&&rH&&lA&&rA;
},
metrics:function(o,W,H){
if(!V(o,11)||!V(o,12)||!V(o,15)||!V(o,16))return null;
var L=pxAngle(o[15],o[11],o[16],W,H),R=pxAngle(o[16],o[12],o[15],W,H);
if(L==null||R==null)return null;
return {hold:(L+R)/2,a:Math.round(L)+'°',b:Math.round(R)+'°',devs:[{label:'Cross angle',val:Math.max(L,R),table:'pos'}]};
},
draw:function(o,W,H){
var lwx=o[15].x*W,lwy=o[15].y*H,rwx=o[16].x*W,rwy=o[16].y*H,dx=rwx-lwx,dy=rwy-lwy,ext=0.05;
ctx.save();ctx.setLineDash([10,8]);ctx.strokeStyle='rgba(255,210,74,0.95)';ctx.lineWidth=Math.max(1.8,W/520);
ctx.beginPath();ctx.moveTo(lwx-dx*ext,lwy-dy*ext);ctx.lineTo(rwx+dx*ext,rwy+dy*ext);ctx.stroke();ctx.restore();
ctx.font='800 '+Math.max(18,W/46)+'px -apple-system,sans-serif';
[['lsh','lwr','rwr'],['rsh','rwr','lwr']].forEach(function(a){
var sh=o[IDX[a[0]]],wr=o[IDX[a[1]]],other=o[IDX[a[2]]],sx=sh.x*W,sy=sh.y*H,wx=wr.x*W,wy=wr.y*H;
ctx.strokeStyle='rgba(255,255,255,0.97)';ctx.lineWidth=Math.max(2.6,W/340);ctx.beginPath();ctx.moveTo(wx,wy);ctx.lineTo(sx,sy);ctx.stroke();
var aw=Math.atan2((sh.y-wr.y)*H,(sh.x-wr.x)*W),ay=Math.atan2((other.y-wr.y)*H,(other.x-wr.x)*W);
var armLen=Math.hypot(sx-wx,sy-wy),r=Math.max(28,armLen*0.34);
var amid=drawArc(wx,wy,ay,aw,r,'rgba(255,90,90,0.95)',W);
var deg=pxAngle(wr,sh,other,W,H);feed('cross_'+a[1],deg);var sdeg=shown('cross_'+a[1]);
label((sdeg==null?Math.round(deg):Math.round(sdeg))+'°',wx+(r+Math.max(20,W/60))*Math.cos(amid),wy+(r+Math.max(20,W/60))*Math.sin(amid));
});
}
},
planche:{
label:'Planche', angleLabel:'Body', holdMax:45, liveA:'body straight', liveB:'body level',
gate:function(o,W,H){
if(!V(o,11)||!V(o,12)||!V(o,23)||!V(o,24)||!V(o,27)||!V(o,28))return false;
var sw=shoulderW(o,W,H);if(!sw)return false;
var ms=mid(o,11,12),ma=mid(o,27,28);
var levelish=Math.abs((ma.y-ms.y))*H<1.4*sw;
var support=(o[15]&&o[16])?((o[15].y+o[16].y)/2 > ms.y):true;
return levelish&&support;
},
metrics:function(o,W,H){
if(!V(o,11)||!V(o,12)||!V(o,23)||!V(o,24))return null; // torso only = hold survives lost feet
var ms=mid(o,11,12),mh=mid(o,23,24);
var torsoTilt=tiltH(ms,mh,W,H);
var ma=feetPt(o,W,H);
if(!ma)return {hold:torsoTilt,a:'—',b:'—',devs:[]};
var level=Math.abs(tiltH(ma,ms,W,H)); // feet->shoulder line vs floor
var bend=pxAngle(ms,mh,ma,W,H); // hip off that line, measured at the shoulder (long baseline)
if(bend==null)bend=0;
return {hold:torsoTilt,a:Math.round(bend)+'°',b:Math.round(level)+'°',
devs:[{label:'Body level',val:level,table:'pos'},{label:'Body straight',val:bend,table:'bend'}]};
},
draw:function(o,W,H){
if(!V(o,11)||!V(o,12)||!V(o,23)||!V(o,24))return;
var ms=mid(o,11,12),mh=mid(o,23,24),sx=ms.x*W,sy=ms.y*H,hx=mh.x*W,hy=mh.y*H;
var ma=feetPt(o,W,H);
if(!ma){ctx.strokeStyle='rgba(255,255,255,0.6)';ctx.lineWidth=Math.max(2,W/360);ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(hx,hy);ctx.stroke();return;}
var ax=ma.x*W,ay=ma.y*H;
// feet -> shoulder line (white)
ctx.strokeStyle='rgba(255,255,255,0.97)';ctx.lineWidth=Math.max(2.8,W/320);
ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(sx,sy);ctx.stroke();
// actual path through the hip (cyan, shows the kink)
ctx.strokeStyle='rgba(120,200,255,0.8)';ctx.lineWidth=Math.max(1.8,W/520);
ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(hx,hy);ctx.lineTo(ax,ay);ctx.stroke();
// horizontal floor reference at the shoulder
var dir=ax>=sx?1:-1,len=Math.abs(ax-sx)||shoulderW(o,W,H)*2;
ctx.save();ctx.setLineDash([10,8]);ctx.strokeStyle='rgba(255,210,74,0.95)';ctx.lineWidth=Math.max(1.8,W/520);
ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(sx+dir*len,sy);ctx.stroke();ctx.restore();
ctx.font='800 '+Math.max(16,W/50)+'px -apple-system,sans-serif';
var level=Math.round(Math.abs(tiltH(ma,ms,W,H))),bend=Math.round(pxAngle(ms,mh,ma,W,H)||0);
feed('pl_level',level);feed('pl_bend',bend);var sl=shown('pl_level'),sb2=shown('pl_bend');
var aRef=Math.atan2(0,dir),aBody=Math.atan2((ma.y-ms.y)*H,(ma.x-ms.x)*W);
var r=Math.max(34,len*0.26),amid=drawArc(sx,sy,aRef,aBody,r,'rgba(255,90,90,0.95)',W);
label('level '+(sl==null?level:Math.round(sl))+'°',sx+(r+Math.max(34,W/34))*Math.cos(amid),sy+(r+Math.max(34,W/34))*Math.sin(amid));
label('bend '+(sb2==null?bend:Math.round(sb2))+'°',hx,hy-Math.max(22,W/34));
}
}
};
var cur='cross';
function angleDed(dev){if(dev<=5)return{ded:'0.00',kind:'g',nr:false};if(dev<=20)return{ded:'0.10',kind:'w',nr:false};if(dev<=45)return{ded:'0.30',kind:'w',nr:false};return{ded:'0.50',kind:'b',nr:true};}
// body/arm bending table: ~free under 7 (noise), then 0.10 / 0.30 / 0.50 / NR
function bendDed(dev){if(dev<=7)return{ded:'0.00',kind:'g',nr:false};if(dev<=15)return{ded:'0.10',kind:'w',nr:false};if(dev<=30)return{ded:'0.30',kind:'w',nr:false};if(dev<=45)return{ded:'0.50',kind:'b',nr:false};return{ded:'0.50',kind:'b',nr:true};}
function renderDeductions(items,recognized){
el.dedEmpty.style.display='none';el.dedResult.style.display='block';
var total=0;items.forEach(function(it){total+=parseFloat(it.ded)||0;});
el.vBig.textContent='-'+total.toFixed(2);
el.verdict.className='verdict '+(!recognized?'bad':(total<=0?'ok':'mid'));
el.vSub.textContent=!recognized?'not recognized':(total<=0?'clean':'execution deductions');
el.dedList.innerHTML='';
items.forEach(function(it){
var row=document.createElement('div');row.className='lrow';
var k=document.createElement('span');k.className='k';k.innerHTML=it.label+'<br><span style="font-size:11px;opacity:.65">'+it.detail+'</span>';
var v=document.createElement('span');v.className='v '+it.kind;v.textContent=(parseFloat(it.ded)>0?'-':'')+it.ded+(it.nr?' NR':'');
row.appendChild(k);row.appendChild(v);el.dedList.appendChild(row);
});
}
function applySkillLabels(){el.lA.textContent=SKILLS[cur].liveA;el.lB.textContent=SKILLS[cur].liveB;}
function resize(){var w=el.video.videoWidth,h=el.video.videoHeight;if(!w||!h)return;el.canvas.width=w;el.canvas.height=h;}
function drawBase(){var w=el.canvas.width,h=el.canvas.height;if(!w||!h)return;ctx.clearRect(0,0,w,h);try{ctx.drawImage(el.video,0,0,w,h);}catch(e){}}
function draw(lm){
drawBase();commitNumbers();var W=el.canvas.width,H=el.canvas.height;if(!lm)return;
ctx.lineWidth=Math.max(2,W/360);ctx.strokeStyle="rgba(61,169,252,0.7)";
for(var i=0;i<BONES.length;i++){var a=BONES[i][0],b=BONES[i][1];if(!V(lm,a)||!V(lm,b))continue;ctx.beginPath();ctx.moveTo(lm[a].x*W,lm[a].y*H);ctx.lineTo(lm[b].x*W,lm[b].y*H);ctx.stroke();}
for(var j in IDX){var idx=IDX[j];if(!V(lm,idx))continue;ctx.beginPath();ctx.arc(lm[idx].x*W,lm[idx].y*H,Math.max(3,W/200),0,Math.PI*2);ctx.fillStyle=lm[idx].visibility>0.7?"#46d39a":"#f5a623";ctx.fill();}
try{ if(SKILLS[cur].gate(lm,W,H)||SKILLS[cur].metrics(lm,W,H)) SKILLS[cur].draw(lm,W,H); }catch(e){}
}
function liveReadout(lm){
var W=el.canvas.width,H=el.canvas.height,m=lm?SKILLS[cur].metrics(lm,W,H):null;
var av=m?parseFloat(m.a):NaN,bv=m?parseFloat(m.b):NaN;
feed('liveA',isNaN(av)?null:av);feed('liveB',isNaN(bv)?null:bv);
var sa=shown('liveA'),sb=shown('liveB');
el.mA.textContent=sa==null?'—':Math.round(sa)+'°';el.mB.textContent=sb==null?'—':Math.round(sb)+'°';
}
// ===================== POSE =====================
var pose=null,poseReady=false,latest=null,busy=false,analyzing=false;
function initPose(){
try{
pose=new Pose({locateFile:function(f){return 'https://cdn.jsdelivr.net/npm/@mediapipe/pose@0.5.1675469404/'+f;}});
pose.setOptions({modelComplexity:parseInt(el.modelSel.value,10),smoothLandmarks:true,enableSegmentation:false,minDetectionConfidence:0.3,minTrackingConfidence:0.3});
pose.onResults(function(r){latest=(r.poseLandmarks&&r.poseLandmarks.length)?fillLandmarks(r.poseLandmarks):null;draw(latest);liveReadout(latest);});
poseReady=true;el.status.innerHTML='<b>ready</b>';log('initialized ✓','ok');
}catch(e){log('init error: '+(e.message||e),'err');}
}
function waitForPose(t){if(typeof Pose!=='undefined'){initPose();return;}if(t<=0){el.status.innerHTML='<span style="color:var(--bad)">library blocked</span>';log('POSE LIBRARY FAILED. disable adblock or use hotspot','err');return;}setTimeout(function(){waitForPose(t-1);},250);}
function process(){if(busy||!poseReady||!el.video.videoWidth)return Promise.resolve();busy=true;resize();return pose.send({image:el.video}).catch(function(e){log('send err: '+(e.message||e),'warn');}).finally(function(){busy=false;});}
function updateTime(){var c=el.video.currentTime||0,d=el.video.duration||0;el.time.textContent=c.toFixed(2)+' / '+d.toFixed(2);if(d)el.scrub.value=Math.round(c/d*1000);}
// ===================== ANALYZE =====================
function seekTo(t){return new Promise(function(res){var done=false;function fin(){if(done)return;done=true;el.video.removeEventListener('seeked',on);res();}function on(){fin();}el.video.addEventListener('seeked',on);el.video.currentTime=t;setTimeout(fin,500);});}
function snap(lm){if(!lm)return null;var o={};SNAP_PTS.forEach(function(i){o[i]=lm[i]?{x:lm[i].x,y:lm[i].y,v:lm[i].visibility}:null;});return o;}
async function runAnalyze(){
if(useKP){ if(!kpData)return; } else if(!poseReady||!el.video.duration){return;}
var sk=SKILLS[cur];
analyzing=true;resetHold();resetSmooth();
el.analyze.disabled=true;['play','stepBack','stepFwd','scrub'].forEach(function(k){el[k].disabled=true;});if(el.video.duration)el.video.pause();
var W,H,frames=[];
el.status.innerHTML='<b>analyzing 0%</b>';
if(useKP&&kpData){
W=kpData.width;H=kpData.height;
frames=kpData.frames.map(function(f){return {t:f.t,s:kpFrameToLm(f)};});
el.progBar.style.width='100%';
} else {
W=el.canvas.width;H=el.canvas.height;var dur=el.video.duration,dt=Math.max(1/30,dur/160);
for(var t=0;t<=dur+1e-6;t+=dt){
var tt=Math.min(t,Math.max(0,dur-0.001));await seekTo(tt);await process();frames.push({t:tt,s:snap(latest)});
var pct=Math.min(100,Math.round(tt/dur*100));el.progBar.style.width=pct+'%';el.status.innerHTML='<b>analyzing '+pct+'%</b>';
}
}
var ang=new Array(frames.length).fill(null),mets=new Array(frames.length).fill(null);
for(var i=0;i<frames.length;i++){var m=sk.metrics(frames[i].s,W,H);mets[i]=m;if(m)ang[i]=m.hold;}
// smooth hold angle
var sm=new Array(frames.length).fill(null);
for(var i2=0;i2<frames.length;i2++){var acc=0,c=0;for(var w=-1;w<=1;w++){var k=i2+w;if(k>=0&&k<frames.length&&ang[k]!=null){acc+=ang[k];c++;}}sm[i2]=c?acc/c:null;}
function cand(i){return sm[i]!=null&&Math.abs(sm[i])<=sk.holdMax;}
var band=holdBand(),MAXGAP=0.25,bestStart=-1,bestEnd=-1,bestDur=0;
for(var sIdx=0;sIdx<frames.length;sIdx++){
if(!cand(sIdx))continue;var mn=sm[sIdx],mx=sm[sIdx],lastGood=sIdx,broke=false;
for(var e=sIdx+1;e<frames.length&&!broke;e++){
if(!cand(e)){if(frames[e].t-frames[lastGood].t>MAXGAP)broke=true;continue;}
var nmn=Math.min(mn,sm[e]),nmx=Math.max(mx,sm[e]);if(nmx-nmn>band){broke=true;break;}
mn=nmn;mx=nmx;lastGood=e;var d=frames[lastGood].t-frames[sIdx].t;if(d>bestDur){bestDur=d;bestStart=sIdx;bestEnd=lastGood;}
}
}
var holdDur=bestDur,found=[],recognized=true;
if(holdDur>=HOLD_REQ)found.push({label:'Hold (2s rule)',ded:'0.00',kind:'g',detail:'held '+holdDur.toFixed(1)+'s'});
else if(holdDur>=MIN_STOP)found.push({label:'Hold (2s rule)',ded:'0.30',kind:'w',detail:'stop '+holdDur.toFixed(1)+'s, under 2s'});
else {found.push({label:'Hold (2s rule)',ded:'0.50',kind:'b',detail:'no complete stop',nr:true});recognized=false;}
var s0=bestStart>=0?bestStart:0,s1=bestEnd>=0?bestEnd:frames.length-1;
var maxDevs=0;for(var di=s0;di<=s1;di++){if(mets[di]&&mets[di].devs)maxDevs=Math.max(maxDevs,mets[di].devs.length);}
for(var j=0;j<maxDevs;j++){
var sum=0,nn=0,lbl=null,table='pos';
for(var i5=s0;i5<=s1;i5++){var dv=mets[i5]&&mets[i5].devs&&mets[i5].devs[j];if(dv){sum+=dv.val;nn++;lbl=dv.label;table=dv.table;}}
if(nn){var avg=sum/nn,ad=(table==='bend'?bendDed:angleDed)(avg);found.push({label:lbl,ded:ad.ded,kind:ad.kind,detail:Math.round(avg)+'°',nr:ad.nr});if(ad.nr)recognized=false;}
}
renderDeductions(found,recognized);
log('skill='+cur+' frames='+frames.length+' hold='+holdDur.toFixed(2)+'s recog='+recognized+' deds='+found.length,'ok');
var parkT=bestStart>=0?frames[Math.floor((bestStart+bestEnd)/2)].t:frames[Math.floor(frames.length/2)].t;
if(el.video.duration){await seekTo(parkT);if(useKP){renderFrame();}else{await process();}}
updateTime();
el.progBar.style.width='0%';el.status.innerHTML='<b>ready</b>';analyzing=false;
el.analyze.disabled=false;['play','stepBack','stepFwd','scrub'].forEach(function(k){el[k].disabled=false;});
}
// ===================== PLAYBACK =====================
var playing=false,lastT=-1;
function loop(){if(!playing)return;if(el.video.currentTime!==lastT&&!busy){lastT=el.video.currentTime;updateTime();renderFrame();}if(el.video.ended){stop();return;}requestAnimationFrame(loop);}
function playV(){if(!el.video.src)return;playing=true;el.play.textContent='Pause';el.video.play();requestAnimationFrame(loop);}
function stop(){playing=false;el.play.textContent='Play';el.video.pause();}
el.file.addEventListener('change',function(e){
var f=e.target.files[0];if(!f)return;log('file: '+f.name,'ok');el.video.src=URL.createObjectURL(f);
el.video.onloadedmetadata=function(){
log('video: '+el.video.videoWidth+'x'+el.video.videoHeight,'ok');
el.placeholder.style.display='none';resize();el.video.currentTime=0;
if(!useKP){el.dedEmpty.style.display='block';el.dedResult.style.display='none';}
['play','stepBack','stepFwd','scrub','analyze'].forEach(function(k){el[k].disabled=false;});
setTimeout(function(){drawBase();updateTime();renderFrame();},80);
try{var c=localStorage.getItem(cacheKey(f));if(c){log('found cached keypoints for this clip — loading instantly (Run on server to refresh)','ok');ingestKeypoints(JSON.parse(c),'cache');}else{log('no cache yet — hit Run on server','ok');}}catch(e){}
};
el.video.onerror=function(){log('VIDEO ERROR: use MP4 (H.264).','err');};
});
// shared: take a keypoints object, smooth it, drive the analyzer, auto-run
function ingestKeypoints(d,src){
if(!d||!d.frames||!d.frames.length){log('keypoints: no frames found','err');return;}
d.frames=smoothSequence(d.frames,4);
kpData=d;useKP=true;
log('keypoints '+(src==='cache'?'(cached) ':'')+'loaded: '+d.frames.length+' frames, '+d.width+'x'+d.height+', '+(d.source||''),'ok');
el.status.innerHTML='<b>keypoints ready</b>';el.placeholder.style.display='none';
el.dedEmpty.style.display='block';el.dedResult.style.display='none';
['analyze','scrub','play','stepBack','stepFwd'].forEach(function(k){el[k].disabled=false;});
renderFrame();runAnalyze();
}
function cacheKey(f){return f?('stoikp:'+f.name+'_'+f.size):null;}
async function runOnServer(force){
var f=el.file.files&&el.file.files[0];
if(!f){log('load a video first, then Run on server','err');return;}
var key=cacheKey(f);
if(!force){try{var c=localStorage.getItem(key);if(c){ingestKeypoints(JSON.parse(c),'cache');return;}}catch(e){}}
if(!window.GradioClient){log('server client still loading — wait a second and click again','err');return;}
var sid=(el.spaceId.value||'').trim();if(!sid){log('set your Space id first','err');return;}
el.serverBtn.disabled=true;el.status.innerHTML='<b>connecting to server…</b>';log('connecting to '+sid,'ok');
try{
var app=await window.GradioClient.connect(sid);
el.status.innerHTML='<b>server analyzing… first run wakes the GPU (~1 min)</b>';log('uploading clip, running pose…','ok');
var res=await app.predict('/predict',[f]);
var txt=res&&res.data&&res.data[0];
if(!txt){log('server returned nothing','err');el.status.innerHTML='<b>server returned nothing</b>';el.serverBtn.disabled=false;return;}
var d=JSON.parse(txt);
try{localStorage.setItem(key,txt);}catch(e){log('clip too big to cache — it will re-run each time','err');}
ingestKeypoints(d,'server');
}catch(err){log('server error: '+err,'err');el.status.innerHTML='<b>server error — check the Space is Running</b>';}
el.serverBtn.disabled=false;
}
el.kpFile.addEventListener('change',function(e){
var f=e.target.files[0];if(!f)return;
var rd=new FileReader();
rd.onload=function(){try{ingestKeypoints(JSON.parse(rd.result),'file');}catch(err){log('keypoints parse error: '+err,'err');}};
rd.readAsText(f);
});
el.serverBtn.addEventListener('click',function(){runOnServer(true);});
el.skillSel.addEventListener('change',function(){cur=el.skillSel.value;applySkillLabels();el.dedEmpty.style.display='block';el.dedResult.style.display='none';if(el.video.src||useKP)renderFrame();log('skill: '+cur,'ok');});
el.analyze.addEventListener('click',function(){runAnalyze();});
el.play.addEventListener('click',function(){playing?stop():playV();});
el.scrub.addEventListener('input',function(){if(!el.video.duration)return;stop();resetSmooth();resetHold();resetLmEma();el.video.currentTime=el.scrub.value/1000*el.video.duration;});
el.video.addEventListener('seeked',function(){if(!playing&&!busy&&!analyzing){resetSmooth();resetHold();resetLmEma();updateTime();renderFrame();}});
el.stepFwd.addEventListener('click',function(){stop();el.video.currentTime=Math.min(el.video.duration,el.video.currentTime+1/30);});
el.stepBack.addEventListener('click',function(){stop();el.video.currentTime=Math.max(0,el.video.currentTime-1/30);});
el.modelSel.addEventListener('change',function(){if(poseReady&&!useKP){pose.setOptions({modelComplexity:parseInt(el.modelSel.value,10)});if(el.video.src)process();}});
el.stillSlider.addEventListener('input',function(){el.stillVal.textContent=el.stillSlider.value+'°';});
document.addEventListener('keydown',function(e){if(el.play.disabled)return;if(e.code==='Space'){e.preventDefault();playing?stop():playV();}if(e.code==='ArrowRight')el.stepFwd.click();if(e.code==='ArrowLeft')el.stepBack.click();});
applySkillLabels();log('UI wired','ok');waitForPose(40);
})();
</script>
</body>
</html>