kicky-ai / index.html
dcrey7's picture
Rename app to Kicky AI + add Substack blog link to README
2ca883e
Raw
History Blame Contribute Delete
17.1 kB
<!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}
/* 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,'&lt;')}</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>