Spaces:
Running on Zero
Running on Zero
| <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} | |
| /* fixed to the viewport so it always exactly fills it and never breaks on resize */ | |
| .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} | |
| /* ONE frosted glass-black box; left sidebar and right column share one width (--side), middle is the big middle */ | |
| .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 = two columns: MIDDLE [video over AI coach] | RIGHT [pose over speed over table]. | |
| A full-height vertical line splits middle|right; horizontal lines align across both. */ | |
| .right{min-height:0;overflow:hidden;display:grid;grid-template-columns:1fr} | |
| .right.show{grid-template-columns:1fr var(--side)} | |
| /* middle column */ | |
| #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)} | |
| /* right column: pose (top) over shot speed over actual-vs-predicted table */ | |
| #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)}} | |
| /* simple loader: spinner + cycling football status words / facts */ | |
| .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} | |
| /* AI coach (bottom-left): bullet points, centered, scrolls if long */ | |
| #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"/> | |
| <!-- left penalty box, 6-yard box, D-arc, goal --> | |
| <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"/> | |
| <!-- right --> | |
| <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; | |
| // @app.api() returns FileData with a path but url:null -> build gradio's file URL ourselves | |
| 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(); | |
| // results are up — now stream the AI coach in separately so it never blocks the analysis | |
| 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> | |