| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>Kicky AI</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;800&family=Rajdhani:wght@600;700&display=swap" rel="stylesheet"> |
| <style> |
| *{box-sizing:border-box} |
| html,body{height:100%;height:100dvh;margin:0;background:#0e7a3c} |
| body{font-family:Sora,system-ui,sans-serif;color:#eef9f1;background:#0e7a3c;overflow:hidden} |
| .pitch{position:fixed;top:0;left:0;width:100vw;height:100vh;height:100dvh;z-index:0} |
| |
| .app{position:fixed;top:0;left:0;right:0;bottom:0;width:100vw;height:100vh;height:100dvh;z-index:1;display:grid;grid-template-rows:auto 1fr;padding:8px 14px 12px;overflow:hidden} |
| header{flex:0 0 auto;text-align:center;padding-bottom:10px} |
| header h1{margin:0;font-size:clamp(1.4rem,2.6vw,2.1rem);font-weight:800;letter-spacing:-.5px;text-shadow:0 2px 14px #0007} |
| header p{margin:5px auto 0;font-size:.92rem;color:#d6f2e1;max-width:1050px;line-height:1.4} |
| |
| .glass{--side:clamp(300px,23vw,440px);min-height:0;display:grid;grid-template-columns:var(--side) 1px 1fr; |
| background:rgba(4,9,6,.7);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px); |
| border:1px solid rgba(255,255,255,.12);border-radius:18px;overflow:hidden} |
| .divider{background:rgba(255,255,255,.14)} |
| .left{padding:18px;display:flex;flex-direction:column;min-height:0} |
| h3{margin:0 0 10px;font:700 12px Rajdhani;letter-spacing:2px;text-transform:uppercase;color:#9fe3b9} |
| #drop{border:2px dashed rgba(255,255,255,.25);border-radius:14px;padding:36px 14px;text-align:center;cursor:pointer;transition:.15s} |
| #drop:hover,#drop.hot{border-color:#facc15;background:rgba(250,204,21,.08)} |
| #drop .big{font-size:2.6rem} |
| #drop .lbl{color:#cdeedb;margin-top:6px;font-size:.9rem} |
| .go{margin-top:12px;border:0;border-radius:12px;padding:13px;cursor:pointer;font:800 15px Sora;color:#3a2a00; |
| background:#facc15;box-shadow:0 10px 24px -12px #facc15aa;transition:.12s} |
| .go:hover{filter:brightness(1.05)}.go:disabled{opacity:.45;cursor:not-allowed} |
| .modesel{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px} |
| .mode{border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.05);color:#cdeedb;border-radius:10px;padding:9px 6px;font:700 12px Sora;cursor:pointer;transition:.12s} |
| .mode.sel{background:#facc15;color:#3a2a00;border-color:#facc15} |
| .mode:hover{border-color:#facc15} |
| .exwrap{margin-top:16px;display:flex;flex-direction:column;min-height:0;flex:1} |
| #exs{margin-top:8px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;overflow:auto;padding-right:6px;align-content:start} |
| .ex{border-radius:10px;overflow:hidden;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#000;aspect-ratio:16/10} |
| .ex img{width:100%;height:100%;object-fit:cover;display:block} |
| .ex:hover{outline:2px solid #facc15} |
| ::-webkit-scrollbar{width:8px;height:8px} |
| ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2);border-radius:8px} |
| ::-webkit-scrollbar-track{background:transparent} |
| |
| |
| .right{min-height:0;overflow:hidden;display:grid;grid-template-columns:1fr} |
| .right.show{grid-template-columns:1fr var(--side)} |
| |
| #midcol{min-height:0;overflow:hidden;display:grid;grid-template-rows:1fr} |
| .right.show #midcol{grid-template-rows:1.7fr 1fr} |
| #stage{min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:6px 0} |
| #stage video{height:100%;width:auto;max-width:100%;border-radius:10px;display:block} |
| #coachcell{display:none} |
| .right.show #coachcell{display:flex;flex-direction:column;min-height:0;padding:10px 18px;border-top:1px solid rgba(255,255,255,.16)} |
| |
| #sidecol{display:none} |
| .right.show #sidecol{display:grid;grid-template-rows:1.7fr 0.5fr 0.5fr;min-height:0;overflow:hidden;border-left:1px solid rgba(255,255,255,.16)} |
| #posecol{min-height:0;display:flex;flex-direction:column;align-items:center;overflow:hidden;padding:4px 6px} |
| .plabel{font:700 11px Rajdhani;letter-spacing:1.5px;color:#86d8a6;text-transform:uppercase;margin:2px 0 6px} |
| .muted{color:#a7d8bb;font-size:14px;padding:30px;text-align:center} |
| .spin{width:34px;height:34px;border:3px solid #ffffff22;border-top-color:#facc15;border-radius:50%;animation:s .8s linear infinite;margin:0 auto} |
| @keyframes s{to{transform:rotate(360deg)}} |
| |
| .loader{display:flex;flex-direction:column;align-items:center;gap:20px;padding:20px} |
| .spinword{font:700 clamp(17px,2.4vh,26px) Sora;color:#facc15;text-shadow:0 2px 14px #0009;transition:opacity .22s ease;min-height:1.4em;text-align:center;max-width:80%} |
| .poseimg{flex:1 1 0;min-height:0;width:100%;height:100%;object-fit:contain;object-position:center;border-radius:8px} |
| .scell{min-height:0;padding:6px 12px;display:flex;flex-direction:column;overflow:auto;border-top:1px solid rgba(255,255,255,.14)} |
| .scell.speed{justify-content:center;align-items:center} |
| .k{font:700 clamp(10px,1.3vh,14px) Rajdhani;letter-spacing:1.5px;color:#86d8a6;text-transform:uppercase} |
| .v{font:800 clamp(28px,5.2vh,54px) Sora;line-height:1.05;margin-top:2px} |
| .v.sky{color:#38bdf8} |
| .sub{font-size:clamp(12px,1.5vh,16px);color:#9fd3b2;margin-top:2px} |
| .cmp{width:100%;border-collapse:collapse} |
| .cmp th{font:700 clamp(9px,1vh,12px) Rajdhani;letter-spacing:1px;color:#86d8a6;text-transform:uppercase;text-align:center;padding:clamp(4px,1vh,9px) 4px;border-bottom:1px solid rgba(255,255,255,.16)} |
| .cmp td{text-align:center;padding:clamp(5px,1.1vh,11px) 4px;border-bottom:1px solid rgba(255,255,255,.08);font:800 clamp(14px,1.9vh,20px) Sora} |
| .cmp th:first-child,.cmp td:first-child{text-align:left;color:#9fd3b2;font:700 clamp(10px,1.2vh,13px) Rajdhani;letter-spacing:1px;text-transform:uppercase} |
| .cmp .goal{color:#34d399}.cmp .nogoal{color:#f87171}.cmp .gold{color:#facc15} |
| |
| #coachcell .hd{flex:0 0 auto;font:700 clamp(12px,1.6vh,17px) Rajdhani;letter-spacing:1px;color:#fde68a;text-transform:uppercase;margin-bottom:8px;text-align:center} |
| .bd{flex:1 1 auto;min-height:0;overflow:auto;list-style:none;margin:0;padding:0 4px;display:flex;flex-direction:column;justify-content:center;gap:8px;text-align:center} |
| .bd li{font-size:clamp(13px,1.7vh,16px);line-height:1.45} |
| .bd li::before{content:'▸ ';color:#facc15;font-weight:700} |
| .bd li.thinking{color:#9fd3b2;animation:pulse 1.2s ease-in-out infinite} |
| .bd li.thinking::before{content:''} |
| @keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}} |
| .warn{color:#fbbf24;background:rgba(58,44,11,.6);border:1px solid #b4880033;border-radius:12px;padding:14px;margin:auto} |
| </style> |
| </head> |
| <body> |
| <svg class="pitch" viewBox="0 0 1200 675" preserveAspectRatio="none"> |
| <g fill="none" stroke="#ffffff" stroke-opacity=".28" stroke-width="3.5"> |
| <line x1="600" y1="0" x2="600" y2="675"/> |
| <circle cx="600" cy="337" r="95"/> |
| |
| <path d="M0 150 H175 V525 H0"/> |
| <path d="M0 250 H66 V425 H0"/> |
| <path d="M175 268 A95 95 0 0 1 175 406"/> |
| <path d="M0 300 H-22 V375 H0"/> |
| |
| <path d="M1200 150 H1025 V525 H1200"/> |
| <path d="M1200 250 H1134 V425 H1200"/> |
| <path d="M1025 268 A95 95 0 0 0 1025 406"/> |
| <path d="M1200 300 H1222 V375 H1200"/> |
| </g> |
| <g fill="#ffffff" fill-opacity=".28"> |
| <circle cx="600" cy="337" r="6"/><circle cx="110" cy="337" r="5"/><circle cx="1090" cy="337" r="5"/> |
| </g> |
| </svg> |
|
|
| <div class="app"> |
| <header> |
| <h1>⚽ Kicky AI 🏆</h1> |
| <p>Your weekend football, analysed like the World Cup. One small RF-DETR model calls <b>goal · foot · speed</b>, then a vision-AI coach grades your strike — ⚡ <b>Offline:</b> MiniCPM-V on the Space's own GPU (zero external calls) · ☁️ <b>Online:</b> NVIDIA Nemotron on Modal. Zero manual labels.</p> |
| </header> |
|
|
| <div class="glass"> |
| <div class="left"> |
| <h3>① Your shot</h3> |
| <div id="drop"><div class="big">⬆️</div><div class="lbl"><b>Drop a clip</b> or click to upload</div> |
| <input id="file" type="file" accept="video/*" hidden></div> |
| <button id="go" class="go" disabled>▶ Analyse my shot</button> |
| <h3 style="margin-top:14px">AI Coach mode</h3> |
| <div class="modesel"> |
| <button class="mode sel" data-mode="online">🌐 Online · Nemotron</button> |
| <button class="mode" data-mode="offline">📴 Offline · MiniCPM</button> |
| </div> |
| <div class="exwrap"><h3 style="margin-top:14px">Pick an example</h3><div id="exs"></div></div> |
| </div> |
| <div class="divider"></div> |
| <div class="right" id="right"> |
| <div id="midcol"> |
| <div id="stage"><div class="muted">Your analysed shot will appear here ⚽</div></div> |
| <div id="coachcell"> |
| <div class="hd" id="coachhd">🧠 AI Coach</div> |
| <ul id="coach" class="bd"></ul> |
| </div> |
| </div> |
| <div id="sidecol"> |
| <div id="posecol"><div class="plabel">🤸 Pose</div><img id="pose" class="poseimg" alt=""></div> |
| <div class="scell speed"> |
| <div class="k">💨 Shot speed</div> |
| <div id="speedv" class="v sky">—</div> |
| <div class="sub">km/h</div> |
| </div> |
| <div class="scell tbl"> |
| <table class="cmp"> |
| <tr><th></th><th>Actual</th><th>Predicted</th></tr> |
| <tr><td>Goal</td><td id="gtGoal">—</td><td id="prGoal">—</td></tr> |
| <tr><td>Foot</td><td id="gtFoot">—</td><td id="prFoot">—</td></tr> |
| </table> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script type="module"> |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; |
| const $ = s => document.querySelector(s); |
| let client, pendingFile = null, busy = false, coachMode = 'online', spinTimer = null; |
| |
| const SPIN_WORDS = [ |
| 'Chasing the ball ⚽', 'Spotting the keeper 🧤', 'Measuring your screamer 💥', |
| 'Consulting VAR 📺', 'Drawing the offside line 🚩', 'Counting your stepovers 🕺', |
| 'Asking the gaffer 🧠', 'Checking for a worldie 🌍', 'Lacing up the boots 👟', |
| 'Rewinding the replay ⏪', 'Finding the back of the net 🥅', 'Calculating your xG 📊', |
| 'Bending it like Beckham ✨', 'Waking the linesman 😴', 'Cueing the crowd roar 📣', |
| 'Reading your body shape 🤸', 'Tracking the strike 🎯', 'Lining up the wall 🧱', |
| 'Polishing the golden boot 🥇', 'Parking the bus 🚌', |
| ]; |
| const COACH_WORDS = [ |
| 'Consulting the gaffer 🧠', 'Reviewing the replay 📺', 'Breaking down your technique 🎬', |
| 'Checking your knee bend 🦵', 'Reading your body shape 🤸', 'Studying your follow-through 👟', |
| 'Comparing you to the pros 🌟', 'Drafting your fixes ✍️', 'Measuring your hip drive ⚙️', |
| 'Grading your strike 📋', 'Spotting what to improve 🔍', 'Writing your drill 🎯', |
| ]; |
| function startSpinner(){ |
| $('#stage').innerHTML = '<div class="loader"><div class="spin"></div><div id="spinword" class="spinword"></div></div>'; |
| let prev = -1; |
| const tick = () => { |
| const w = $('#spinword'); if(!w) return; |
| let i; do { i = (Math.random() * SPIN_WORDS.length) | 0; } while(i === prev && SPIN_WORDS.length > 1); |
| prev = i; |
| w.style.opacity = 0; |
| setTimeout(() => { const e = $('#spinword'); if(e){ e.textContent = SPIN_WORDS[i]; e.style.opacity = 1; } }, 200); |
| }; |
| tick(); |
| spinTimer = setInterval(tick, 1700); |
| } |
| function stopSpinner(){ if(spinTimer){ clearInterval(spinTimer); spinTimer = null; } } |
| |
| function fireConfetti(){ |
| const go = () => { confetti({particleCount:220,spread:100,origin:{y:.6}}); |
| setTimeout(()=>confetti({particleCount:160,angle:60,spread:80,origin:{x:0}}),200); |
| setTimeout(()=>confetti({particleCount:160,angle:120,spread:80,origin:{x:1}}),200); }; |
| if(!window.confetti){const s=document.createElement('script'); |
| s.src='https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js'; |
| s.onload=go;document.head.appendChild(s);} else go(); |
| } |
| async function init(){ |
| client = await Client.connect(window.location.origin); |
| try{ |
| const names = await (await fetch('/api/examples')).json(); |
| $('#exs').innerHTML = names.map(n => |
| `<div class="ex" data-n="${n}"><img src="/thumb/${encodeURIComponent(n)}" loading="lazy" alt=""></div>`).join(''); |
| document.querySelectorAll('.ex').forEach(el => el.onclick = async () => { |
| if(busy) return; |
| const n = el.dataset.n; |
| try{ const blob = await (await fetch('/examples/'+encodeURIComponent(n))).blob(); |
| run(new File([blob], n, {type:'video/mp4'}), n); }catch(e){ console.error('example', e); } |
| }); |
| }catch(e){ console.warn('examples', e); } |
| } |
| function setBusy(b){ busy=b; $('#go').disabled = b || !pendingFile; |
| if(b){ $('#right').classList.remove('show'); startSpinner(); } else { stopSpinner(); } } |
| |
| async function run(src, hint){ |
| if(busy) return; setBusy(true); |
| try{ |
| const r = await client.predict("/analyse", { video: handle_file(src), hint: hint || "" }); |
| stopSpinner(); |
| const d = Array.isArray(r.data) ? r.data[0] : r.data; |
| |
| const fileUrl = fd => fd && (fd.url || (fd.path ? `${window.location.origin}/gradio_api/file=${fd.path}` : null)); |
| $('#stage').innerHTML = `<video src="${fileUrl(d.video)}" controls autoplay loop muted playsinline></video>`; |
| const goal = d.goal === 'goal'; |
| const goalLbl = g => g==='goal' ? 'GOAL' : (g ? 'NO GOAL' : '—'); |
| const goalCls = g => g==='goal' ? 'goal' : (g ? 'nogoal' : ''); |
| $('#prGoal').textContent = goalLbl(d.goal); $('#prGoal').className = goalCls(d.goal); |
| $('#gtGoal').textContent = goalLbl(d.gt_goal); $('#gtGoal').className = goalCls(d.gt_goal); |
| $('#prFoot').textContent = d.foot || '—'; $('#prFoot').className = d.foot ? 'gold' : ''; |
| $('#gtFoot').textContent = d.gt_leg || '—'; $('#gtFoot').className = d.gt_leg ? 'gold' : ''; |
| $('#speedv').textContent = d.speed!=null ? d.speed : '—'; |
| const poseUrl = fileUrl(d.pose); |
| if(poseUrl){ $('#pose').src = poseUrl; $('#pose').style.display=''; } |
| else { $('#pose').style.display='none'; } |
| $('#right').classList.add('show'); |
| if(goal) fireConfetti(); |
| |
| const label = coachMode==='offline' ? 'MiniCPM-V · on-Space GPU' : 'Nemotron · online'; |
| $('#coachhd').textContent = '🧠 AI Coach · ' + label; |
| $('#coach').innerHTML = '<li class="thinking">⏳ Coaching your shot…</li>'; |
| const ctimer = setInterval(() => { |
| const li = $('#coach .thinking'); if(li) li.textContent = COACH_WORDS[(Math.random()*COACH_WORDS.length)|0]; |
| }, 1700); |
| client.predict("/coach", { key: d.key, mode: coachMode }).then(cr => { |
| clearInterval(ctimer); |
| const cd = Array.isArray(cr.data) ? cr.data[0] : cr.data; |
| $('#coachhd').textContent = '🧠 AI Coach · ' + (cd.coach_model || label); |
| const txt = cd.coach || '⚠️ Couldn’t reach the AI coach right now — the analysis above is complete.'; |
| const pts = txt.split('\n').map(s => s.trim()).filter(Boolean).slice(0, 5); |
| $('#coach').innerHTML = pts.map(p => `<li>${p.replace(/</g,'<')}</li>`).join(''); |
| }).catch(err => { clearInterval(ctimer); console.error('coach', err); $('#coach').innerHTML = '<li>⚠️ Coach unavailable right now.</li>'; }); |
| }catch(e){ console.error(e); stopSpinner(); $('#stage').innerHTML = `<div class="warn">Analysis failed: ${e?.message||e}. Try another clip.</div>`; } |
| finally{ setBusy(false); } |
| } |
| |
| $('#drop').onclick = () => $('#file').click(); |
| $('#file').onchange = e => { pendingFile = e.target.files[0]; if(pendingFile){ $('#go').disabled=false; $('#drop .lbl').innerHTML='✅ <b>'+pendingFile.name+'</b>'; } }; |
| $('#drop').ondragover = e => { e.preventDefault(); $('#drop').classList.add('hot'); }; |
| $('#drop').ondragleave = () => $('#drop').classList.remove('hot'); |
| $('#drop').ondrop = e => { e.preventDefault(); $('#drop').classList.remove('hot'); pendingFile = e.dataTransfer.files[0]; |
| if(pendingFile){ $('#go').disabled=false; $('#drop .lbl').innerHTML='✅ <b>'+pendingFile.name+'</b>'; } }; |
| $('#go').onclick = () => pendingFile && run(pendingFile, pendingFile.name); |
| document.querySelectorAll('.mode').forEach(b => b.onclick = () => { |
| if(busy) return; |
| document.querySelectorAll('.mode').forEach(x => x.classList.remove('sel')); |
| b.classList.add('sel'); coachMode = b.dataset.mode; |
| }); |
| init(); |
| </script> |
| </body> |
| </html> |
|
|