BODY MOTION RECORDER WITH MEDIAPIPE

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/b31e5f43537f913e/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>BODY:RECORD</title>
<style>
:root{--fg:#111;--mute:#8a8a8a;--line:#e6e6e6;--ok:#2f9e6e;--mid:#e0a020;--bad:#e0452f}
*{box-sizing:border-box}
body{margin:0;background:#fff;color:var(--fg);font:13px/1.4 -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif}
header{display:flex;align-items:center;gap:16px;padding:14px 18px;border-bottom:1px solid var(--line)}
header b{letter-spacing:.14em;font-weight:600}
#status{color:var(--mute);flex:1}
#tbar{width:120px;height:4px;background:var(--line);border-radius:2px;overflow:hidden}
#tbar i{display:block;height:100%;width:0;background:var(--fg)}
main{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:12px 18px}
.card{border:1px solid var(--line);border-radius:6px;overflow:hidden;position:relative;background:#fff}
.stage{position:relative;aspect-ratio:4/3;width:100%}
.stage.lowc{outline:2px solid var(--bad);outline-offset:-2px}
.mirror{position:absolute;inset:0;transform:scaleX(-1)}
.mirror video,.mirror canvas{position:absolute;inset:0;width:100%;height:100%}
.mirror video{opacity:.8;filter:grayscale(.6)}
#low{position:absolute;left:12px;top:10px;color:var(--bad);font-weight:600;letter-spacing:.1em;display:none}
#count{position:absolute;inset:0;display:none;align-items:center;justify-content:center;font-size:140px;font-weight:200;color:var(--fg);pointer-events:none}
#flipmsg{position:absolute;right:12px;top:10px;color:var(--mid);font-weight:600;letter-spacing:.1em;display:none}
#hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--mute);text-align:center;padding:20px}
#view{width:100%;aspect-ratio:4/3}
#view canvas{display:block;width:100%;height:100%}
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:4px 18px 12px}
button,select{font:inherit;background:#fff;color:var(--fg);border:1px solid var(--fg);border-radius:4px;padding:6px 12px;cursor:pointer}
button:hover{background:#f3f3f3}
button:disabled{opacity:.35;cursor:default}
button.rec{border-color:var(--bad);color:var(--bad)}
label.chk{display:flex;gap:6px;align-items:center;color:#444}
.grid{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:12px;padding:0 18px 24px}
.box{border:1px solid var(--line);border-radius:6px;padding:12px}
.box h4{margin:0 0 8px;font-size:11px;letter-spacing:.12em;font-weight:600;color:var(--mute);text-transform:uppercase}
#tl{width:100%;height:80px;display:block}
.ov{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.ov .big{font-size:22px;font-weight:600;min-width:58px}
.meter{flex:1;height:6px;background:var(--line);border-radius:3px;overflow:hidden}
.meter i{display:block;height:100%;width:0;background:var(--ok)}
#joints{display:grid;grid-template-columns:1fr 1fr;gap:4px 14px}
.jr{display:grid;grid-template-columns:78px 1fr 30px;gap:6px;align-items:center;font-size:11px;color:#444}
.jr .meter{height:4px}
.sl{display:grid;grid-template-columns:1fr 90px 40px;gap:8px;align-items:center;margin-bottom:6px;font-size:12px;color:#444}
.sl b{font-weight:500;text-align:right;color:var(--fg)}
input[type=range]{width:100%;accent-color:#111}
#scrub{flex:1;min-width:160px}
@media(max-width:860px){main{grid-template-columns:1fr}.grid{grid-template-columns:1fr}}
</style>
<style>
body{font-size:12px}
header{padding:8px 12px}
main{padding:8px 12px;gap:8px}
.bar{padding:2px 12px 8px;gap:6px}
button,select{padding:4px 9px;font-size:12px}
.grid{padding:0 12px 14px;gap:8px;grid-template-columns:1.1fr 1fr 1.2fr}
.box{padding:8px 10px}
.box h4{margin-bottom:6px}
#tl{height:64px}
.jr{grid-template-columns:70px 1fr 28px;font-size:10.5px}
#joints{gap:2px 12px}
.sl{grid-template-columns:1fr 90px 38px;gap:1px 8px;margin-bottom:5px}
.sl small{grid-column:1/-1;color:#999;font-size:10.5px;line-height:1.25}
.card:last-child{display:flex}
#view{flex:1;min-height:240px;aspect-ratio:auto}
.bar input[type=range]{width:110px}
#trimbar span,#trimbar b{color:#444;font-weight:500}
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}</script>
</head>
<body>
<header><b>BODY:RECORD</b><span id="status">Kamera starten, um zu beginnen.</span><div id="tbar"><i></i></div></header>

<main>
  <section class="card">
    <div class="stage" id="stage">
      <div class="mirror"><video id="vid" playsinline muted></video><canvas id="ov"></canvas></div>
      <div id="low">LOW CONFIDENCE</div><div id="flipmsg">FLIP KORRIGIERT</div>
      <div id="hint">Ganzkörper im Bild, Abstand ca. 2–3 m.<br>Aufnahme drücken → 3 s Countdown → Aufnahme. Stoppen (oder Max-Dauer) → Glättung → Loop.</div>
      <div id="count"></div>
    </div>
  </section>
  <section class="card"><div id="view"></div></section>
</main>

<section class="bar">
  <button id="btnCam">Kamera starten</button>
  <button id="btnRec" class="rec" disabled>● Aufnahme</button>
  <button id="btnLive" disabled>Live / neue Aufnahme</button>
  <button id="btnPlay" disabled>Pause</button>
  <input type="range" id="scrub" min="0" max="1000" value="0" disabled>
  <label class="chk"><input type="checkbox" id="chkRaw" checked>Roh-Skelett (rot)</label>
  <select id="selFps"><option value="24">24 fps</option><option value="30" selected>30 fps</option></select>
  <select id="selModel"><option value="lite">Modell: Lite</option><option value="full">Modell: Full</option><option value="heavy" selected>Modell: Heavy</option></select>
  <button id="btnCal" disabled>Körper neu kalibrieren</button>
  <button id="btnJson" disabled>Export JSON</button>
  <button id="btnFbx" disabled>Export FBX (Mixamo)</button>
  <select id="selPrefix"><option value="mixamorig1:">Prefix mixamorig1:</option><option value="mixamorig:">Prefix mixamorig:</option></select>
  <button id="btnGlb" disabled>Export GLB</button>
  <button id="btnImp">Import JSON</button><input type="file" id="fileImp" accept=".json" hidden>
</section>

<section class="bar" id="trimbar">
  <span>Schnitt</span> Start <input type="range" id="trimA" min="0" max="1" value="0" disabled><b id="trimAv">–</b>
  Ende <input type="range" id="trimB" min="0" max="1" value="1" disabled><b id="trimBv">–</b>
  <button id="btnSetA" disabled>Start = Playhead</button><button id="btnSetB" disabled>Ende = Playhead</button>
  <span id="clipInfo" style="color:var(--mute)"></span>
</section>

<section class="grid">
  <div class="box">
    <h4>Konfidenz</h4>
    <div class="ov"><span class="big" id="confNum">–</span><div class="meter"><i id="confBar"></i></div><span id="fpsNum" style="color:var(--mute)"></span></div>
    <canvas id="tl"></canvas>
    <div style="color:var(--mute);font-size:11px;margin-top:6px" id="tlInfo">Live: letzte ~20 s · rote Striche = Flip korrigiert · Linie = Schwelle</div>
  </div>
  <div class="box"><h4>Gelenke</h4><div id="joints"></div></div>
  <div class="box"><h4>Parameter</h4><div id="sliders"></div></div>
</section>

<script type="module">
import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {GLTFExporter} from 'three/addons/exporters/GLTFExporter.js';
import {PoseLandmarker, FilesetResolver} from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14';

const $=id=>document.getElementById(id);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
/* ---------- Skelett ---------- */
const SEL=[0,11,12,13,14,15,16,23,24,25,26,27,28,31,32];
const NAMES=['Kopf','Schulter L','Schulter R','Ellbogen L','Ellbogen R','Handgelenk L','Handgelenk R','Hüfte L','Hüfte R','Knie L','Knie R','Knöchel L','Knöchel R','Fuß L','Fuß R'];
const NJ=15, PAIRS=[[1,2],[3,4],[5,6],[7,8],[9,10],[11,12],[13,14]];
const BONES=[[15,16],[16,0],[16,1],[16,2],[1,3],[3,5],[2,4],[4,6],[15,7],[15,8],[7,9],[9,11],[11,13],[8,10],[10,12],[12,14]];
const getp=(f,i)=>[f[i*3],f[i*3+1],f[i*3+2]];
const setp=(f,i,p)=>{f[i*3]=p[0];f[i*3+1]=p[1];f[i*3+2]=p[2]};
const sb=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
const mid=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2,(a[2]+b[2])/2];
const dst=(a,b)=>Math.hypot(a[0]-b[0],a[1]-b[1],a[2]-b[2]);
const nrm=v=>{const l=Math.hypot(v[0],v[1],v[2])||1e-9;return [v[0]/l,v[1]/l,v[2]/l]};
const addS=(a,d,s)=>[a[0]+d[0]*s,a[1]+d[1]*s,a[2]+d[2]*s];
const median=a=>{if(!a.length)return 0;const s=[...a].sort((x,y)=>x-y),m=s.length>>1;return s.length%2?s[m]:(s[m-1]+s[m])/2};
const col=c=>c<.3?'#e0452f':c<.6?'#e0a020':'#2f9e6e';

function measure(f){
  const g=i=>getp(f,i),pel=mid(g(7),g(8)),nk=mid(g(1),g(2));
  return {hip:dst(g(7),g(8)),sh:dst(g(1),g(2)),spine:dst(pel,nk),head:dst(g(0),nk),
    upper:(dst(g(1),g(3))+dst(g(2),g(4)))/2,fore:(dst(g(3),g(5))+dst(g(4),g(6)))/2,
    thigh:(dst(g(7),g(9))+dst(g(8),g(10)))/2,shin:(dst(g(9),g(11))+dst(g(10),g(12)))/2,
    foot:(dst(g(11),g(13))+dst(g(12),g(14)))/2};
}
function chain(f,ids,Ls){for(let k=1;k<ids.length;k++){const p=getp(f,ids[k-1]);setp(f,ids[k],addS(p,nrm(sb(getp(f,ids[k]),p)),Ls[k-1]))}}
/* Knochenlängen erzwingen (Hierarchie: Becken → Wirbelsäule → Schultern/Kopf → Arme, Beine) */
function enforce(f,B){
  if(!B)return f;
  const hl=getp(f,7),hr=getp(f,8),c=mid(hl,hr),d=nrm(sb(hl,hr));
  setp(f,7,addS(c,d,B.hip/2));setp(f,8,addS(c,d,-B.hip/2));
  let sl=getp(f,1),sr=getp(f,2);const sc=mid(sl,sr);
  const nc=addS(c,nrm(sb(sc,c)),B.spine);
  const d2=nrm(sb(sl,sr));setp(f,1,addS(nc,d2,B.sh/2));setp(f,2,addS(nc,d2,-B.sh/2));
  setp(f,0,addS(nc,nrm(sb(getp(f,0),nc)),B.head));
  chain(f,[1,3,5],[B.upper,B.fore]);chain(f,[2,4,6],[B.upper,B.fore]);
  chain(f,[7,9,11,13],[B.thigh,B.shin,B.foot]);chain(f,[8,10,12,14],[B.thigh,B.shin,B.foot]);
  return f;
}
/* Flip-Erkennung: L/R-Vertauschung und Tiefen-Spiegelung gegen Vorhersage prüfen */
function fixFlips(W,C,Pp,Vp,dt){
  const pr=new Float32Array(45);for(let i=0;i<45;i++)pr[i]=Pp[i]+Vp[i]*dt;
  const cost=(X,Cx)=>{let s=0,w=0;for(let j=0;j<NJ;j++){const o=j*3;s+=Cx[j]*Math.hypot(X[o]-pr[o],X[o+1]-pr[o+1],X[o+2]-pr[o+2]);w+=Cx[j]}return w>.5?s/w:0};
  const c0=cost(W,C);if(c0<.08)return 0;
  const Ws=Float32Array.from(W),Cs=Float32Array.from(C);
  for(const [a,b] of PAIRS){for(let k=0;k<3;k++){Ws[a*3+k]=W[b*3+k];Ws[b*3+k]=W[a*3+k]}Cs[a]=C[b];Cs[b]=C[a]}
  const Wz=Float32Array.from(W);for(let j=0;j<NJ;j++)Wz[j*3+2]=-W[j*3+2];
  const cs=cost(Ws,Cs),cz=cost(Wz,C);
  if(cs<.6*c0&&cs<=cz){W.set(Ws);C.set(Cs);return 1}
  if(cz<.6*c0){W.set(Wz);return 2}
  return 0;
}
class OneEuro{
  constructor(){this.x=null;this.dx=0}
  a(fc,dt){return 1/(1+(1/(2*Math.PI*fc))/dt)}
  f(x,dt,minC,beta){if(this.x===null){this.x=x;this.dx=0;return x}
    const d=(x-this.x)/dt;this.dx+=this.a(1,dt)*(d-this.dx);
    this.x+=this.a(minC+beta*Math.abs(this.dx),dt)*(x-this.x);return this.x}
}

/* ---------- Parameter-UI ---------- */
const DESC={liveSmooth:'Glättung der Live-Anzeige. Höher = ruhiger, aber träger.',
 confThr:'Unter diesem Wert gilt ein Gelenk als unsicher: live wird es gehalten, offline verworfen und interpoliert.',
 mpConf:'Erkennungsschwelle von MediaPipe. Niedrig = Tracking reißt seltener ab, dafür mehr Rauschen.',
 smooth:'Stärke der Glättung nach der Aufnahme (Gauß, in Sekunden). 0 = aus.',
 outlier:'Wie streng Ausreißer verworfen werden. Niedriger = strenger.',
 blend:'Überblendet Ende und Anfang des Schnitts zu einem nahtlosen Loop. 0 = aus.',
 maxDur:'Aufnahme stoppt automatisch nach dieser Zeit.',speed:'Abspielgeschwindigkeit.',
 rootXY:'Stärke der Hüftbewegung seitlich/auf-ab im Raum (aus Bildposition). 0 = Hüfte fest.',
 rootZ:'Stärke der Vor-/Zurück-Bewegung (Tiefe, aus Körpergröße im Bild geschätzt, ungenauer). 0 = aus.',
 legThr:'Beine unter dieser Konfidenz werden gerade ausgestreckt und glatt mit der Hüfte mitgeführt. Höher = öfter Fallback.'};
const SL=[['liveSmooth','Live-Glättung',0,1,.05,.6,''],['confThr','Konfidenz-Schwelle',.1,.9,.05,.5,''],
 ['mpConf','MediaPipe-Konfidenz',.05,.8,.05,.2,''],['smooth','Glättung offline',0,.3,.01,.07,' s'],
 ['outlier','Ausreißer-Filter',2,8,.5,3.5,''],['blend','Loop-Überblendung',0,1,.05,.3,' s'],
 ['legThr','Bein-Fallback',.2,.95,.05,.6,''],['rootXY','Raum XY',0,1,.05,1,''],['rootZ','Raum Tiefe',0,1,.05,.6,''],
 ['maxDur','Max. Dauer',5,60,1,30,' s'],['speed','Tempo',.25,2,.05,1,'×']];
for(const [id,l,mn,mx,st,v,u] of SL){
  const d=document.createElement('label');d.className='sl';
  d.innerHTML=`<span>${l}</span><input type="range" id="${id}" min="${mn}" max="${mx}" step="${st}" value="${v}"><b id="${id}_v"></b><small>${DESC[id]}</small>`;
  $('sliders').appendChild(d);
  const inp=d.querySelector('input'),out=d.querySelector('b');
  const upd=()=>out.textContent=(+inp.value)+u;upd();inp.addEventListener('input',upd);
}
const val=id=>+$(id).value;
const jointRows=[];
NAMES.forEach(n=>{const r=document.createElement('div');r.className='jr';r.innerHTML=`<span>${n}</span><div class="meter"><i></i></div><span>–</span>`;$('joints').appendChild(r);jointRows.push(r)});

/* ---------- 3D ---------- */
const view=$('view');
const renderer=new THREE.WebGLRenderer({antialias:true});renderer.setPixelRatio(Math.min(2,devicePixelRatio));
renderer.setClearColor(0xffffff,1);view.appendChild(renderer.domElement);
const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(40,4/3,.1,50);camera.position.set(0,.3,3.2);
const controls=new OrbitControls(camera,renderer.domElement);controls.enableDamping=true;
const grid=new THREE.GridHelper(4,16,0xdddddd,0xeeeeee);grid.position.y=-.95;scene.add(grid);
const joints3=[],bones3=[];
const sg=new THREE.SphereGeometry(1,14,10),cg=new THREE.CylinderGeometry(1,1,1,8);
for(let i=0;i<17;i++){const m=new THREE.Mesh(sg,new THREE.MeshBasicMaterial({color:0x222222}));m.scale.setScalar(i<15?.026:.015);scene.add(m);joints3.push(m)}
for(let i=0;i<BONES.length;i++){const m=new THREE.Mesh(cg,new THREE.MeshBasicMaterial({color:0x555555}));scene.add(m);bones3.push(m)}
const ghostGeo=new THREE.BufferGeometry();ghostGeo.setAttribute('position',new THREE.BufferAttribute(new Float32Array(BONES.length*6),3));
const ghost=new THREE.LineSegments(ghostGeo,new THREE.LineBasicMaterial({color:0xe0452f,transparent:true,opacity:.55}));ghost.frustumCulled=false;scene.add(ghost);
function resize(){const w=view.clientWidth,h=view.clientHeight;renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix()}
new ResizeObserver(resize).observe(view);resize();
const E1=new Float32Array(51),E2=new Float32Array(51),UP=new THREE.Vector3(0,1,0),tv=new THREE.Vector3(),tq=new THREE.Quaternion();
function ext(f,o){for(let i=0;i<45;i++)o[i]=f[i];for(let k=0;k<3;k++){o[45+k]=(f[21+k]+f[24+k])/2;o[48+k]=(f[3+k]+f[6+k])/2}}
function updateSkeleton(F,C,R,root){
  ext(F,E1);
  if(root)for(let i=0;i<17;i++){E1[i*3]+=root[0];E1[i*3+1]+=root[1];E1[i*3+2]+=root[2]}
  for(let i=0;i<17;i++)joints3[i].position.set(E1[i*3],-E1[i*3+1],-E1[i*3+2]);
  for(let j=0;j<NJ;j++)joints3[j].material.color.set(C?col(C[j]):'#222222');
  BONES.forEach(([a,b],i)=>{
    const pa=new THREE.Vector3(E1[a*3],-E1[a*3+1],-E1[a*3+2]),pb=new THREE.Vector3(E1[b*3],-E1[b*3+1],-E1[b*3+2]);
    tv.subVectors(pb,pa);const l=tv.length()||1e-6;const m=bones3[i];
    m.position.copy(pa).addScaledVector(tv,.5);m.quaternion.copy(tq.setFromUnitVectors(UP,tv.normalize()));m.scale.set(.011,l,.011);
  });
  ghost.visible=!!R&&$('chkRaw').checked;
  if(R){ext(R,E2);if(root)for(let i=0;i<17;i++){E2[i*3]+=root[0];E2[i*3+1]+=root[1];E2[i*3+2]+=root[2]}const p=ghostGeo.attributes.position.array;
    BONES.forEach(([a,b],i)=>{p.set([E2[a*3],-E2[a*3+1],-E2[a*3+2],E2[b*3],-E2[b*3+1],-E2[b*3+2]],i*6)});
    ghostGeo.attributes.position.needsUpdate=true}
}

/* ---------- Live-Tracker ---------- */
const L={P:null,V:new Float32Array(45),lost:new Int16Array(NJ),fl:Array.from({length:45},()=>new OneEuro()),bones:null,t:0,flips:0,zflips:0,wl:[1,1]};
function resetLive(){L.P=null;L.V.fill(0);L.lost.fill(0);L.wl=[1,1];L.fl.forEach(f=>{f.x=null;f.dx=0})}
function liveUpdate(Wr,Cr,t){
  const dt=clamp((t-L.t)/1000,.005,.1);L.t=t;
  const W=Float32Array.from(Wr),C=Float32Array.from(Cr);
  let ev=0;if(L.P)ev=fixFlips(W,C,L.P,L.V,dt);
  if(ev===1)L.flips++;if(ev===2)L.zflips++;
  const thr=val('confThr'),minC=.3+4*(1-val('liveSmooth')),beta=2;
  const F=new Float32Array(45),Ce=new Float32Array(NJ);
  let mc=1;for(let j=0;j<NJ;j++)mc=Math.min(mc,C[j]);
  if(mc>.6){const m=measure(W);if(!L.bones)L.bones=m;else for(const k in m)L.bones[k]+=(m[k]-L.bones[k])*.03}
  for(let j=0;j<NJ;j++){
    const o=j*3;
    if(!L.P){for(let a=0;a<3;a++){F[o+a]=W[o+a];L.fl[o+a].f(W[o+a],dt,minC,beta)}Ce[j]=C[j];continue}
    const pr=[L.P[o]+L.V[o]*dt,L.P[o+1]+L.V[o+1]*dt,L.P[o+2]+L.V[o+2]*dt];
    const d=Math.hypot(W[o]-pr[0],W[o+1]-pr[1],W[o+2]-pr[2]);
    const bad=C[j]<thr||(d>8*dt+.06&&L.lost[j]<12);
    if(bad){L.lost[j]++;for(let a=0;a<3;a++){F[o+a]=pr[a];L.fl[o+a].x=pr[a];L.fl[o+a].dx=0;L.V[o+a]*=.85}Ce[j]=Math.min(C[j],.25)}
    else{L.lost[j]=0;for(let a=0;a<3;a++){F[o+a]=L.fl[o+a].f(W[o+a],dt,j>=9?minC*.5:minC,beta);L.V[o+a]=.6*L.V[o+a]+.4*(F[o+a]-L.P[o+a])/dt}Ce[j]=C[j]}
  }
  if(L.bones)enforce(F,L.bones);
  const lt=val('legThr');
  for(let s=0;s<2;s++){const lc=Math.min(Ce[s?10:9],Ce[s?12:11]);L.wl[s]+=(clamp((lc-(lt-.15))/.3,0,1)-L.wl[s])*.15}
  applyLegs(F,L.bones,L.wl);if(L.bones)enforce(F,L.bones);
  L.P=F;
  let lostN=0;for(let j=0;j<NJ;j++)if(L.lost[j]>0)lostN++;
  return {F,Ce,ev,lostN};
}
function isT(F,Ce){
  let sgn=[];
  for(const [s,e,w] of [[1,3,5],[2,4,6]]){
    if(Ce[s]<.4||Ce[e]<.4||Ce[w]<.4)return false;
    const S_=getp(F,s),E=getp(F,e),Wr=getp(F,w),v=sb(Wr,S_),len=Math.hypot(v[0],v[1],v[2]);
    if(len<.2||Math.abs(v[1])/len>.42||Math.abs(v[0])/len<.75)return false;
    if(len/(dst(S_,E)+dst(E,Wr))<.92)return false;
    sgn.push(v[0]);
  }
  return sgn[0]*sgn[1]<0;
}

/* ---------- Root-Bewegung & Bein-Fallback ---------- */
const RT={f:[new OneEuro(),new OneEuro(),new OneEuro()],cur:null,ref:null};
/* Hüftposition im Raum aus Bildposition + Maßstab (Meter pro Bildhöhe aus Schulter-/Hüftbreite und Torso, am wenigsten verkürzte Strecke) */
function rawRoot(I,W,C){
  for(const j of [1,2,7,8])if(C[j]<.5)return null;
  const asp=vid.videoWidth/vid.videoHeight;
  const ip=j=>[I[j*3],I[j*3+1]],wp=j=>getp(W,j),mi=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
  const im=(a,b)=>Math.hypot((a[0]-b[0])*asp,a[1]-b[1]);
  const rs=[[im(ip(1),ip(2)),dst(wp(1),wp(2))],[im(ip(7),ip(8)),dst(wp(7),wp(8))],[im(mi(ip(1),ip(2)),mi(ip(7),ip(8))),dst(mid(wp(1),wp(2)),mid(wp(7),wp(8)))]];
  let r=0;for(const [di,dw] of rs)if(dw>.05)r=Math.max(r,di/dw);
  if(r<=0)return null;
  const s=clamp(1/r,.3,15),c=mi(ip(7),ip(8));
  return [(c[0]-.5)*asp*s,(c[1]-.5)*s,1.07*s];
}
/* Beine ohne verlässliches Tracking: gerade ausgestreckt nach unten, Fuß leicht nach vorn */
function legFallbackPos(f,B,s){
  const L=B||{thigh:.42,shin:.42,foot:.2},hip=getp(f,s?8:7),d=sb(getp(f,7),getp(f,8));
  const fw=nrm([d[2],0,-d[0]]);
  const knee=[hip[0],hip[1]+L.thigh,hip[2]],ankle=[knee[0],knee[1]+L.shin,knee[2]];
  return [knee,ankle,addS(ankle,nrm([fw[0]*.9,.1,fw[2]*.9]),L.foot)];
}
function applyLegs(f,B,w){
  for(let s=0;s<2;s++){
    const wi=w[s];if(wi>=.999)continue;
    const fb=legFallbackPos(f,B,s),ids=s?[10,12,14]:[9,11,13];
    ids.forEach((id,k)=>{const p=getp(f,id);setp(f,id,[fb[k][0]+(p[0]-fb[k][0])*wi,fb[k][1]+(p[1]-fb[k][1])*wi,fb[k][2]+(p[2]-fb[k][2])*wi])});
  }
}
function smooth1D(x,sig){
  const n=x.length,rad=Math.ceil(3*sig),y=new Float32Array(n),g=[];
  for(let d=0;d<=rad;d++)g.push(Math.exp(-d*d/(2*sig*sig)));
  for(let i=0;i<n;i++){let s=0,w=0;for(let d=-rad;d<=rad;d++){const q=i+d;if(q<0||q>=n)continue;s+=g[Math.abs(d)]*x[q];w+=g[Math.abs(d)]}y[i]=s/w}
  x.set(y);
}
function fillGaps(X,V,M){
  let any=false;for(let m=0;m<M;m++)if(V[m]){any=true;break}
  if(!any)return false;
  const pv=new Int32Array(M),nv=new Int32Array(M);let l=-1;
  for(let m=0;m<M;m++){if(V[m])l=m;pv[m]=l}
  l=-1;for(let m=M-1;m>=0;m--){if(V[m])l=m;nv[m]=l}
  for(let m=0;m<M;m++){if(V[m])continue;const a=pv[m],b=nv[m];
    for(let ax=0;ax<3;ax++)X[m*3+ax]=(a>=0&&b>=0)?X[a*3+ax]+(X[b*3+ax]-X[a*3+ax])*((m-a)/(b-a)):(a>=0?X[a*3+ax]:X[b*3+ax])}
  return true;
}
function cleanRoot(X,V,M,fps,P){
  const Y=Float32Array.from(X);
  if(!fillGaps(Y,V,M))return new Float32Array(M*3);
  const half=Math.max(2,Math.round(.15*fps)),V2=Uint8Array.from(V);
  for(let a=0;a<3;a++)for(let m=0;m<M;m++){
    const buf=[];for(let q=Math.max(0,m-half);q<=Math.min(M-1,m+half);q++)buf.push(Y[q*3+a]);
    const md=median(buf),mad=median(buf.map(v=>Math.abs(v-md)));
    if(Math.abs(Y[m*3+a]-md)>P.outlier*1.4826*mad+.08)V2[m]=0;
  }
  const Z=Float32Array.from(X);fillGaps(Z,V2,M);
  for(let a=0;a<3;a++){const sg=P.smooth*fps*(a===2?6:2);if(sg>.4){const c=new Float32Array(M);for(let m=0;m<M;m++)c[m]=Z[m*3+a];smooth1D(c,sg);for(let m=0;m<M;m++)Z[m*3+a]=c[m]}}
  return Z;
}

/* ---------- Offline-Verarbeitung (Stufe 2) ---------- */
function processRecording(rec,fps,P){
  const n=rec.W.length;
  // A: Flips sequenziell korrigieren
  const W1=[],C1=[];let st=null,sv=new Float32Array(45),flips=0,zflips=0;
  for(let i=0;i<n;i++){
    const W=Float32Array.from(rec.W[i]),C=Float32Array.from(rec.C[i]);
    const dt=i?Math.max(.005,(rec.t[i]-rec.t[i-1])/1000):1/30;
    if(st){const ev=fixFlips(W,C,st,sv,dt);if(ev===1)flips++;if(ev===2)zflips++}
    if(!st)st=Float32Array.from(W);
    for(let j=0;j<NJ;j++)for(let a=0;a<3;a++){const k=j*3+a;
      if(C[j]>=.5){sv[k]=.5*sv[k]+.5*(W[k]-st[k])/dt;st[k]=W[k]}else st[k]+=sv[k]*dt}
    W1.push(W);C1.push(C);
  }
  // B: auf festes Raster resampeln
  const T=rec.t[n-1],M=Math.max(2,Math.floor(T/1000*fps)+1);
  const R=new Float32Array(M*45),Cr=new Float32Array(M*NJ),Rt=new Float32Array(M*3),Vr=new Uint8Array(M);
  let k=0;
  for(let m=0;m<M;m++){
    const tm=m/fps*1000;while(k<n-2&&rec.t[k+1]<tm)k++;
    const a=rec.t[k],b=rec.t[k+1],u=clamp((tm-a)/((b-a)||1),0,1);
    // kubische Hermite-Interpolation (weich zwischen den ~15 fps Messpunkten), linear bei großen Sprüngen
    const k0=Math.max(0,k-1),k3=Math.min(n-1,k+2),h=b-a||1,
      t1=Math.max(1,rec.t[k+1]-rec.t[k0]),t2=Math.max(1,rec.t[k3]-rec.t[k]),
      u2=u*u,u3=u2*u,h00=2*u3-3*u2+1,h10=u3-2*u2+u,h01=-2*u3+3*u2,h11=u3-u2;
    for(let q=0;q<45;q++){
      const p0=W1[k][q],p1=W1[k+1][q],lin=p0*(1-u)+p1*u;
      if(Math.abs(p1-p0)>.25){R[m*45+q]=lin;continue}
      const s1=(p1-W1[k0][q])/t1,s2=(W1[k3][q]-p0)/t2;
      R[m*45+q]=h00*p0+h10*h*s1+h01*p1+h11*h*s2;
    }
    for(let j=0;j<NJ;j++)Cr[m*NJ+j]=Math.min(C1[k][j],C1[k+1][j]);
    {const r0=rec.root[k],r1=rec.root[k+1];
      if(r0&&r1&&!isNaN(r0[0])&&!isNaN(r1[0])){Vr[m]=1;for(let a2=0;a2<3;a2++)Rt[m*3+a2]=r0[a2]*(1-u)+r1[a2]*u}}
  }
  // C: Ausreißer (Hampel) + niedrige Konfidenz
  const valid=new Uint8Array(M*NJ).fill(1),half=Math.max(2,Math.round(.1*fps));
  for(let j=0;j<NJ;j++){
    for(let m=0;m<M;m++)if(Cr[m*NJ+j]<val('confThr')*.6)valid[m*NJ+j]=0;
    for(let a=0;a<3;a++)for(let m=0;m<M;m++){
      const lo=Math.max(0,m-half),hi=Math.min(M-1,m+half),buf=[];
      for(let q=lo;q<=hi;q++)buf.push(R[q*45+j*3+a]);
      const md=median(buf),mad=median(buf.map(v=>Math.abs(v-md)));
      if(Math.abs(R[m*45+j*3+a]-md)>P.outlier*1.4826*mad+.03)valid[m*NJ+j]=0;
    }
  }
  // D: Lücken füllen
  const Fd=Float32Array.from(R),Wt=new Float32Array(M*NJ),invalidPct=new Float32Array(NJ),invSeries=new Float32Array(M);
  for(let j=0;j<NJ;j++){
    let any=false;for(let m=0;m<M;m++)if(valid[m*NJ+j]){any=true;break}
    if(!any)for(let m=0;m<M;m++)valid[m*NJ+j]=1;
    const pv=new Int32Array(M),nv=new Int32Array(M);let last=-1;
    for(let m=0;m<M;m++){if(valid[m*NJ+j])last=m;pv[m]=last}
    last=-1;for(let m=M-1;m>=0;m--){if(valid[m*NJ+j])last=m;nv[m]=last}
    for(let m=0;m<M;m++){
      if(valid[m*NJ+j]){Wt[m*NJ+j]=Math.max(.15,Cr[m*NJ+j]);continue}
      invalidPct[j]++;invSeries[m]+=1/NJ;Wt[m*NJ+j]=.02;
      const a=pv[m],b=nv[m];
      for(let ax=0;ax<3;ax++){const o=j*3+ax;
        Fd[m*45+o]=(a>=0&&b>=0)?R[a*45+o]+(R[b*45+o]-R[a*45+o])*((m-a)/(b-a)):(a>=0?R[a*45+o]:R[b*45+o])}
    }
    invalidPct[j]/=M;
  }
  // E: Knochenlängen (Median) + erzwingen
  const samp={};let cnt=0;
  for(let m=0;m<M;m++){let ok=true;for(let j=0;j<NJ;j++)if(!valid[m*NJ+j]){ok=false;break}
    if(!ok)continue;const ms=measure(Fd.subarray(m*45,m*45+45));for(const q in ms)(samp[q]=samp[q]||[]).push(ms[q]);cnt++}
  if(cnt<5)for(let m=0;m<M;m++){const ms=measure(Fd.subarray(m*45,m*45+45));for(const q in ms)(samp[q]=samp[q]||[]).push(ms[q])}
  const B={};for(const q in samp)B[q]=median(samp[q]);
  for(let m=0;m<M;m++)enforce(Fd.subarray(m*45,m*45+45),B);
  // E2: Bein-Fallback – unsichere Beine werden gerade und weich mit der Hüfte mitgeführt
  const lt=val('legThr');
  const wl=new Float32Array(M*2);
  for(let s=0;s<2;s++){
    const kj=s?10:9,aj=s?12:11,ra=new Float32Array(M);
    for(let m=0;m<M;m++){const lc=(valid[m*NJ+kj]&&valid[m*NJ+aj])?Math.min(Cr[m*NJ+kj],Cr[m*NJ+aj]):0;ra[m]=clamp((lc-(lt-.15))/.3,0,1)}
    smooth1D(ra,Math.max(1,.15*fps));
    for(let m=0;m<M;m++)wl[m*2+s]=ra[m];
  }
  for(let m=0;m<M;m++){const f=Fd.subarray(m*45,m*45+45);applyLegs(f,B,[wl[m*2],wl[m*2+1]]);enforce(f,B)}
  // F: konfidenzgewichtete Gauß-Glättung (zero-phase)
  let Fs=Fd;const sig0=P.smooth*fps;
  if(sig0>.4){
    Fs=new Float32Array(M*45);
    for(let j=0;j<NJ;j++){
      const sig=sig0*(j>=9?1.8:1),rad=Math.ceil(3*sig),g=[];for(let d=0;d<=rad;d++)g.push(Math.exp(-d*d/(2*sig*sig)));
      for(let a=0;a<3;a++)for(let m=0;m<M;m++){
        let s=0,w=0;for(let d=-rad;d<=rad;d++){const q=m+d;if(q<0||q>=M)continue;const ww=g[Math.abs(d)]*Wt[q*NJ+j];s+=ww*Fd[q*45+j*3+a];w+=ww}
        Fs[m*45+j*3+a]=w?s/w:Fd[m*45+j*3+a];
      }
    }
    for(let m=0;m<M;m++)enforce(Fs.subarray(m*45,m*45+45),B);
  }
  // Schnitt + Loop-Überblendung passieren danach in buildClip()
  const fullConf=new Float32Array(M);
  for(let m=0;m<M;m++){let s=0;for(let j=0;j<NJ;j++)s+=Cr[m*NJ+j];fullConf[m]=s/NJ}
  let mi=0;for(let j=0;j<NJ;j++)mi+=invalidPct[j]/NJ;
  return {fps,fullM:M,Fs,R,Cr,fullConf,rootFull:cleanRoot(Rt,Vr,M,fps,P),fullInv:invSeries,a:0,b:M-1,stats:{n,flips,zflips,dur:T/1000,invalid:mi,bones:B},pos:0,playing:true};
}

/* ---------- Zustand ---------- */
const S={mode:'live',recording:false,rec:null,auto:true,tAcc:0,notT:0,armed:false,F:new Float32Array(45),C:null,R:null,hasLive:false};
let PB=null,lm=null,lastTs=0,lastWr=null,lastI=new Float32Array(45),hist=[],flipUntil=0,fpsE=0,lastFrameT=0,lastRec=null,busy=false;
const setStatus=t=>$('status').textContent=t;
const hold=1.0;

async function initLandmarker(key){
  busy=true;setStatus('Lade Modell ('+key+') …');
  const fs=await FilesetResolver.forVisionTasks('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm');
  const c=val('mpConf'),url=`https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_${key}/float16/1/pose_landmarker_${key}.task`;
  const mk=d=>PoseLandmarker.createFromOptions(fs,{baseOptions:{modelAssetPath:url,delegate:d},runningMode:'VIDEO',numPoses:1,
    minPoseDetectionConfidence:c,minPosePresenceConfidence:c,minTrackingConfidence:c});
  try{lm=await mk('GPU')}catch(e){lm=await mk('CPU')}
  lastTs=0;busy=false;resetLive();
}
const vid=$('vid'),ov=$('ov'),octx=ov.getContext('2d');
async function startCam(){
  try{
    $('btnCam').disabled=true;
    const st=await navigator.mediaDevices.getUserMedia({video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:30}},audio:false});
    vid.srcObject=st;await vid.play();
    ov.width=vid.videoWidth;ov.height=vid.videoHeight;$('stage').style.aspectRatio=vid.videoWidth+'/'+vid.videoHeight;$('hint').style.display='none';
    await initLandmarker($('selModel').value);
    ['btnRec','btnLive','btnCal'].forEach(i=>$(i).disabled=false);
    setStatus('Bereit – Aufnahme drücken, 3 s Countdown.');
    if('requestVideoFrameCallback' in vid){const cb=now=>{vid.requestVideoFrameCallback(cb);onFrame(now)};vid.requestVideoFrameCallback(cb)}
    else{let last=-1;const cb=()=>{requestAnimationFrame(cb);if(vid.currentTime!==last){last=vid.currentTime;onFrame(performance.now())}};cb()}
  }catch(e){setStatus('Kamera/Modell-Fehler: '+e.message);$('btnCam').disabled=false}
}

/* ---------- Frame-Verarbeitung ---------- */
function onFrame(now){
  if(!lm||busy)return;
  const ts=Math.max(now,lastTs+1),dtf=lastTs?(ts-lastTs)/1000:1/30;lastTs=ts;
  let res;try{res=lm.detectForVideo(vid,ts)}catch(e){return}
  fpsE=fpsE?fpsE*.9+.1/dtf:1/dtf;
  let Wr,Cr=new Float32Array(NJ),I,has=false;
  if(res.worldLandmarks&&res.worldLandmarks[0]){
    has=true;const wl=res.worldLandmarks[0],il=res.landmarks[0];Wr=new Float32Array(45);I=new Float32Array(45);
    for(let j=0;j<NJ;j++){const w=wl[SEL[j]],i=il[SEL[j]];Wr.set([w.x,w.y,w.z],j*3);I.set([i.x,i.y,i.z],j*3);
      const inF=(i.x>-.02&&i.x<1.02&&i.y>-.02&&i.y<1.02)?1:.15;Cr[j]=(i.visibility??w.visibility??0)*inF}
    lastWr=Wr;lastI=I;
  }else{Wr=lastWr?Float32Array.from(lastWr):null;I=lastI}
  if(!Wr)return drawOverlay(null,Cr,false);
  const o=liveUpdate(Wr,Cr,ts);
  const rr=has?rawRoot(I,Wr,Cr):null;
  if(rr){const c=[RT.f[0].f(rr[0],dtf,1,1),RT.f[1].f(rr[1],dtf,1,1),RT.f[2].f(rr[2],dtf,.4,.5)];RT.cur=c;if(!RT.ref)RT.ref=c.slice()}
  S.root=RT.cur?[(RT.cur[0]-RT.ref[0])*val('rootXY'),(RT.cur[1]-RT.ref[1])*val('rootXY'),(RT.cur[2]-RT.ref[2])*val('rootZ')]:null;
  let cm=0;for(let j=0;j<NJ;j++)cm+=Cr[j];cm/=NJ;
  hist.push({c:cm,ev:o.ev});if(hist.length>600)hist.shift();
  if(o.ev)flipUntil=performance.now()+1200;
  S.F=o.F;S.C=o.Ce;S.R=Wr;S.cm=cm;S.hasLive=true;
  const low=cm<val('confThr')||o.lostN>=3||!has;
  $('stage').classList.toggle('lowc',low);$('low').style.display=low?'block':'none';
  // Countdown + Aufnahme
  if(S.mode==='live'){
    if(S.countT!=null){
      const rem=3-(performance.now()-S.countT)/1000;
      if(rem<=0){hideCount();startRec()}
      else{$('count').style.display='flex';$('count').textContent=Math.ceil(rem);setStatus('Aufnahme startet in '+Math.ceil(rem)+' …')}
    }else if(S.recording){
      const el=(now-S.rec.t0)/1000;
      S.rec.t.push(now-S.rec.t0);S.rec.W.push(Float32Array.from(Wr));S.rec.C.push(has?Float32Array.from(Cr):new Float32Array(NJ));
      S.rec.root.push(rr?Float32Array.from(rr):new Float32Array([NaN,NaN,NaN]));
      $('tbar').firstElementChild.style.width=clamp(el/val('maxDur'),0,1)*100+'%';
      setStatus(`● Aufnahme ${el.toFixed(1)} s / ${val('maxDur')} s`);
      if(el>=val('maxDur'))stopRec(0);
    }else if(!S.processing)setStatus('Bereit');
  }
  drawOverlay(I,Cr,has);
}
function drawOverlay(I,C,has){
  octx.clearRect(0,0,ov.width,ov.height);if(!I)return;
  const w=ov.width,h=ov.height,p=j=>[I[j*3]*w,I[j*3+1]*h];
  octx.lineWidth=2;octx.strokeStyle='rgba(0,0,0,.28)';octx.beginPath();
  const segs=[[1,2],[1,3],[3,5],[2,4],[4,6],[7,8],[1,7],[2,8],[7,9],[9,11],[11,13],[8,10],[10,12],[12,14],[0,1],[0,2]];
  for(const [a,b] of segs){const A=p(a),B=p(b);octx.moveTo(A[0],A[1]);octx.lineTo(B[0],B[1])}
  octx.stroke();
  for(let j=0;j<NJ;j++){const [x,y]=p(j);octx.beginPath();octx.arc(x,y,6,0,7);octx.fillStyle=col(C[j]);octx.fill();
    if(L.lost[j]>0){octx.beginPath();octx.arc(x,y,11,0,7);octx.strokeStyle='#e0452f';octx.lineWidth=2;octx.stroke()}}
}

/* ---------- Aufnahme / Ablauf ---------- */
function hideCount(){S.countT=null;$('count').style.display='none'}
function startRec(){
  S.recording=true;
  S.rec={t:[],W:[],C:[],root:[],t0:lastTs};
  if(RT.cur)RT.ref=RT.cur.slice();
  $('btnRec').textContent='■ Stoppen';
}
function stopRec(trim){
  S.recording=false;$('btnRec').textContent='● Aufnahme';$('tbar').firstElementChild.style.width='0%';
  const r=S.rec;S.rec=null;
  if(trim>0){const lim=r.t[r.t.length-1]-trim*1000;let k=r.t.length;while(k>2&&r.t[k-1]>lim)k--;r.t.length=r.W.length=r.C.length=r.root.length=k}
  if(r.W.length<10){setStatus('Aufnahme zu kurz – nochmal.');return}
  lastRec=r;runProcess();
}
function runProcess(){
  if(!lastRec)return;S.processing=true;setStatus('Verarbeite …');
  setTimeout(()=>{
    const fps=+$('selFps').value;
    const prev=PB&&PB.fullM?{a:PB.a,b:PB.b,n:PB.fullM}:null;
    PB=processRecording(lastRec,fps,{smooth:val('smooth'),outlier:val('outlier')});
    if(prev&&prev.n===PB.fullM){PB.a=prev.a;PB.b=prev.b}
    S.processing=false;enterPlay();
    const s=PB.stats;
    setStatus(`Loop · ${s.dur.toFixed(1)} s @ ${fps} fps · Flips korrigiert: ${s.flips} (+${s.zflips} Tiefe) · verworfene Messwerte: ${(s.invalid*100).toFixed(1)} %`);
  },30);
}
['blend','rootXY','rootZ'].forEach(id=>$(id).addEventListener('input',()=>{if(PB&&S.mode==='play')rebuild()}));
let deb;['smooth','outlier','legThr'].forEach(id=>$(id).addEventListener('input',()=>{clearTimeout(deb);deb=setTimeout(()=>lastRec&&S.mode==='play'&&runProcess(),300)}));
$('selFps').addEventListener('change',()=>lastRec&&runProcess());
$('mpConf').addEventListener('change',()=>{if(lm){const c=val('mpConf');lm.setOptions({minPoseDetectionConfidence:c,minPosePresenceConfidence:c,minTrackingConfidence:c})}});
$('selModel').addEventListener('change',async()=>{if(lm){await initLandmarker($('selModel').value);setStatus('Modell gewechselt.')}});
$('btnCam').onclick=startCam;
$('btnRec').onclick=()=>{
  if(S.recording){stopRec(0);return}
  if(S.countT!=null){hideCount();$('btnRec').textContent='● Aufnahme';setStatus('Bereit');return}
  S.mode='live';S.countT=performance.now();$('btnRec').textContent='Abbrechen';
  $('tlInfo').textContent='Live: letzte ~20 s · rote Striche = Flip korrigiert · Linie = Schwelle';
};
$('btnLive').onclick=()=>{hideCount();if(S.recording){S.recording=false;S.rec=null;$('tbar').firstElementChild.style.width='0%'}$('btnRec').textContent='● Aufnahme';S.mode='live';$('tlInfo').textContent='Live: letzte ~20 s · rote Striche = Flip korrigiert · Linie = Schwelle';setStatus('Bereit')};
$('btnPlay').onclick=()=>{if(PB){PB.playing=!PB.playing;$('btnPlay').textContent=PB.playing?'Pause':'Abspielen'}};
$('scrub').addEventListener('input',e=>{if(PB){PB.playing=false;$('btnPlay').textContent='Abspielen';PB.pos=e.target.value/1000*(PB.M-1)}});
$('btnCal').onclick=()=>{L.bones=null;resetLive();RT.ref=null;RT.cur=null;RT.f.forEach(f=>{f.x=null;f.dx=0});setStatus('Kalibriere Körper … kurz stillstehen.')};
/* ---------- Schnitt ---------- */
function buildClip(P){
  const len=P.b-P.a+1,K=Math.min(Math.round(val('blend')*P.fps),Math.floor(len/3)),a=P.a,B=P.stats.bones;
  const M2=K>1?len-K:len,out=new Float32Array(M2*45);
  for(let m=0;m<M2;m++){
    if(K>1&&m<K){const w=m/K;for(let q=0;q<45;q++)out[m*45+q]=(1-w)*P.Fs[(a+M2+m)*45+q]+w*P.Fs[(a+m)*45+q];enforce(out.subarray(m*45,m*45+45),B)}
    else out.set(P.Fs.subarray((a+m)*45,(a+m)*45+45),m*45);
  }
  P.M=M2;P.frames=out;P.raw=P.R.subarray(a*45,(a+M2)*45);P.Cj=P.Cr.subarray(a*NJ,(a+M2)*NJ);
  P.conf=P.fullConf.subarray(a,a+M2);P.inv=P.fullInv.subarray(a,a+M2);P.pos=(P.pos||0)%M2;
  const rt=new Float32Array(M2*3),RF=P.rootFull,base=[RF[a*3],RF[a*3+1],RF[a*3+2]],sc=[val('rootXY'),val('rootXY'),val('rootZ')];
  for(let m=0;m<M2;m++)for(let ax=0;ax<3;ax++){
    const v=(K>1&&m<K)?(1-m/K)*RF[(a+M2+m)*3+ax]+(m/K)*RF[(a+m)*3+ax]:RF[(a+m)*3+ax];
    rt[m*3+ax]=(v-base[ax])*sc[ax];
  }
  P.root=rt;
}
function updTrim(){
  $('trimAv').textContent=(PB.a/PB.fps).toFixed(2)+' s';$('trimBv').textContent=(PB.b/PB.fps).toFixed(2)+' s';
  $('clipInfo').textContent=`Clip: ${(PB.M/PB.fps).toFixed(2)} s · ${PB.M} Frames`;
}
function rebuild(){buildClip(PB);updTrim()}
function enterPlay(){
  S.mode='play';
  $('trimA').max=$('trimB').max=PB.fullM-1;$('trimA').value=PB.a;$('trimB').value=PB.b;
  ['btnPlay','btnJson','btnGlb','btnFbx','trimA','trimB','btnSetA','btnSetB','scrub'].forEach(i=>$(i).disabled=false);
  $('btnPlay').textContent=PB.playing?'Pause':'Abspielen';
  $('tlInfo').textContent='Aufnahme: schwarz = Roh-Konfidenz · rot = verworfene Gelenke · grau = weggeschnitten · Strich = Playhead';
  rebuild();
}
const MINF=6;
$('trimA').addEventListener('input',e=>{if(!PB)return;PB.a=Math.min(+e.target.value,PB.b-MINF);e.target.value=PB.a;rebuild()});
$('trimB').addEventListener('input',e=>{if(!PB)return;PB.b=Math.max(+e.target.value,PB.a+MINF);e.target.value=PB.b;rebuild()});
$('btnSetA').onclick=()=>{if(!PB)return;PB.a=Math.min(PB.a+Math.floor(PB.pos),PB.b-MINF);$('trimA').value=PB.a;PB.pos=0;rebuild()};
$('btnSetB').onclick=()=>{if(!PB)return;PB.b=Math.max(PB.a+Math.floor(PB.pos),PB.a+MINF);$('trimB').value=PB.b;rebuild()};

/* ---------- Export / Import (Format „bodyrec“, Y-up, Meter) ---------- */
const JN=['head','shoulder_L','shoulder_R','elbow_L','elbow_R','wrist_L','wrist_R','hip_L','hip_R','knee_L','knee_R','ankle_L','ankle_R','foot_L','foot_R','pelvis','neck'];
const PAR=[16,16,16,1,2,3,4,15,15,7,8,9,10,11,12,-1,15];
const E3=new Float32Array(51);
function pts3(m){ext(PB.frames.subarray(m*45,m*45+45),E3);const o=new Float32Array(51);
  if(PB.root)for(let i=0;i<17;i++){E3[i*3]+=PB.root[m*3];E3[i*3+1]+=PB.root[m*3+1];E3[i*3+2]+=PB.root[m*3+2]}
  for(let i=0;i<17;i++){o[i*3]=E3[i*3];o[i*3+1]=-E3[i*3+1];o[i*3+2]=-E3[i*3+2]}return o}
const dl=(blob,name)=>{const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;a.click()};
$('btnJson').onclick=()=>{
  if(!PB)return;const rows=[];
  for(let m=0;m<PB.M;m++)rows.push(JSON.stringify(Array.from(pts3(m),v=>+v.toFixed(4)+0)));
  const head={format:'bodyrec',version:1,fps:PB.fps,frameCount:PB.M,loop:true,units:'m',
    axes:'Y-up, +Z zum Betrachter, Ursprung = Hüftposition am Clip-Start, Root-Bewegung im Raum enthalten (Tiefe nur grob geschätzt), L/R = linke/rechte Körperseite der Person',
    joints:JN,parents:PAR};
  const s=JSON.stringify(head).slice(0,-1)+',"frames":[\n'+rows.join(',\n')+'\n]}';
  dl(new Blob([s],{type:'application/json'}),'body_record.json');
};
$('btnGlb').onclick=()=>{
  if(!PB)return;const root=new THREE.Group();root.name='bodyrec';
  const nodes=JN.map(n=>{const o=new THREE.Object3D();o.name=n;root.add(o);return o});
  const times=Array.from({length:PB.M},(_,m)=>m/PB.fps),vals=JN.map(()=>new Float32Array(PB.M*3));
  for(let m=0;m<PB.M;m++){const p=pts3(m);for(let i=0;i<17;i++){vals[i][m*3]=p[i*3];vals[i][m*3+1]=p[i*3+1];vals[i][m*3+2]=p[i*3+2]}}
  nodes.forEach((o,i)=>o.position.set(vals[i][0],vals[i][1],vals[i][2]));
  const clip=new THREE.AnimationClip('motion',-1,JN.map((n,i)=>new THREE.VectorKeyframeTrack(n+'.position',times,vals[i])));
  new GLTFExporter().parse(root,res=>dl(new Blob([res],{type:'model/gltf-binary'}),'body_record.glb'),e=>setStatus('GLB-Fehler: '+e),{binary:true,animations:[clip]});
};
$('btnImp').onclick=()=>$('fileImp').click();
$('fileImp').onchange=async e=>{
  const f=e.target.files[0];e.target.value='';if(!f)return;
  try{
    const j=JSON.parse(await f.text());if(j.format!=='bodyrec')throw new Error('keine bodyrec-Datei');
    const M=j.frames.length,F=new Float32Array(M*45);
    for(let m=0;m<M;m++)for(let k=0;k<NJ;k++){F[m*45+k*3]=j.frames[m][k*3];F[m*45+k*3+1]=-j.frames[m][k*3+1];F[m*45+k*3+2]=-j.frames[m][k*3+2]}
    $('blend').value=0;$('blend').dispatchEvent(new Event('input'));
    lastRec=null;hideCount();
    PB={fps:j.fps,fullM:M,Fs:F,R:F,rootFull:new Float32Array(M*3),Cr:new Float32Array(M*NJ).fill(1),fullConf:new Float32Array(M).fill(1),fullInv:new Float32Array(M),
      a:0,b:M-1,stats:{n:M,flips:0,zflips:0,dur:M/j.fps,invalid:0,bones:null},pos:0,playing:true};
    enterPlay();setStatus(`Importiert: ${f.name} · ${M} Frames @ ${j.fps} fps`);
  }catch(err){setStatus('Import fehlgeschlagen: '+err.message)}
};

const MX_REST=[
["Hips",null,[0.0,100.052,0.0],[0,0,0]],
["Spine","Hips",[0.0,9.7173,-1.1802],[-6.9251,0.0,0.0]],
["Spine1","Spine",[0.0,11.4201,-0.0],[0,0,0]],
["Spine2","Spine1",[0.0,13.0516,0.0],[0,0,0]],
["Neck","Spine2",[0.0,14.683,0.0],[6.9251,0.0,0.0]],
["Head","Neck",[0.0,6.7202,1.9722],[0,0,0]],
["HeadTop_End","Head",[0.0,20.9998,6.1628],[0,0,0]],
["LeftShoulder","Spine2",[6.1402,13.0007,-0.2336],[-74.5519,87.8449,-179.703]],
["LeftArm","LeftShoulder",[-0.0,13.0059,-0.0],[-11.3737,-0.0,-1.7449]],
["LeftForeArm","LeftArm",[-0.0,25.5608,-0.0],[-5.6438,-0.0,6.4746]],
["LeftHand","LeftForeArm",[-0.0,23.4041,0.0],[6.5666,12.9738,3.2586]],
["LeftHandThumb1","LeftHand",[-2.8677,3.9358,1.1883],[13.7854,-0.0,30.1043]],
["LeftHandThumb2","LeftHandThumb1",[-0.4054,3.9896,-0.0],[9.6834,-0.0,0.0]],
["LeftHandThumb3","LeftHandThumb2",[-0.0373,3.5999,0.0],[-0.0443,0.0,0.0]],
["LeftHandThumb4","LeftHandThumb3",[0.4427,2.9791,-0.0],[0,0,0]],
["LeftHandIndex1","LeftHand",[-3.4842,11.7579,0.1131],[2.1697,-0.0,6.7115]],
["LeftHandIndex2","LeftHandIndex1",[0.0231,3.2984,0.0],[6.9865,0.0,-0.0]],
["LeftHandIndex3","LeftHandIndex2",[-0.0068,3.081,0.0],[2.8785,0.0,0.0]],
["LeftHandIndex4","LeftHandIndex3",[-0.0163,2.7121,-0.0],[0,0,0]],
["LeftHandMiddle1","LeftHand",[-1.0293,12.3471,-0.3158],[5.2538,0.0,-0.1688]],
["LeftHandMiddle2","LeftHandMiddle1",[0.0099,3.4322,-0.0],[6.0393,0.0,0.0]],
["LeftHandMiddle3","LeftHandMiddle2",[-0.01,3.1609,0.0],[-3.1621,0.0,0.0]],
["LeftHandMiddle4","LeftHandMiddle3",[0.0,2.8764,0.0],[0,0,0]],
["LeftHandRing1","LeftHand",[1.2667,12.5059,-0.2771],[14.1024,0.0,-2.7183]],
["LeftHandRing2","LeftHandRing1",[-0.0066,2.7261,0.0],[-4.2977,0.0,-0.0]],
["LeftHandRing3","LeftHandRing2",[0.0034,2.6203,-0.0],[-0.2458,-0.0,0.0]],
["LeftHandRing4","LeftHandRing3",[0.0032,2.2269,0.0],[0,0,0]],
["LeftHandPinky1","LeftHand",[3.2468,11.102,-0.1128],[10.9445,0.0,-6.0596]],
["LeftHandPinky2","LeftHandPinky1",[0.0084,2.7346,-0.0],[4.5542,0.0,0.0]],
["LeftHandPinky3","LeftHandPinky2",[-0.0029,2.194,0.0],[1.5437,0.0,0.0]],
["LeftHandPinky4","LeftHandPinky3",[-0.0055,1.9755,0.0],[0,0,0]],
["RightShoulder","Spine2",[-6.1402,12.9937,-0.1759],[-74.5589,-88.3725,179.774]],
["RightArm","RightShoulder",[0.0,13.0059,-0.0],[-11.4749,0.0,2.6572]],
["RightForeArm","RightArm",[0.0,25.5711,-0.0],[-5.6442,-0.0,-6.6754]],
["RightHand","RightForeArm",[0.0,23.4017,-0.0],[6.7613,-12.3595,-3.9658]],
["RightHandThumb1","RightHand",[2.8851,4.0543,1.212],[13.7655,-0.0,-29.2579]],
["RightHandThumb2","RightHandThumb1",[0.4284,4.0748,0.0],[9.9972,0.0,-0.0]],
["RightHandThumb3","RightHandThumb2",[0.0365,3.5304,0.0],[-5.7883,0.0,0.0]],
["RightHandThumb4","RightHandThumb3",[-0.4649,2.8735,-0.0],[0,0,0]],
["RightHandIndex1","RightHand",[3.6145,12.3466,-0.0078],[5.1081,-0.0,-3.5256]],
["RightHandIndex2","RightHandIndex1",[-0.0119,3.1174,0.0],[4.8143,0.0,-0.0]],
["RightHandIndex3","RightHandIndex2",[0.0066,2.8561,-0.0],[-0.1834,0.0,0.0]],
["RightHandIndex4","RightHandIndex3",[0.0053,2.5346,0.0],[0,0,0]],
["RightHandMiddle1","RightHand",[0.9224,12.1764,-0.2222],[2.8,0.0,0.1626]],
["RightHandMiddle2","RightHandMiddle1",[-0.0162,3.4792,0.0],[8.1797,0.0,0.0]],
["RightHandMiddle3","RightHandMiddle2",[0.0137,3.226,-0.0],[-3.1231,0.0,0.0]],
["RightHandMiddle4","RightHandMiddle3",[0.0025,2.928,0.0],[0,0,0]],
["RightHandRing1","RightHand",[-1.2166,12.2821,-0.151],[8.1157,0.0,5.1482]],
["RightHandRing2","RightHandRing1",[-0.0037,2.8216,-0.0],[5.0641,0.0,-0.0]],
["RightHandRing3","RightHandRing2",[0.0078,2.6877,-0.0],[-5.5827,0.0,0.0]],
["RightHandRing4","RightHandRing3",[-0.0041,2.3684,-0.0],[0,0,0]],
["RightHandPinky1","RightHand",[-3.3203,11.1946,-0.138],[9.7961,0.0,6.6609]],
["RightHandPinky2","RightHandPinky1",[-0.0081,2.6405,0.0],[6.9326,0.0,0.0]],
["RightHandPinky3","RightHandPinky2",[0.0056,2.149,-0.0],[-2.2854,-0.0,0.0]],
["RightHandPinky4","RightHandPinky3",[0.0025,1.8884,-0.0],[0,0,0]],
["LeftUpLeg","Hips",[8.0782,-5.4024,-0.5359],[-3.6117,0.3169,-174.9916]],
["LeftLeg","LeftUpLeg",[0.0,42.7898,-0.0],[-0.3048,0.0,-0.4501]],
["LeftFoot","LeftLeg",[0.0,42.1737,0.0],[59.3652,-1.9435,-0.0]],
["LeftToeBase","LeftFoot",[-0.0,16.1823,0.0],[33.7905,-6.7718,-0.0]],
["LeftToe_End","LeftToeBase",[0.0,6.2059,0.0],[0,0,0]],
["RightUpLeg","Hips",[-8.0782,-5.4024,-0.7518],[-3.303,-0.29,174.9883]],
["RightLeg","RightUpLeg",[0.0,42.7759,-0.0],[-0.7312,-0.0,0.452]],
["RightFoot","RightLeg",[-0.0,42.1797,-0.0],[59.6706,1.9057,-0.0]],
["RightToeBase","RightFoot",[0.0,16.2605,0.0],[33.6018,6.8709,0.0]],
["RightToe_End","RightToeBase",[0.0,6.18,0.0],[0,0,0]]];

/* ---------- Mixamo-Retarget: 17 Punkte → Mixamo-Skelett (Rotationen) + FBX-Export ---------- */
const m3={
  mul:(A,B)=>{const C=new Array(9);for(let r=0;r<3;r++)for(let c=0;c<3;c++)C[r*3+c]=A[r*3]*B[c]+A[r*3+1]*B[3+c]+A[r*3+2]*B[6+c];return C},
  T:A=>[A[0],A[3],A[6],A[1],A[4],A[7],A[2],A[5],A[8]],
  vec:(A,v)=>[A[0]*v[0]+A[1]*v[1]+A[2]*v[2],A[3]*v[0]+A[4]*v[1]+A[5]*v[2],A[6]*v[0]+A[7]*v[1]+A[8]*v[2]],
  cols:(x,y,z)=>[x[0],y[0],z[0],x[1],y[1],z[1],x[2],y[2],z[2]],
  rx:a=>{a*=Math.PI/180;const c=Math.cos(a),s=Math.sin(a);return [1,0,0,0,c,-s,0,s,c]},
  ry:a=>{a*=Math.PI/180;const c=Math.cos(a),s=Math.sin(a);return [c,0,s,0,1,0,-s,0,c]},
  rz:a=>{a*=Math.PI/180;const c=Math.cos(a),s=Math.sin(a);return [c,-s,0,s,c,0,0,0,1]},
  euler:e=>m3.mul(m3.rz(e[2]),m3.mul(m3.ry(e[1]),m3.rx(e[0]))),   // FBX XYZ: R = Rz·Ry·Rx
  toEuler:M=>{ // Rz·Ry·Rx → Grad
    const s=clamp(-M[6],-1,1),b=Math.asin(s);let a,c;
    if(Math.abs(s)<.99999){a=Math.atan2(M[7],M[8]);c=Math.atan2(M[3],M[0])}
    else{a=Math.atan2(-M[5],M[4]);c=0}
    return [a*180/Math.PI,b*180/Math.PI,c*180/Math.PI]},
  toQ:M=>{const t=M[0]+M[4]+M[8];let x,y,z,w;
    if(t>0){const s=Math.sqrt(t+1)*2;w=s/4;x=(M[7]-M[5])/s;y=(M[2]-M[6])/s;z=(M[3]-M[1])/s}
    else if(M[0]>M[4]&&M[0]>M[8]){const s=Math.sqrt(1+M[0]-M[4]-M[8])*2;w=(M[7]-M[5])/s;x=s/4;y=(M[1]+M[3])/s;z=(M[2]+M[6])/s}
    else if(M[4]>M[8]){const s=Math.sqrt(1+M[4]-M[0]-M[8])*2;w=(M[2]-M[6])/s;x=(M[1]+M[3])/s;y=s/4;z=(M[5]+M[7])/s}
    else{const s=Math.sqrt(1+M[8]-M[0]-M[4])*2;w=(M[3]-M[1])/s;x=(M[2]+M[6])/s;y=(M[5]+M[7])/s;z=s/4}
    return [x,y,z,w]},
  fromQ:q=>{const [x,y,z,w]=q;return [1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w),2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w),2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y)]}
};
const qm={
  mul:(a,b)=>[a[3]*b[0]+a[0]*b[3]+a[1]*b[2]-a[2]*b[1],a[3]*b[1]-a[0]*b[2]+a[1]*b[3]+a[2]*b[0],a[3]*b[2]+a[0]*b[1]-a[1]*b[0]+a[2]*b[3],a[3]*b[3]-a[0]*b[0]-a[1]*b[1]-a[2]*b[2]],
  rot:(q,v)=>m3.vec(m3.fromQ(q),v),
  unit:(a,b)=>{ // kleinste Drehung a→b
    const d=a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
    if(d<-.999999){const ax=Math.abs(a[0])<.9?[1,0,0]:[0,1,0];let c=[a[1]*ax[2]-a[2]*ax[1],a[2]*ax[0]-a[0]*ax[2],a[0]*ax[1]-a[1]*ax[0]];const l=Math.hypot(...c);return [c[0]/l,c[1]/l,c[2]/l,0]}
    const c=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],w=1+d,l=Math.hypot(c[0],c[1],c[2],w);
    return [c[0]/l,c[1]/l,c[2]/l,w/l]},
  slerp:(a,b,t)=>{let d=a[0]*b[0]+a[1]*b[1]+a[2]*b[2]+a[3]*b[3];if(d<0){b=[-b[0],-b[1],-b[2],-b[3]];d=-d}
    if(d>.9995){const r=a.map((v,i)=>v+(b[i]-v)*t);const l=Math.hypot(...r);return r.map(v=>v/l)}
    const th=Math.acos(d),s=Math.sin(th),wa=Math.sin((1-t)*th)/s,wb=Math.sin(t*th)/s;return a.map((v,i)=>v*wa+b[i]*wb)}
};
const v3={sub:(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]],add:(a,b)=>[a[0]+b[0],a[1]+b[1],a[2]+b[2]],mul:(a,s)=>[a[0]*s,a[1]*s,a[2]*s],
  dot:(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2],cross:(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],
  nrm:a=>{const l=Math.hypot(a[0],a[1],a[2])||1e-9;return [a[0]/l,a[1]/l,a[2]/l]},len:a=>Math.hypot(a[0],a[1],a[2])};
function mxFrame(x,y){x=v3.nrm(x);y=v3.nrm(v3.sub(y,v3.mul(x,v3.dot(x,y))));return m3.cols(x,y,v3.cross(x,y))}

const MX={};
(function(){
  MX.idx={};MX.rest=MX_REST;MX_REST.forEach((b,i)=>MX.idx[b[0]]=i);
  MX.Rpre=[];MX.R0=[];MX.P0=[];
  MX_REST.forEach(([n,p,T,pre],i)=>{
    const Rp=m3.euler(pre);MX.Rpre[i]=Rp;
    if(p===null){MX.R0[i]=Rp;MX.P0[i]=T.slice()}
    else{const pi=MX.idx[p],o=m3.vec(MX.R0[pi],T);MX.R0[i]=m3.mul(MX.R0[pi],Rp);MX.P0[i]=v3.add(MX.P0[pi],o)}
  });
  const P=n=>MX.P0[MX.idx[n]],UP=[0,1,0];
  const pelR=v3.mul(v3.add(P('LeftUpLeg'),P('RightUpLeg')),.5),nkR=v3.mul(v3.add(P('LeftArm'),P('RightArm')),.5);
  const spR=v3.nrm(v3.sub(nkR,pelR));
  MX.FhR=mxFrame(v3.sub(P('LeftUpLeg'),P('RightUpLeg')),v3.nrm(v3.add(v3.mul(UP,.5),v3.mul(spR,.5))));
  MX.FcR=mxFrame(v3.sub(P('LeftArm'),P('RightArm')),spR);
  MX.headV=[0,19.5,10.9];                 // grobe Ruhelage Nacken-Mitte → Nase
  MX.legLen=v3.len(v3.sub(P('LeftLeg'),P('LeftUpLeg')))+v3.len(v3.sub(P('LeftFoot'),P('LeftLeg')));
  MX.hips0=P('Hips');
  // Bone → [Kind-Ruhelage-Bone, Index Start-Punkt, Index End-Punkt] für Richtungs-Retarget
  MX.dir={LeftArm:['LeftForeArm',1,3],LeftForeArm:['LeftHand',3,5],RightArm:['RightForeArm',2,4],RightForeArm:['RightHand',4,6],
    LeftUpLeg:['LeftLeg',7,9],LeftLeg:['LeftFoot',9,11],LeftFoot:['LeftToeBase',11,13],
    RightUpLeg:['RightLeg',8,10],RightLeg:['RightFoot',10,12],RightFoot:['RightToeBase',12,14]};
})();

/* pts: Float32Array(51) in Metern, Y-up, +Z zum Betrachter (Person schaut +Z, links = +X). ref: {p0:[x,y,z], k:Skalierung} */
function mxRetarget(pts,ref){
  const g=i=>[pts[i*3],pts[i*3+1],pts[i*3+2]],UP=[0,1,0];
  const hipL=g(7),hipR=g(8),pel=g(15),nk=g(16),shL=g(1),shR=g(2),nose=g(0);
  const sp=v3.nrm(v3.sub(nk,pel));
  const Fh=mxFrame(v3.sub(hipL,hipR),v3.nrm(v3.add(v3.mul(UP,.5),v3.mul(sp,.5))));
  const Fc=mxFrame(v3.sub(shL,shR),sp);
  const qh=m3.toQ(m3.mul(Fh,m3.T(MX.FhR))),qc=m3.toQ(m3.mul(Fc,m3.T(MX.FcR)));
  const Dq=new Array(MX_REST.length);
  const set=(n,q)=>Dq[MX.idx[n]]=q;
  set('Hips',qh);set('Spine',qm.slerp(qh,qc,1/3));set('Spine1',qm.slerp(qh,qc,2/3));set('Spine2',qc);
  const hv=v3.sub(nose,nk),rv=v3.nrm(qm.rot(qc,MX.headV));
  const qHead=hv[1]*0+v3.len(hv)>.05?qm.mul(qm.unit(rv,v3.nrm(hv)),qc):qc;
  set('Head',qHead);set('Neck',qm.slerp(qc,qHead,.4));set('HeadTop_End',qHead);
  set('LeftShoulder',qc);set('RightShoulder',qc);
  MX_REST.forEach(([n,p],i)=>{
    if(Dq[i])return;
    const par=Dq[MX.idx[p]]; // Eltern stehen vor Kindern
    const d=MX.dir[n];
    if(d){
      const d0=v3.nrm(v3.sub(MX.P0[MX.idx[d[0]]],MX.P0[i])),d1=v3.sub(g(d[2]),g(d[1]));
      Dq[i]=v3.len(d1)>.02?qm.mul(qm.unit(v3.nrm(qm.rot(par,d0)),v3.nrm(d1)),par):par;
    }else Dq[i]=par;
  });
  const Rw=[],eul=[];
  MX_REST.forEach(([n,p],i)=>{
    Rw[i]=m3.mul(m3.fromQ(Dq[i]),MX.R0[i]);
    const Rp=p===null?[1,0,0,0,1,0,0,0,1]:Rw[MX.idx[p]];
    eul[i]=m3.toEuler(m3.mul(m3.T(MX.Rpre[i]),m3.mul(m3.T(Rp),Rw[i])));
  });
  const k=ref.k*100;
  const hips=[MX.hips0[0]+(pel[0]-ref.p0[0])*k,MX.hips0[1]+(pel[1]-ref.p0[1])*k,MX.hips0[2]+(pel[2]-ref.p0[2])*k];
  return {hips,eul};
}
/* Winkel über Frames durchgängig halten (kein Sprung ±180°) */
function mxUnwrap(seq){
  for(let i=1;i<seq.length;i++)for(let b=0;b<seq[i].eul.length;b++)for(let a=0;a<3;a++){
    let v=seq[i].eul[b][a];const pv=seq[i-1].eul[b][a];
    while(v-pv>180)v-=360;while(v-pv<-180)v+=360;seq[i].eul[b][a]=v;
  }
}
/* ASCII-FBX im Aufbau einer Mixamo-Animation (65 Bones, Prefix mixamorig1:) */
function mxBuildFbx(seq,fps,prefix){
  prefix=prefix||'mixamorig1:';
  const N=MX_REST.length,KT=46186158000,nf=seq.length,dur=Math.round((nf-1)/fps*KT);
  const f=v=>{const r=Math.round(v*1e5)/1e5;return Object.is(r,-0)?'0':String(r)};
  const times=[];for(let i=0;i<nf;i++)times.push(Math.round(i/fps*KT));
  let id=1000000;const nid=()=>++id;
  const mid=[],aid=[];for(let i=0;i<N;i++){mid[i]=nid();aid[i]=nid()}
  const stack=nid(),layer=nid(),cnT=nid(),cnR=[];for(let i=0;i<N;i++)cnR[i]=nid();
  const curves=[];                       // {id,node,axis,vals}
  const addC=(node,axis,vals)=>{curves.push({id:nid(),node,axis,vals})};
  ['X','Y','Z'].forEach((ax,a)=>addC(cnT,ax,seq.map(s=>s.hips[a])));
  for(let i=0;i<N;i++)['X','Y','Z'].forEach((ax,a)=>addC(cnR[i],ax,seq.map(s=>s.eul[i][a])));
  const o=[];
  o.push('; FBX 7.7.0 project file\n; BODY:RECORD Export (Mixamo-Skelett)\n');
  o.push('FBXHeaderExtension:  {\n\tFBXHeaderVersion: 1004\n\tFBXVersion: 7700\n\tCreator: "BODY:RECORD"\n}\n');
  o.push('GlobalSettings:  {\n\tVersion: 1000\n\tProperties70:  {\n\t\tP: "UpAxis", "int", "Integer", "",1\n\t\tP: "UpAxisSign", "int", "Integer", "",1\n\t\tP: "FrontAxis", "int", "Integer", "",2\n\t\tP: "FrontAxisSign", "int", "Integer", "",1\n\t\tP: "CoordAxis", "int", "Integer", "",0\n\t\tP: "CoordAxisSign", "int", "Integer", "",1\n\t\tP: "UnitScaleFactor", "double", "Number", "",1\n\t\tP: "OriginalUnitScaleFactor", "double", "Number", "",1\n\t\tP: "TimeMode", "enum", "", "",14\n\t\tP: "TimeProtocol", "enum", "", "",2\n\t\tP: "CustomFrameRate", "double", "Number", "",'+fps+'\n\t\tP: "TimeSpanStart", "KTime", "Time", "",0\n\t\tP: "TimeSpanStop", "KTime", "Time", "",'+dur+'\n\t}\n}\n');
  o.push('Documents:  {\n\tCount: 1\n\tDocument: 912097184, "", "Scene" {\n\t\tProperties70:  {\n\t\t\tP: "ActiveAnimStackName", "KString", "", "", "mixamo.com"\n\t\t}\n\t\tRootNode: 0\n\t}\n}\n');
  o.push('References:  {\n}\n');
  o.push('Definitions:  {\n\tVersion: 100\n\tCount: '+(1+N*2+3+N+1+curves.length)+'\n\tObjectType: "GlobalSettings" {\n\t\tCount: 1\n\t}\n\tObjectType: "Model" {\n\t\tCount: '+N+'\n\t}\n\tObjectType: "NodeAttribute" {\n\t\tCount: '+N+'\n\t}\n\tObjectType: "AnimationStack" {\n\t\tCount: 1\n\t}\n\tObjectType: "AnimationLayer" {\n\t\tCount: 1\n\t}\n\tObjectType: "AnimationCurveNode" {\n\t\tCount: '+(N+1)+'\n\t}\n\tObjectType: "AnimationCurve" {\n\t\tCount: '+curves.length+'\n\t}\n}\n');
  o.push('Objects:  {\n');
  for(let i=0;i<N;i++)o.push('\tNodeAttribute: '+aid[i]+', "NodeAttribute::", "LimbNode" {\n\t\tTypeFlags: "Skeleton"\n\t}\n');
  MX_REST.forEach(([n,p,T,pre],i)=>{
    const e0=seq[0].eul[i],t0=i===0?seq[0].hips:T;
    o.push('\tModel: '+mid[i]+', "Model::'+prefix+n+'", "LimbNode" {\n\t\tVersion: 232\n\t\tProperties70:  {\n');
    if(pre[0]||pre[1]||pre[2])o.push('\t\t\tP: "PreRotation", "Vector3D", "Vector", "",'+pre.map(f).join(',')+'\n');
    o.push('\t\t\tP: "RotationActive", "bool", "", "",1\n\t\t\tP: "ScalingMax", "Vector3D", "Vector", "",0,0,0\n\t\t\tP: "DefaultAttributeIndex", "int", "Integer", "",0\n');
    o.push('\t\t\tP: "Lcl Translation", "Lcl Translation", "", "'+(i===0?'A+':'A')+'",'+t0.map(f).join(',')+'\n');
    o.push('\t\t\tP: "Lcl Rotation", "Lcl Rotation", "", "A+",'+e0.map(f).join(',')+'\n\t\t}\n\t\tShading: Y\n\t\tCulling: "CullingOff"\n\t}\n');
  });
  o.push('\tAnimationStack: '+stack+', "AnimStack::mixamo.com", "" {\n\t\tProperties70:  {\n\t\t\tP: "LocalStop", "KTime", "Time", "",'+dur+'\n\t\t\tP: "ReferenceStop", "KTime", "Time", "",'+dur+'\n\t\t}\n\t}\n');
  o.push('\tAnimationLayer: '+layer+', "AnimLayer::Layer0", "" {\n\t}\n');
  const cnode=(cid,kind,v)=>o.push('\tAnimationCurveNode: '+cid+', "AnimCurveNode::'+kind+'", "" {\n\t\tProperties70:  {\n\t\t\tP: "d|X", "Number", "", "A",'+f(v[0])+'\n\t\t\tP: "d|Y", "Number", "", "A",'+f(v[1])+'\n\t\t\tP: "d|Z", "Number", "", "A",'+f(v[2])+'\n\t\t}\n\t}\n');
  cnode(cnT,'T',seq[0].hips);for(let i=0;i<N;i++)cnode(cnR[i],'R',seq[0].eul[i]);
  const tj=times.join(',');
  for(const c of curves){
    o.push('\tAnimationCurve: '+c.id+', "AnimCurve::", "" {\n\t\tDefault: '+f(c.vals[0])+'\n\t\tKeyVer: 4009\n\t\tKeyTime: *'+nf+' {\n\t\t\ta: '+tj+'\n\t\t} \n\t\tKeyValueFloat: *'+nf+' {\n\t\t\ta: '+c.vals.map(f).join(',')+'\n\t\t} \n\t\tKeyAttrFlags: *1 {\n\t\t\ta: 8456\n\t\t} \n\t\tKeyAttrDataFloat: *4 {\n\t\t\ta: 0,0,218434821,0\n\t\t} \n\t\tKeyAttrRefCount: *1 {\n\t\t\ta: '+nf+'\n\t\t} \n\t}\n');
  }
  o.push('}\n');
  o.push('Connections:  {\n');
  MX_REST.forEach(([n,p],i)=>{
    o.push('\tC: "OO",'+mid[i]+','+(p===null?0:mid[MX.idx[p]])+'\n\tC: "OO",'+aid[i]+','+mid[i]+'\n');
    o.push('\tC: "OP",'+cnR[i]+','+mid[i]+', "Lcl Rotation"\n');
    if(i===0)o.push('\tC: "OP",'+cnT+','+mid[i]+', "Lcl Translation"\n');
  });
  o.push('\tC: "OO",'+layer+','+stack+'\n\tC: "OO",'+cnT+','+layer+'\n');
  for(let i=0;i<N;i++)o.push('\tC: "OO",'+cnR[i]+','+layer+'\n');
  for(const c of curves)o.push('\tC: "OP",'+c.id+','+c.node+', "d|'+c.axis+'"\n');
  o.push('}\n');
  o.push('Takes:  {\n\tCurrent: "mixamo.com"\n\tTake: "mixamo.com" {\n\t\tFileName: "mixamo.com.tak"\n\t\tLocalTime: 0,'+dur+'\n\t\tReferenceTime: 0,'+dur+'\n\t}\n}\n');
  return o.join('');
}

/* ---------- FBX-Export (Mixamo-Skelett) ---------- */
$('btnFbx').onclick=()=>{
  if(!PB)return;
  const seq=[];let ref=null;
  for(let m=0;m<PB.M;m++){
    const p=pts3(m);
    if(!ref){
      const gp=i=>[p[i*3],p[i*3+1],p[i*3+2]];
      const own=(dst(gp(7),gp(9))+dst(gp(9),gp(11))+dst(gp(8),gp(10))+dst(gp(10),gp(12)))/2;
      ref={p0:gp(15),k:MX.legLen/(Math.max(.3,own)*100)};
    }
    seq.push(mxRetarget(p,ref));
  }
  mxUnwrap(seq);
  const txt=mxBuildFbx(seq,PB.fps,$('selPrefix').value);
  dl(new Blob([txt],{type:'application/octet-stream'}),'body_record.fbx');
  setStatus(`FBX exportiert · ${seq.length} Frames @ ${PB.fps} fps · Mixamo-Skelett (${$('selPrefix').value}Hips …), Hüfte in cm`);
};

/* ---------- Render-Loop ---------- */
const tmp=new Float32Array(45);let lr=performance.now(),fc=0;
function loop(t){
  requestAnimationFrame(loop);const dt=Math.min(.1,(t-lr)/1000);lr=t;fc++;
  let F=null,C=null,R=null,root=null;
  if(S.mode==='play'&&PB){
    if(PB.playing)PB.pos=(PB.pos+dt*PB.fps*val('speed'))%PB.M;
    const i0=Math.floor(PB.pos),i1=(i0+1)%PB.M,u=PB.pos-i0;
    for(let q=0;q<45;q++)tmp[q]=PB.frames[i0*45+q]*(1-u)+PB.frames[i1*45+q]*u;
    F=tmp;C=PB.Cj.subarray(i0*NJ,i0*NJ+NJ);R=PB.raw.subarray(i0*45,i0*45+45);
    root=[0,1,2].map(a=>PB.root[i0*3+a]*(1-u)+PB.root[i1*3+a]*u);
    $('scrub').value=PB.pos/(PB.M-1)*1000;
    S.cmShow=PB.conf[i0];
  }else if(S.hasLive){F=S.F;C=S.C;R=S.R;root=S.root;S.cmShow=S.cm}
  if(F)updateSkeleton(F,C,R,root);
  {const dx=((root?root[0]:0)-controls.target.x)*.08,dz=((root?-root[2]:0)-controls.target.z)*.08;
    controls.target.x+=dx;controls.target.z+=dz;camera.position.x+=dx;camera.position.z+=dz}
  $('flipmsg').style.display=performance.now()<flipUntil?'block':'none';
  if(fc%4===0&&C){
    for(let j=0;j<NJ;j++){const r=jointRows[j],c=C[j];const b=r.children[1].firstChild;b.style.width=(c*100)+'%';b.style.background=col(c);r.children[2].textContent=c.toFixed(2)}
    const cm=S.cmShow||0;$('confNum').textContent=cm.toFixed(2);const b=$('confBar');b.style.width=cm*100+'%';b.style.background=col(cm);
    $('fpsNum').textContent=fpsE?fpsE.toFixed(0)+' fps':'';
  }
  controls.update();renderer.render(scene,camera);drawTL();
}
function drawTL(){
  const cv=$('tl'),r=devicePixelRatio||1,w=cv.clientWidth,h=cv.clientHeight;
  if(cv.width!==Math.round(w*r)){cv.width=Math.round(w*r);cv.height=Math.round(h*r)}
  const g=cv.getContext('2d');g.setTransform(r,0,0,r,0,0);g.clearRect(0,0,w,h);
  const ty=h-val('confThr')*h;g.strokeStyle='#ddd';g.setLineDash([4,4]);g.beginPath();g.moveTo(0,ty);g.lineTo(w,ty);g.stroke();g.setLineDash([]);
  let data,n,inv=null,evs=null;
  if(S.mode==='play'&&PB){data=PB.fullConf;n=PB.fullM;inv=PB.fullInv}else{data=hist.map(x=>x.c);n=Math.max(600,data.length);evs=hist}
  if(!data.length)return;
  g.beginPath();for(let i=0;i<data.length;i++){const x=i/(n-1)*w,y=h-data[i]*h;i?g.lineTo(x,y):g.moveTo(x,y)}
  g.strokeStyle='#111';g.lineWidth=1.2;g.stroke();g.lineTo((data.length-1)/(n-1)*w,h);g.lineTo(0,h);g.fillStyle='rgba(0,0,0,.05)';g.fill();
  if(inv){g.fillStyle='rgba(224,69,47,.55)';for(let i=0;i<n;i++)if(inv[i]>0)g.fillRect(i/(n-1)*w,h-inv[i]*h*.6,Math.max(1,w/n),inv[i]*h*.6);
    const xa=PB.a/(n-1)*w,xb=PB.b/(n-1)*w;g.fillStyle='rgba(255,255,255,.8)';g.fillRect(0,0,xa,h);g.fillRect(xb,0,w-xb,h);
    g.strokeStyle='#999';g.beginPath();g.moveTo(xa,0);g.lineTo(xa,h);g.moveTo(xb,0);g.lineTo(xb,h);g.stroke();
    const px=(PB.a+PB.pos)/(n-1)*w;g.strokeStyle='#111';g.beginPath();g.moveTo(px,0);g.lineTo(px,h);g.stroke()}
  if(evs){g.strokeStyle='#e0452f';evs.forEach((e,i)=>{if(e.ev){const x=i/(n-1)*w;g.beginPath();g.moveTo(x,0);g.lineTo(x,h);g.stroke()}})}
}
requestAnimationFrame(loop);
</script>
</body>
</html>