"""
NextJump Broadcast — Hugging Face Space.
Upload a jump-rope video and get back a broadcast-ready overlay video (vertical 9:16 or
horizontal 16:9) with the AI jump count, speed gauge, splits, rhythm and — optionally —
official start/end beep detection. All of the work is done by claude_anim_v2.main();
this file is only the Gradio front end.
Layout expected next to this file (run sync_assets.py from the repo to populate it):
claude_anim_v2.py, beep.WAV, elitejumps_logo.png, nextjump_logo.png, graphics/art/SalmaproMedium.otf,
movenet-pytorch/{movenet/, _models/movenet_thunder.pth}
When run from inside the repo without syncing, the repo root is used instead.
The counting checkpoint is private and is NOT part of the Space: it is downloaded at startup from the
private model repo (like NextJump-Tournament/app.py), authenticated with the DATASET_SECRET Space secret.
NEXTJUMP_MODEL_REPO model repo id (default lumos-motion/nextjump)
NEXTJUMP_MODEL_FILE ONNX file in that repo (default nextjump_all_128.onnx)
DATASET_SECRET / HF_TOKEN read token for the private repo (locally, a `huggingface-cli login` also works)
NEXTJUMP_ONNX optional local path that skips the download
"""
import os
import sys
import time
import uuid
import base64
import tempfile
import cv2
import gradio as gr
from huggingface_hub import hf_hub_download
HERE = os.path.dirname(os.path.abspath(__file__))
for _root in (HERE, os.path.dirname(HERE)):
if os.path.isfile(os.path.join(_root, 'claude_anim_v2.py')):
sys.path.insert(0, _root)
break
import claude_anim_v2 as nj # noqa: E402
MODEL_REPO = os.environ.get('NEXTJUMP_MODEL_REPO', 'lumos-motion/nextjump')
MODEL_FILE = os.environ.get('NEXTJUMP_MODEL_FILE', 'nextjump_all_128.onnx')
HF_TOKEN = os.environ.get('DATASET_SECRET') or os.environ.get('HF_TOKEN') # None -> cached CLI login
def resolve_model():
"""Path to the counting checkpoint, downloaded (and cached) from the private model repo."""
override = os.environ.get('NEXTJUMP_ONNX')
if override:
return override
try:
path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE, repo_type='model', token=HF_TOKEN)
print(f'Counting model: {MODEL_REPO}/{MODEL_FILE} -> {path}')
return path
except Exception as e:
local = os.path.join(nj.ROOT, 'checkpoint', 'onnx', MODEL_FILE) # local dev inside the repo
if os.path.isfile(local):
print(f'Could not download {MODEL_REPO}/{MODEL_FILE} ({type(e).__name__}); using local {local}')
return local
raise RuntimeError(f'Could not download {MODEL_FILE} from the private repo {MODEL_REPO} '
f'({type(e).__name__}: {e}). If this is an auth error, add a read token for the repo '
'as the DATASET_SECRET secret in the Space settings.') from e
# Render worker processes (multiprocessing 'spawn') re-import this file as __mp_main__: they must not
# download models or build the UI again, so all of that is skipped there.
IS_RENDER_WORKER = __name__ == '__mp_main__'
ONNX_PATH = None if IS_RENDER_WORKER else resolve_model() # at startup, so the first request doesn't wait
if not IS_RENDER_WORKER:
try: # also warm the torchvision person-detector weights used by smart framing (public, ~75 MB)
nj.load_person_detector('coco', 'cpu')
except Exception as e:
print(f'Could not pre-load the person detector ({e}); it will be downloaded on first use')
MAX_SECONDS = float(os.environ.get('NEXTJUMP_MAX_SECONDS', 200)) # longest accepted upload
OUT_DIR = os.path.join(tempfile.gettempdir(), 'nextjump_space')
KEEP_OUTPUTS_S = 2 * 3600
FORMATS = {'Social (TikTok / Reels / Shorts, full screen)': 'social',
'Broadcast vertical 9:16 (full stats panel)': 'vertical',
'Broadcast horizontal 16:9 (YouTube / TV)': 'horizontal'}
DEFAULT_FILL = {'social': 0.25, 'vertical': 0.50, 'horizontal': 0.60}
FRAMING = {'Smart: find the most active jumper(s)': 'smart',
'Pose: follow the first person found': 'pose',
'Off: fixed centre crop': 'off'}
def probe(path):
cap = cv2.VideoCapture(path)
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
w, h = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
ok = cap.isOpened() and cap.read()[0]
cap.release()
return dict(ok=ok, fps=fps, frames=frames, w=w, h=h, duration=frames / fps if fps else 0.0)
def cleanup_old_outputs():
if not os.path.isdir(OUT_DIR):
return
now = time.time()
for f in os.listdir(OUT_DIR):
p = os.path.join(OUT_DIR, f)
try:
if now - os.path.getmtime(p) > KEEP_OUTPUTS_S:
os.remove(p)
except OSError:
pass
def summary_markdown(s, info):
lines = [f'## {s["final"]} jumps']
if s['event'] is not None:
t0, t1 = s['event']
lines.append(f'Counted between the beeps: start **{t0:.2f} s**, end **{t1:.2f} s** '
f'({t1 - t0:.2f} s event). Nothing before the start beep or after the end beep is counted.')
elif s['beep_requested']:
lines.append('⚠️ Beep detection was on, but no start/end beep pair matching the event length was found, '
'so the whole video was counted.')
else:
lines.append(f'Counted over the detected jumping window ({s["duration"]:.1f} s).')
if s.get('misses'):
lines.append(f'\n**{s["misses"]}** miss{"es" if s["misses"] != 1 else ""} detected'
+ (f'; the first jump after each miss was not counted (rulebook), '
f'**{s["skipped"]}** jump{"s" if s["skipped"] != 1 else ""} removed.' if s.get('skipped') else '.'))
lines.append(f'\nAverage speed **{s["avg_jps"]:.2f}** jumps/s · peak **{s["peak_jps"]:.2f}** jumps/s')
if s['splits']:
lines.append(f'\n| {s["split_seconds"]} s split | ' + ' | '.join(str(k + 1) for k in range(len(s['splits']))) + ' |')
lines.append('|---|' + '---|' * len(s['splits']))
lines.append('| jumps | ' + ' | '.join(str(v) for v in s['splits']) + ' |')
lines.append(f'\nOutput {s["size"][0]}×{s["size"][1]} @ {s["fps"]:.2f} fps, {s["frames"]} frames · '
f'source {info["w"]}×{info["h"]} @ {info["fps"]:.2f} fps')
return '\n'.join(lines)
def generate(video, fmt_label, title, theme_label, turbo, beep, event_length, framing_label, jumpers, skip_after_miss,
count_on_crop, out_fps, fill, smooth, max_zoom, foot_rings, skeleton, show_pace, mirror,
progress=gr.Progress()):
if not video:
raise gr.Error('Please upload a video first.')
info = probe(video)
if not info['ok']:
raise gr.Error('Could not read that video file. Please upload an MP4 or MOV.')
if info['duration'] > MAX_SECONDS:
raise gr.Error(f'That video is {info["duration"]:.0f} s long; the limit here is {MAX_SECONDS:.0f} s. '
'Please trim it around the event.')
if info['fps'] < 45:
gr.Warning(f'This video is {info["fps"]:.0f} fps. The counting model was trained on 60 fps footage, '
'so counts may be less accurate.')
if beep and not event_length:
raise gr.Error('Please enter the event length (seconds between the beeps).')
cleanup_old_outputs()
os.makedirs(OUT_DIR, exist_ok=True)
fmt = fmt_label if fmt_label in FORMATS.values() else FORMATS.get(fmt_label, 'social')
base = os.path.splitext(os.path.basename(video))[0][:40] or 'video'
output = os.path.join(OUT_DIR, f'{base}_nextjump_{fmt}_{uuid.uuid4().hex[:6]}.mp4')
argv = ['--video', video, '--output', output, '--onnx', ONNX_PATH, '--format', fmt, '--title', title or '',
'--theme', theme_label if theme_label in ('elitejumps', 'nextjump') else 'elitejumps',
'--fill', str(fill), '--smooth', str(smooth), '--max_zoom', str(max_zoom)]
if out_fps != 'Auto':
argv += ['--out_fps', str(out_fps)]
if turbo:
argv.append('--turbo')
framing = framing_label if framing_label in FRAMING.values() else FRAMING.get(framing_label, 'smart')
argv += ['--framing', framing, '--jumpers', str(int(jumpers or 1))]
if count_on_crop and framing == 'smart':
argv.append('--count_on_crop')
if skip_after_miss:
argv.append('--skip_after_miss')
if beep:
argv += ['--beep', '--event_length', str(float(event_length))]
if foot_rings:
argv.append('--fx')
if skeleton:
argv.append('--skeleton')
if not show_pace:
argv.append('--no_pace')
if mirror:
argv.append('--mirror')
progress(0, desc='Starting')
try:
s = nj.main(argv, progress=lambda f, desc: progress(f, desc=desc))
except Exception as e: # surface a readable message in the UI; full trace goes to the logs
import traceback
traceback.print_exc()
raise gr.Error(f'Processing failed: {e}')
return s['output'], s['output'], summary_markdown(s, info)
def _logo_html():
path = os.path.join(nj.ROOT, nj.THEMES[nj.DEFAULT_THEME]['logo'])
logo = ''
if os.path.isfile(path):
with open(path, 'rb') as f:
logo = f''
return f"""
Upload a jump-rope video and get a broadcast-ready graphic: AI jump count, speed gauge, splits, rhythm and auto-framing on the jumper. Turn on beep detection for official timed events.