#!/usr/bin/env python3
"""
NEXT Athlete OS · RC39 limb intent + MediaPipe-ready bridge metadata.

This pass does NOT require MediaPipe to be installed. It strengthens the contract
MediaPipe will later write into:
- recomputes sprite-percent runtime landmarks from cleaned alpha masks
- adds dominantContactFoot + contactSide so runtime can lock a named foot/hand
- adds per-sequence limbIntent/anchorLandmark entries instead of letting runtime guess
- adds mediaPipeBridge placeholders under metrics.skeleton.mediaPipe for future extraction
"""
from __future__ import annotations

import json
import math
from pathlib import Path
from typing import Any, Dict, List, Tuple

import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont

ROOT = Path(__file__).resolve().parents[1]
PACKS_PATH = ROOT / "assets/data/image-admin/pose-atlas-packs.json"
SEQUENCES_PATH = ROOT / "assets/data/image-admin/pose-atlas-exercise-sequences.json"
REPORT_PATH = ROOT / "assets/data/image-admin/rc39_limb_intent_mediapipe_bridge_report.json"
QA_DIR = ROOT / "qa_screenshots"
QA_DIR.mkdir(exist_ok=True)

ALPHA_THRESHOLD = 16
Point = Dict[str, Any]


def pct(v: float, total: float) -> float:
    if total <= 0:
        return 50.0
    return round(max(0.0, min(100.0, (float(v) / float(total)) * 100.0)), 2)


def load_rgba(path: Path) -> Image.Image:
    return Image.open(path).convert("RGBA")


def mask_for(img: Image.Image) -> np.ndarray:
    arr = np.array(img)
    return (arr[:, :, 3] >= ALPHA_THRESHOLD).astype("uint8")


def bbox_from_mask(mask: np.ndarray) -> Tuple[int, int, int, int] | None:
    ys, xs = np.where(mask > 0)
    if not xs.size or not ys.size:
        return None
    return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())


def point(x: float, y: float, w: int, h: int, confidence: float = 0.55, source: str = "alpha-rc39") -> Point:
    return {"x": pct(x, w), "y": pct(y, h), "confidence": round(float(confidence), 2), "source": source}


def side_foot(mask: np.ndarray, x0: int, y0: int, x1: int, y1: int, side: str) -> Tuple[float, float, float, int]:
    """Find a stable foot point on the visual left/right side in the lower support area."""
    h, w = mask.shape[:2]
    vb_w, vb_h = max(1, x1 - x0 + 1), max(1, y1 - y0 + 1)
    split = x0 + vb_w * 0.5
    lower_y = max(y0, int(y0 + vb_h * 0.58))
    ys, xs = np.where(mask > 0)
    sel = ys >= lower_y
    if side == "left":
        sel &= xs <= split + max(8, vb_w * 0.08)
    else:
        sel &= xs >= split - max(8, vb_w * 0.08)
    sx, sy = xs[sel], ys[sel]
    if not sx.size:
        # Fallback to visible extremes if the split missed a compact/floor pose.
        if side == "left":
            sx = np.array([x0], dtype=np.float32)
        else:
            sx = np.array([x1], dtype=np.float32)
        sy = np.array([y1], dtype=np.float32)
    bottom = float(sy.max())
    bottom_band = sy >= bottom - max(3, int(vb_h * 0.045))
    bx = sx[bottom_band] if bottom_band.any() else sx
    by = sy[bottom_band] if bottom_band.any() else sy
    # Median inside bottom band is less jittery than min/max toe pixels.
    fx = float(np.median(bx))
    fy = float(np.percentile(by, 88))
    area = int(sx.size)
    conf = 0.44 + min(0.28, math.log10(max(1, area)) * 0.08)
    return fx, fy, conf, area


def side_hand(mask: np.ndarray, x0: int, y0: int, x1: int, y1: int, side: str) -> Tuple[float, float, float, int]:
    h, w = mask.shape[:2]
    vb_w, vb_h = max(1, x1 - x0 + 1), max(1, y1 - y0 + 1)
    top = int(y0 + vb_h * 0.18)
    bottom = int(y0 + vb_h * 0.72)
    ys, xs = np.where(mask > 0)
    sel = (ys >= top) & (ys <= bottom)
    if side == "left":
        # Extremity band on visual left.
        cutoff = x0 + vb_w * 0.42
        sel &= xs <= cutoff
    else:
        cutoff = x0 + vb_w * 0.58
        sel &= xs >= cutoff
    sx, sy = xs[sel], ys[sel]
    if sx.size:
        if side == "left":
            extreme = float(np.percentile(sx, 4))
            band = sx <= extreme + max(6, vb_w * 0.08)
        else:
            extreme = float(np.percentile(sx, 96))
            band = sx >= extreme - max(6, vb_w * 0.08)
        fx = float(np.median(sx[band])) if band.any() else extreme
        fy = float(np.median(sy[band])) if band.any() else float(y0 + vb_h * 0.46)
        area = int(sx.size)
        conf = 0.36 + min(0.24, math.log10(max(1, area)) * 0.07)
        return fx, fy, conf, area
    # fallback proportional hand point
    return (x0 + vb_w * (0.18 if side == "left" else 0.82), y0 + vb_h * 0.46, 0.28, 0)


def recompute_runtime_landmarks(path: Path) -> Dict[str, Any]:
    img = load_rgba(path)
    w, h = img.size
    mask = mask_for(img)
    box = bbox_from_mask(mask)
    if box is None:
        base = point(w * 0.5, h * 0.95, w, h, 0.1)
        return {
            "spriteSize": [w, h], "visibleBox": [0, 0, w, h], "scaleHeightPx": float(h),
            "alphaCoverage": 0.0, "runtimeLandmarks": {"footCenter": base}, "contactSide": "unknown",
            "contactQuality": "empty", "limbBinding": {}
        }
    x0, y0, x1, y1 = box
    vb_w, vb_h = max(1, x1 - x0 + 1), max(1, y1 - y0 + 1)
    ys, xs = np.where(mask > 0)
    center_x = x0 + vb_w * 0.5

    lf_x, lf_y, lf_conf, lf_area = side_foot(mask, x0, y0, x1, y1, "left")
    rf_x, rf_y, rf_conf, rf_area = side_foot(mask, x0, y0, x1, y1, "right")
    lh_x, lh_y, lh_conf, lh_area = side_hand(mask, x0, y0, x1, y1, "left")
    rh_x, rh_y, rh_conf, rh_area = side_hand(mask, x0, y0, x1, y1, "right")

    # Detect support foot. Lower y wins; if both are essentially same height and substantial,
    # mark both and use the midpoint. This prevents lateral/cross steps from locking a random toe.
    y_delta = abs(lf_y - rf_y)
    area_ratio = min(lf_area, rf_area) / max(1, max(lf_area, rf_area))
    if y_delta <= max(4, vb_h * 0.018) and area_ratio > 0.28:
        contact_side = "both"
        dom_x, dom_y = (lf_x + rf_x) / 2.0, max(lf_y, rf_y)
        dom_conf = max(lf_conf, rf_conf)
        dom_name = "footCenter"
    elif lf_y > rf_y:
        contact_side = "visualLeft"
        dom_x, dom_y, dom_conf, dom_name = lf_x, lf_y, lf_conf, "leftFoot"
    else:
        contact_side = "visualRight"
        dom_x, dom_y, dom_conf, dom_name = rf_x, rf_y, rf_conf, "rightFoot"

    lower_y = max(y0, int(y0 + vb_h * 0.62))
    lower_sel = ys >= lower_y
    lower_cx = float(np.median(xs[lower_sel])) if lower_sel.any() else (lf_x + rf_x) / 2.0
    lower_cy = float(np.percentile(ys[lower_sel], 82)) if lower_sel.any() else max(lf_y, rf_y)
    hand_cx, hand_cy = (lh_x + rh_x) / 2.0, (lh_y + rh_y) / 2.0

    rlm = {
        "headTop": point(center_x, y0, w, h, 0.50),
        "headCenter": point(center_x, y0 + vb_h * 0.12, w, h, 0.48),
        "shoulderCenter": point(center_x, y0 + vb_h * 0.30, w, h, 0.46),
        "hipCenter": point(center_x, y0 + vb_h * 0.60, w, h, 0.46),
        "kneeCenter": point(center_x, y0 + vb_h * 0.79, w, h, 0.43),
        "leftHand": point(lh_x, lh_y, w, h, lh_conf),
        "rightHand": point(rh_x, rh_y, w, h, rh_conf),
        "handCenter": point(hand_cx, hand_cy, w, h, max(0.32, min(lh_conf, rh_conf))),
        "leftFoot": point(lf_x, lf_y, w, h, lf_conf),
        "rightFoot": point(rf_x, rf_y, w, h, rf_conf),
        "footCenter": point((lf_x + rf_x) / 2.0, max(lf_y, rf_y), w, h, max(lf_conf, rf_conf)),
        "dominantContactFoot": point(dom_x, dom_y, w, h, dom_conf),
        "primaryContactFoot": point(dom_x, dom_y, w, h, dom_conf),
        "rearFoot": point(max(lf_x, rf_x), max(lf_y, rf_y), w, h, max(lf_conf, rf_conf) * 0.95),
        "frontFoot": point(min(lf_x, rf_x), max(lf_y, rf_y), w, h, max(lf_conf, rf_conf) * 0.95),
        "supportCenter": point((lower_cx + hand_cx) / 2.0, (lower_cy + hand_cy) / 2.0, w, h, 0.42),
        "lowerCenter": point(lower_cx, lower_cy, w, h, 0.52),
    }
    return {
        "spriteSize": [w, h],
        "visibleBox": [int(x0), int(y0), int(x1 + 1), int(y1 + 1)],
        "bottomContactX": rlm["footCenter"]["x"],
        "bottomContactY": rlm["footCenter"]["y"],
        "bottomContactMinX": min(rlm["leftFoot"]["x"], rlm["rightFoot"]["x"]),
        "bottomContactMaxX": max(rlm["leftFoot"]["x"], rlm["rightFoot"]["x"]),
        "lowerContactMinX": min(rlm["leftFoot"]["x"], rlm["rightFoot"]["x"]),
        "lowerContactMaxX": max(rlm["leftFoot"]["x"], rlm["rightFoot"]["x"]),
        "frontContactX": min(rlm["leftFoot"]["x"], rlm["rightFoot"]["x"]),
        "rearFootX": max(rlm["leftFoot"]["x"], rlm["rightFoot"]["x"]),
        "alphaCoverage": round(float(mask.sum()) / float(max(1, w * h)), 4),
        "scaleHeightPx": float(vb_h),
        "runtimeLandmarks": rlm,
        "contactSide": contact_side,
        "contactQuality": "dual" if contact_side == "both" else "single-dominant",
        "dominantFootName": dom_name,
        "limbBinding": {
            "schema": "next-limb-binding-v2",
            "coordinateSpace": "sprite-percent-unflipped",
            "leftRightPolicy": "visual-left-right; image and skeleton are transformed together by runtime flip",
            "anchorPolicy": "sequence limbIntent > explicit anchorLandmark > contact-aware fallback",
            "feet": {
                "visualLeft": "leftFoot",
                "visualRight": "rightFoot",
                "center": "footCenter",
                "dominantContact": "dominantContactFoot",
                "primaryContact": "primaryContactFoot",
                "rear": "rearFoot",
                "front": "frontFoot",
            },
            "hands": {"visualLeft": "leftHand", "visualRight": "rightHand", "center": "handCenter"},
            "support": {"center": "supportCenter", "lower": "lowerCenter"},
            "detectedContactSide": contact_side,
            "dominantFootName": dom_name,
        },
    }


def phase_anchor(entry: Dict[str, Any], profile: Dict[str, Any], seq_key: str, index: int, count: int) -> Tuple[str, str, float]:
    phase = str(entry.get("phase", "")).lower()
    contact = str(entry.get("contact", "")).lower()
    family = str(profile.get("family", "")).lower()
    anchor = str(profile.get("anchor", "")).lower()
    explicit = str(entry.get("anchorLandmark") or entry.get("anchorPoint") or profile.get("anchorLandmark") or "").strip()
    if explicit:
        return explicit, "explicit", 0.98
    if "bar" in contact or family in {"fixed_bar_sequence", "fixed_bar_vertical_pull"} or "bar" in anchor:
        return "barTop", "bar-lock", 0.96
    if "fixed" in contact or family in {"fixed_knee_pivot_sequence", "fixed_anchor_pivot"} or "knee" in anchor:
        return "kneeCenter", "fixed-knee-pivot", 0.94
    if "feet-locked" in contact or family == "ground_extend_return" or anchor == "rear-foot-lock":
        return "rearFoot", "rear-foot-lock", 0.92
    if "hands" in anchor or "hands" in contact:
        return "supportCenter", "hands-feet-support", 0.88

    # Explicit phase words override alpha-derived dominant contact. These are usually
    # authored from the exercise choreography and prevent left/right swaps in lateral hops.
    if "left" in phase and "right" not in phase:
        return "leftFoot", "phase-visual-left", 0.86
    if "right" in phase and "left" not in phase:
        return "rightFoot", "phase-visual-right", 0.86
    if "takeoff" in phase or "land" in phase:
        return "dominantContactFoot", "takeoff-land-dominant-contact", 0.80
    if family == "linear_ground" and anchor == "feet":
        return "dominantContactFoot", "linear-ground-dominant-contact", 0.78
    if "jump_arc" in family:
        return "footCenter", "jump-center-support", 0.76
    if anchor == "feet" or contact in {"grounded", "ground/load", "land/reset"}:
        return "footCenter", "grounded-foot-center", 0.72
    return "footCenter", "fallback-foot-center", 0.58


def enrich_packs(data: Dict[str, Any]) -> Dict[str, Any]:
    items: List[Dict[str, Any]] = []
    media_stub_count = 0
    for pose in data.get("poses", []):
        pose_key = pose.get("poseKey", "")
        for athlete_id, athlete_data in (pose.get("athletes") or {}).items():
            src = athlete_data.get("src")
            if not src:
                continue
            path = ROOT / src
            if not path.exists():
                items.append({"poseKey": pose_key, "athleteId": athlete_id, "status": "missing-file", "src": src})
                continue
            vm = recompute_runtime_landmarks(path)
            metrics = athlete_data.setdefault("metrics", {})
            sk = metrics.setdefault("skeleton", {})
            for key in ["visibleBox", "spriteSize", "bottomContactX", "bottomContactY", "bottomContactMinX", "bottomContactMaxX", "lowerContactMinX", "lowerContactMaxX", "frontContactX", "rearFootX", "alphaCoverage", "scaleHeightPx"]:
                metrics[key] = vm[key]
            athlete_data["spriteSize"] = vm["spriteSize"]
            sk["schema"] = "rc39-bound-runtime-skeleton-v2"
            sk["quality"] = "estimated-alpha-bound-mediapipe-ready"
            sk["coordinateSpace"] = "sprite-percent-unflipped"
            sk["runtimeLandmarks"] = vm["runtimeLandmarks"]
            sk["limbBinding"] = vm["limbBinding"]
            sk["contactSide"] = vm["contactSide"]
            sk["contactQuality"] = vm["contactQuality"]
            sk["dominantFootName"] = vm["dominantFootName"]
            sk["humanBox"] = vm["visibleBox"]
            sk["mainComponentBox"] = vm["visibleBox"]
            sk["scaleHeightPx"] = vm["scaleHeightPx"]
            sk["headToFootPx"] = vm["scaleHeightPx"]
            sk["mediaPipe"] = {
                "schema": "mediapipe-pose-33-v1",
                "status": "not-run-in-sandbox",
                "coordinateSpace": "sprite-percent-unflipped",
                "expectedRuntimeWriteTarget": "metrics.skeleton.runtimeLandmarks",
                "notes": "RC39 prepares the contract. Run tools/mediapipe_pose_extract_browser.html where MediaPipe model assets are available.",
            }
            media_stub_count += 1
            items.append({
                "poseKey": pose_key,
                "athleteId": athlete_id,
                "status": "runtime-landmarks-v2",
                "contactSide": vm["contactSide"],
                "dominantFootName": vm["dominantFootName"],
                "leftFoot": vm["runtimeLandmarks"].get("leftFoot"),
                "rightFoot": vm["runtimeLandmarks"].get("rightFoot"),
                "dominantContactFoot": vm["runtimeLandmarks"].get("dominantContactFoot"),
            })
    data["version"] = "v97-rc39-limb-intent-mediapipe-ready"
    data["runtimeLandmarkSchema"] = "rc39-bound-runtime-skeleton-v2"
    data["mediaPipeBridge"] = {
        "schema": "mediapipe-pose-33-v1",
        "status": "prepared-not-executed",
        "writeTarget": "poses[].athletes[athleteId].metrics.skeleton.mediaPipe + runtimeLandmarks",
        "browserTool": "tools/mediapipe_pose_extract_browser.html",
    }
    return {"items": items, "mediaStubCount": media_stub_count}


def enrich_sequences(data: Dict[str, Any]) -> Dict[str, Any]:
    edited: List[Dict[str, Any]] = []
    total = 0
    for seq_key, seq in (data.get("sequences") or {}).items():
        profile = seq.get("motionProfile") or {}
        poses = seq.get("poses") or []
        seq["bindingSchema"] = "rc39-sequence-limb-intent-v1"
        for idx, entry in enumerate(poses):
            if not isinstance(entry, dict):
                continue
            anchor, source, confidence = phase_anchor(entry, profile, seq_key, idx, len(poses))
            old_anchor = entry.get("anchorLandmark")
            entry["anchorLandmark"] = anchor
            entry["limbIntent"] = {
                "schema": "rc39-limb-intent-v1",
                "primaryAnchor": anchor,
                "anchorSource": source,
                "confidence": confidence,
                "linkedToSpriteLandmarks": True,
                "coordinateSpace": "sprite-percent-unflipped",
                "runtimeTransformPolicy": "skeleton and sprite share the same anchor + flip transform",
            }
            total += 1
            if old_anchor != anchor:
                edited.append({
                    "sequence": seq_key,
                    "index": idx,
                    "poseKey": entry.get("poseKey"),
                    "phase": entry.get("phase", ""),
                    "anchorLandmark": anchor,
                    "source": source,
                    "confidence": confidence,
                })
    data["version"] = "v97-rc39-limb-intent-mediapipe-ready"
    data["limbIntentSchema"] = "rc39-sequence-limb-intent-v1"
    return {"edited": edited, "total": total}


def pct_to_px(pt: Dict[str, Any], w: int, h: int) -> Tuple[float, float]:
    return float(pt.get("x", 50)) * w / 100.0, float(pt.get("y", 50)) * h / 100.0


def draw_marker(draw: ImageDraw.ImageDraw, x: float, y: float, color: Tuple[int, int, int], label: str = ""):
    r = 8
    draw.ellipse((x-r, y-r, x+r, y+r), fill=color, outline=(255, 255, 255), width=2)
    if label:
        draw.text((x + 10, y - 10), label, fill=color)


def qa_limb_map(packs: Dict[str, Any], athlete_id: str = "athlete-b") -> Path:
    # A few poses that exercise footwork/jump/hand binding.
    keys = [
        "sheet_05_lateral_horizontal_jump__pose07",
        "sheet_05_lateral_horizontal_jump__pose08",
        "sheet_05_lateral_horizontal_jump__pose10",
        "sheet_06_footwork_shuffle__pose08",
        "sheet_06_footwork_shuffle__pose10",
        "sheet_07_plank_push_crawl__pose03",
        "sheet_10_medball_throw_slam__pose08",
        "sheet_12_bar_box_partner_special__pose10",
    ]
    cards = []
    for key in keys:
        pose = next((p for p in packs.get("poses", []) if p.get("poseKey") == key), None)
        if not pose:
            continue
        a = (pose.get("athletes") or {}).get(athlete_id) or next(iter((pose.get("athletes") or {}).values()), None)
        if not a:
            continue
        img = load_rgba(ROOT / a["src"])
        # normalize card size but keep sprite visible
        canvas = Image.new("RGB", (320, 360), (9, 19, 32))
        scale = min(250 / max(1, img.width), 270 / max(1, img.height))
        resized = img.resize((max(1, int(img.width * scale)), max(1, int(img.height * scale))), Image.Resampling.LANCZOS)
        ox = (320 - resized.width) // 2
        oy = 46 + (270 - resized.height) // 2
        canvas.paste(resized, (ox, oy), resized)
        draw = ImageDraw.Draw(canvas)
        sk = a.get("metrics", {}).get("skeleton", {})
        lm = sk.get("runtimeLandmarks", {})
        for name, color, lab in [
            ("leftFoot", (64, 220, 255), "LF"),
            ("rightFoot", (255, 180, 74), "RF"),
            ("dominantContactFoot", (255, 74, 146), "DOM"),
            ("leftHand", (126, 255, 114), "LH"),
            ("rightHand", (194, 143, 255), "RH"),
        ]:
            if name in lm:
                x, y = pct_to_px(lm[name], resized.width, resized.height)
                draw_marker(draw, ox + x, oy + y, color, lab)
        draw.text((12, 12), key.replace("sheet_", "s"), fill=(220, 245, 255))
        draw.text((12, 330), f"contact: {sk.get('contactSide','?')}", fill=(150, 220, 235))
        cards.append(canvas)
    cols = 4
    rows = math.ceil(len(cards) / cols)
    out = Image.new("RGB", (cols * 320, rows * 360 + 46), (3, 10, 20))
    d = ImageDraw.Draw(out)
    d.text((18, 14), "RC39 limb binding QA · LF/RF/DOM + hands are sprite-linked", fill=(220, 250, 255))
    for i, card in enumerate(cards):
        out.paste(card, ((i % cols) * 320, 46 + (i // cols) * 360))
    path = QA_DIR / "rc39_limb_binding_contact_map.jpg"
    out.save(path, quality=92)
    return path


def qa_sequence_trace(packs: Dict[str, Any], seqs: Dict[str, Any], seq_key: str = "lateral-line-hops", athlete_id: str = "athlete-b") -> Path:
    seq = seqs.get("sequences", {}).get(seq_key, {})
    poses = seq.get("poses", [])
    W, H = 1280, 720
    out = Image.new("RGB", (W, H), (4, 13, 25))
    d = ImageDraw.Draw(out)
    d.rectangle((60, 575, 1220, 578), fill=(90, 226, 255))
    d.text((60, 26), f"RC39 sequence anchor trace · {seq_key}", fill=(230, 250, 255))
    x_positions = np.linspace(105, 1175, max(1, len(poses)))
    for i, entry in enumerate(poses):
        key = entry.get("poseKey")
        pose = next((p for p in packs.get("poses", []) if p.get("poseKey") == key), None)
        if not pose:
            continue
        a = (pose.get("athletes") or {}).get(athlete_id) or next(iter((pose.get("athletes") or {}).values()), None)
        img = load_rgba(ROOT / a["src"])
        target_h = 255
        scale = target_h / max(1, img.height)
        rw, rh = max(1, int(img.width * scale)), max(1, int(img.height * scale))
        rim = img.resize((rw, rh), Image.Resampling.LANCZOS)
        lm = a.get("metrics", {}).get("skeleton", {}).get("runtimeLandmarks", {})
        anchor_name = entry.get("anchorLandmark") or entry.get("limbIntent", {}).get("primaryAnchor") or "footCenter"
        if anchor_name == "barTop":
            ax, ay = rw * 0.5, 0
        elif anchor_name in lm:
            ax, ay = pct_to_px(lm[anchor_name], rw, rh)
        else:
            ax, ay = pct_to_px(lm.get("footCenter", {"x": 50, "y": 100}), rw, rh)
        world_x = float(x_positions[i])
        world_y = 575.0
        ox, oy = int(world_x - ax), int(world_y - ay)
        out.paste(rim, (ox, oy), rim)
        draw_marker(d, world_x, world_y, (255, 74, 146), str(i+1))
        d.text((int(world_x - 76), 610), f"{entry.get('phase','pose')}\n{anchor_name}", fill=(195, 230, 238))
    path = QA_DIR / "rc39_sequence_anchor_trace_lateral_line_hops.jpg"
    out.save(path, quality=92)
    return path


def write_mediapipe_browser_tool() -> Path:
    tool = ROOT / "tools/mediapipe_pose_extract_browser.html"
    tool.write_text("""<!doctype html>
<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">
<title>NEXT Pose Atlas · MediaPipe extractor bridge</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#07111f;color:#e9fbff;margin:0;padding:28px}main{max-width:980px;margin:auto}.card{border:1px solid rgba(120,233,255,.24);background:rgba(255,255,255,.04);border-radius:18px;padding:18px;margin:14px 0}button{background:#76e8ff;border:0;border-radius:999px;padding:10px 14px;font-weight:900}input{display:block;margin:10px 0}.log{white-space:pre-wrap;background:#020811;border-radius:12px;padding:12px;color:#bcefff;min-height:120px}</style>
</head><body><main><h1>NEXT Pose Atlas · MediaPipe extractor bridge</h1>
<p>This browser tool is intentionally outside the pass runtime. It reads local atlas sprites, runs MediaPipe Pose Landmarker in IMAGE mode, and downloads a JSON patch that can be merged into <code>metrics.skeleton.mediaPipe</code> and <code>runtimeLandmarks</code>.</p>
<div class=\"card\"><label>pose-atlas-packs.json <input id=\"packs\" type=\"file\" accept=\"application/json\"></label><label>Sprite folder <input id=\"sprites\" type=\"file\" webkitdirectory multiple></label><button id=\"run\">Run extraction</button></div><div class=\"log\" id=\"log\">Waiting…</div>
<script type=\"module\">
const logEl=document.getElementById('log'); const log=(s)=>{logEl.textContent+='\\n'+s};
const LM_NAMES=['nose','leftEyeInner','leftEye','leftEyeOuter','rightEyeInner','rightEye','rightEyeOuter','leftEar','rightEar','mouthLeft','mouthRight','leftShoulder','rightShoulder','leftElbow','rightElbow','leftWrist','rightWrist','leftPinky','rightPinky','leftIndex','rightIndex','leftThumb','rightThumb','leftHip','rightHip','leftKnee','rightKnee','leftAnkle','rightAnkle','leftHeel','rightHeel','leftFootIndex','rightFootIndex'];
function pct(v){return Math.max(0,Math.min(100,v*100));}
function derived(landmarks){const p=(name)=>landmarks[LM_NAMES.indexOf(name)]||null; const mid=(a,b,name)=>a&&b?{x:+((a.x+b.x)*50).toFixed(2),y:+((a.y+b.y)*50).toFixed(2),visibility:+Math.min(a.visibility??1,b.visibility??1).toFixed(3),source:name}:null; const lf=p('leftAnkle'), rf=p('rightAnkle'), lh=p('leftWrist'), rh=p('rightWrist'); return {leftFoot:lf&&{x:+pct(lf.x).toFixed(2),y:+pct(lf.y).toFixed(2),visibility:lf.visibility??1,source:'mediapipe'},rightFoot:rf&&{x:+pct(rf.x).toFixed(2),y:+pct(rf.y).toFixed(2),visibility:rf.visibility??1,source:'mediapipe'},leftHand:lh&&{x:+pct(lh.x).toFixed(2),y:+pct(lh.y).toFixed(2),visibility:lh.visibility??1,source:'mediapipe'},rightHand:rh&&{x:+pct(rh.x).toFixed(2),y:+pct(rh.y).toFixed(2),visibility:rh.visibility??1,source:'mediapipe'},shoulderCenter:mid(p('leftShoulder'),p('rightShoulder'),'mediapipe'),hipCenter:mid(p('leftHip'),p('rightHip'),'mediapipe'),kneeCenter:mid(p('leftKnee'),p('rightKnee'),'mediapipe'),footCenter:mid(lf,rf,'mediapipe')};}
document.getElementById('run').onclick=async()=>{logEl.textContent='Loading MediaPipe…'; const {FilesetResolver,PoseLandmarker}=await import('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22/vision_bundle.mjs'); const resolver=await FilesetResolver.forVisionTasks('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22/wasm'); const landmarker=await PoseLandmarker.createFromOptions(resolver,{baseOptions:{modelAssetPath:'https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task'},runningMode:'IMAGE',numPoses:1}); log('Model ready'); const packsFile=document.getElementById('packs').files[0]; if(!packsFile){log('Missing packs json');return} const packs=JSON.parse(await packsFile.text()); const files=[...document.getElementById('sprites').files]; const byName=new Map(files.map(f=>[f.name,f])); const patch={schema:'rc39-mediapipe-pose-33-patch-v1',createdAt:new Date().toISOString(),items:[]}; for(const pose of packs.poses||[]){for(const [athleteId,a] of Object.entries(pose.athletes||{})){const file=byName.get((a.src||'').split('/').pop()); if(!file) continue; const img=new Image(); img.src=URL.createObjectURL(file); await img.decode(); const res=landmarker.detect(img); URL.revokeObjectURL(img.src); const lms=res.landmarks?.[0]||[]; patch.items.push({poseKey:pose.poseKey,athleteId,src:a.src,status:lms.length?'detected':'not-detected',mediaPipe:{schema:'mediapipe-pose-33-v1',landmarks:lms.map((p,i)=>({name:LM_NAMES[i],x:+pct(p.x).toFixed(2),y:+pct(p.y).toFixed(2),z:+(p.z||0).toFixed(5),visibility:+(p.visibility??0).toFixed(3)})),derivedRuntimeLandmarks:derived(lms)}}); log(`${patch.items.length}: ${pose.poseKey} · ${athleteId} · ${lms.length?'detected':'not detected'}`);}}
 const blob=new Blob([JSON.stringify(patch,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='rc39_mediapipe_pose_patch.json'; a.click();};
</script></main></body></html>
""", encoding="utf-8")
    return tool


def main() -> None:
    packs = json.loads(PACKS_PATH.read_text(encoding="utf-8"))
    seqs = json.loads(SEQUENCES_PATH.read_text(encoding="utf-8"))
    pack_report = enrich_packs(packs)
    seq_report = enrich_sequences(seqs)
    PACKS_PATH.write_text(json.dumps(packs, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    SEQUENCES_PATH.write_text(json.dumps(seqs, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    browser_tool = write_mediapipe_browser_tool()
    limb_qa = qa_limb_map(packs)
    seq_qa = qa_sequence_trace(packs, seqs)
    report = {
        "version": "rc39-limb-intent-mediapipe-ready",
        "intent": "Bind skeleton and images through named hands/feet anchors; prepare a safe MediaPipe extraction target without changing runtime architecture.",
        "spriteRuntimeLandmarksUpdated": len(pack_report["items"]),
        "mediaPipeStubCount": pack_report["mediaStubCount"],
        "sequenceEntriesWithLimbIntent": seq_report["total"],
        "sequenceEntriesChangedThisRun": len(seq_report["edited"]),
        "mediaPipeBrowserTool": str(browser_tool.relative_to(ROOT)),
        "qaImages": [str(limb_qa.relative_to(ROOT)), str(seq_qa.relative_to(ROOT))],
        "notes": [
            "MediaPipe itself was not executed in this sandbox because the mediapipe/tasks-vision model package is not locally installed and the sandbox cannot fetch model assets.",
            "Runtime now has explicit per-sequence limbIntent and per-sprite dominantContactFoot, so path anchoring should no longer choose a random bottom pixel.",
            "The existing pose-motion-service remains the single runtime motion engine; no parallel animation system was introduced.",
        ],
        "sequenceEdits": seq_report["edited"],
        "sampleSpriteItems": pack_report["items"][:40],
    }
    REPORT_PATH.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({k: report[k] for k in ["version", "spriteRuntimeLandmarksUpdated", "mediaPipeStubCount", "sequenceEntriesWithLimbIntent", "sequenceEntriesChangedThisRun", "qaImages"]}, indent=2))


if __name__ == "__main__":
    main()
