diff --git a/.gitignore b/.gitignore index fff1c4c..fb78a80 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ shots/ .dev.vars .dev.vars.* !.dev.vars.example +.freemocap-venv/ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 6484f5b..d5fb94b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,27 @@ npm run capture # boots the app headless and screenshots it into shots/ npm run deploy # vite build + Cloudflare Workers static assets ``` +The browser-based **Animation Studio** lives at +[`/animation.html`](http://localhost:5174/animation.html). Upload a reference +video, generate pose keys directly on Tilt's 23-bone rig, correct individual +bones with the rotation gizmo, optionally seed and bake butt/grip/blade stick +landmarks, and export either reloadable +`.tiltanim.json` or a JavaScript pose module for `src/anim/poses/generated/`. + +The Capture source menu has two mocap paths: + +- **Browser MediaPipe** processes one uploaded video locally for the quickest + reference-to-pose workflow. +- **Local FreeMoCap** sends the same uploaded video through an isolated local + [FreeMoCap](https://github.com/freemocap/freemocap) worker, then loads the + filtered motion directly into the editor. Run `npm run freemocap:setup` once + before `npm run dev`. Existing `freemocap_data_by_frame.csv` and + `*_body_3d_xyz.csv` files remain available through the advanced import. + +The FreeMoCap worker is intentionally local-development only; a static +Cloudflare deployment cannot run its Python/native processing stack. Browser +MediaPipe remains available everywhere. + Cloudflare setup, Pages alternative, and CI notes: **[DEPLOY.md](DEPLOY.md)**. In the browser: @@ -134,6 +155,9 @@ src/ poses/goalie.js ready, butterfly, shuffle, reach studio/ img2mesh.js character studio: pose presets, fixed views, capture API + animationStudio.js reference mocap, pose editor, timeline, clip storage + mediapipePose.js browser pose detection and native-rig retargeting + freemocapImport.js FreeMoCap body XYZ CSV adapter render/ rink, materials, camera game/ match.js the loop diff --git a/animation.html b/animation.html new file mode 100644 index 0000000..d10ef49 --- /dev/null +++ b/animation.html @@ -0,0 +1,172 @@ + + + + + +tilt — animation studio + + + + +
+
TILT / ANIMATION
+ + new clip + + + +
+ +
+
+
Reference video
+
+ +
Drop in movementUpload a side or ¾ view with the full body visible.MP4, WebM, or MOV supported by your browser.
The file stays on this device.
+
+
+ +
+
Tilt rig previewNO TRACK
+
drag background to orbit · click a joint to edit · Q/W rotate gizmo
+
+ + +
+ + + + + + diff --git a/index.html b/index.html index 04b2e2f..deb9b2b 100644 --- a/index.html +++ b/index.html @@ -66,6 +66,8 @@ #menu .foot { margin-top:28px; font-size:11px; color:#4a6074; letter-spacing:0.08em; } + #menu .studio-link { margin-top:14px; color:#6a849c; font-size:11px; text-decoration:none; } + #menu .studio-link:hover { color:#9fc6e5; } @@ -88,9 +90,9 @@

Esc returns here · pad or keyboard once you’re in

+ Open animation studio →
TILT…
- diff --git a/package-lock.json b/package-lock.json index bd9cd3b..1c6758c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "tilt", "version": "0.0.1", "dependencies": { + "@mediapipe/tasks-vision": "^0.10.35", "box3d.js": "^0.0.2", "three": "^0.185.1" }, @@ -1231,6 +1232,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.35", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz", + "integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==", + "license": "Apache-2.0" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", diff --git a/package.json b/package.json index c579aad..0bed4e4 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,19 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "node test/skaterSim.mjs && node test/rink.mjs && node test/ai.mjs && node test/pose.mjs && node test/input.mjs && node test/physics.mjs && node test/hits.mjs && node test/puck.mjs && node test/goalie.mjs && node test/shootout.mjs", + "test": "node test/skaterSim.mjs && node test/rink.mjs && node test/ai.mjs && node test/pose.mjs && node test/animationClip.mjs && node test/freemocapImport.mjs && node test/input.mjs && node test/physics.mjs && node test/hits.mjs && node test/puck.mjs && node test/goalie.mjs && node test/shootout.mjs", "capture": "node tools/capture.mjs", "img2mesh": "node tools/img2mesh.mjs", "img2mesh:player": "node tools/img2mesh.mjs --subject player", "img2mesh:goalie": "node tools/img2mesh.mjs --subject goalie", + "freemocap:setup": "sh tools/setup-freemocap.sh", "deploy": "npm run build && wrangler deploy", "deploy:dry": "npm run build && wrangler deploy --dry-run", "pages:deploy": "npm run build && wrangler pages deploy dist --project-name=tilt", "cf:whoami": "wrangler whoami" }, "dependencies": { + "@mediapipe/tasks-vision": "^0.10.35", "box3d.js": "^0.0.2", "three": "^0.185.1" }, diff --git a/shared/player.js b/shared/player.js new file mode 100644 index 0000000..ced4603 --- /dev/null +++ b/shared/player.js @@ -0,0 +1,121 @@ +/** + * Player definition — identity traits that are not sim state. + * + * Appearance (body style, jersey) and locomotion (velocity, effort) live + * elsewhere. This bag is the stable "who is this skater" record: how they hold + * a stick, which side they shoot from, and anything else that picks a pose set + * rather than a frame of motion. + * + * Kept plain and three.js-free so the same record can travel over the wire. + */ + +/** Which side of the body the stick lives on, and which way they shoot. */ +export const SHOT_SIDE = Object.freeze({ + LEFT: 'left', + RIGHT: 'right', +}); + +/** + * Defaults for a new player bag. + * + * Authored stickwork (GRIP targets, arm poses) is written for a **right** + * shot: top hand on the right, lower hand on the left, forehand at −X in + * skater space. A **left** shot mirrors that set across the body midline. + */ +export const PLAYER_DEFAULTS = Object.freeze({ + shotSide: SHOT_SIDE.RIGHT, +}); + +/** + * Accept the spellings we are likely to see and fold them to `'left' | 'right'`. + * + * @param {unknown} v + * @returns {'left' | 'right'} + */ +export function normalizeShotSide(v) { + if (v === SHOT_SIDE.LEFT || v === 'L' || v === 'l' || v === -1 || v === false) { + return SHOT_SIDE.LEFT; + } + if (v === SHOT_SIDE.RIGHT || v === 'R' || v === 'r' || v === 1 || v === true) { + return SHOT_SIDE.RIGHT; + } + return PLAYER_DEFAULTS.shotSide; +} + +/** + * Sign used to mirror authored right-shot content onto a left shot. + * `+1` = as authored (right), `-1` = mirror across the sagittal plane (left). + * + * @param {unknown} side + * @returns {1 | -1} + */ +export function shotSign(side) { + return normalizeShotSide(side) === SHOT_SIDE.LEFT ? -1 : 1; +} + +/** + * Top hand on the stick for this shot side. + * Right shot: right hand on top. Left shot: left hand on top. + * + * @param {unknown} side + * @returns {'L' | 'R'} + */ +export function topHandFor(side) { + return normalizeShotSide(side) === SHOT_SIDE.LEFT ? 'L' : 'R'; +} + +/** + * Lower (blade-side) hand — the one IK pins to the shaft. + * + * @param {unknown} side + * @returns {'L' | 'R'} + */ +export function lowerHandFor(side) { + return normalizeShotSide(side) === SHOT_SIDE.LEFT ? 'R' : 'L'; +} + +/** + * Rough real-world split (~62% left shot in the NHL). Used when a roster + * entry does not specify a side, so a lineup is not all clones. + * + * @param {number} u01 uniform in [0, 1) + * @returns {'left' | 'right'} + */ +export function rollShotSide(u01) { + const u = Number.isFinite(u01) ? u01 : 0.5; + return u < 0.62 ? SHOT_SIDE.LEFT : SHOT_SIDE.RIGHT; +} + +/** + * Normalize a partial player bag. Missing keys take the defaults. + * + * @param {unknown} raw + * @returns {{ shotSide: 'left' | 'right' }} + */ +export function normalizePlayer(raw) { + if (!raw || typeof raw !== 'object') { + return { shotSide: PLAYER_DEFAULTS.shotSide }; + } + return { + shotSide: normalizeShotSide(/** @type {{ shotSide?: unknown }} */ (raw).shotSide), + }; +} + +/** + * Compact wire form for roster snapshots. + * @returns {{ ss: 'L' | 'R' }} + */ +export function packPlayer(player) { + const p = normalizePlayer(player); + return { ss: p.shotSide === SHOT_SIDE.LEFT ? 'L' : 'R' }; +} + +/** Inverse of packPlayer — also accepts a full `{ shotSide }` bag. */ +export function unpackPlayer(raw) { + if (!raw || typeof raw !== 'object') return normalizePlayer(null); + if ('shotSide' in raw) return normalizePlayer(raw); + const ss = /** @type {{ ss?: unknown }} */ (raw).ss; + if (ss === 'L' || ss === 'l') return { shotSide: SHOT_SIDE.LEFT }; + if (ss === 'R' || ss === 'r') return { shotSide: SHOT_SIDE.RIGHT }; + return normalizePlayer(null); +} diff --git a/shared/skaterSim.js b/shared/skaterSim.js index 2c30638..5dcd11f 100644 --- a/shared/skaterSim.js +++ b/shared/skaterSim.js @@ -1,5 +1,6 @@ import { clamp, lerpAngle, wrapAngle } from './scalar.js'; import { clampToRink } from './rink.js'; +import { normalizePlayer } from './player.js'; /** * Skating locomotion. @@ -55,6 +56,7 @@ export const SKATE = Object.freeze({ }); export function createSkaterState(id, spawn = {}, opts = {}) { + const player = normalizePlayer(opts.player ?? opts); return { id, name: opts.name ?? `Skater ${id}`, @@ -62,6 +64,12 @@ export function createSkaterState(id, spawn = {}, opts = {}) { seed: opts.seed ?? 1337, team: opts.team ?? 0, + /** + * Stable identity traits — shot side, and anything else that picks a pose + * set rather than a frame of motion. See `shared/player.js`. + */ + shotSide: player.shotSide, + x: spawn.x ?? 0, y: 0, z: spawn.z ?? 0, diff --git a/src/anim/clip.js b/src/anim/clip.js new file mode 100644 index 0000000..6d5c17f --- /dev/null +++ b/src/anim/clip.js @@ -0,0 +1,345 @@ +import * as THREE from 'three'; +import { BONEDEF } from '../character/skeleton.js'; + +/** + * Compact animation format shared by the reference-video studio and runtime. + * + * The tracks are local bone quaternions, just like the authored pose functions + * in `anim/poses`: they are independent of body proportions and can be applied + * directly to every Tilt skater built from the 23-bone skeleton. + */ +export const TILT_CLIP_FORMAT = 'tilt-animation'; +export const TILT_CLIP_VERSION = 1; +export const TILT_RIG = 'tilt-23'; +export const CLIP_BONES = BONEDEF.map(([name]) => name); + +const _qa = new THREE.Quaternion(); +const _qb = new THREE.Quaternion(); +const _stickTarget = new THREE.Vector3(); +const _stickHand = new THREE.Vector3(); +const _stickHandLocal = new THREE.Vector3(); +const _stickHandQ = new THREE.Quaternion(); +const _stickDirection = new THREE.Vector3(); +const _stickLowerHand = new THREE.Vector3(); +const _sampledStick = {}; +const STICK_TRACK_REACH = 1.12; + +export function createTiltClip({ name = 'reference-motion', fps = 12, loop = true, shotSide = 'right' } = {}) { + return { + format: TILT_CLIP_FORMAT, + version: TILT_CLIP_VERSION, + rig: TILT_RIG, + name, + fps, + loop, + shotSide: shotSide === 'left' ? 'left' : 'right', + duration: 0, + keyframes: [], + }; +} + +function finiteNumber(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function normalizedQuat(value) { + if (!Array.isArray(value) || value.length !== 4) return [0, 0, 0, 1]; + _qa.set( + finiteNumber(value[0]), + finiteNumber(value[1]), + finiteNumber(value[2]), + finiteNumber(value[3], 1), + ); + if (_qa.lengthSq() < 1e-8) _qa.identity(); + else _qa.normalize(); + return _qa.toArray(); +} + +function finiteArray(value, length) { + if (!Array.isArray(value) || value.length !== length) return null; + const out = value.map((n) => finiteNumber(n)); + return out.every(Number.isFinite) ? out : null; +} + +function sanitizeStick(stick) { + if (!stick) return null; + const butt = finiteArray(stick.butt, 2); + const grip = finiteArray(stick.grip, 2); + const blade = finiteArray(stick.blade, 2); + const target = finiteArray(stick.target, 3); + if (!butt || !blade || !target) return null; + const dx = blade[0] - butt[0]; + const dy = blade[1] - butt[1]; + const suppliedAngle = Number(stick.angle); + return { + butt: butt.map((n) => Math.max(0, Math.min(1, n))), + grip: (grip ?? butt).map((n) => Math.max(0, Math.min(1, n))), + blade: blade.map((n) => Math.max(0, Math.min(1, n))), + target, + // Older clips only stored the two 2D marks and an unstable affine target. + // Deriving the camera-plane shaft angle here migrates them on load. + angle: Number.isFinite(suppliedAngle) ? suppliedAngle : Math.atan2(-dy, dx), + roll: finiteNumber(stick.roll), + alignHands: stick.alignHands === true, + confidence: Math.max(0, Math.min(1, finiteNumber(stick.confidence, 1))), + }; +} + +export function sanitizeKeyframe(frame) { + const rotations = {}; + for (const name of CLIP_BONES) { + if (frame?.rotations?.[name]) rotations[name] = normalizedQuat(frame.rotations[name]); + } + const root = Array.isArray(frame?.root) && frame.root.length === 3 + ? frame.root.map((n) => finiteNumber(n)) + : [0, 0, 0]; + const clean = { + time: Math.max(0, finiteNumber(frame?.time)), + root, + rotations, + confidence: Math.max(0, Math.min(1, finiteNumber(frame?.confidence, 1))), + }; + const stick = sanitizeStick(frame?.stick); + if (stick) clean.stick = stick; + return clean; +} + +/** + * Keep a detected shaft line on one continuous angular branch. + * + * Video tracking sees a thin line much more reliably than it identifies which + * end is which, so the same line can arrive as angle or angle + PI. Choosing + * the representation nearest the previous key removes those false half-turns + * without changing the visible 2D shaft line. + */ +function stabilizeStickAngles(clip) { + let previous = null; + for (const frame of clip.keyframes) { + if (!frame.stick || !Number.isFinite(frame.stick.angle)) continue; + let angle = frame.stick.angle; + if (previous !== null) { + while (angle - previous > Math.PI / 2) angle -= Math.PI; + while (angle - previous < -Math.PI / 2) angle += Math.PI; + } + frame.stick.angle = angle; + previous = angle; + } + return clip; +} + +export function sanitizeTiltClip(input) { + if (!input || input.format !== TILT_CLIP_FORMAT) { + throw new Error('Not a Tilt animation clip'); + } + if (Number(input.version) !== TILT_CLIP_VERSION) { + throw new Error(`Unsupported Tilt animation version: ${input.version}`); + } + if (input.rig !== TILT_RIG) throw new Error(`Clip targets ${input.rig}, expected ${TILT_RIG}`); + + const clip = createTiltClip({ + name: String(input.name || 'reference-motion'), + fps: Math.max(1, Math.min(60, finiteNumber(input.fps, 12))), + loop: input.loop !== false, + shotSide: input.shotSide, + }); + clip.keyframes = (Array.isArray(input.keyframes) ? input.keyframes : []) + .map(sanitizeKeyframe) + .sort((a, b) => a.time - b.time); + // Later duplicate frames win. This also keeps the timeline deterministic. + clip.keyframes = clip.keyframes.filter((frame, i, all) => ( + i === all.length - 1 || Math.abs(all[i + 1].time - frame.time) > 1e-5 + )); + stabilizeStickAngles(clip); + clip.duration = clip.keyframes.length + ? Math.max(finiteNumber(input.duration), clip.keyframes.at(-1).time) + : Math.max(0, finiteNumber(input.duration)); + return clip; +} + +export function captureSkeletonKeyframe(skelData, time, { confidence = 1 } = {}) { + const rotations = {}; + for (const name of CLIP_BONES) rotations[name] = skelData.bones[name].quaternion.toArray(); + return sanitizeKeyframe({ + time, + root: skelData.bones.root.position.toArray(), + rotations, + confidence, + }); +} + +export function setClipKeyframe(clip, frame, epsilon = 1 / 240) { + const next = sanitizeKeyframe(frame); + const index = clip.keyframes.findIndex((item) => Math.abs(item.time - next.time) <= epsilon); + if (index >= 0) clip.keyframes[index] = next; + else clip.keyframes.push(next); + clip.keyframes.sort((a, b) => a.time - b.time); + clip.duration = Math.max(clip.duration || 0, next.time); + return next; +} + +export function deleteClipKeyframe(clip, time, epsilon = 1 / 240) { + const index = clip.keyframes.findIndex((item) => Math.abs(item.time - time) <= epsilon); + if (index < 0) return false; + clip.keyframes.splice(index, 1); + clip.duration = clip.keyframes.length ? clip.keyframes.at(-1).time : 0; + return true; +} + +export function frameSpan(clip, rawTime) { + const frames = clip.keyframes; + if (!frames.length) return null; + const duration = Math.max(clip.duration || 0, frames.at(-1).time); + let time = finiteNumber(rawTime); + if (clip.loop && duration > 0) time = ((time % duration) + duration) % duration; + else time = Math.max(0, Math.min(duration, time)); + if (time <= frames[0].time) return { a: frames[0], b: frames[0], alpha: 0, time }; + if (time >= frames.at(-1).time) return { a: frames.at(-1), b: frames.at(-1), alpha: 0, time }; + + let lo = 0; + let hi = frames.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (frames[mid].time <= time) lo = mid; + else hi = mid; + } + const a = frames[lo]; + const b = frames[hi]; + const alpha = (time - a.time) / Math.max(1e-6, b.time - a.time); + return { a, b, alpha, time }; +} + +export function applyTiltClip(skelData, clip, time) { + const span = frameSpan(clip, time); + if (!span) return false; + const { a, b, alpha } = span; + for (const name of CLIP_BONES) { + const av = a.rotations[name] ?? [0, 0, 0, 1]; + const bv = b.rotations[name] ?? av; + _qa.fromArray(av); + _qb.fromArray(bv); + skelData.bones[name].quaternion.slerpQuaternions(_qa, _qb, alpha); + } + skelData.bones.root.position.set( + a.root[0] + (b.root[0] - a.root[0]) * alpha, + a.root[1] + (b.root[1] - a.root[1]) * alpha, + a.root[2] + (b.root[2] - a.root[2]) * alpha, + ); + skelData.rootBone.updateMatrixWorld(true); + return true; +} + +function lerpArray(a, b, alpha, out) { + for (let i = 0; i < a.length; i++) out[i] = a[i] + (b[i] - a[i]) * alpha; + return out; +} + +/** Sample the optional baked stick landmarks and rig-local blade target. */ +export function sampleTiltStick(clip, time, out = {}) { + const span = frameSpan(clip, time); + if (!span) return null; + let a = span.a.stick; + let b = span.b.stick; + if (!a && !b) return null; + if (!a) a = b; + if (!b) b = a; + const alpha = span.alpha; + out.butt = lerpArray(a.butt, b.butt, alpha, out.butt ?? [0, 0]); + out.grip = lerpArray(a.grip, b.grip, alpha, out.grip ?? [0, 0]); + out.blade = lerpArray(a.blade, b.blade, alpha, out.blade ?? [0, 0]); + out.target = lerpArray(a.target, b.target, alpha, out.target ?? [0, 0, 0]); + const angleA = Number.isFinite(a.angle) + ? a.angle + : Math.atan2(-(a.blade[1] - a.butt[1]), a.blade[0] - a.butt[0]); + const angleB = Number.isFinite(b.angle) + ? b.angle + : Math.atan2(-(b.blade[1] - b.butt[1]), b.blade[0] - b.butt[0]); + const angleDelta = Math.atan2(Math.sin(angleB - angleA), Math.cos(angleB - angleA)); + out.angle = angleA + angleDelta * alpha; + out.roll = a.roll + (b.roll - a.roll) * alpha; + out.alignHands = alpha < 0.5 ? a.alignHands === true : b.alignHands === true; + out.confidence = a.confidence + (b.confidence - a.confidence) * alpha; + return out; +} + +/** Aim only the real stick, leaving the currently edited skeleton pose intact. */ +export function applyTiltStickPose(skater, stick) { + if (stick && skater.stick) { + // The socket bone is the source of truth. Skaters can shoot from either + // side, and assuming handR here made left-shot clips orbit the wrong hand. + const socketBone = skater.stick.group.parent; + if (!socketBone?.isBone) return false; + const lowerSide = socketBone === skater.skelData.bones.handL ? 'R' : 'L'; + const lowerBone = skater.skelData.bones[`hand${lowerSide}`]; + socketBone.getWorldPosition(_stickHand); + socketBone.getWorldQuaternion(_stickHandQ).invert(); + + if (stick.alignHands && lowerBone && typeof skater.stick.aimThroughHands === 'function') { + lowerBone.getWorldPosition(_stickLowerHand); + skater.stick.aimThroughHands(_stickHand, _stickLowerHand, _stickHandQ, stick.roll); + } else { + if (Number.isFinite(stick.angle)) { + socketBone.getWorldPosition(_stickHand); + _stickHandLocal.copy(_stickHand); + skater.mover.worldToLocal(_stickHandLocal); + _stickDirection.set(Math.cos(stick.angle), Math.sin(stick.angle), 0).multiplyScalar(STICK_TRACK_REACH); + _stickTarget.copy(_stickHandLocal).add(_stickDirection); + } else _stickTarget.fromArray(stick.target); + skater.mover.localToWorld(_stickTarget); + skater.stick.aimAt(_stickTarget, _stickHand, _stickHandQ, stick.roll); + } + skater.mover.updateMatrixWorld(true); + return true; + } + return false; +} + +/** Sample and aim only the stick, without reloading the skeleton key. */ +export function applyTiltStick(skater, clip, time) { + const stick = sampleTiltStick(clip, time, _sampledStick); + return applyTiltStickPose(skater, stick); +} + +/** Apply the skeleton pose and, when present, aim the skater's real stick. */ +export function applyTiltAnimation(skater, clip, time) { + if (!applyTiltClip(skater.skelData, clip, time)) return false; + applyTiltStick(skater, clip, time); + return true; +} + +/** One smoothing pass over imported quaternion tracks; first/last stay fixed. */ +export function smoothTiltClip(clip, amount = 0.35) { + const weight = Math.max(0, Math.min(1, amount)); + if (clip.keyframes.length < 3 || weight === 0) return clip; + stabilizeStickAngles(clip); + const source = clip.keyframes.map((frame) => structuredClone(frame)); + for (let i = 1; i < clip.keyframes.length - 1; i++) { + for (const name of CLIP_BONES) { + const prev = source[i - 1].rotations[name]; + const cur = source[i].rotations[name]; + const next = source[i + 1].rotations[name]; + if (!prev || !cur || !next) continue; + _qa.fromArray(prev).slerp(_qb.fromArray(next), 0.5); + _qb.fromArray(cur).slerp(_qa, weight); + clip.keyframes[i].rotations[name] = _qb.normalize().toArray(); + } + const prevStick = source[i - 1].stick; + const curStick = source[i].stick; + const nextStick = source[i + 1].stick; + if (prevStick && curStick && nextStick) { + const neighbors = (prevStick.angle + nextStick.angle) * 0.5; + clip.keyframes[i].stick.angle = curStick.angle + (neighbors - curStick.angle) * weight; + } + } + return clip; +} + +export function clipAsJson(clip) { + return JSON.stringify(sanitizeTiltClip(clip), null, 2) + '\n'; +} + +export function clipAsModule(clip) { + const safeName = String(clip.name || 'referenceMotion').replace(/[^a-zA-Z0-9_$]/g, '_'); + const exportName = /^[a-zA-Z_$]/.test(safeName) ? safeName : `clip_${safeName}`; + return `// Generated by Tilt Animation Studio for src/anim/poses/generated/.\nimport { applyTiltAnimation } from '../../clip.js';\n\nexport const ${exportName} = ${clipAsJson(clip).trim()};\n\nexport function play${exportName[0].toUpperCase()}${exportName.slice(1)}(skater, time) {\n return applyTiltAnimation(skater, ${exportName}, time);\n}\n\nexport default ${exportName};\n`; +} diff --git a/src/anim/clips/shot1.js b/src/anim/clips/shot1.js new file mode 100644 index 0000000..d80384c --- /dev/null +++ b/src/anim/clips/shot1.js @@ -0,0 +1,2254 @@ +// Generated from the Animation Studio project "shot1". +// Keep this data in native Tilt quaternion-track format so the same clip can +// be previewed in the studio and sampled by the in-game action layer. +export const shot1 = { + "format": "tilt-animation", + "version": 1, + "rig": "tilt-23", + "name": "shot1", + "fps": 2, + "loop": false, + "shotSide": "left", + "duration": 6, + "keyframes": [ + { + "time": 0, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.1424012386861498, + 0.48035959659252264, + -0.10175942316430032, + 0.8594309541664112 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + -0.03800470771638989, + -0.1724136112095496, + 0.025252375119695906, + 0.9839672283221065 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.15935781977341176, + -0.10169626908747578, + -0.17237868440321327, + 0.96672050939978 + ], + "forearmL": [ + -0.4314072500861108, + -0.19354748361335317, + 0.08515515492565379, + 0.8770266562366659 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.5653131773746618, + 0.3375797504205544, + 0.027312049978043967, + 0.7521402631946765 + ], + "forearmR": [ + -0.39313696151063926, + 0.1848804532655838, + 0.043030120791900224, + 0.8996726939277023 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.5858528588401594, + -0.02690797342607368, + -0.0204085217782056, + 0.8097134561026776 + ], + "shinL": [ + 0.5443196659900045, + 0.0034081276155130385, + 0.10268721349756868, + 0.8325622031216593 + ], + "footL": [ + 0.061581388694109604, + -0.06905370009193917, + -0.03171096384178764, + 0.9952053726956775 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + 0.035226505140340335, + -0.002092247466992011, + -0.036009797111613014, + 0.9987281964318997 + ], + "shinR": [ + 0.2697778217635681, + -0.005990454981677435, + 0.10574497651459988, + 0.9570799555290789 + ], + "footR": [ + -0.0377734262106853, + -0.3177423197652423, + -0.15468512394862127, + 0.9347114522308521 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.6321318716842798, + 0.6835087645111335 + ], + "grip": [ + 0.7328641463961696, + 0.4199307704939251 + ], + "blade": [ + 0.8275995197695829, + 0.1720443830609584 + ], + "target": [ + 0.8592870089731268, + 1.967345342001193, + -0.03107117667720502 + ], + "angle": 1.205752267234986, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 0.5, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.20835601417185817, + 0.35252585105221756, + -0.0815361963855841, + 0.9086611823871963 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + -0.027844643232455873, + -0.11611694514106972, + 0.0751541493833579, + 0.9899966589462952 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.20151465266723242, + -0.13046490349738726, + -0.2649957781256729, + 0.9338886396628836 + ], + "forearmL": [ + -0.3102591437425554, + -0.1483606910251052, + -0.06879167310816044, + 0.9364806857558088 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.3663753919411232, + 0.2308831146898731, + 0.3226236110679548, + 0.8416507976070388 + ], + "forearmR": [ + -0.24528274111955833, + 0.12706940759873622, + 0.19312923170996893, + 0.9414833203092338 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.5903795505266807, + -0.027511659327617302, + -0.04958984893633341, + 0.8051310090931955 + ], + "shinL": [ + 0.4935533357341731, + 0.001486845712786759, + 0.15150125851525428, + 0.8564171079233478 + ], + "footL": [ + 0.03075796772467382, + -0.03270381245456206, + -0.014961409054484088, + 0.998879654568802 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.052209785596673566, + 0.002047970093380899, + -0.023848145140979313, + 0.9983492425397943 + ], + "shinR": [ + 0.3034859930118122, + -0.00704486013811677, + 0.13009782627483063, + 0.94388621008564 + ], + "footR": [ + -0.04500927826501564, + -0.28913579056579497, + -0.14037885150052376, + 0.9458691439809997 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.5276568917529982, + 0.6291149218763149 + ], + "grip": [ + 0.6695590032277685, + 0.48893931045887495 + ], + "blade": [ + 0.7959464457677226, + 0.36408960350666686 + ], + "target": [ + 1.1161072147291424, + 1.6486506228292028, + 0.18319924742352375 + ], + "angle": 0.7792775861306582, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 1, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.2333195359999522, + 0.30949897362190065, + -0.07789797041188436, + 0.9185337694682033 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + -0.021828505002312527, + -0.0857856073378634, + 0.07065262662097962, + 0.9935655752362773 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.21350976080437478, + -0.1384693706439321, + -0.2867817281614678, + 0.9235778558559393 + ], + "forearmL": [ + -0.2774773353278385, + -0.13647113825479845, + -0.11523926090101262, + 0.9439819222579374 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.2958178900203294, + 0.1894805128734229, + 0.3376405741053175, + 0.8732627061216899 + ], + "forearmR": [ + -0.21546541871087535, + 0.11288779069347821, + 0.1876037201736027, + 0.9516490131498679 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.589411498453861, + -0.02701327801223673, + -0.016268726051156982, + 0.8072172550522445 + ], + "shinL": [ + 0.4855753469929015, + 0.0022538147465265205, + 0.12024664089436816, + 0.8658823523235895 + ], + "footL": [ + 0.02268041439865097, + 0.006718711073239805, + 0.004109381844846369, + 0.99971174380672 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.12019719165194487, + 0.005248973296110908, + -0.01573259712501628, + 0.9926114893481863 + ], + "shinR": [ + 0.3146603492318846, + -0.006234252901758496, + 0.09592223099241996, + 0.9443245863120702 + ], + "footR": [ + -0.048773644036376865, + -0.24907791261160184, + -0.1205731305133267, + 0.9597100839809263 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.6441797795508141, + 0.5349370966869192 + ], + "grip": [ + 0.5594058425024564, + 0.5241838211032996 + ], + "blade": [ + 0.3568983880899963, + 0.49849646316223467 + ], + "target": [ + -0.851693801505238, + 0.9929977309491225, + 0.2279662245111944 + ], + "angle": -0.12617265293163094, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 1.5, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.26902458272209206, + 0.09912006356064884, + -0.09773465737613132, + 0.9530209460649369 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + 0.024799846915273625, + 0.07839049196177986, + -0.039138169098191546, + 0.9958454207770454 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.2356151251669499, + -0.1548164207431797, + -0.3671469252238808, + 0.8864087792657988 + ], + "forearmL": [ + -0.1772928654460654, + -0.1019446023712493, + -0.2828546166522955, + 0.937106079239578 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.04144921105986703, + 0.02787390986949012, + 0.08068436281314562, + 0.9954873387685746 + ], + "forearmR": [ + -0.08264862867564095, + 0.03478604055977613, + -0.048854675242069726, + 0.9947725148330154 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.5051862521981133, + -0.019007732784217236, + 0.2900537698177804, + 0.8125849908156823 + ], + "shinL": [ + 0.4255345617317797, + 0.009575159075292251, + -0.17138930893698462, + 0.8885124410398135 + ], + "footL": [ + 0.03767849978705306, + 0.25100158973891695, + 0.12460536995347007, + 0.9591934290752269 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.4261187277677712, + 0.020389323349452097, + 0.07482128640058314, + 0.9013372734111742 + ], + "shinR": [ + 0.37152876034151494, + 0.0007441117655591047, + -0.18190172027140866, + 0.9104271473869173 + ], + "footR": [ + -0.05899852135389569, + 0.03903087012125429, + 0.02127403519718365, + 0.9972678582417367 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.44088786986985345, + 0.4883576395153239 + ], + "grip": [ + 0.40248266416890915, + 0.45398991462157534 + ], + "blade": [ + 0.12361518540213037, + 0.2044393493492672 + ], + "target": [ + -0.8453494691718837, + 1.6560702860514314, + 0.30828046099316664 + ], + "angle": -0.7299744525104748, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 2, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.2513116688125648, + 0.007049301942593291, + -0.10726138457196611, + 0.9619187844306143 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + 0.0552974629606191, + 0.13493769372567166, + -0.14156197014033536, + 0.9791293162867216 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.19452926274146445, + -0.13947672664297328, + -0.5968745702809596, + 0.765797202929172 + ], + "forearmL": [ + -0.10801793785212954, + -0.0785639203996728, + -0.4057579035157576, + 0.9041683246210878 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.026976972905503327, + 0.00835466131646944, + -0.1941171284075199, + 0.9805717633227878 + ], + "forearmR": [ + -0.2583704037628298, + 0.08264045271657709, + -0.5230943517771333, + 0.8079527146888562 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.35061955821266694, + -0.010278731897717452, + 0.41495818820961017, + 0.8395057921819997 + ], + "shinL": [ + 0.350772449828588, + 0.011642431119136005, + -0.27782334582662443, + 0.8942244297448974 + ], + "footL": [ + 0.08623482998846822, + 0.3309855384312256, + 0.16561735655063636, + 0.9249773071029532 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.4722644468713695, + 0.023695372708935403, + 0.16344584241736498, + 0.8658465673142914 + ], + "shinR": [ + 0.37221696202954646, + 0.003979671559603368, + -0.30001677347453365, + 0.8783100995808389 + ], + "footR": [ + -0.0011229692437527555, + 0.14638733211597776, + 0.0719267423513731, + 0.9866083476591728 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.3183904435760557, + 0.3932499973223162 + ], + "grip": [ + 0.31699137763653573, + 0.3106198171543125 + ], + "blade": [ + 0.3117334086886875, + 0.00007911338481626708 + ], + "target": [ + -0.19719153344965792, + 2.3086901033968834, + 0.2882780819434723 + ], + "angle": -1.5538662866516644, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 2.5, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.2624066629617467, + -0.036719778529144126, + -0.10993649499524279, + 0.9579709641561229 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + 0.06422111909134513, + 0.14448125034246506, + -0.18227178926570897, + 0.9704523744110322 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.2494167090765901, + -0.17285548318354632, + -0.6147075400765165, + 0.7280432180440006 + ], + "forearmL": [ + -0.12292745513691967, + -0.08390753915612482, + -0.3837267874414855, + 0.9113737533230596 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.11190733436240002, + 0.05542112254631647, + -0.28199772726882516, + 0.9512531363978916 + ], + "forearmR": [ + -0.41345143594227685, + 0.15552544105378113, + -0.506757853420672, + 0.7403149500710957 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.30735111712409285, + -0.007846499723396308, + 0.4490937440312469, + 0.8389210524934154 + ], + "shinL": [ + 0.3172677963806189, + 0.012637871600340645, + -0.3280342422871473, + 0.889704987885477 + ], + "footL": [ + 0.08749776566717948, + 0.3726415302416668, + 0.1861185288032178, + 0.9048990685035133 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.4792716138765806, + 0.02429329475446259, + 0.18393623573865478, + 0.8578321614071163 + ], + "shinR": [ + 0.3419043944841755, + 0.00493898454043002, + -0.32232151804906634, + 0.8827150335565065 + ], + "footR": [ + 0.01513211616500479, + 0.1716166431326573, + 0.08373559738960831, + 0.9814820918335966 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.30741079244278346, + 0.292870169614924 + ], + "grip": [ + 0.3291805445746793, + 0.2537469991450917 + ], + "blade": [ + 0.4606611736823605, + 0.017458637435911202 + ], + "target": [ + 0.34903685390961225, + 2.2019192890000623, + 0.3191967011579334 + ], + "angle": -2.078571502960851, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 3, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.31407649433167806, + -0.06103560212445206, + -0.0958930241120887, + 0.9425683735985775 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + 0.11265564175231728, + 0.12877544519558387, + -0.186790129046017, + 0.9673856722047445 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.3183806815202235, + -0.21330905701613398, + -0.5996780140032256, + 0.702509264957234 + ], + "forearmL": [ + -0.11454921931008992, + -0.07189978976628905, + -0.2683490155867608, + 0.9537807412716136 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.1812374771704752, + 0.09680549555615561, + -0.2790636695420055, + 0.9380325907135417 + ], + "forearmR": [ + -0.3431865364644047, + 0.12897425375607618, + -0.4223394203833255, + 0.8289861621570689 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.3269686979193377, + -0.008202452535797964, + 0.48838247377260824, + 0.8090159143459902 + ], + "shinL": [ + 0.25205259243727274, + 0.013999220486439305, + -0.4047830325323011, + 0.8788766745370912 + ], + "footL": [ + 0.08126943913421596, + 0.4287304305086323, + 0.21343974495099674, + 0.8740875079155097 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.5215066282105115, + 0.02621492580757439, + 0.1840724651870835, + 0.8327430227611377 + ], + "shinR": [ + 0.2616782386916397, + 0.008167609337829293, + -0.4064697061741333, + 0.8753514537119703 + ], + "footR": [ + 0.039578056119772095, + 0.22404740884418528, + 0.10860977912456339, + 0.9676984302676638 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.34624517095376595, + 0.41917581866530607 + ], + "grip": [ + 0.3387002318697776, + 0.35198750980437554 + ], + "blade": [ + 0.30362232195106753, + 0.039615810102281955 + ], + "target": [ + -0.357148923150023, + 2.2605798231797225, + 0.36988558236966784 + ], + "angle": -1.4589693961978414, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 3.5, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.2781284298814824, + 0.04657200749064489, + -0.03166345393401852, + 0.9588915737949317 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + 0.17086037129114168, + -0.02835586144374497, + 0.00579507240461204, + 0.9848700908139418 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.2420714835310137, + -0.16390202588872477, + -0.4992587994306597, + 0.8156458630813513 + ], + "forearmL": [ + -0.16842873733939448, + -0.0813570812336248, + -0.04894000121227585, + 0.9811308078196022 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.0844457757875477, + 0.050327128538404944, + 0.0015570023545890474, + 0.9951550968710285 + ], + "forearmR": [ + -0.340305811288707, + 0.1558681251148301, + -0.021877861515487748, + 0.927048241221476 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.5054588684716497, + -0.017184927258869002, + 0.4246348959217719, + 0.7509335627896605 + ], + "shinL": [ + 0.4117404069109199, + 0.016391519619880758, + -0.42536600327777413, + 0.8057697677729501 + ], + "footL": [ + 0.022360437875986627, + 0.36191793767932956, + 0.17852614574881315, + 0.9146823669930501 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.4410803956740026, + 0.019714176585858636, + -0.024561702617041867, + 0.8969148000552555 + ], + "shinR": [ + 0.2720176171629757, + 0.0063591101910666185, + -0.34491826994258257, + 0.8983303205004107 + ], + "footR": [ + -0.03903401714552296, + 0.1818493874936155, + 0.09069511054224055, + 0.9783565519264492 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.5372913023683661, + 0.574572728604298 + ], + "grip": [ + 0.4877498941870233, + 0.5778254101404352 + ], + "blade": [ + 0.09495924808691664, + 0.6036144001800516 + ], + "target": [ + -1.195942439686632, + 0.826560993127218, + 0.33768667801142893 + ], + "angle": 0.065561717834973, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 4, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.2735720928549051, + 0.10314665739135728, + -0.0044569251571401815, + 0.9562945220475337 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + 0.15533561929623133, + -0.07727347443432422, + 0.060549911041887866, + 0.9829717004062343 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.276068266018399, + -0.17971153220315186, + -0.38770662124344235, + 0.8609144286947363 + ], + "forearmL": [ + -0.15703974907856383, + -0.07557569866581104, + -0.041656871493119196, + 0.9838147874665852 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.13943032918187773, + 0.08702556484765298, + 0.10158929643268963, + 0.9811551096628358 + ], + "forearmR": [ + -0.2959806755768183, + 0.13959424910883833, + 0.03812022575877443, + 0.9441693352830995 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.5657636306271153, + -0.020804943458785453, + 0.3601829151127845, + 0.741449213532632 + ], + "shinL": [ + 0.4466429312299876, + 0.016243041573484322, + -0.4054162092885593, + 0.7974233209712529 + ], + "footL": [ + 0.009188775884033714, + 0.3349029833791962, + 0.16478945704814218, + 0.9276852876742443 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.3825134999754173, + 0.015678114356152326, + -0.12531661380022005, + 0.9152777531287639 + ], + "shinR": [ + 0.29358255979584663, + 0.003650287833859323, + -0.25525738186464647, + 0.9212163833682367 + ], + "footR": [ + -0.050071341495734854, + 0.08009230902238468, + 0.041119306805482994, + 0.9946794887822495 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.6653972590811386, + 0.4477328642494995 + ], + "grip": [ + 0.6714880387910485, + 0.530144552811987 + ], + "blade": [ + 0.6969415565257658, + 0.8745450197932995 + ], + "target": [ + 0.14427794671345612, + -0.27369652474521977, + 0.34107472090398067 + ], + "angle": -1.4970237064382383, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 4.5, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.2669550587263268, + 0.3769003064206214, + 0.0876885936437135, + 0.8826051587121235 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + 0.09938744585963295, + -0.20481153645287148, + 0.08159535638814314, + 0.9703177664853945 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.44048998136402784, + -0.2621558357435249, + 0.0010226597235873866, + 0.8586278869630442 + ], + "forearmL": [ + -0.01389810936425371, + -0.011084324981872793, + -0.06605251797740538, + 0.9976577795840817 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.4892672410713617, + 0.30124429287149446, + 0.25234756429123784, + 0.7785821405754507 + ], + "forearmR": [ + -0.09109938187200826, + 0.041607213380802276, + -0.0075373536949217695, + 0.9949436821833185 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.6730837488526942, + -0.030814676946469978, + -0.01613047989881715, + 0.7387479477692793 + ], + "shinL": [ + 0.588898978796729, + 0.011675722202766789, + -0.17981630905212015, + 0.7878627832827784 + ], + "footL": [ + 0.001228525065585278, + 0.15580054934203819, + 0.07655307423280752, + 0.9848168897700673 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + -0.13664578708959008, + 0.0019439001451532145, + -0.3129332796540645, + 0.9398919685835405 + ], + "shinR": [ + 0.3457288007705875, + -0.0025283840400315565, + -0.051978348196595615, + 0.9368903110346649 + ], + "footR": [ + -0.033464031896720724, + -0.22541783922471714, + -0.10950111633725501, + 0.9675104453438061 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.6723015798056182, + 0.3943874913079602 + ], + "grip": [ + 0.7416794501062088, + 0.4330159422901323 + ], + "blade": [ + 0.959642367161498, + 0.5543740903699502 + ], + "target": [ + 1.5354196104307083, + 0.47106941362482435, + 0.1951312977467503 + ], + "angle": -0.5080363229898343, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 5, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.1525071361469121, + 0.5944714376239206, + 0.03539276613418495, + 0.7887284928154856 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + 0.03106623865948565, + -0.12589469859970864, + 0.0076831577287631205, + 0.991527298044464 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.3227054166191687, + -0.19089595032330642, + 0.030003301140207266, + 0.9265634096787444 + ], + "forearmL": [ + -0.27556085617580317, + -0.12688252035256645, + 0.008222361151671553, + 0.9528375692371343 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.5250560537964677, + 0.3167792328746481, + 0.1069958614941171, + 0.7826358946624936 + ], + "forearmR": [ + -0.19819082586606476, + 0.09685212214934934, + 0.07346218824784391, + 0.9725962008333846 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.5984305853697489, + -0.02974574663513356, + -0.1865861353439645, + 0.7785766751891183 + ], + "shinL": [ + 0.5723881281149029, + 0.005318544555884895, + 0.04481138913773463, + 0.8187401805704122 + ], + "footL": [ + 0.08510029502513743, + 0.0075633855460005076, + 0.00675345879580873, + 0.9963207946138687 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + 0.042490695412609615, + -0.004227866483641558, + -0.16840789075834886, + 0.984792083781482 + ], + "shinR": [ + 0.37396885435325267, + -0.008060641352027421, + 0.13772133325580996, + 0.91712330490559 + ], + "footR": [ + 0.010784002572592603, + -0.280394648523761, + -0.13807894356336806, + 0.9498403822286011 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.6064730536681555, + 0.36811544892794584 + ], + "grip": [ + 0.7081034494074825, + 0.3511080759873867 + ], + "blade": [ + 0.9877839196811706, + 0.3043048531175681 + ], + "target": [ + 1.6727958492483501, + 1.3313507310374415, + -0.14718641309592795 + ], + "angle": 0.1658089336578465, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 5.5, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.14334523748797567, + 0.6347280136051153, + 0.013618125833753842, + 0.7592015794788606 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + -0.0034855185284371447, + -0.15305832889923812, + 0.0215808455857946, + 0.9879753368475162 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.30432815484158526, + -0.18009312757016321, + 0.026575507855664628, + 0.9350104715750123 + ], + "forearmL": [ + -0.3339203354645313, + -0.15217743771242714, + 0.03233480394092643, + 0.9296739737502804 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.5227108060728941, + 0.31427983808674165, + 0.07918982869247895, + 0.7884989331752152 + ], + "forearmR": [ + -0.23672465652477143, + 0.1172900881630829, + 0.1105475673699263, + 0.9581146630547499 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.5832516520189395, + -0.02930090954620991, + -0.204561193325595, + 0.7855658376623512 + ], + "shinL": [ + 0.5572355929697836, + 0.003805085771443313, + 0.09361295689401583, + 0.8250518950656649 + ], + "footL": [ + 0.09309714013990349, + -0.027280071882839242, + -0.010071566008897718, + 0.9952322762722111 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + 0.03941667272251619, + -0.003606133607512728, + -0.13306088880921277, + 0.9903171823114012 + ], + "shinR": [ + 0.3872572167274447, + -0.009096575554995474, + 0.16990978615798313, + 0.9061345181447586 + ], + "footR": [ + 0.046599420580507235, + -0.26967181180443917, + -0.13409238688898376, + 0.9524310157161447 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.5725943723571782, + 0.4043582120528042 + ], + "grip": [ + 0.6160565723088103, + 0.37063334595261116 + ], + "blade": [ + 0.8873446960787164, + 0.16012500401652568 + ], + "target": [ + 1.431040679836204, + 1.8389766058840942, + -0.20974598755429397 + ], + "angle": 0.6599086566739849, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + }, + { + "time": 6, + "root": [ + 0, + 0, + 0 + ], + "rotations": { + "root": [ + 0, + 0, + 0, + 1 + ], + "pelvis": [ + 0.1424012386861498, + 0.48035959659252264, + -0.10175942316430032, + 0.8594309541664112 + ], + "spine1": [ + 0, + 0, + 0, + 1 + ], + "spine2": [ + 0, + 0, + 0, + 1 + ], + "spine3": [ + 0, + 0, + 0, + 1 + ], + "neck": [ + -0.03800470771638989, + -0.1724136112095496, + 0.025252375119695906, + 0.9839672283221065 + ], + "head": [ + 0, + 0, + 0, + 1 + ], + "clavicleL": [ + 0, + 0, + 0, + 1 + ], + "upperArmL": [ + -0.15935781977341176, + -0.10169626908747578, + -0.17237868440321327, + 0.96672050939978 + ], + "forearmL": [ + -0.4314072500861107, + -0.1935474836133531, + 0.08515515492565377, + 0.8770266562366656 + ], + "handL": [ + 0, + 0, + 0, + 1 + ], + "clavicleR": [ + 0, + 0, + 0, + 1 + ], + "upperArmR": [ + -0.5653131773746618, + 0.3375797504205544, + 0.027312049978043967, + 0.7521402631946765 + ], + "forearmR": [ + -0.39313696151063926, + 0.1848804532655838, + 0.043030120791900224, + 0.8996726939277023 + ], + "handR": [ + 0, + 0, + 0, + 1 + ], + "thighL": [ + -0.5858528588401593, + -0.026907973426073675, + -0.020408521778205596, + 0.8097134561026774 + ], + "shinL": [ + 0.5443196659900045, + 0.0034081276155130385, + 0.10268721349756868, + 0.8325622031216593 + ], + "footL": [ + 0.061581388694109604, + -0.06905370009193917, + -0.03171096384178764, + 0.9952053726956775 + ], + "toeL": [ + 0, + 0, + 0, + 1 + ], + "thighR": [ + 0.035226505140340335, + -0.002092247466992011, + -0.036009797111613014, + 0.9987281964318997 + ], + "shinR": [ + 0.2697778217635681, + -0.005990454981677435, + 0.10574497651459988, + 0.9570799555290789 + ], + "footR": [ + -0.0377734262106853, + -0.3177423197652423, + -0.15468512394862127, + 0.9347114522308521 + ], + "toeR": [ + 0, + 0, + 0, + 1 + ] + }, + "confidence": 1, + "stick": { + "butt": [ + 0.5477665056949813, + 0.433785748295776 + ], + "grip": [ + 0.5734228392015912, + 0.39737236523627645 + ], + "blade": [ + 0.7339558403343752, + 0.169531950609879 + ], + "target": [ + 1.1045500236998165, + 1.8367075040302976, + -0.031071176677204992 + ], + "angle": 0.9569996213467011, + "roll": 0, + "alignHands": true, + "confidence": 1 + } + } + ] +}; + +export default shot1; + diff --git a/src/anim/poses/stickwork.js b/src/anim/poses/stickwork.js index 0bf9f49..f84d003 100644 --- a/src/anim/poses/stickwork.js +++ b/src/anim/poses/stickwork.js @@ -1,5 +1,6 @@ import { E } from '../../core/math.js'; import { clamp, lerp } from '../../../shared/scalar.js'; +import * as THREE from 'three'; /** * Upper-body authoring for everything done with the stick. @@ -11,9 +12,9 @@ import { clamp, lerp } from '../../../shared/scalar.js'; * * Each one is a function of a single phase 0..1 so the animator can drive it * from a timer, hold it (wind-up), or run it once and blend out (shoot, pass, - * poke). The right arm carries the stick; the left joins it for two-handed - * work and is pinned onto the shaft by IK afterwards, so what is authored here - * for the left side is only a starting guess that the IK refines. + * poke). Poses are authored for a **right** shot (top hand right, lower hand + * left, forehand at −X). A left shot runs the same functions and then + * `mirrorStickwork` swaps the arms and flips the coil across the midline. */ /** @@ -36,6 +37,39 @@ export const STICK_SPINE = ['spine1', 'spine2', 'spine3', 'neck', 'head']; export const STICK_BONES = STICK_ARMS.concat(STICK_SPINE); +/** Left/right arm pairs the mirror swaps. */ +const ARM_PAIRS = [ + ['clavicleL', 'clavicleR'], + ['upperArmL', 'upperArmR'], + ['forearmL', 'forearmR'], + ['handL', 'handR'], +]; + +const _mirrorL = new THREE.Euler(); +const _mirrorR = new THREE.Euler(); + +/** + * Mirror a right-shot stickwork pose onto a left shot. + * + * Arms swap sides with yaw/roll flipped; spine coil flips the same way. Call + * after any stickwork writer when `shotSign < 0`. Idempotent only if you do + * not call it twice — the animator applies it once per layer write. + */ +export function mirrorStickwork(P) { + for (const [l, r] of ARM_PAIRS) { + if (!P.q[l] || !P.q[r]) continue; + _mirrorL.setFromQuaternion(P.q[l], 'XYZ'); + _mirrorR.setFromQuaternion(P.q[r], 'XYZ'); + E(P.q[l], _mirrorR.x, -_mirrorR.y, -_mirrorR.z); + E(P.q[r], _mirrorL.x, -_mirrorL.y, -_mirrorL.z); + } + for (const n of STICK_SPINE) { + if (!P.q[n]) continue; + _mirrorL.setFromQuaternion(P.q[n], 'XYZ'); + E(P.q[n], _mirrorL.x, -_mirrorL.y, -_mirrorL.z); + } +} + /** * The neutral carry, and the hustle variant. * @@ -102,8 +136,9 @@ export function poseCarry(P, { hustle = 0, reach = 0, lateral = 0 }) { * Wind-up. `phase` 0..1 is how loaded the shot is, and it is *held* — the * animator parks here for as long as the Skill Stick is pulled back. * - * Hands high and back, stick raised behind the head — not hanging blade-down - * from waist height. The torso coils open so the follow-through has something + * Both hands stay on the stick, relatively square, and the whole grip draws + * back and *up* as a unit from the carry — not a golf swing that parks the + * stick behind the head. The torso coils open so the downswing has something * to spend. */ export function poseWindup(P, { phase = 0, aim = 0 }) { @@ -112,58 +147,115 @@ export function poseWindup(P, { phase = 0, aim = 0 }) { // left is the opposite sign. const side = -aim; - // Torso coils open, loading the shot side. - E(P.q.spine1, -0.06 - 0.08 * w, -0.18 - 0.42 * w + side * 0.08, -0.05 * w); - E(P.q.spine2, -0.07 - 0.1 * w, -0.22 - 0.48 * w + side * 0.1, -0.06 * w); - E(P.q.spine3, -0.04 - 0.07 * w, -0.18 - 0.38 * w + side * 0.08, -0.04 * w); + // Torso coils open on the shot side — enough load, not a full pirouette. + E(P.q.spine1, -0.03 - 0.04 * w, -0.1 - 0.18 * w + side * 0.08, -0.02 * w); + E(P.q.spine2, -0.04 - 0.05 * w, -0.12 - 0.22 * w + side * 0.1, -0.03 * w); + E(P.q.spine3, -0.02 - 0.04 * w, -0.08 - 0.16 * w + side * 0.08, -0.02 * w); // Eyes stay on the target while the body turns away from it. - E(P.q.neck, 0.04, 0.28 + 0.42 * w - side * 0.2, 0); - E(P.q.head, 0.04, 0.22 + 0.32 * w - side * 0.25, 0); + E(P.q.neck, 0.02, 0.12 + 0.18 * w - side * 0.16, 0); + E(P.q.head, 0.02, 0.1 + 0.14 * w - side * 0.18, 0); - // Top hand: high and back, roughly shoulder/head height, so the aimed stick - // can sit up behind the head instead of dangling at the hip. - E(P.q.clavicleR, -0.1 * w, -0.22 * w, -0.1); - E(P.q.upperArmR, 0.15 + 0.65 * w, -0.55 - 0.35 * w + side * 0.15, -0.55 - 0.35 * w); - E(P.q.forearmR, -0.45 - 0.25 * w, 0.22, -0.12); - E(P.q.handR, -0.05, 0.2, 0.22); + // Carry-like arms that lift and pull back *together* on the forehand side. + // Keeping the seed close to the two-handed carry means the lower-hand IK only + // finishes the last few centimetres, and the hands stay square on the shaft. + E(P.q.clavicleR, -0.02 * w, -0.05 * w, -0.05); + E( + P.q.upperArmR, + lerp(-0.42, -0.3, w) + side * 0.03, + lerp(0.42, 0.22, w) + side * 0.1, + lerp(0.68, 0.62, w), + ); + E( + P.q.forearmR, + lerp(-1.35, -1.2, w), + lerp(0.02, 0.05, w), + lerp(0.32, 0.28, w), + ); + E(P.q.handR, lerp(-0.12, -0.09, w), lerp(0.12, 0.13, w), lerp(0.04, 0.06, w)); - // Lower hand comes up with it; IK pins it to the shaft. - E(P.q.clavicleL, 0.04, 0.12 * w, 0.08); - E(P.q.upperArmL, -0.35 - 0.1 * w, 0.45 + 0.2 * w, 0.4 + 0.15 * w); - E(P.q.forearmL, -0.85 - 0.15 * w, -0.18, -0.12); - E(P.q.handL, -0.08, 0, -0.1); + E(P.q.clavicleL, 0.03, lerp(0.06, 0.07, w), lerp(-0.06, -0.03, w)); + E( + P.q.upperArmL, + lerp(-0.38, -0.26, w), + lerp(0.1, 0.16, w) + side * 0.06, + lerp(-0.48, -0.4, w), + ); + E( + P.q.forearmL, + lerp(-1.32, -1.2, w), + lerp(-0.08, -0.09, w), + lerp(0.18, 0.15, w), + ); + E(P.q.handL, -0.08, 0, lerp(0.08, 0.02, w)); } /** - * Follow-through. `phase` 0..1 runs once, fast. + * Shot swing. `phase` 0..1 runs once, fast. * - * The coil released: the torso whips through the shot, the stick sweeps across - * and finishes high. Front-loaded easing, so the contact reads at the start of - * the animation rather than in the middle of it. + * Three beats, matching the grip path: still loaded → square through the puck + * with the blade on the ice → follow-through high across the body. Front-loaded + * easing so contact reads early, not in the middle of a slow blend. */ export function poseShot(P, { phase = 0, power = 1, aim = 0 }) { const t = clamp(phase, 0, 1); - // Fast out of the coil, then settle. + // Fast out of the coil, then settle into the follow. const s = 1 - (1 - t) * (1 - t); const p = clamp(power, 0.2, 1); + // 0..1 through the "square on the ice" window, then 0..1 into the follow. + // Contact lands around t≈0.28 so the blade is flush when the puck leaves. + const down = clamp(t / 0.28, 0, 1); + const through = clamp((t - 0.28) / 0.55, 0, 1); + const square = 1 - (1 - down) * (1 - down); - const twist = lerp(-0.42 * p, 0.44 * p, s); - E(P.q.spine1, -0.06 + 0.12 * s, twist * 0.9, 0.04 * s); - E(P.q.spine2, -0.07 + 0.14 * s, twist, 0.05 * s); - E(P.q.spine3, -0.05 + 0.1 * s, twist * 0.8, 0.03 * s); + const twist = lerp(-0.28 * p, 0.38 * p, s); + E(P.q.spine1, -0.04 + 0.1 * s, twist * 0.9, 0.03 * s); + E(P.q.spine2, -0.05 + 0.12 * s, twist, 0.04 * s); + E(P.q.spine3, -0.03 + 0.08 * s, twist * 0.8, 0.03 * s); E(P.q.neck, 0.02, -twist * 0.5 + aim * 0.2, 0); E(P.q.head, 0.02, -twist * 0.4 + aim * 0.25, 0); - // Top hand drives through and finishes high across the body. - E(P.q.clavicleR, lerp(-0.05, 0.04, s), lerp(-0.14, 0.1, s), -0.06); - E(P.q.upperArmR, lerp(0.3, -1.05 * p, s), lerp(-0.94, 0.3, s), lerp(-0.72, -0.1, s)); - E(P.q.forearmR, lerp(-1.12, -0.42, s), 0.16, -0.1); - E(P.q.handR, -0.1, 0.1, 0.16); + // Arms: wind-up square → contact square (carry-like) → follow high. + // The early half is deliberately close to the carry pose so both hands stay + // on the shaft and the stick reads flat through the ice, not rotating off it. + E( + P.q.clavicleR, + lerp(lerp(-0.02, 0.02, square), 0.04, through), + lerp(lerp(-0.06, -0.04, square), 0.1, through), + -0.05, + ); + E( + P.q.upperArmR, + lerp(lerp(-0.3, -0.42, square), -1.0 * p, through), + lerp(lerp(0.22, 0.4, square), 0.28, through), + lerp(lerp(0.62, 0.68, square), -0.08, through), + ); + E( + P.q.forearmR, + lerp(lerp(-1.2, -1.34, square), -0.4, through), + lerp(0.05, 0.12, through), + lerp(0.28, -0.08, through), + ); + E(P.q.handR, -0.1, 0.1, 0.12); - E(P.q.clavicleL, 0.03, lerp(0.1, -0.04, s), 0.06); - E(P.q.upperArmL, lerp(-0.86, -0.3, s), lerp(0.66, 0.12, s), lerp(0.4, 0.5, s)); - E(P.q.forearmL, lerp(-1.36, -0.6, s), -0.28, -0.2); - E(P.q.handL, -0.08, 0, -0.14); + E( + P.q.clavicleL, + 0.03, + lerp(lerp(0.07, 0.06, square), -0.04, through), + lerp(-0.03, 0.05, through), + ); + E( + P.q.upperArmL, + lerp(lerp(-0.26, -0.38, square), -0.28, through), + lerp(lerp(0.16, 0.12, square), 0.1, through), + lerp(lerp(-0.4, -0.48, square), 0.48, through), + ); + E( + P.q.forearmL, + lerp(lerp(-1.2, -1.32, square), -0.55, through), + lerp(-0.09, -0.2, through), + lerp(0.15, -0.14, through), + ); + E(P.q.handL, -0.08, 0, -0.08); } /** diff --git a/src/anim/skateAnimator.js b/src/anim/skateAnimator.js index cc78a43..5964508 100644 --- a/src/anim/skateAnimator.js +++ b/src/anim/skateAnimator.js @@ -1,11 +1,15 @@ import * as THREE from 'three'; import { E, clamp, segDist, smooth } from '../core/math.js'; import { lerp, lerpAngle } from '../../shared/scalar.js'; +import { lowerHandFor, normalizeShotSide, shotSign, topHandFor } from '../../shared/player.js'; import { poseSkate, poseStop } from './poses/skate.js'; import { STICK_ARMS, STICK_BONES, STICK_SPINE, - poseCarry, posePass, posePoke, poseShot, poseWindup, + mirrorStickwork, + poseCarry, posePass, posePoke, } from './poses/stickwork.js'; +import { frameSpan, sampleTiltStick } from './clip.js'; +import { shot1 } from './clips/shot1.js'; import { STICK } from '../character/stick.js'; /** @@ -133,18 +137,68 @@ export function buildAnimator(skelData, mover) { actionTime: 0, actionPower: 1, actionAim: 0, + /** A released held wind-up starts shot1 at its midpoint, not frame zero. */ + actionFromWindup: false, /** Eased 0..1 between the settled grip and the one-handed dangle. */ hustleGrip: 0, + + /** + * Shot side — which hand is on top of the stick, which side the blade + * lives on, and whether stickwork poses are mirrored. Authored content is + * for `'right'`; `'left'` flips grips and arms across the body. + */ + shotSide: 'right', + /** `+1` right (as authored), `-1` left (mirrored). */ + shotSign: 1, + /** Top hand bone suffix for this shot side: `'R'` or `'L'`. */ + topHand: 'R', + /** Lower hand bone suffix — the one IK pins to the shaft. */ + lowerHand: 'L', + }; + + /** Apply a player shot side. Call once at create (or if a roster swaps it). */ + anim.setShotSide = function setShotSide(side) { + anim.shotSide = normalizeShotSide(side); + anim.shotSign = shotSign(anim.shotSide); + anim.topHand = topHandFor(anim.shotSide); + anim.lowerHand = lowerHandFor(anim.shotSide); }; /** How long each one-shot action runs, seconds. */ const ACTION_TIME = { shoot: 0.42, pass: 0.3, poke: 0.34 }; /** Seconds to blend the override in and out over the skating pose. */ const ACTION_BLEND = 0.09; + /** shot1 reaches its final key before the normal action fade begins. */ + const SHOT_MOTION_TIME = ACTION_TIME.shoot - ACTION_BLEND; /** Scratch pose the action layer writes into before being blended over. */ const overlay = newPose(); const _actionSpine = new THREE.Quaternion(); + const _clipQa = new THREE.Quaternion(); + const _clipQb = new THREE.Quaternion(); + + /** Map game action time onto the saved reference motion. */ + function shot1Time() { + if (anim.action === 'windup') return clamp(anim.charge, 0, 1) * shot1.duration * 0.5; + if (anim.action === 'shoot') { + const phase = clamp(anim.actionTime / SHOT_MOTION_TIME, 0, 1); + const start = anim.actionFromWindup ? 0.5 : 0; + return (start + phase * (1 - start)) * shot1.duration; + } + return null; + } + + /** Sample shot1 into the upper-body action layer without replacing skating legs. */ + function poseShot1(P, time) { + const span = frameSpan(shot1, time); + if (!span) return false; + for (const name of STICK_BONES) { + const a = span.a.rotations[name] ?? [0, 0, 0, 1]; + const b = span.b.rotations[name] ?? a; + P.q[name].slerpQuaternions(_clipQa.fromArray(a), _clipQb.fromArray(b), span.alpha); + } + return true; + } const _localFoot = new THREE.Vector3(); @@ -256,6 +310,7 @@ export function buildAnimator(skelData, mover) { * instead, because it is held for as long as the stick is pulled back. */ anim.playAction = function playAction(name, { power = 1, aim = 0 } = {}) { + anim.actionFromWindup = name === 'shoot' && anim.action === 'windup'; anim.action = name; anim.actionTime = 0; anim.actionPower = power; @@ -276,7 +331,10 @@ export function buildAnimator(skelData, mover) { if (anim.action === 'windup') { // Held. Blends in over ACTION_BLEND and then stays until released. const w = Math.min(1, anim.actionTime / ACTION_BLEND); - poseWindup(overlay, { phase: anim.charge, aim: anim.actionAim }); + poseShot1(overlay, shot1Time()); + // shot1 was authored as a left shot. Mirror only when the runtime + // skater uses the opposite socket side. + if (anim.shotSide !== shot1.shotSide) mirrorStickwork(overlay); return w; } @@ -288,14 +346,23 @@ export function buildAnimator(skelData, mover) { } // Snap in, ease out — a shot should look like it started the instant the // button did, and a slow blend in front of it steals that. - const w = t > 1 - ACTION_BLEND / duration + const fading = t > 1 - ACTION_BLEND / duration; + const w = fading ? Math.max(0, (1 - t) * duration / ACTION_BLEND) - : Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5)); + // A released held wind-up is already fully blended in. Dropping its + // weight back to zero for the shoot action caused a one-frame snap to + // carry before the second half of shot1 began. + : anim.action === 'shoot' && anim.actionFromWindup + ? 1 + : Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5)); const args = { phase: t, power: anim.actionPower, aim: anim.actionAim }; - if (anim.action === 'shoot') poseShot(overlay, args); + if (anim.action === 'shoot') poseShot1(overlay, shot1Time()); else if (anim.action === 'pass') posePass(overlay, args); else posePoke(overlay, args); + if (anim.action === 'shoot') { + if (anim.shotSide !== shot1.shotSide) mirrorStickwork(overlay); + } else if (anim.shotSign < 0) mirrorStickwork(overlay); return w; } @@ -303,8 +370,13 @@ export function buildAnimator(skelData, mover) { function gripFor() { if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)]; if (anim.action === 'shoot') { + // Three beats matching poseShot: loaded → blade square on the ice → + // follow-through. Contact is short and early so the bottom of the blade + // is flush when the puck leaves, not halfway through a blend to high. const t = anim.actionTime / (ACTION_TIME.shoot); - return ['windup', 'follow', Math.min(1, t / 0.45)]; + if (t < 0.28) return ['windup', 'contact', Math.min(1, t / 0.28)]; + if (t < 0.55) return ['contact', 'follow', (t - 0.28) / 0.27]; + return ['follow', 'follow', 1]; } if (anim.action === 'poke') return ['carry', 'poke', Math.min(1, anim.actionTime / 0.1)]; if (anim.action === 'pass') return ['carry', 'follow', Math.min(1, anim.actionTime / 0.2) * 0.5]; @@ -530,7 +602,9 @@ export function buildAnimator(skelData, mover) { const _shaftB = new THREE.Vector3(); const _shaftDir = new THREE.Vector3(); const _handPos = new THREE.Vector3(); + const _lowerHandPos = new THREE.Vector3(); const _handQuat = new THREE.Quaternion(); + const _shot1Stick = {}; anim.update = function update(dt) { dt *= anim.speed; @@ -574,6 +648,8 @@ export function buildAnimator(skelData, mover) { reach: anim.handling.y, lateral: anim.handling.x, }); + // Authored for a right shot; left shots run the mirrored pose set. + if (anim.shotSign < 0) mirrorStickwork(overlay); for (const n of STICK_ARMS) cur.q[n].copy(overlay.q[n]); for (const n of STICK_SPINE) cur.q[n].multiply(overlay.q[n]); @@ -602,40 +678,77 @@ export function buildAnimator(skelData, mover) { mover.updateMatrixWorld(true); // ---- the stick, last --------------------------------------------------- - // Socket first, because it hangs off the right hand and the arm has only - // just been posed. Then the lower hand is pulled onto the shaft, which - // needs the stick already placed — hence the second matrix refresh. + // Socket first: it hangs off the top hand for this shot side. Then the + // lower hand is pulled onto the shaft, which needs the stick already + // placed — hence the second matrix refresh. if (anim.stick) { - const [from, to, t] = gripFor(); - const roll = anim.stick.stanceTarget(from, to, t, _stickTarget); + const top = anim.topHand; + const lower = anim.lowerHand; + const clipTime = shot1Time(); + const referenceStick = clipTime === null ? null : sampleTiltStick(shot1, clipTime, _shot1Stick); + let referenceHandsAligned = false; + + // The applied studio guide carries a real 3D constraint: the shaft must + // cross both saved hand sockets. Use that instead of reconstructing + // camera depth from the 2D line, and keep the saved arm pose untouched. + if (referenceStick?.alignHands && typeof anim.stick.aimThroughHands === 'function') { + B[`hand${top}`].getWorldPosition(_handPos); + B[`hand${lower}`].getWorldPosition(_lowerHandPos); + B[`hand${top}`].getWorldQuaternion(_handQuat).invert(); + referenceHandsAligned = anim.stick.aimThroughHands( + _handPos, + _lowerHandPos, + _handQuat, + referenceStick.roll, + ); + mover.updateMatrixWorld(true); + } else { + const [from, to, t] = gripFor(); + let roll = anim.stick.stanceTarget(from, to, t, _stickTarget); + // GRIP targets are authored for a right shot (forehand at −X). Flip the + // blade across the body for a left shot, and the roll with it so the + // face stays open the same way relative to the forehand. + if (anim.shotSign < 0) { + _stickTarget.x *= -1; + roll = -roll; + } // Stickhandling moves the *target*, not just the arm pose. Nudging only // the shoulders moved the blade by centimetres; the puck follows the // blade now, so the Skill Stick has to move the blade to mean anything. // // Lateral is *subtracted*: skater local +X is the left side, but the Skill // Stick's +X is "push right". Adding them lined the deke up mirrored — - // stick right sent the puck to the skater's left. - if (anim.hasPuck) { - _stickTarget.x -= anim.handling.x * STICK_REACH.side; - _stickTarget.z += anim.handling.y * STICK_REACH.fwd; + // stick right sent the puck to the skater's left. Screen-right stays + // skater-right for both shot sides. + if (anim.hasPuck) { + _stickTarget.x -= anim.handling.x * STICK_REACH.side; + _stickTarget.z += anim.handling.y * STICK_REACH.fwd; + } + _stickTarget.applyMatrix4(mover.matrixWorld); + B[`hand${top}`].getWorldPosition(_handPos); + B[`hand${top}`].getWorldQuaternion(_handQuat); + _handQuat.invert(); + anim.stick.aimAt(_stickTarget, _handPos, _handQuat, roll); + mover.updateMatrixWorld(true); } - _stickTarget.applyMatrix4(mover.matrixWorld); - B.handR.getWorldPosition(_handPos); - B.handR.getWorldQuaternion(_handQuat); - _handQuat.invert(); - anim.stick.aimAt(_stickTarget, _handPos, _handQuat, roll); - mover.updateMatrixWorld(true); // Two hands on it whenever the stick is being used for something, and // not while it is being dangled out on one. - const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1); - if (twoHanded > 0.05) { + // + // Skip the lower-hand IK while a pose crossfade is still running (get-up + // from a knockdown, state change). IK overwrites the bone fully, so + // applying it on top of a blend from a limp pose yanks the lower hand + // onto the shaft mid-rise — measured as a ~0.9 m jump on handR for a + // left shot, where the lower hand *is* the right. + const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1) + * (anim.blend >= 1 ? 1 : 0); + if (twoHanded > 0.05 && !referenceHandsAligned) { // Preferred lower-hand grip is a bit down the shaft (hands apart, the // way the reference draws a carry). If that point is past the arm's // reach, slide up toward the butt until it is — never leave the hand // waving short of the stick, and never stack both hands on the butt. anim.stick.shaftSegment(_shaftA, _shaftB); - B.upperArmL.getWorldPosition(_H); + B[`upperArm${lower}`].getWorldPosition(_H); _shaftDir.subVectors(_shaftB, _shaftA); const len = _shaftDir.length() || 1; const armReach = ARM.upper + ARM.fore - 0.03; @@ -661,7 +774,7 @@ export function buildAnimator(skelData, mover) { _shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT); } } - solveArm('L', _shaftPoint); + solveArm(lower, _shaftPoint); mover.updateMatrixWorld(true); } } diff --git a/src/character/skater.js b/src/character/skater.js index 2c621fd..af03665 100644 --- a/src/character/skater.js +++ b/src/character/skater.js @@ -11,6 +11,7 @@ import { REACTION_ATTACK, createRagdoll } from '../physics/ragdoll.js'; import { createBodyProxy } from '../physics/bodyProxy.js'; import { buildStick } from './stick.js'; import { HIT } from '../game/hits.js'; +import { lowerHandFor, normalizeShotSide, topHandFor } from '../../shared/player.js'; const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); @@ -34,10 +35,14 @@ export function createSkater({ position = { x: 0, z: 0 }, facing = 0, bodyStyle = null, + /** `'left' | 'right'` — which side they shoot from. See `shared/player.js`. */ + shotSide = 'right', }) { const rng = makeRng(seed); const materials = buildMaterials(rng, team); const skelData = buildSkeleton(); + const side = normalizeShotSide(shotSide); + const top = topHandFor(side); const mover = new THREE.Group(); mover.name = 'skater:' + index; @@ -63,12 +68,12 @@ export function createSkater({ const animator = buildAnimator(skelData, mover); animator.setTransform(mover.position, facing); + animator.setShotSide(side); - // Socketed to the right hand, not to the mover: the arm pose decides where - // the stick is, which is the correct dependency order and the only way the - // hands can actually be on it. + // Socketed to the *top* hand for this shot side, not to the mover: the arm + // pose decides where the stick is. Right shot → handR, left shot → handL. const stick = buildStick(materials, physics, index); - stick.attachTo(skelData.bones.handR); + stick.attachTo(skelData.bones[`hand${top}`]); stick.setGrip('carry'); animator.stick = stick; @@ -122,6 +127,12 @@ export function createSkater({ index, seed, team, + /** `'left' | 'right'` — stick hand, shoot side, mirrored pose set. */ + shotSide: side, + /** Top hand on the stick for this shot side. */ + topHand: top, + /** Lower hand, pinned to the shaft by IK. */ + lowerHand: lowerHandFor(side), rng, materials, skelData, diff --git a/src/character/stick.js b/src/character/stick.js index 00f7bd9..2e6793c 100644 --- a/src/character/stick.js +++ b/src/character/stick.js @@ -55,7 +55,9 @@ export const STICK = { * angle as the free variable, which is what a wrist is for. `roll` is the blade * face angle about the shaft, which is the part that genuinely is authored. * - * +X is the skater's left, +Z is forward, so a right-hander carries at −X. + * +X is the skater's left, +Z is forward. Targets below are for a **right** + * shot (forehand at −X); the animator mirrors X (and roll) for a left shot. + * See `shared/player.js` `shotSide`. */ export const GRIP = { /** @@ -67,12 +69,21 @@ export const GRIP = { /** Hustling: stick dangles out in front on one hand. */ hustle: { target: [-0.14, 0.03, 1.05], roll: 0.14 }, /** - * Wind-up: blade high and back behind the head, not hanging down from the - * hands. y well above the shoulders, z behind the body. + * Wind-up: stick drawn back and *up* from the carry with both hands still + * square on the shaft. Blade lifts to about waist height and comes back + * toward the body — loaded, not a horizontal golf swing over the head. + * Geometry of a 1.1 m stick means a high behind-the-head target forces the + * shaft flat at ear height; keep the target lower so the angle stays real. */ - windup: { target: [-0.28, 1.55, -0.48], roll: -0.2 }, + windup: { target: [-0.42, 0.52, 0.18], roll: -0.08 }, + /** + * Shot contact: bottom of the blade flush with the ice, stick squared up + * through the puck. Same height as carry so the lie sits the sole on the + * ice rather than the toe or the heel. + */ + contact: { target: [-0.18, 0.03, 0.52], roll: 0.06 }, /** Follow-through: swept across the body and finishing high. */ - follow: { target: [0.34, 0.95, 0.85], roll: 0.55 }, + follow: { target: [0.28, 0.72, 0.88], roll: 0.42 }, /** Poke: thrust out flat, as far ahead as the arm reaches. */ poke: { target: [-0.18, 0.03, 1.42], roll: 0.05 }, }; @@ -153,6 +164,7 @@ export function buildStick(materials, physics, index) { const _aimLocal = new THREE.Vector3(); const _aimQuat = new THREE.Quaternion(); const _rollQuat = new THREE.Quaternion(); + const _shaftAxis = new THREE.Vector3(0, -1, 0); // The axis that must end up pointing at the target is the grip-to-*blade* // direction, not the shaft's −Y. The blade sits forward of the shaft end by // the toe offset, which puts it ~6° off axis — aiming −Y instead left the @@ -233,6 +245,30 @@ export function buildStick(materials, physics, index) { group.quaternion.copy(_aimQuat); }, + /** + * Put the shaft itself through both hand sockets. + * + * Reference video gives us a reliable 2D shaft line, but not reliable + * camera depth. Once the animator has placed both hands, those two 3D + * sockets are the better depth constraint. The group origin is snapped to + * the top hand and local -Y is aimed at the lower hand, so every camera + * angle sees the same two-hand contact instead of a camera-plane illusion. + */ + aimThroughHands(topHandWorld, lowerHandWorld, handQuatInverse, roll = 0) { + group.position.set(0, 0, 0); + _aimDir.subVectors(lowerHandWorld, topHandWorld); + if (_aimDir.lengthSq() < 1e-10) return false; + _aimDir.normalize(); + _aimLocal.copy(_aimDir).applyQuaternion(handQuatInverse).normalize(); + _aimQuat.setFromUnitVectors(_shaftAxis, _aimLocal); + if (roll) { + _rollQuat.setFromAxisAngle(_aimLocal, roll); + _aimQuat.premultiply(_rollQuat); + } + group.quaternion.copy(_aimQuat); + return true; + }, + /** Static placement, for a rig with no animator driving it. */ setGrip(name = 'carry') { const g = GRIP[name] ?? GRIP.carry; diff --git a/src/game/match.js b/src/game/match.js index a024cfa..88b2d95 100644 --- a/src/game/match.js +++ b/src/game/match.js @@ -10,6 +10,7 @@ import { createPossession } from './possession.js'; import { makeRng } from '../core/rng.js'; import { clamp, wrapAngle } from '../../shared/scalar.js'; import { FACEOFF_DOTS, insideRink, nearestFaceoffDot, rinkPenetration } from '../../shared/rink.js'; +import { rollShotSide } from '../../shared/player.js'; /** * The match loop. @@ -46,10 +47,14 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202 for (let i = 0; i < count; i++) { const spawn = spawns[i]; const team = spawn.team; + // Shot side is a player trait, not a spawn — roll it once so a lineup is a + // mix of left and right shots rather than six mirrored clones. + const shotSide = rollShotSide(rng.f()); const s = createSkaterState(i, spawn, { seed: seed + i * 977, team, name: `${team === 0 ? 'Home' : 'Away'} ${(i % perTeam) + 1}`, + shotSide, }); states.push(s); brains.push(createBrain(rng.f, {})); @@ -62,6 +67,7 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202 team, position: { x: spawn.x, z: spawn.z }, facing: spawn.yaw, + shotSide, // A little variety in build so three placeholder bodies are not clones. bodyStyle: { mass: rng.range(-0.35, 0.5), diff --git a/src/studio/animationStudio.js b/src/studio/animationStudio.js new file mode 100644 index 0000000..e58c213 --- /dev/null +++ b/src/studio/animationStudio.js @@ -0,0 +1,930 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; +import { createSkater } from '../character/skater.js'; +import { BONEDEF } from '../character/skeleton.js'; +import { + applyTiltAnimation, + applyTiltStickPose, + captureSkeletonKeyframe, + clipAsJson, + clipAsModule, + createTiltClip, + deleteClipKeyframe, + sanitizeTiltClip, + sampleTiltStick, + setClipKeyframe, + smoothTiltClip, +} from '../anim/clip.js'; +import { + bakeStickLandmarks, + detectVideoPose, + drawPoseOverlay, + drawStickOverlay, + loadPoseLandmarker, + retargetPoseToSkeleton, +} from './mediapipePose.js'; +import { createSeededStickTracker, stickGripFromWrists } from './stickTracker.js'; +import { parseFreeMoCapCsv } from './freemocapImport.js'; + +const $ = (id) => document.getElementById(id); +const el = { + stage: $('stage'), video: $('referenceVideo'), overlay: $('poseOverlay'), videoEmpty: $('videoEmpty'), + fileInput: $('fileInput'), clipInput: $('clipInput'), chooseVideo: $('chooseVideo'), clipName: $('clipName'), + mocapSource: $('mocapSource'), browserMocapControls: $('browserMocapControls'), + freeMocapControls: $('freeMocapControls'), freeMocapFps: $('freeMocapFps'), + importFreeMocap: $('importFreeMocap'), importFreeMocapCsv: $('importFreeMocapCsv'), + freeMocapInput: $('freeMocapInput'), freeMocapState: $('freeMocapState'), + captureFps: $('captureFps'), trimIn: $('trimIn'), trimOut: $('trimOut'), mirror: $('mirrorPose'), + extract: $('extract'), progress: $('progress'), status: $('status'), confidence: $('confidence'), + scrubber: $('scrubber'), markers: $('markers'), timelineEnd: $('timelineEnd'), timecode: $('timecode'), + playPause: $('playPause'), prevKey: $('prevKey'), nextKey: $('nextKey'), timelineSource: $('timelineSource'), + boneSelect: $('boneSelect'), setKey: $('setKey'), deleteKey: $('deleteKey'), resetBone: $('resetBone'), + smoothKeys: $('smoothKeys'), keyCount: $('keyCount'), durationLabel: $('durationLabel'), loop: $('loopClip'), + trackStick: $('trackStick'), seedStick: $('seedStick'), correctStick: $('correctStick'), clearStick: $('clearStick'), + applyStick: $('applyStick'), flipStick: $('flipStick'), shotSide: $('shotSide'), stickStatus: $('stickStatus'), + saveState: $('saveState'), saveProject: $('saveProject'), loadProject: $('loadProject'), importClip: $('importClip'), + exportJson: $('exportJson'), exportModule: $('exportModule'), newClip: $('newClip'), +}; + +let clip = createTiltClip(); +let currentTime = 0; +let videoUrl = null; +let videoFile = null; +let videoIn = 0; +let playing = false; +let localPlayStarted = 0; +let localPlayOffset = 0; +let selectedBone = 'pelvis'; +let lastLandmarks = null; +let dirty = false; +let extracting = false; +let markersSignature = ''; +let activeMarker = null; +let stickSeed = null; +let stickTracker = null; +let currentStick = null; +let pickingStick = null; +let stickPickPurpose = null; +const sampledStick = {}; + +// ---- Three.js rig preview ------------------------------------------------- +const renderer = new THREE.WebGLRenderer({ canvas: el.stage, antialias: true, alpha: false }); +renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); +renderer.shadowMap.enabled = true; +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.05; +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x0a0f16); +scene.fog = new THREE.Fog(0x0a0f16, 7, 18); +scene.add(new THREE.HemisphereLight(0xd9ecff, 0x111827, 1.8)); +const key = new THREE.DirectionalLight(0xffffff, 2.4); +key.position.set(3, 7, 5); +key.castShadow = true; +key.shadow.mapSize.set(1024, 1024); +scene.add(key); +const rim = new THREE.DirectionalLight(0x68e0c2, 1.1); +rim.position.set(-4, 3, -4); +scene.add(rim); + +const floor = new THREE.Mesh( + new THREE.CircleGeometry(3.4, 64), + new THREE.MeshStandardMaterial({ color: 0x141f2c, roughness: 0.94, metalness: 0.03 }), +); +floor.rotation.x = -Math.PI / 2; +floor.receiveShadow = true; +scene.add(floor); +const grid = new THREE.GridHelper(6, 24, 0x35506a, 0x1b2a3a); +grid.position.y = 0.002; +scene.add(grid); + +const camera = new THREE.PerspectiveCamera(36, 1, 0.03, 30); +camera.position.set(2.8, 1.7, 3.8); +const orbit = new OrbitControls(camera, el.stage); +orbit.target.set(0, 0.92, 0); +orbit.enableDamping = true; +orbit.dampingFactor = 0.08; +orbit.minDistance = 1.6; +orbit.maxDistance = 9; +orbit.update(); + +const skater = createSkater({ seed: 2201, scene, physics: null, index: 0, team: 0 }); +skater.animator.moveSpeed = 0; +skater.animator.bladeSpeed = 0; +skater.animator.effort = 0; +for (let i = 0; i < 30; i++) skater.animator.update(1 / 60); +skater.mover.updateMatrixWorld(true); + +function setStudioShotSide(side, { updateClip = true } = {}) { + const normalized = side === 'left' ? 'left' : 'right'; + const topHand = normalized === 'left' ? 'L' : 'R'; + skater.skelData.bones[`hand${topHand}`].add(skater.stick.group); + skater.shotSide = normalized; + skater.topHand = topHand; + skater.lowerHand = topHand === 'L' ? 'R' : 'L'; + skater.animator.setShotSide(normalized); + el.shotSide.value = normalized; + if (updateClip) clip.shotSide = normalized; + skater.mover.updateMatrixWorld(true); +} + +function stickBakeOptions() { + return { + mirror: el.mirror.checked, + socketHand: skater.stick.group.parent === skater.skelData.bones.handL ? 'L' : 'R', + }; +} + +const skeletonHelper = new THREE.SkeletonHelper(skater.skelData.rootBone); +skeletonHelper.material.color.set(0x68e0c2); +skeletonHelper.material.transparent = true; +skeletonHelper.material.opacity = 0.48; +skeletonHelper.material.depthTest = false; +scene.add(skeletonHelper); + +const transform = new TransformControls(camera, el.stage); +transform.setMode('rotate'); +transform.setSpace('local'); +transform.setSize(0.62); +scene.add(transform.getHelper()); +transform.addEventListener('dragging-changed', (event) => { + orbit.enabled = !event.value; + if (event.value) stopPlayback(); +}); +transform.addEventListener('objectChange', () => { + skater.mover.updateMatrixWorld(true); + updateRotationInputs(); + markDirty('pose changed — set key to keep it'); +}); + +const handleGeo = new THREE.SphereGeometry(0.025, 10, 8); +const handleMat = new THREE.MeshBasicMaterial({ color: 0xf6c85f, depthTest: false, transparent: true, opacity: 0.82 }); +const selectedMat = new THREE.MeshBasicMaterial({ color: 0x68e0c2, depthTest: false }); +const handles = BONEDEF.filter(([name]) => name !== 'root').map(([name]) => { + const mesh = new THREE.Mesh(handleGeo, handleMat); + mesh.userData.boneName = name; + mesh.renderOrder = 20; + scene.add(mesh); + return mesh; +}); + +function updateHandles() { + for (const handle of handles) { + skater.skelData.bones[handle.userData.boneName].getWorldPosition(handle.position); + handle.material = handle.userData.boneName === selectedBone ? selectedMat : handleMat; + } +} + +function resize() { + const rect = el.stage.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + renderer.setSize(rect.width, rect.height, false); + camera.aspect = rect.width / rect.height; + camera.updateProjectionMatrix(); +} +new ResizeObserver(resize).observe(el.stage); +resize(); + +function layoutVideoOverlay() { + const wrap = el.video.parentElement.getBoundingClientRect(); + if (!wrap.width || !wrap.height || !el.video.videoWidth || !el.video.videoHeight) return; + const scale = Math.min(wrap.width / el.video.videoWidth, wrap.height / el.video.videoHeight); + const width = el.video.videoWidth * scale; + const height = el.video.videoHeight * scale; + Object.assign(el.overlay.style, { + width: `${width}px`, height: `${height}px`, + left: `${(wrap.width - width) * 0.5}px`, top: `${(wrap.height - height) * 0.5}px`, + }); +} + +function renderReferenceOverlay() { + drawPoseOverlay(el.overlay, el.video, lastLandmarks, { mirror: el.mirror.checked }); + drawStickOverlay(el.overlay, currentStick, { picking: pickingStick }); +} +new ResizeObserver(() => { layoutVideoOverlay(); renderReferenceOverlay(); }).observe(el.video.parentElement); + +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); +el.stage.addEventListener('pointerup', (event) => { + if (transform.dragging) return; + const rect = el.stage.getBoundingClientRect(); + pointer.set(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); + raycaster.setFromCamera(pointer, camera); + const hit = raycaster.intersectObjects(handles, false)[0]; + if (hit) selectBone(hit.object.userData.boneName); +}); + +// ---- bone inspector ------------------------------------------------------- +const boneGroups = [ + ['Core', ['root', 'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head']], + ['Left arm', ['clavicleL', 'upperArmL', 'forearmL', 'handL']], + ['Right arm', ['clavicleR', 'upperArmR', 'forearmR', 'handR']], + ['Left leg', ['thighL', 'shinL', 'footL', 'toeL']], + ['Right leg', ['thighR', 'shinR', 'footR', 'toeR']], +]; +for (const [label, names] of boneGroups) { + const group = document.createElement('optgroup'); + group.label = label; + for (const name of names) group.append(new Option(name, name)); + el.boneSelect.append(group); +} +const euler = new THREE.Euler(0, 0, 0, 'XYZ'); +const deg = THREE.MathUtils.radToDeg; +const rad = THREE.MathUtils.degToRad; + +function selectBone(name) { + selectedBone = name; + el.boneSelect.value = name; + transform.attach(skater.skelData.bones[name]); + updateRotationInputs(); + updateHandles(); +} + +function updateRotationInputs() { + euler.setFromQuaternion(skater.skelData.bones[selectedBone].quaternion, 'XYZ'); + for (const input of document.querySelectorAll('.rot,.rot-num')) { + input.value = Math.round(deg(euler[input.dataset.axis])); + } +} + +function setRotationAxis(axis, degrees) { + stopPlayback(); + euler.setFromQuaternion(skater.skelData.bones[selectedBone].quaternion, 'XYZ'); + euler[axis] = rad(Number(degrees) || 0); + skater.skelData.bones[selectedBone].quaternion.setFromEuler(euler); + skater.mover.updateMatrixWorld(true); + updateRotationInputs(); + markDirty('pose changed — set key to keep it'); +} +for (const input of document.querySelectorAll('.rot,.rot-num')) { + input.addEventListener('input', () => setRotationAxis(input.dataset.axis, input.value)); +} +el.boneSelect.addEventListener('change', () => selectBone(el.boneSelect.value)); +el.resetBone.addEventListener('click', () => { + stopPlayback(); + skater.skelData.bones[selectedBone].quaternion.identity(); + skater.mover.updateMatrixWorld(true); + updateRotationInputs(); + markDirty('bone reset — set key to keep it'); +}); +window.addEventListener('keydown', (event) => { + if (event.target.matches('input,select')) return; + if (event.key.toLowerCase() === 'q' || event.key.toLowerCase() === 'w') transform.setMode('rotate'); +}); + +// ---- timeline + clips ----------------------------------------------------- +function timelineDuration() { + const videoRange = el.video.readyState >= 1 ? Math.max(0, Number(el.trimOut.value) - Number(el.trimIn.value)) : 0; + return Math.max(clip.duration || 0, videoRange, 0.1); +} + +function formatTime(time) { + const mins = Math.floor(time / 60).toString().padStart(2, '0'); + const secs = Math.floor(time % 60).toString().padStart(2, '0'); + const ms = Math.floor((time % 1) * 1000).toString().padStart(3, '0'); + return `${mins}:${secs}.${ms}`; +} + +function markDirty(message = 'unsaved') { + dirty = true; + el.saveState.textContent = message; +} + +function editKeyTime() { + const tolerance = 0.5 / Math.max(1, Number(clip.fps) || 12) + 1e-5; + let nearest = null; + let distance = Infinity; + for (const frame of clip.keyframes) { + const nextDistance = Math.abs(frame.time - currentTime); + if (nextDistance < distance) { nearest = frame; distance = nextDistance; } + } + return nearest && distance <= tolerance ? nearest.time : currentTime; +} + +function updateTimeline() { + const duration = timelineDuration(); + el.scrubber.max = duration; + el.scrubber.value = Math.min(currentTime, duration); + el.timelineEnd.textContent = `${duration.toFixed(2)} s`; + el.timecode.textContent = formatTime(currentTime); + el.keyCount.textContent = clip.keyframes.length; + el.durationLabel.textContent = `${(clip.duration || 0).toFixed(2)} s`; + const signature = `${duration.toFixed(4)}|${clip.keyframes.map((frame) => frame.time.toFixed(4)).join(',')}`; + if (signature !== markersSignature) { + markersSignature = signature; + activeMarker = null; + el.markers.replaceChildren(); + for (const frame of clip.keyframes) { + const marker = document.createElement('button'); + marker.className = 'key-marker'; + marker.dataset.time = frame.time; + marker.style.left = `${(frame.time / duration) * 100}%`; + marker.title = `key ${frame.time.toFixed(3)}s · ${Math.round(frame.confidence * 100)}% body${frame.stick ? ` · ${Math.round(frame.stick.confidence * 100)}% stick` : ''}`; + marker.addEventListener('click', () => setCurrentTime(frame.time, { seekVideo: true })); + el.markers.append(marker); + } + } + const exact = [...el.markers.children].find((marker) => Math.abs(Number(marker.dataset.time) - currentTime) < 1 / 240) ?? null; + if (exact !== activeMarker) { + activeMarker?.classList.remove('current'); + exact?.classList.add('current'); + activeMarker = exact; + } +} + +function setCurrentTime(time, { seekVideo = false } = {}) { + currentTime = Math.max(0, Math.min(timelineDuration(), Number(time) || 0)); + if (clip.keyframes.length) applyTiltAnimation(skater, clip, currentTime); + const stick = sampleTiltStick(clip, currentTime, sampledStick); + currentStick = stick ? structuredClone(stick) + : stickSeed ? { ...stickSeed, grip: stickSeed.grip ?? stickSeed.butt, confidence: 1 } : null; + if (stick && !pickingStick) el.stickStatus.textContent = `Baked stick key · ${Math.round(stick.confidence * 100)}% tracking confidence.`; + skater.mover.updateMatrixWorld(true); + updateRotationInputs(); + updateHandles(); + if (seekVideo && el.video.readyState >= 1) el.video.currentTime = Math.min(el.video.duration, videoIn + currentTime); + renderReferenceOverlay(); + updateTimeline(); +} + +el.scrubber.addEventListener('input', () => { + if (playing) stopPlayback(); + setCurrentTime(el.scrubber.value, { seekVideo: true }); +}); +el.setKey.addEventListener('click', () => { + stopPlayback(); + const keyTime = editKeyTime(); + const frame = captureSkeletonKeyframe(skater.skelData, keyTime); + const stick = sampleTiltStick(clip, currentTime, {}); + if (stick) frame.stick = structuredClone(stick); + setClipKeyframe(clip, frame, 0.5 / Math.max(1, Number(clip.fps) || 12) + 1e-5); + markDirty('key saved in clip · project unsaved'); + setCurrentTime(keyTime); +}); +el.deleteKey.addEventListener('click', () => { + if (deleteClipKeyframe(clip, currentTime, 1 / Math.max(30, clip.fps * 2))) { + markDirty(); + setCurrentTime(currentTime); + } +}); +el.smoothKeys.addEventListener('click', () => { + smoothTiltClip(clip, 0.35); + markDirty('smoothed'); + setCurrentTime(currentTime); +}); +el.loop.addEventListener('change', () => { clip.loop = el.loop.value === 'loop'; markDirty(); }); +el.clipName.addEventListener('input', () => { clip.name = el.clipName.value.trim() || 'reference-motion'; markDirty(); }); +el.prevKey.addEventListener('click', () => { + const frame = [...clip.keyframes].reverse().find((item) => item.time < currentTime - 1e-4) ?? clip.keyframes.at(-1); + if (frame) setCurrentTime(frame.time, { seekVideo: true }); +}); +el.nextKey.addEventListener('click', () => { + const frame = clip.keyframes.find((item) => item.time > currentTime + 1e-4) ?? clip.keyframes[0]; + if (frame) setCurrentTime(frame.time, { seekVideo: true }); +}); + +function stopPlayback() { + playing = false; + el.video.pause(); + el.playPause.textContent = '▶ Play'; +} + +function startPlayback() { + if (timelineDuration() <= 0) return; + playing = true; + el.playPause.textContent = '❚❚ Pause'; + if (el.video.readyState >= 2) { + if (currentTime >= timelineDuration() - 0.01) setCurrentTime(0, { seekVideo: true }); + el.video.currentTime = Math.min(el.video.duration, videoIn + currentTime); + el.video.play().catch(() => stopPlayback()); + el.timelineSource.textContent = 'video + clip'; + } else { + localPlayStarted = performance.now() / 1000; + localPlayOffset = currentTime; + el.timelineSource.textContent = 'clip'; + } +} +el.playPause.addEventListener('click', () => playing ? stopPlayback() : startPlayback()); +el.video.addEventListener('ended', () => { + if (clip.loop) { setCurrentTime(0, { seekVideo: true }); startPlayback(); } + else stopPlayback(); +}); + +// ---- video + MediaPipe extraction ---------------------------------------- +function clearStickTracking() { + stickSeed = null; + stickTracker = null; + currentStick = null; + pickingStick = null; + stickPickPurpose = null; + el.trackStick.checked = false; + el.overlay.classList.remove('picking'); + el.stickStatus.textContent = 'Choose the top-hand socket, then mark both shaft ends in either order.'; + renderReferenceOverlay(); +} + +el.seedStick.addEventListener('click', async () => { + if (el.video.readyState < 1) { el.stickStatus.textContent = 'Choose a video first.'; return; } + stopPlayback(); + videoIn = Math.max(0, Number(el.trimIn.value) || 0); + await seekVideo(videoIn); + currentTime = 0; + pickingStick = {}; + stickPickPurpose = 'seed'; + currentStick = null; + el.overlay.classList.add('picking'); + el.stickStatus.textContent = 'Click either end of the stick.'; + renderReferenceOverlay(); +}); + +el.correctStick.addEventListener('click', async () => { + if (el.video.readyState < 1) { el.stickStatus.textContent = 'Choose a video first.'; return; } + stopPlayback(); + await seekVideo(Math.min(el.video.duration, videoIn + currentTime)); + pickingStick = {}; + stickPickPurpose = 'correct'; + el.overlay.classList.add('picking'); + el.stickStatus.textContent = 'Correction: click either end of the stick.'; + renderReferenceOverlay(); +}); + +el.overlay.addEventListener('pointerdown', async (event) => { + if (!pickingStick) return; + event.preventDefault(); + const rect = el.overlay.getBoundingClientRect(); + const point = [ + Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), + Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)), + ]; + if (!pickingStick.butt) { + pickingStick.butt = point; + el.stickStatus.textContent = 'Now click the other end.'; + } else { + pickingStick.blade = point; + const picked = { butt: [...pickingStick.butt], blade: [...point], confidence: 1 }; + if (stickPickPurpose === 'seed') { + const dx = point[0] - pickingStick.butt[0]; + const dy = point[1] - pickingStick.butt[1]; + stickSeed = { + butt: picked.butt, blade: picked.blade, + grip: [pickingStick.butt[0] + dx * 0.3, pickingStick.butt[1] + dy * 0.3], + }; + currentStick = { ...stickSeed, confidence: 1 }; + stickTracker = createSeededStickTracker(el.video, stickSeed); + el.trackStick.checked = true; + el.stickStatus.textContent = 'Seeded. Generate will track butt, grip, and blade on every sampled frame.'; + } else { + el.stickStatus.textContent = 'Re-detecting the body frame and baking the correction…'; + const pose = await detectVideoPose(el.video, performance.now()); + if (pose) { + picked.grip = stickGripFromWrists(picked, pose.normalized); + const baked = bakeStickLandmarks( + skater.skelData, skater.mover, pose.normalized, picked, stickBakeOptions(), + ); + if (baked) { + const frame = captureSkeletonKeyframe(skater.skelData, currentTime); + frame.stick = baked; + setClipKeyframe(clip, frame); + currentStick = baked; + markDirty('stick key corrected · unsaved'); + updateTimeline(); + el.stickStatus.textContent = `Corrected stick landmarks at ${currentTime.toFixed(3)} s.`; + } + } else el.stickStatus.textContent = 'Could not detect the body in this correction frame.'; + } + pickingStick = null; + stickPickPurpose = null; + el.overlay.classList.remove('picking'); + } + renderReferenceOverlay(); +}); +el.clearStick.addEventListener('click', clearStickTracking); +el.applyStick.addEventListener('click', () => { + stopPlayback(); + if (!currentStick?.butt || !currentStick?.blade) { + el.stickStatus.textContent = 'Draw or correct the stick guide first.'; + return; + } + + const keyTime = editKeyTime(); + const dx = currentStick.blade[0] - currentStick.butt[0]; + const dy = currentStick.blade[1] - currentStick.butt[1]; + const applied = { + butt: [...currentStick.butt], + grip: [...(currentStick.grip ?? currentStick.butt)], + blade: [...currentStick.blade], + target: currentStick.target ? [...currentStick.target] : [0, 0, 0], + angle: Number.isFinite(currentStick.angle) ? currentStick.angle : Math.atan2(-dy, dx), + roll: Number(currentStick.roll) || 0, + alignHands: true, + confidence: Number.isFinite(currentStick.confidence) ? currentStick.confidence : 1, + }; + const frame = captureSkeletonKeyframe(skater.skelData, keyTime); + frame.stick = applied; + setClipKeyframe(clip, frame, 0.5 / Math.max(1, Number(clip.fps) || 12) + 1e-5); + currentTime = keyTime; + currentStick = structuredClone(applied); + applyTiltStickPose(skater, applied); + skater.mover.updateMatrixWorld(true); + updateHandles(); + updateRotationInputs(); + markDirty('stick + pose saved in clip · project unsaved'); + updateTimeline(); + renderReferenceOverlay(); + el.stickStatus.textContent = `Applied at ${keyTime.toFixed(3)} s · shaft constrained through both hands.`; +}); +el.flipStick.addEventListener('click', () => { + let flipped = 0; + for (const frame of clip.keyframes) { + if (!frame.stick) continue; + const angle = Number.isFinite(frame.stick.angle) + ? frame.stick.angle + : Math.atan2( + -(frame.stick.blade[1] - frame.stick.butt[1]), + frame.stick.blade[0] - frame.stick.butt[0], + ); + [frame.stick.butt, frame.stick.blade] = [frame.stick.blade, frame.stick.butt]; + frame.stick.angle = Math.atan2(Math.sin(angle + Math.PI), Math.cos(angle + Math.PI)); + flipped++; + } + if (!flipped) { + el.stickStatus.textContent = 'No baked stick keys to flip.'; + return; + } + markDirty(`flipped ${flipped} stick keys · unsaved`); + setCurrentTime(currentTime); + el.stickStatus.textContent = `Flipped the shaft direction on ${flipped} baked keys.`; +}); + +el.shotSide.addEventListener('change', () => { + setStudioShotSide(el.shotSide.value); + markDirty(`${el.shotSide.value} shot · unsaved`); + setCurrentTime(currentTime); + el.stickStatus.textContent = `${el.shotSide.value === 'left' ? 'Left' : 'Right'} hand is now the stick socket.`; +}); + +function useVideo(file) { + stopPlayback(); + clearStickTracking(); + lastLandmarks = null; + if (videoUrl) URL.revokeObjectURL(videoUrl); + videoUrl = URL.createObjectURL(file); + videoFile = file; + el.video.src = videoUrl; + el.video.load(); + el.videoEmpty.hidden = true; + el.status.textContent = `${file.name} loaded. Set the capture range, then generate.`; +} +el.chooseVideo.addEventListener('click', () => el.fileInput.click()); +el.fileInput.addEventListener('change', () => { if (el.fileInput.files[0]) useVideo(el.fileInput.files[0]); }); + +async function refreshFreeMoCapStatus() { + el.freeMocapState.textContent = 'Checking local worker…'; + try { + const response = await fetch('/api/freemocap/status'); + const status = await response.json(); + if (!response.ok || !status.available) throw new Error(status.message || 'worker unavailable'); + el.freeMocapState.textContent = `Ready · FreeMoCap ${status.version}`; + el.importFreeMocap.disabled = false; + } catch (error) { + el.freeMocapState.textContent = `Worker unavailable · run npm run freemocap:setup (${error.message})`; + el.importFreeMocap.disabled = true; + } +} + +el.mocapSource.addEventListener('change', () => { + const useFreeMoCap = el.mocapSource.value === 'freemocap'; + el.browserMocapControls.hidden = useFreeMoCap; + el.freeMocapControls.hidden = !useFreeMoCap; + el.progress.value = 0; + el.status.textContent = useFreeMoCap + ? 'Choose a video, then process it through the local FreeMoCap worker.' + : 'Choose a video, set a short in/out range, then generate.'; + if (useFreeMoCap) refreshFreeMoCapStatus(); +}); + +async function loadFreeMoCapResult(text, filename, sourceFps) { + const imported = parseFreeMoCapCsv(text); + const stride = Math.max(1, Math.ceil(imported.frames.length / 3000)); + const frames = imported.frames.filter((_frame, index) => index % stride === 0); + const firstFrame = frames[0].frame; + const generatedName = filename.replace(/(?:_freemocap_data_by_frame|_body_3d_xyz)?\.(?:csv|mp4|mov|webm|m4v)$/i, '') || 'freemocap-motion'; + if (!el.clipName.value.trim() || el.clipName.value === 'reference-motion') el.clipName.value = generatedName; + const next = createTiltClip({ + name: el.clipName.value.trim() || generatedName, + fps: sourceFps / stride, + loop: el.loop.value === 'loop', + shotSide: el.shotSide.value, + }); + for (let i = 0; i < frames.length; i++) { + const source = frames[i]; + const time = (source.frame - firstFrame) / sourceFps; + const confidence = retargetPoseToSkeleton(skater.skelData, source.landmarks, { mirror: el.mirror.checked }); + setClipKeyframe(next, captureSkeletonKeyframe(skater.skelData, time, { confidence })); + el.progress.value = (i + 1) / frames.length; + if (i % 60 === 0) await new Promise(requestAnimationFrame); + } + next.duration = next.keyframes.at(-1)?.time ?? 0; + smoothTiltClip(next, 0.16); + clip = next; + lastLandmarks = null; + currentStick = null; + markersSignature = ''; + markDirty(`${next.keyframes.length} FreeMoCap keys · unsaved`); + el.timelineSource.textContent = `FreeMoCap ${imported.tracker}`; + el.confidence.textContent = '3D IMPORT'; + el.status.textContent = `Imported ${next.keyframes.length} ${imported.trajectory} keys from ${imported.tracker} at ${sourceFps} fps${stride > 1 ? ` (sampled every ${stride} frames)` : ''}.`; + setCurrentTime(0); +} + +el.importFreeMocap.addEventListener('click', async () => { + if (!videoFile || extracting) { + if (!videoFile) el.status.textContent = 'Choose or drop a reference video first.'; + return; + } + extracting = true; + stopPlayback(); + el.importFreeMocap.disabled = true; + el.progress.removeAttribute('value'); + const sourceFps = Math.max(1, Math.min(60, Number(el.freeMocapFps.value) || 30)); + try { + el.status.textContent = `Uploading ${videoFile.name}; FreeMoCap processing can take several minutes…`; + const query = new URLSearchParams({ filename: videoFile.name, fps: String(sourceFps) }); + const response = await fetch(`/api/freemocap/process?${query}`, { + method: 'POST', + headers: { 'Content-Type': videoFile.type || 'application/octet-stream' }, + body: videoFile, + }); + const result = await response.text(); + if (!response.ok) throw new Error(result || `worker returned ${response.status}`); + const detectedFps = Number(response.headers.get('X-Tilt-Source-Fps')) || sourceFps; + el.freeMocapFps.value = String(Number(detectedFps.toFixed(3))); + el.progress.value = 0.85; + await loadFreeMoCapResult(result, videoFile.name, detectedFps); + } catch (error) { + console.error(error); + el.status.textContent = `FreeMoCap processing failed: ${error.message}`; + el.confidence.textContent = 'IMPORT ERROR'; + } finally { + extracting = false; + el.progress.value = Number(el.progress.value) || 0; + refreshFreeMoCapStatus(); + } +}); + +el.importFreeMocapCsv.addEventListener('click', () => el.freeMocapInput.click()); +el.freeMocapInput.addEventListener('change', async () => { + const file = el.freeMocapInput.files[0]; + if (!file || extracting) return; + const sourceFps = Math.max(1, Math.min(60, Number(el.freeMocapFps.value) || 30)); + try { + el.status.textContent = `Reading ${file.name}…`; + await loadFreeMoCapResult(await file.text(), file.name, sourceFps); + } catch (error) { + console.error(error); + el.status.textContent = `FreeMoCap CSV import failed: ${error.message}`; + el.confidence.textContent = 'IMPORT ERROR'; + } finally { + el.freeMocapInput.value = ''; + } +}); +document.addEventListener('dragover', (event) => event.preventDefault()); +document.addEventListener('drop', (event) => { + event.preventDefault(); + const file = [...event.dataTransfer.files].find((item) => item.type.startsWith('video/')); + if (file) useVideo(file); +}); +el.video.addEventListener('loadedmetadata', () => { + el.trimIn.max = el.video.duration; + el.trimOut.max = el.video.duration; + el.trimOut.value = el.video.duration.toFixed(3); + videoIn = 0; + currentTime = 0; + layoutVideoOverlay(); + updateTimeline(); +}); +for (const input of [el.trimIn, el.trimOut]) input.addEventListener('change', () => { + videoIn = Math.max(0, Number(el.trimIn.value) || 0); + if (Number(el.trimOut.value) <= videoIn) el.trimOut.value = Math.min(el.video.duration || videoIn + 1, videoIn + 1).toFixed(3); + setCurrentTime(0, { seekVideo: true }); +}); + +function seekVideo(time) { + return new Promise((resolve, reject) => { + const done = () => { cleanup(); resolve(); }; + const fail = () => { cleanup(); reject(new Error('Could not seek reference video')); }; + const cleanup = () => { + el.video.removeEventListener('seeked', done); + el.video.removeEventListener('canplay', done); + el.video.removeEventListener('error', fail); + }; + el.video.addEventListener('seeked', done, { once: true }); + el.video.addEventListener('canplay', done, { once: true }); + el.video.addEventListener('error', fail, { once: true }); + if (Math.abs(el.video.currentTime - time) < 1e-4 && !el.video.seeking && el.video.readyState >= 2) { + cleanup(); + requestAnimationFrame(resolve); + } else el.video.currentTime = time; + }); +} + +el.extract.addEventListener('click', async () => { + if (extracting || el.video.readyState < 1) { + if (el.video.readyState < 1) el.status.textContent = 'Choose a video first.'; + return; + } + extracting = true; + stopPlayback(); + el.extract.disabled = true; + el.progress.value = 0; + try { + const fps = Math.max(1, Math.min(30, Number(el.captureFps.value) || 12)); + const start = Math.max(0, Number(el.trimIn.value) || 0); + const end = Math.min(el.video.duration, Math.max(start + 1 / fps, Number(el.trimOut.value) || el.video.duration)); + const count = Math.max(2, Math.floor((end - start) * fps) + 1); + if (count > 3000) throw new Error('Capture range is too long; trim it below 3,000 sampled frames'); + el.status.textContent = 'Loading MediaPipe pose model…'; + await loadPoseLandmarker(); + const next = createTiltClip({ + name: el.clipName.value.trim() || 'reference-motion', + fps, + loop: el.loop.value === 'loop', + shotSide: el.shotSide.value, + }); + next.duration = end - start; + let found = 0; + let sticksFound = 0; + if (el.trackStick.checked && !stickTracker) throw new Error('Mark the stick butt and blade before enabling stick tracking'); + stickTracker?.reset(); + for (let i = 0; i < count; i++) { + const sourceTime = Math.min(end, start + i / fps); + const clipTime = sourceTime - start; + el.status.textContent = `Tracking frame ${i + 1} / ${count}`; + await seekVideo(sourceTime); + const pose = await detectVideoPose(el.video, performance.now()); + if (pose) { + const confidence = retargetPoseToSkeleton(skater.skelData, pose.world, { mirror: el.mirror.checked }); + const frame = captureSkeletonKeyframe(skater.skelData, clipTime, { confidence }); + if (el.trackStick.checked && stickTracker) { + const tracked = stickTracker.track(); + tracked.grip = stickGripFromWrists(tracked, pose.normalized); + const baked = bakeStickLandmarks( + skater.skelData, skater.mover, pose.normalized, tracked, stickBakeOptions(), + ); + if (baked) { + frame.stick = baked; + currentStick = baked; + sticksFound++; + } + } + setClipKeyframe(next, frame); + lastLandmarks = pose.normalized; + renderReferenceOverlay(); + el.confidence.textContent = `${Math.round(confidence * 100)}% TRACK`; + found++; + } + el.progress.value = (i + 1) / count; + if (i % 3 === 0) await new Promise(requestAnimationFrame); + } + if (!found) throw new Error('No full-body pose was detected in the selected range'); + clip = next; + videoIn = start; + smoothTiltClip(clip, 0.22); + markDirty(`${found} tracked keys · unsaved`); + el.status.textContent = `Generated ${found} editable keys at ${fps} fps${sticksFound ? ` with ${sticksFound} baked stick tracks` : ''}. Low-confidence frames can be corrected in the studio.`; + setCurrentTime(0, { seekVideo: true }); + } catch (error) { + console.error(error); + el.status.textContent = `Capture failed: ${error.message}`; + el.confidence.textContent = 'TRACK ERROR'; + } finally { + extracting = false; + el.extract.disabled = false; + } +}); + +el.mirror.addEventListener('change', () => { + renderReferenceOverlay(); +}); + +// ---- save/import/export --------------------------------------------------- +const STORAGE_KEY = 'tilt-animation-projects-v1'; +function projects() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } + catch { return {}; } +} +function storeProjects(value) { localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); } + +el.saveProject.addEventListener('click', () => { + clip.name = el.clipName.value.trim() || 'reference-motion'; + try { + const all = projects(); + all[clip.name] = sanitizeTiltClip(clip); + storeProjects(all); + dirty = false; + el.saveState.textContent = 'saved locally'; + } catch (error) { + el.status.textContent = `Local save failed (${error.message}). Export JSON to keep this clip.`; + } +}); +el.loadProject.addEventListener('click', () => { + const all = projects(); + const names = Object.keys(all).sort(); + if (!names.length) { el.status.textContent = 'No locally saved animation projects yet.'; return; } + const name = prompt(`Open saved project:\n${names.join('\n')}`, names[0]); + if (name && all[name]) loadClip(all[name], 'saved project'); +}); + +function loadClip(data, source = 'file') { + stopPlayback(); + clip = sanitizeTiltClip(data); + setStudioShotSide(clip.shotSide, { updateClip: false }); + el.clipName.value = clip.name; + el.loop.value = clip.loop ? 'loop' : 'once'; + currentTime = 0; + dirty = false; + el.saveState.textContent = source; + const stickKeys = clip.keyframes.filter((frame) => frame.stick).length; + el.status.textContent = `Loaded ${clip.keyframes.length} keys${stickKeys ? ` including ${stickKeys} stick tracks` : ''} from ${source}.`; + setCurrentTime(0); +} +el.importClip.addEventListener('click', () => el.clipInput.click()); +el.clipInput.addEventListener('change', async () => { + const file = el.clipInput.files[0]; + if (!file) return; + try { loadClip(JSON.parse(await file.text()), file.name); } + catch (error) { el.status.textContent = `Import failed: ${error.message}`; } + el.clipInput.value = ''; +}); + +function safeFilename(name) { + return String(name || 'reference-motion').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'reference-motion'; +} +function download(contents, filename, type) { + const url = URL.createObjectURL(new Blob([contents], { type })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} +el.exportJson.addEventListener('click', () => { + clip.name = el.clipName.value.trim() || 'reference-motion'; + download(clipAsJson(clip), `${safeFilename(clip.name)}.tiltanim.json`, 'application/json'); + el.status.textContent = 'Exported a reloadable Tilt animation JSON clip.'; +}); +el.exportModule.addEventListener('click', () => { + clip.name = el.clipName.value.trim() || 'reference-motion'; + download(clipAsModule(clip), `${safeFilename(clip.name)}.js`, 'text/javascript'); + el.status.textContent = `Exported a pose module. Put it in src/anim/poses/generated/ and import it where the runtime action is driven.`; +}); +el.newClip.addEventListener('click', () => { + if (dirty && !confirm('Discard the unsaved animation and start a new clip?')) return; + clip = createTiltClip({ name: 'reference-motion', shotSide: el.shotSide.value }); + el.clipName.value = clip.name; + dirty = false; + el.saveState.textContent = 'new clip'; + currentTime = 0; + updateTimeline(); +}); +window.addEventListener('beforeunload', (event) => { + if (!dirty) return; + event.preventDefault(); +}); + +// ---- render loop ---------------------------------------------------------- +function frame() { + requestAnimationFrame(frame); + if (playing) { + const duration = timelineDuration(); + if (el.video.readyState >= 2 && !el.video.paused) { + const time = el.video.currentTime - videoIn; + if (time >= duration - 1e-3) { + if (clip.loop) { el.video.currentTime = videoIn; currentTime = 0; } + else stopPlayback(); + } else setCurrentTime(time); + } else if (el.video.readyState < 2) { + let time = localPlayOffset + performance.now() / 1000 - localPlayStarted; + if (time >= duration) { + if (clip.loop) { localPlayStarted = performance.now() / 1000; localPlayOffset = 0; time %= duration; } + else { time = duration; stopPlayback(); } + } + setCurrentTime(time); + } + } + orbit.update(); + skater.mover.updateMatrixWorld(true); + updateHandles(); + renderer.render(scene, camera); +} + +selectBone(selectedBone); +updateTimeline(); +frame(); + +window.tiltAnimationStudio = { + get clip() { return clip; }, + get skater() { return skater; }, + setTime: setCurrentTime, + loadClip, +}; diff --git a/src/studio/freemocapImport.js b/src/studio/freemocapImport.js new file mode 100644 index 0000000..df9db72 --- /dev/null +++ b/src/studio/freemocapImport.js @@ -0,0 +1,115 @@ +/** + * FreeMoCap CSV adapter. + * + * Supports the current tidy `freemocap_data_by_frame.csv` output and the + * per-trajectory `*_body_3d_xyz.csv` output. FreeMoCap uses millimetres with Z + * up; the returned pose uses MediaPipe's index order and camera-style axes so + * it can pass through Tilt's existing direction-only retargeter. + */ + +const LANDMARK_INDEX = new Map([ + ['nose', 0], + ['left_eye_inner', 1], ['left_eye', 2], ['left_eye_outer', 3], + ['right_eye_inner', 4], ['right_eye', 5], ['right_eye_outer', 6], + ['left_ear', 7], ['right_ear', 8], + ['mouth_left', 9], ['mouth_right', 10], + ['left_shoulder', 11], ['right_shoulder', 12], + ['left_elbow', 13], ['right_elbow', 14], + ['left_wrist', 15], ['right_wrist', 16], + ['left_pinky', 17], ['right_pinky', 18], + ['left_index', 19], ['right_index', 20], + ['left_thumb', 21], ['right_thumb', 22], + ['left_hip', 23], ['right_hip', 24], + ['left_knee', 25], ['right_knee', 26], + ['left_ankle', 27], ['right_ankle', 28], + ['left_heel', 29], ['right_heel', 30], + ['left_foot_index', 31], ['right_foot_index', 32], + // RTMPose names use big toe rather than MediaPipe's foot index. + ['left_big_toe', 31], ['right_big_toe', 32], +]); + +const REQUIRED = [7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]; + +function csvRows(text) { + const rows = []; + let row = []; + let field = ''; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '"') { + if (quoted && text[i + 1] === '"') { field += '"'; i++; } + else quoted = !quoted; + } else if (char === ',' && !quoted) { + row.push(field); + field = ''; + } else if ((char === '\n' || char === '\r') && !quoted) { + if (char === '\r' && text[i + 1] === '\n') i++; + row.push(field); + if (row.some((value) => value.length)) rows.push(row); + row = []; + field = ''; + } else field += char; + } + row.push(field); + if (row.some((value) => value.length)) rows.push(row); + return rows; +} + +function normalizedName(value) { + return String(value ?? '').trim().toLowerCase().replace(/[ -]+/g, '_'); +} + +function blankPose() { + return Array.from({ length: 33 }, () => ({ x: 0, y: 0, z: 0, visibility: 0 })); +} + +/** Parse a FreeMoCap body XYZ CSV into ordered frames. */ +export function parseFreeMoCapCsv(text) { + const rows = csvRows(String(text).replace(/^\uFEFF/, '')); + if (rows.length < 2) throw new Error('FreeMoCap CSV is empty'); + const headers = rows[0].map(normalizedName); + const column = (name) => headers.indexOf(name); + const frameCol = column('frame'); + const keypointCol = column('keypoint'); + const xCol = column('x'); + const yCol = column('y'); + const zCol = column('z'); + const modelCol = column('model'); + const trajectoryCol = column('trajectory'); + if ([frameCol, keypointCol, xCol, yCol, zCol].some((index) => index < 0)) { + throw new Error('Expected FreeMoCap columns: frame, keypoint, x, y, z'); + } + + const availableTrajectories = new Set( + trajectoryCol < 0 ? [] : rows.slice(1).map((row) => normalizedName(row[trajectoryCol])), + ); + const preferredTrajectory = availableTrajectories.has('rigid_3d_xyz') ? 'rigid_3d_xyz' : '3d_xyz'; + const frames = new Map(); + let tracker = 'unknown'; + for (const row of rows.slice(1)) { + const model = modelCol < 0 ? '' : normalizedName(row[modelCol]); + const trajectory = trajectoryCol < 0 ? '3d_xyz' : normalizedName(row[trajectoryCol]); + if (model && !model.endsWith('.body') && !model.endsWith('_body') && model !== 'body') continue; + if (trajectory !== preferredTrajectory) continue; + const index = LANDMARK_INDEX.get(normalizedName(row[keypointCol])); + const frameNumber = Number(row[frameCol]); + const x = Number(row[xCol]); + const y = Number(row[yCol]); + const z = Number(row[zCol]); + if (index === undefined || !Number.isFinite(frameNumber) || ![x, y, z].every(Number.isFinite)) continue; + if (model.startsWith('mediapipe')) tracker = 'MediaPipe'; + else if (model.startsWith('rtmpose')) tracker = 'RTMPose'; + if (!frames.has(frameNumber)) frames.set(frameNumber, blankPose()); + // FreeMoCap is X/Y ground plane, Z up. This conversion makes the existing + // retargeter produce Tilt coordinates (X, Z-up, Y-depth). + frames.get(frameNumber)[index] = { x, y: -z, z: -y, visibility: 1 }; + } + + const parsed = [...frames].sort((a, b) => a[0] - b[0]).map(([frame, landmarks]) => ({ frame, landmarks })); + const complete = parsed.filter(({ landmarks }) => REQUIRED.every((index) => landmarks[index].visibility > 0)); + if (!complete.length) { + throw new Error('No complete FreeMoCap body frames found; export body 3d_xyz or rigid_3d_xyz data'); + } + return { frames: complete, tracker, trajectory: preferredTrajectory }; +} diff --git a/src/studio/mediapipePose.js b/src/studio/mediapipePose.js new file mode 100644 index 0000000..8b284e5 --- /dev/null +++ b/src/studio/mediapipePose.js @@ -0,0 +1,262 @@ +import * as THREE from 'three'; + +// Pose-only paths. The model is loaded lazily after the user presses Extract, +// so opening the studio does not pay a network/model cost. +const DEFAULT_PATHS = { + wasm: 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.32/wasm', + model: 'https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_full/float16/1/pose_landmarker_full.task', +}; + +export const POSE_CONNECTIONS = [ + [0, 2], [2, 5], [5, 0], [7, 8], + [11, 12], [11, 13], [13, 15], [15, 17], [15, 19], [15, 21], + [12, 14], [14, 16], [16, 18], [16, 20], [16, 22], + [11, 23], [12, 24], [23, 24], + [23, 25], [25, 27], [27, 29], [29, 31], [27, 31], + [24, 26], [26, 28], [28, 30], [30, 32], [28, 32], +]; + +let landmarkerPromise = null; +let lastVideoTimestamp = -1; + +export async function loadPoseLandmarker({ wasm = DEFAULT_PATHS.wasm, model = DEFAULT_PATHS.model } = {}) { + if (!landmarkerPromise) { + landmarkerPromise = import('@mediapipe/tasks-vision').then(async ({ FilesetResolver, PoseLandmarker }) => { + const vision = await FilesetResolver.forVisionTasks(wasm); + return PoseLandmarker.createFromOptions(vision, { + baseOptions: { modelAssetPath: model, delegate: 'GPU' }, + runningMode: 'VIDEO', + numPoses: 1, + minPoseDetectionConfidence: 0.5, + minPosePresenceConfidence: 0.5, + minTrackingConfidence: 0.5, + }); + }).catch((error) => { + landmarkerPromise = null; + throw error; + }); + } + return landmarkerPromise; +} + +export async function detectVideoPose(video, timestampMs) { + const landmarker = await loadPoseLandmarker(); + lastVideoTimestamp = Math.max(lastVideoTimestamp + 1, timestampMs); + const result = landmarker.detectForVideo(video, lastVideoTimestamp); + if (!result.worldLandmarks?.[0] || !result.landmarks?.[0]) return null; + return { + world: result.worldLandmarks[0], + normalized: result.landmarks[0], + }; +} + +export function drawPoseOverlay(canvas, video, landmarks, { mirror = false } = {}) { + const ctx = canvas.getContext('2d'); + const width = Math.max(1, video.videoWidth || canvas.clientWidth || 1); + const height = Math.max(1, video.videoHeight || canvas.clientHeight || 1); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + ctx.clearRect(0, 0, width, height); + if (!landmarks) return; + const point = (landmark) => ({ + x: (mirror ? 1 - landmark.x : landmark.x) * width, + y: landmark.y * height, + }); + ctx.strokeStyle = '#68e0c2'; + ctx.lineWidth = Math.max(2, width / 420); + ctx.globalAlpha = 0.82; + for (const [a, b] of POSE_CONNECTIONS) { + if ((landmarks[a]?.visibility ?? 1) < 0.35 || (landmarks[b]?.visibility ?? 1) < 0.35) continue; + const pa = point(landmarks[a]); + const pb = point(landmarks[b]); + ctx.beginPath(); + ctx.moveTo(pa.x, pa.y); + ctx.lineTo(pb.x, pb.y); + ctx.stroke(); + } + ctx.fillStyle = '#f6c85f'; + ctx.globalAlpha = 0.95; + for (const landmark of landmarks) { + if ((landmark.visibility ?? 1) < 0.35) continue; + const p = point(landmark); + ctx.beginPath(); + ctx.arc(p.x, p.y, Math.max(2.5, width / 240), 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; +} + +export function drawStickOverlay(canvas, stick, { picking = null } = {}) { + if (!stick && !picking) return; + const ctx = canvas.getContext('2d'); + const width = canvas.width; + const height = canvas.height; + const butt = stick?.butt ?? picking?.butt; + const blade = stick?.blade ?? picking?.blade; + const grip = stick?.grip; + ctx.save(); + ctx.lineCap = 'round'; + ctx.lineWidth = Math.max(4, width / 180); + ctx.strokeStyle = '#ff5f72'; + if (butt && blade) { + ctx.beginPath(); + ctx.moveTo(butt[0] * width, butt[1] * height); + ctx.lineTo(blade[0] * width, blade[1] * height); + ctx.stroke(); + } + for (const [point, color, radius] of [[butt, '#ffffff', 7], [grip, '#68e0c2', 6], [blade, '#ffcf5c', 8]]) { + if (!point) continue; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(point[0] * width, point[1] * height, Math.max(4, width / 300 * radius), 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); +} + +const _v = Array.from({ length: 33 }, () => new THREE.Vector3()); +const _a = new THREE.Vector3(); +const _x = new THREE.Vector3(); +const _y = new THREE.Vector3(); +const _z = new THREE.Vector3(); +const _parentQ = new THREE.Quaternion(); +const _targetQ = new THREE.Quaternion(); +const _matrix = new THREE.Matrix4(); +const _hipMid = new THREE.Vector3(); +const _shoulderMid = new THREE.Vector3(); +const _earMid = new THREE.Vector3(); +const _side = new THREE.Vector3(); +const _up = new THREE.Vector3(); +const _mapped = new THREE.Vector3(); +const _anchorWorld = new THREE.Vector3(); + +function midpoint(a, b, out) { + return out.copy(a).add(b).multiplyScalar(0.5); +} + +function setWorldFrame(bone, xAxis, yAxis) { + _y.copy(yAxis).normalize(); + if (_y.lengthSq() < 1e-8) return; + _x.copy(xAxis).addScaledVector(_y, -xAxis.dot(_y)); + if (_x.lengthSq() < 1e-8) { + _x.set(1, 0, 0).addScaledVector(_y, -_y.x); + if (_x.lengthSq() < 1e-8) _x.set(0, 0, 1).addScaledVector(_y, -_y.z); + } + else _x.normalize(); + _x.normalize(); + _z.crossVectors(_x, _y).normalize(); + _x.crossVectors(_y, _z).normalize(); + _matrix.makeBasis(_x, _y, _z); + _targetQ.setFromRotationMatrix(_matrix); + bone.parent?.getWorldQuaternion(_parentQ); + bone.quaternion.copy(_parentQ.invert()).multiply(_targetQ).normalize(); + bone.updateWorldMatrix(false, true); +} + +function aimBone(bone, targetWorldDirection, restDirection) { + bone.parent?.getWorldQuaternion(_parentQ); + _a.copy(targetWorldDirection).normalize().applyQuaternion(_parentQ.invert()); + if (_a.lengthSq() < 1e-8) return; + bone.quaternion.setFromUnitVectors(restDirection, _a).normalize(); + bone.updateWorldMatrix(false, true); +} + +/** + * Retarget one MediaPipe world pose onto Tilt's native local-quaternion rig. + * No source limb lengths are copied; directions alone drive the skeleton. + */ +export function retargetPoseToSkeleton(skelData, landmarks, { mirror = false } = {}) { + if (!landmarks || landmarks.length < 33) return 0; + const sx = mirror ? -1 : 1; + for (let i = 0; i < 33; i++) { + const p = landmarks[i]; + // MediaPipe is camera X / image-down Y. Tilt is left-positive X / up Y. + _v[i].set(p.x * sx, -p.y, -p.z); + } + + const B = skelData.bones; + for (const bone of skelData.list) bone.quaternion.identity(); + B.root.position.set(0, 0, 0); + skelData.rootBone.updateMatrixWorld(true); + + midpoint(_v[23], _v[24], _hipMid); + midpoint(_v[11], _v[12], _shoulderMid); + _side.subVectors(_v[11], _v[12]); + _up.subVectors(_shoulderMid, _hipMid); + setWorldFrame(B.pelvis, _side, _up); + + // The torso frame lives on the pelvis. Keeping the small spine chain neutral + // avoids multiplying the same source rotation four times. + for (const name of ['spine1', 'spine2', 'spine3']) B[name].quaternion.identity(); + B.spine3.updateWorldMatrix(true, true); + + midpoint(_v[7], _v[8], _earMid); + _side.subVectors(_v[7], _v[8]); + _up.subVectors(_earMid, _shoulderMid); + setWorldFrame(B.neck, _side, _up); + B.head.quaternion.identity(); + + const rest = (childName) => B[childName].position.clone().normalize(); + aimBone(B.upperArmL, _a.subVectors(_v[13], _v[11]), rest('forearmL')); + aimBone(B.forearmL, _a.subVectors(_v[15], _v[13]), rest('handL')); + aimBone(B.upperArmR, _a.subVectors(_v[14], _v[12]), rest('forearmR')); + aimBone(B.forearmR, _a.subVectors(_v[16], _v[14]), rest('handR')); + aimBone(B.thighL, _a.subVectors(_v[25], _v[23]), rest('shinL')); + aimBone(B.shinL, _a.subVectors(_v[27], _v[25]), rest('footL')); + aimBone(B.footL, _a.subVectors(_v[31], _v[27]), rest('toeL')); + aimBone(B.thighR, _a.subVectors(_v[26], _v[24]), rest('shinR')); + aimBone(B.shinR, _a.subVectors(_v[28], _v[26]), rest('footR')); + aimBone(B.footR, _a.subVectors(_v[32], _v[28]), rest('toeR')); + skelData.rootBone.updateMatrixWorld(true); + + const important = [11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]; + return important.reduce((sum, index) => sum + (landmarks[index].visibility ?? 1), 0) / important.length; +} + +/** + * Bake the marked shaft line into Tilt's mover-local camera plane. + * + * Monocular video does not contain reliable stick depth. The old affine body + * fit invented depth independently for the blade and made the aim flip as the + * body fit changed. Butt→blade is the measured direction, so preserve it and + * leave depth neutral. This is stable, faithful to the reference silhouette, + * and can be replaced by a real keypoint/depth model later without changing + * the clip shape. + */ +export function bakeStickLandmarks( + skelData, + mover, + normalizedPose, + trackedStick, + { mirror = false, socketHand = 'R' } = {}, +) { + const hand = socketHand === 'L' ? 'L' : 'R'; + let butt = trackedStick.butt; + let blade = trackedStick.blade; + // The seed UI accepts the ends in either order. The shaft end closest to the + // selected top-hand wrist is the butt; this also prevents a 180° aim error. + const wrist = normalizedPose?.[hand === 'L' ? 15 : 16]; + if (wrist) { + const buttDistance = Math.hypot(butt[0] - wrist.x, butt[1] - wrist.y); + const bladeDistance = Math.hypot(blade[0] - wrist.x, blade[1] - wrist.y); + if (bladeDistance < buttDistance) [butt, blade] = [blade, butt]; + } + mover.updateMatrixWorld(true); + skelData.bones[`hand${hand}`].getWorldPosition(_anchorWorld); + mover.worldToLocal(_anchorWorld); + const dx = (blade[0] - butt[0]) * (mirror ? -1 : 1); + const dy = blade[1] - butt[1]; + const angle = Math.atan2(-dy, dx); + _mapped.copy(_anchorWorld).addScaledVector(_x.set(Math.cos(angle), Math.sin(angle), 0), 1.12); + return { + butt: [...butt], + grip: [...trackedStick.grip], + blade: [...blade], + target: _mapped.toArray(), + angle, + roll: Number(trackedStick.roll) || 0, + confidence: Math.max(0, Math.min(1, Number(trackedStick.confidence) || 0)), + }; +} diff --git a/src/studio/stickTracker.js b/src/studio/stickTracker.js new file mode 100644 index 0000000..87f23f9 --- /dev/null +++ b/src/studio/stickTracker.js @@ -0,0 +1,132 @@ +/** + * Tiny seeded image tracker for a hockey stick's two useful endpoints. + * + * A hockey stick is not one of MediaPipe Pose's semantic landmarks. Rather + * than ship a second large generic detector (which would only return a box), + * the editor asks for one butt/blade seed and follows the appearance around + * those points through the already-sampled frames. + */ + +const MAX_IMAGE_SIDE = 480; +const PATCH_RADIUS = 4; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function pointPatch(data, width, height, point, radius = PATCH_RADIUS) { + const cx = clamp(Math.round(point[0] * width), radius, width - radius - 1); + const cy = clamp(Math.round(point[1] * height), radius, height - radius - 1); + const values = new Float32Array((radius * 2 + 1) ** 2 * 3); + let k = 0; + for (let y = -radius; y <= radius; y++) { + for (let x = -radius; x <= radius; x++) { + const i = ((cy + y) * width + cx + x) * 4; + values[k++] = data[i]; + values[k++] = data[i + 1]; + values[k++] = data[i + 2]; + } + } + return values; +} + +function patchError(data, width, height, x, y, template, radius = PATCH_RADIUS) { + if (x < radius || y < radius || x >= width - radius || y >= height - radius) return Infinity; + let error = 0; + let k = 0; + for (let py = -radius; py <= radius; py++) { + for (let px = -radius; px <= radius; px++) { + const i = ((y + py) * width + x + px) * 4; + for (let c = 0; c < 3; c++) { + const delta = data[i + c] - template[k++]; + error += delta * delta; + } + } + } + return error / (template.length * 255 * 255); +} + +function findPatch(data, width, height, previous, template) { + const px = previous[0] * width; + const py = previous[1] * height; + const radius = Math.round(Math.min(width, height) * 0.085); + let bestX = Math.round(px); + let bestY = Math.round(py); + let bestError = Infinity; + + // Coarse search, then one-pixel refinement. The motion prior keeps a patch + // with similar colours elsewhere on the jersey from winning too easily. + for (let y = Math.round(py - radius); y <= py + radius; y += 3) { + for (let x = Math.round(px - radius); x <= px + radius; x += 3) { + const appearance = patchError(data, width, height, x, y, template); + const motion = ((x - px) ** 2 + (y - py) ** 2) / Math.max(1, radius * radius) * 0.012; + const score = appearance + motion; + if (score < bestError) { bestError = score; bestX = x; bestY = y; } + } + } + const coarseX = bestX; + const coarseY = bestY; + for (let y = coarseY - 3; y <= coarseY + 3; y++) { + for (let x = coarseX - 3; x <= coarseX + 3; x++) { + const score = patchError(data, width, height, x, y, template); + if (score < bestError) { bestError = score; bestX = x; bestY = y; } + } + } + return { + point: [clamp(bestX / width, 0, 1), clamp(bestY / height, 0, 1)], + confidence: clamp(1 - bestError * 5, 0, 1), + }; +} + +function framePixels(video, canvas, context) { + context.drawImage(video, 0, 0, canvas.width, canvas.height); + return context.getImageData(0, 0, canvas.width, canvas.height).data; +} + +export function createSeededStickTracker(video, seed) { + const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(video.videoWidth, video.videoHeight)); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(32, Math.round(video.videoWidth * scale)); + canvas.height = Math.max(32, Math.round(video.videoHeight * scale)); + const context = canvas.getContext('2d', { willReadFrequently: true }); + const pixels = framePixels(video, canvas, context); + const templates = { + butt: pointPatch(pixels, canvas.width, canvas.height, seed.butt), + blade: pointPatch(pixels, canvas.width, canvas.height, seed.blade), + }; + let previous = { butt: [...seed.butt], blade: [...seed.blade] }; + + return { + seed: structuredClone(seed), + track() { + const frame = framePixels(video, canvas, context); + const butt = findPatch(frame, canvas.width, canvas.height, previous.butt, templates.butt); + const blade = findPatch(frame, canvas.width, canvas.height, previous.blade, templates.blade); + previous = { butt: butt.point, blade: blade.point }; + const initialLength = Math.hypot(seed.blade[0] - seed.butt[0], seed.blade[1] - seed.butt[1]); + const length = Math.hypot(blade.point[0] - butt.point[0], blade.point[1] - butt.point[1]); + const lengthRatio = initialLength > 1e-4 ? length / initialLength : 1; + const geometryConfidence = clamp(1 - Math.abs(Math.log(Math.max(0.01, lengthRatio))) * 1.2, 0, 1); + return { + butt: butt.point, + blade: blade.point, + confidence: Math.min(butt.confidence, blade.confidence, geometryConfidence), + }; + }, + reset() { previous = { butt: [...seed.butt], blade: [...seed.blade] }; }, + }; +} + +export function stickGripFromWrists(stick, normalizedPose) { + const wrist = [ + (normalizedPose[15].x + normalizedPose[16].x) * 0.5, + (normalizedPose[15].y + normalizedPose[16].y) * 0.5, + ]; + const ax = stick.butt[0]; + const ay = stick.butt[1]; + const dx = stick.blade[0] - ax; + const dy = stick.blade[1] - ay; + const lengthSq = dx * dx + dy * dy || 1; + const t = clamp(((wrist[0] - ax) * dx + (wrist[1] - ay) * dy) / lengthSq, 0, 1); + return [ax + dx * t, ay + dy * t]; +} diff --git a/test/animationClip.mjs b/test/animationClip.mjs new file mode 100644 index 0000000..0c192a2 --- /dev/null +++ b/test/animationClip.mjs @@ -0,0 +1,144 @@ +import * as THREE from 'three'; +import { buildSkeleton } from '../src/character/skeleton.js'; +import { buildStick } from '../src/character/stick.js'; +import { + applyTiltAnimation, + applyTiltClip, + captureSkeletonKeyframe, + clipAsModule, + createTiltClip, + deleteClipKeyframe, + frameSpan, + sanitizeTiltClip, + sampleTiltStick, + setClipKeyframe, + smoothTiltClip, +} from '../src/anim/clip.js'; +import { done, ok, section } from './harness.mjs'; + +section('Tilt clips capture and interpolate the native skeleton'); +{ + const skel = buildSkeleton(); + const clip = createTiltClip({ name: 'test-motion', loop: false, shotSide: 'left' }); + skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0); + setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0)); + skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2); + setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1)); + + skel.bones.upperArmL.quaternion.identity(); + ok(applyTiltClip(skel, clip, 0.5), 'a populated clip applies'); + const angle = 2 * Math.acos(skel.bones.upperArmL.quaternion.w); + ok(Math.abs(angle - Math.PI / 4) < 1e-5, 'quaternions slerp halfway'); + ok(frameSpan(clip, 2).a.time === 1, 'non-looping clips clamp at the end'); + ok(sanitizeTiltClip(clip).shotSide === 'left', 'clip preserves its authored stick socket side'); +} + +section('clip validation, replacement, smoothing, and export'); +{ + const skel = buildSkeleton(); + const clip = createTiltClip({ name: 'hip check' }); + setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0)); + setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0)); + ok(clip.keyframes.length === 1, 'setting the same time replaces a key'); + setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0.5)); + setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1)); + smoothTiltClip(clip); + const copy = sanitizeTiltClip(JSON.parse(JSON.stringify(clip))); + ok(copy.keyframes.length === 3 && copy.rig === 'tilt-23', 'serialized clips validate'); + const module = clipAsModule(copy); + ok(module.includes('export const hip_check') && module.includes('applyTiltAnimation'), 'module export is drop-in runtime JavaScript'); + ok(deleteClipKeyframe(copy, 0.5) && copy.keyframes.length === 2, 'keys can be deleted'); +} + +section('stick landmarks survive storage and interpolate with the pose'); +{ + const skel = buildSkeleton(); + const clip = createTiltClip({ loop: false }); + const a = captureSkeletonKeyframe(skel, 0); + a.stick = { butt: [0.4, 0.2], grip: [0.45, 0.4], blade: [0.5, 0.8], target: [-0.2, 0.03, 0.6], roll: 0, confidence: 0.8 }; + const b = captureSkeletonKeyframe(skel, 1); + b.stick = { butt: [0.6, 0.2], grip: [0.6, 0.4], blade: [0.8, 0.8], target: [0.4, 0.2, 1.0], roll: 0.4, confidence: 1 }; + setClipKeyframe(clip, a); + setClipKeyframe(clip, b); + const stick = sampleTiltStick(clip, 0.5); + ok(Math.abs(stick.blade[0] - 0.65) < 1e-6, 'blade landmark interpolates'); + ok(Math.abs(stick.target[0] - 0.1) < 1e-6, 'rig-local blade target interpolates'); + ok(Number.isFinite(stick.angle), 'legacy butt/blade marks derive a stable shaft angle'); + ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.confidence === 0.8, 'stick metadata serializes'); +} + +section('applied stick guides constrain the 3D shaft through both hands'); +{ + const skel = buildSkeleton(); + const mover = new THREE.Group(); + mover.add(skel.rootBone); + const stick = buildStick(null, null, 0); + stick.attachTo(skel.bones.handR); + const clip = createTiltClip({ loop: false }); + const frame = captureSkeletonKeyframe(skel, 0); + frame.stick = { + butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8], + target: [0, 0, 0], angle: 0.5, roll: 0.2, alignHands: true, confidence: 1, + }; + setClipKeyframe(clip, frame); + const skater = { skelData: skel, mover, stick }; + ok(applyTiltAnimation(skater, clip, 0), 'two-hand guide applies'); + + const shaftA = new THREE.Vector3(); + const shaftB = new THREE.Vector3(); + const lowerHand = skel.bones.handL.getWorldPosition(new THREE.Vector3()); + stick.shaftSegment(shaftA, shaftB); + const shaft = shaftB.clone().sub(shaftA); + const t = THREE.MathUtils.clamp(lowerHand.clone().sub(shaftA).dot(shaft) / shaft.lengthSq(), 0, 1); + const closest = shaftA.clone().addScaledVector(shaft, t); + ok(shaftA.distanceTo(skel.bones.handR.getWorldPosition(new THREE.Vector3())) < 1e-6, 'shaft starts at the socket hand'); + ok(closest.distanceTo(lowerHand) < 1e-6, 'shaft crosses the lower hand in depth as well as screen space'); + ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.alignHands, 'two-hand constraint survives save/load'); +} + +section('equivalent stick-line directions do not bake a false half-turn'); +{ + const skel = buildSkeleton(); + const clip = createTiltClip({ loop: false }); + for (const [time, angle] of [[0, 0.2], [0.5, Math.PI - 0.1], [1, 0.3]]) { + const frame = captureSkeletonKeyframe(skel, time); + frame.stick = { + butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8], + target: [0, 0, 0], angle, roll: 0, confidence: 1, + }; + setClipKeyframe(clip, frame); + } + const clean = sanitizeTiltClip(clip); + const delta = Math.abs(clean.keyframes[1].stick.angle - clean.keyframes[0].stick.angle); + ok(delta < Math.PI / 2, 'a PI-flipped detector result stays on the nearest shaft-line branch'); +} + +section('stick playback follows the actual socket bone'); +{ + const skel = buildSkeleton(); + const mover = new THREE.Group(); + mover.add(skel.rootBone); + const stickGroup = new THREE.Group(); + skel.bones.handL.add(stickGroup); + const clip = createTiltClip({ loop: false, shotSide: 'left' }); + const frame = captureSkeletonKeyframe(skel, 0); + frame.stick = { + butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8], + target: [0, 0, 0], angle: -0.4, roll: 0, confidence: 1, + }; + setClipKeyframe(clip, frame); + let actualHand = null; + const skater = { + skelData: skel, + mover, + stick: { + group: stickGroup, + aimAt(_target, hand) { actualHand = hand.clone(); }, + }, + }; + applyTiltAnimation(skater, clip, 0); + const expectedHand = skel.bones.handL.getWorldPosition(new THREE.Vector3()); + ok(actualHand?.distanceTo(expectedHand) < 1e-6, 'left socket playback anchors at handL instead of handR'); +} + +done('animation clip'); diff --git a/test/freemocapImport.mjs b/test/freemocapImport.mjs new file mode 100644 index 0000000..dc65391 --- /dev/null +++ b/test/freemocapImport.mjs @@ -0,0 +1,43 @@ +import { parseFreeMoCapCsv } from '../src/studio/freemocapImport.js'; +import { done, ok, section } from './harness.mjs'; + +const points = [ + 'left_ear', 'right_ear', 'left_shoulder', 'right_shoulder', + 'left_elbow', 'right_elbow', 'left_wrist', 'right_wrist', + 'left_hip', 'right_hip', 'left_knee', 'right_knee', + 'left_ankle', 'right_ankle', 'left_heel', 'right_heel', + 'left_foot_index', 'right_foot_index', +]; + +section('FreeMoCap tidy XYZ data imports into MediaPipe landmark order'); +{ + const lines = ['frame,keypoint,x,y,z,model,trajectory,reprojection_error']; + for (const trajectory of ['3d_xyz', 'rigid_3d_xyz']) { + for (const frame of [0, 1]) { + for (const [index, point] of points.entries()) { + const base = trajectory === 'rigid_3d_xyz' ? 200 : 100; + lines.push(`${frame},${point},${base + index},20,300,mediapipe_body,${trajectory},0.5`); + } + } + } + // Non-body rows in the tidy all-data file must not contaminate the pose. + lines.push('0,left_wrist,999,999,999,mediapipe_left_hand,rigid_3d_xyz,0.5'); + const result = parseFreeMoCapCsv(lines.join('\n')); + ok(result.frames.length === 2, 'all complete body frames import'); + ok(result.trajectory === 'rigid_3d_xyz', 'rigid body trajectory is preferred when available'); + ok(result.tracker === 'MediaPipe', 'tracker metadata is reported'); + const leftEar = result.frames[0].landmarks[7]; + ok(leftEar.x === 200 && leftEar.y === -300 && leftEar.z === -20, 'FreeMoCap Z-up axes convert for the Tilt retargeter'); +} + +section('FreeMoCap per-trajectory CSV and RTMPose toe names are accepted'); +{ + const rtmposePoints = points.map((point) => point.replace('foot_index', 'big_toe')); + const lines = ['frame,keypoint,x,y,z']; + for (const [index, point] of rtmposePoints.entries()) lines.push(`4,${point},${index},10,20`); + const result = parseFreeMoCapCsv(lines.join('\r\n')); + ok(result.frames[0].frame === 4, 'source frame numbers survive import'); + ok(result.frames[0].landmarks[31].visibility === 1, 'RTMPose big toe maps to the Tilt foot-index landmark'); +} + +done('FreeMoCap import'); diff --git a/test/pose.mjs b/test/pose.mjs index ba35f81..af108f0 100644 --- a/test/pose.mjs +++ b/test/pose.mjs @@ -1,8 +1,11 @@ import * as THREE from 'three'; import { buildSkeleton } from '../src/character/skeleton.js'; import { buildAnimator } from '../src/anim/skateAnimator.js'; +import { shot1 } from '../src/anim/clips/shot1.js'; +import { frameSpan } from '../src/anim/clip.js'; import { buildStick } from '../src/character/stick.js'; import { segDist } from '../src/core/math.js'; +import { topHandFor } from '../shared/player.js'; import { done, ok, section } from './harness.mjs'; /** @@ -16,7 +19,7 @@ import { done, ok, section } from './harness.mjs'; const DT = 1 / 60; -function rig() { +function rig(shotSide = 'right') { const skelData = buildSkeleton(); const mover = new THREE.Group(); // Same hierarchy as createSkater: skeleton rides on the mover so body yaw @@ -24,10 +27,12 @@ function rig() { // the stick target orbited in world space while the hand sat still. mover.add(skelData.rootBone); const anim = buildAnimator(skelData, mover); - // The stick is part of the pose now — it hangs off the hand and the animator - // aims it, so a rig without one is not the rig the game runs. + anim.setShotSide(shotSide); + // The stick is part of the pose now — it hangs off the top hand for this + // shot side and the animator aims it, so a rig without one is not the rig + // the game runs. const stick = buildStick(null, null, 0); - stick.attachTo(skelData.bones.handR); + stick.attachTo(skelData.bones[`hand${topHandFor(shotSide)}`]); anim.stick = stick; return { skelData, mover, anim, stick }; } @@ -262,26 +267,28 @@ section('the stick is held, not floating'); // Puck carry must be two-handed: top hand on the butt, lower hand on the // shaft. The old pose parked the stick on the hip and left the off-hand // ~25 cm short — the failure the motion-reference carry frame calls out. - const r = drive(rig(), 4, { ...GLIDE, effort: 0.2, hasPuck: true }); - r.mover.updateMatrixWorld(true); - const butt = new THREE.Vector3(); - const heel = new THREE.Vector3(); - r.stick.shaftSegment(butt, heel); - const handR = new THREE.Vector3(); - r.skelData.bones.handR.getWorldPosition(handR); - ok(handR.distanceTo(butt) < 0.12, `the top hand is on the butt of the stick (${handR.distanceTo(butt).toFixed(3)}m)`); + for (const side of ['right', 'left']) { + const r = drive(rig(side), 4, { ...GLIDE, effort: 0.2, hasPuck: true }); + r.mover.updateMatrixWorld(true); + const butt = new THREE.Vector3(); + const heel = new THREE.Vector3(); + r.stick.shaftSegment(butt, heel); + const top = new THREE.Vector3(); + r.skelData.bones[`hand${r.anim.topHand}`].getWorldPosition(top); + ok(top.distanceTo(butt) < 0.12, `${side}: top hand is on the butt (${top.distanceTo(butt).toFixed(3)}m)`); - const handL = new THREE.Vector3(); - const closest = new THREE.Vector3(); - r.skelData.bones.handL.getWorldPosition(handL); - const gap = segDist(handL, butt, heel, closest); - ok(gap < 0.08, `the lower hand is on the shaft (${gap.toFixed(3)}m)`); + const lower = new THREE.Vector3(); + const closest = new THREE.Vector3(); + r.skelData.bones[`hand${r.anim.lowerHand}`].getWorldPosition(lower); + const gap = segDist(lower, butt, heel, closest); + ok(gap < 0.08, `${side}: lower hand is on the shaft (${gap.toFixed(3)}m)`); - // Stick sits in front of the body, not parked out on the hip. - const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert(); - const handLocal = handR.clone().applyMatrix4(inv); - ok(Math.abs(handLocal.x) < 0.28, `top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`); - ok(handLocal.z > 0.25, `top hand is out in front (z=${handLocal.z.toFixed(2)})`); + // Stick sits in front of the body, not parked out on the hip. + const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert(); + const handLocal = top.clone().applyMatrix4(inv); + ok(Math.abs(handLocal.x) < 0.28, `${side}: top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`); + ok(handLocal.z > 0.25, `${side}: top hand is out in front (z=${handLocal.z.toFixed(2)})`); + } } section('the stick stays in the socket when the body turns'); @@ -297,13 +304,14 @@ section('the stick stays in the socket when the body turns'); const handQ = new THREE.Quaternion(); const stickQ = new THREE.Quaternion(); let maxDelta = 0; + const topBone = r.skelData.bones[`hand${r.anim.topHand}`]; for (let i = 0; i < 48; i++) { const yaw = (i / 48) * Math.PI * 2; r.anim.setTransform(r.mover.position, yaw); Object.assign(r.anim, { ...GLIDE, hasPuck: true, originYaw: yaw, yawRate: 0 }); r.anim.update(DT); - r.skelData.bones.handR.getWorldQuaternion(handQ); + topBone.getWorldQuaternion(handQ); r.stick.group.getWorldQuaternion(stickQ); local.copy(handQ).invert().multiply(stickQ); if (i === 0) local0.copy(local); @@ -313,6 +321,26 @@ section('the stick stays in the socket when the body turns'); ok(maxDelta < 0.02, `stick local pose is stable across a full spin (delta ${maxDelta.toFixed(4)})`); } +section('shot side puts the blade on the matching forehand'); +{ + // Right shot: blade on the skater's right (−X). Left shot: mirrored to +X. + const right = drive(rig('right'), 3, { ...GLIDE, hasPuck: true }); + const left = drive(rig('left'), 3, { ...GLIDE, hasPuck: true }); + const invR = new THREE.Matrix4().copy(right.mover.matrixWorld).invert(); + const invL = new THREE.Matrix4().copy(left.mover.matrixWorld).invert(); + const br = new THREE.Vector3(); + const bl = new THREE.Vector3(); + right.stick.bladeWorld(br); + left.stick.bladeWorld(bl); + br.applyMatrix4(invR); + bl.applyMatrix4(invL); + ok(br.x < -0.05, `right shot carries on the right (x=${br.x.toFixed(2)})`); + ok(bl.x > 0.05, `left shot carries on the left (x=${bl.x.toFixed(2)})`); + ok(right.anim.topHand === 'R' && right.anim.lowerHand === 'L', 'right shot: top R, lower L'); + ok(left.anim.topHand === 'L' && left.anim.lowerHand === 'R', 'left shot: top L, lower R'); + ok(right.anim.shotSign === 1 && left.anim.shotSign === -1, 'shotSign tracks the side'); +} + section('stick actions run and finish'); { for (const action of ['shoot', 'pass', 'poke']) { @@ -334,9 +362,9 @@ section('stick actions run and finish'); } } -section('a wind-up lifts the blade off the ice and holds'); +section('a wind-up uses the first half of saved shot1 and holds'); { - const r = rig(); + const r = rig('left'); drive(r, 1, { ...GLIDE, hasPuck: true }); const flat = new THREE.Vector3(); r.stick.bladeWorld(flat); @@ -353,11 +381,71 @@ section('a wind-up lifts the blade off the ice and holds'); const back = new THREE.Vector3(); r.stick.bladeWorld(back); const backLocal = back.clone().applyMatrix4(inv); - // High and back behind the head — not hanging blade-down at hip height. - ok(backLocal.y > 1.1, `the blade is up high (y=${backLocal.y.toFixed(2)})`); - ok(backLocal.y > flatLocal.y + 0.8, `well above the carry (${flatLocal.y.toFixed(2)} → ${backLocal.y.toFixed(2)})`); - ok(backLocal.z < -0.15, `and back behind the body (z=${backLocal.z.toFixed(2)})`); + // The saved midpoint is the held load pose. + ok(backLocal.y > 0.28, `the blade is lifted (y=${backLocal.y.toFixed(2)})`); + ok(backLocal.y > flatLocal.y + 0.2, `well above the carry (${flatLocal.y.toFixed(2)} → ${backLocal.y.toFixed(2)})`); + ok(backLocal.z < flatLocal.z - 0.2, `and drawn back from the carry (z ${flatLocal.z.toFixed(2)} → ${backLocal.z.toFixed(2)})`); + const savedMidpoint = shot1.keyframes.find((frame) => frame.time === shot1.duration * 0.5); + const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo( + new THREE.Quaternion().fromArray(savedMidpoint.rotations.upperArmL), + ); + ok(poseDelta < 1e-6, `held wind-up lands exactly on shot1's midpoint (Δ=${poseDelta.toFixed(6)})`); ok(r.anim.action === 'windup', 'and the wind-up is held, not played once'); + + // Both hands stay on the shaft through the load. + const butt = new THREE.Vector3(); + const heel = new THREE.Vector3(); + r.stick.shaftSegment(butt, heel); + const handR = new THREE.Vector3(); + r.skelData.bones.handR.getWorldPosition(handR); + const onShaft = segDist(handR, butt, heel, new THREE.Vector3()); + ok(onShaft < 0.1, `lower hand stays on the stick (dist ${onShaft.toFixed(3)})`); +} + +section('a regular shot plays the whole saved shot1 clip'); +{ + const r = rig('left'); + drive(r, 1, { ...GLIDE, hasPuck: true }); + r.anim.playAction('shoot', { power: 1 }); + // The 6-second reference is compressed into the 0.33-second motion window; + // the remaining 0.09 seconds are the existing blend back to skating. + r.anim.actionTime = 0.33 - DT; + Object.assign(r.anim, { ...GLIDE, hasPuck: true }); + r.anim.update(DT); + const savedEnd = shot1.keyframes.at(-1); + const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo( + new THREE.Quaternion().fromArray(savedEnd.rotations.upperArmL), + ); + ok(poseDelta < 1e-5, `regular shot reaches shot1's final key before fading (Δ=${poseDelta.toFixed(6)})`); + + const butt = new THREE.Vector3(); + const heel = new THREE.Vector3(); + const lowerHand = r.skelData.bones.handR.getWorldPosition(new THREE.Vector3()); + r.stick.shaftSegment(butt, heel); + const onShaft = segDist(lowerHand, butt, heel, new THREE.Vector3()); + ok(onShaft < 1e-5, `saved shot keeps the guide through both hands (dist ${onShaft.toFixed(6)})`); +} + +section('releasing a held wind-up continues through shot1 second half'); +{ + const r = rig('left'); + drive(r, 1, { ...GLIDE, hasPuck: true }); + r.anim.action = 'windup'; + drive(r, 0.2, { ...GLIDE, hasPuck: true, charge: 1 }); + r.anim.playAction('shoot', { power: 1 }); + Object.assign(r.anim, { ...GLIDE, hasPuck: true }); + r.anim.update(DT); + + const referenceTime = shot1.duration * (0.5 + 0.5 * DT / 0.33); + const span = frameSpan(shot1, referenceTime); + const expected = new THREE.Quaternion().slerpQuaternions( + new THREE.Quaternion().fromArray(span.a.rotations.upperArmL), + new THREE.Quaternion().fromArray(span.b.rotations.upperArmL), + span.alpha, + ); + const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo(expected); + ok(r.anim.actionFromWindup, 'release remembers that the first half was already held'); + ok(poseDelta < 1e-5, `release continues from the midpoint instead of replaying the load (Δ=${poseDelta.toFixed(6)})`); } section('Skill Stick right moves the blade to the skater\'s right'); diff --git a/test/skaterSim.mjs b/test/skaterSim.mjs index 3bc923e..91add3c 100644 --- a/test/skaterSim.mjs +++ b/test/skaterSim.mjs @@ -1,5 +1,8 @@ import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js'; import { RINK, insideRink } from '../shared/rink.js'; +import { + normalizePlayer, normalizeShotSide, packPlayer, rollShotSide, shotSign, unpackPlayer, +} from '../shared/player.js'; import { done, near, ok, section } from './harness.mjs'; const DT = 1 / 120; @@ -234,6 +237,7 @@ section('intent from a controller is clamped before the sim sees it'); // The clamped state must still step without producing garbage. stepSkater(s, DT); + ok(Number.isFinite(s.x) && Number.isFinite(s.vx), 'and the sim steps cleanly afterwards'); } @@ -243,4 +247,28 @@ section('rink dimensions are the ones we think they are'); near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide'); } +section('player definition carries shot side'); +{ + ok(normalizeShotSide('left') === 'left', 'left stays left'); + ok(normalizeShotSide('L') === 'left', 'L is left'); + ok(normalizeShotSide(-1) === 'left', '-1 is left'); + ok(normalizeShotSide('right') === 'right', 'right stays right'); + ok(normalizeShotSide('R') === 'right', 'R is right'); + ok(normalizeShotSide(undefined) === 'right', 'missing defaults to right (authored side)'); + ok(shotSign('left') === -1 && shotSign('right') === 1, 'shotSign is ±1'); + ok(rollShotSide(0.1) === 'left' && rollShotSide(0.9) === 'right', 'roll respects the NHL-ish split'); + + const packed = packPlayer({ shotSide: 'left' }); + ok(packed.ss === 'L', 'pack uses a short wire form'); + ok(unpackPlayer(packed).shotSide === 'left', 'unpack restores shot side'); + ok(normalizePlayer({ shotSide: 'left' }).shotSide === 'left', 'normalizePlayer keeps shot side'); + + const lefty = createSkaterState(0, START, { shotSide: 'left', name: 'Lefty' }); + const righty = createSkaterState(1, START, { shotSide: 'right' }); + const plain = createSkaterState(2, START); + ok(lefty.shotSide === 'left', 'state stores left shot'); + ok(righty.shotSide === 'right', 'state stores right shot'); + ok(plain.shotSide === 'right', 'state defaults to right shot'); +} + done('skaterSim'); diff --git a/tools/freemocap-vite-plugin.mjs b/tools/freemocap-vite-plugin.mjs new file mode 100644 index 0000000..4c3cc6f --- /dev/null +++ b/tools/freemocap-vite-plugin.mjs @@ -0,0 +1,106 @@ +import { spawn } from 'node:child_process'; +import { createWriteStream } from 'node:fs'; +import { access, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { extname, resolve } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.webm', '.m4v']); + +function run(command, args, options = {}) { + return new Promise((resolveRun, reject) => { + const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout = (stdout + chunk).slice(-16000); }); + child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-32000); }); + child.once('error', reject); + child.once('close', (code) => { + if (code === 0) resolveRun({ stdout: stdout.trim(), stderr: stderr.trim() }); + else reject(new Error((stderr || stdout || `${command} exited with ${code}`).trim())); + }); + }); +} + +export function freeMocapBridge({ projectRoot }) { + const bundledPython = resolve(projectRoot, '.freemocap-venv/bin/python'); + const worker = resolve(projectRoot, 'tools/freemocap_worker.py'); + const python = process.env.TILT_FREEMOCAP_PYTHON || bundledPython; + + async function workerStatus() { + try { + await access(python); + const result = await run(python, ['-c', 'import freemocap; print(freemocap.__version__)']); + const version = result.stdout.split('\n').at(-1)?.replace(/\x1b\[[0-9;]*m/g, '') || 'installed'; + return { available: true, version }; + } catch (error) { + return { + available: false, + message: `FreeMoCap worker is not installed (${error.message.split('\n').at(-1)})`, + }; + } + } + + return { + name: 'tilt-freemocap-bridge', + configureServer(server) { + server.middlewares.use('/api/freemocap/status', async (_request, response) => { + const status = await workerStatus(); + response.statusCode = status.available ? 200 : 503; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(status)); + }); + + server.middlewares.use('/api/freemocap/process', async (request, response) => { + if (request.method !== 'POST') { + response.statusCode = 405; + response.end('POST a video file to this endpoint'); + return; + } + const status = await workerStatus(); + if (!status.available) { + response.statusCode = 503; + response.end(`${status.message}. Run npm run freemocap:setup.`); + return; + } + + const url = new URL(request.url || '/', 'http://localhost'); + const requestedName = url.searchParams.get('filename') || 'reference.mp4'; + const requestedExtension = extname(requestedName).toLowerCase(); + const extension = VIDEO_EXTENSIONS.has(requestedExtension) ? requestedExtension : '.mp4'; + const fps = Math.max(1, Math.min(60, Number(url.searchParams.get('fps')) || 30)); + const jobRoot = await mkdtemp(resolve(tmpdir(), 'tilt-freemocap-')); + const input = resolve(jobRoot, `upload${extension}`); + const recording = resolve(jobRoot, 'recording'); + const output = resolve(jobRoot, 'result.csv'); + const metadata = resolve(jobRoot, 'result.json'); + try { + await pipeline(request, createWriteStream(input)); + server.config.logger.info(`FreeMoCap processing ${requestedName}…`); + await run(python, [ + worker, + '--input', input, + '--recording', recording, + '--output', output, + '--metadata', metadata, + '--fps', String(fps), + ], { cwd: projectRoot }); + const csv = await readFile(output); + const resultMetadata = JSON.parse(await readFile(metadata, 'utf8')); + response.statusCode = 200; + response.setHeader('Content-Type', 'text/csv; charset=utf-8'); + response.setHeader('X-Tilt-Mocap-Backend', `FreeMoCap ${status.version}`); + response.setHeader('X-Tilt-Source-Fps', String(resultMetadata.fps || fps)); + response.end(csv); + } catch (error) { + server.config.logger.error(error.stack || error.message); + response.statusCode = 500; + response.setHeader('Content-Type', 'text/plain; charset=utf-8'); + response.end(error.message); + } finally { + await rm(jobRoot, { recursive: true, force: true }); + } + }); + }, + }; +} diff --git a/tools/freemocap_worker.py b/tools/freemocap_worker.py new file mode 100644 index 0000000..d57d15a --- /dev/null +++ b/tools/freemocap_worker.py @@ -0,0 +1,83 @@ +"""Run FreeMoCap's stable headless pipeline for one uploaded reference video.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--recording", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--metadata", required=True, type=Path) + parser.add_argument("--fps", required=True, type=float) + return parser.parse_args() + + +def prepare_mp4(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + if source.suffix.lower() == ".mp4": + shutil.copyfile(source, destination) + return + subprocess.run( + [ + "ffmpeg", "-y", "-i", str(source), "-an", + "-c:v", "libx264", "-pix_fmt", "yuv420p", str(destination), + ], + check=True, + ) + + +def main() -> None: + args = parse_args() + synchronized = args.recording / "synchronized_videos" + prepared_video = synchronized / "camera_0.mp4" + prepare_mp4(args.input, prepared_video) + + # FreeMoCap 1.8.x exposes this stable headless entry point. Blender and the + # notebook are deliberately disabled: Tilt only needs its filtered body XYZ. + from freemocap.core_processes.process_motion_capture_videos.process_recording_headless import ( + process_recording_headless, + ) + from freemocap.data_layer.recording_models.post_processing_parameter_models import ( + ProcessingParameterModel, + ) + import cv2 + + capture = cv2.VideoCapture(str(prepared_video)) + detected_fps = float(capture.get(cv2.CAP_PROP_FPS)) + capture.release() + source_fps = detected_fps if detected_fps > 0 else args.fps + + parameters = ProcessingParameterModel() + parameters.post_processing_parameters_model.framerate = source_fps + parameters.post_processing_parameters_model.butterworth_filter_parameters.sampling_rate = source_fps + # Preserve MediaPipe's monocular depth estimate; the default flattened mode + # throws that axis away before FreeMoCap's interpolation and rigid-bone pass. + parameters.anipose_triangulate_3d_parameters_model.flatten_single_camera_data = False + process_recording_headless( + recording_path=args.recording, + recording_processing_parameter_model=parameters, + run_blender=False, + make_jupyter_notebook=False, + use_tqdm=False, + ) + + result = args.recording / f"{args.recording.name}_by_frame.csv" + if not result.is_file(): + matches = sorted(args.recording.glob("*_by_frame.csv")) + if not matches: + raise FileNotFoundError("FreeMoCap completed without producing a by-frame CSV") + result = matches[0] + args.output.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(result, args.output) + args.metadata.write_text(json.dumps({"fps": source_fps}), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/setup-freemocap.sh b/tools/setup-freemocap.sh new file mode 100644 index 0000000..a775bbc --- /dev/null +++ b/tools/setup-freemocap.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." +uv venv --python 3.12 .freemocap-venv +uv pip install --python .freemocap-venv/bin/python "freemocap==1.8.2" +.freemocap-venv/bin/python -c "import freemocap; print('FreeMoCap', freemocap.__version__, 'ready')" diff --git a/vite.config.js b/vite.config.js index 87df60c..c57001c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,9 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; +import { freeMocapBridge } from './tools/freemocap-vite-plugin.mjs'; export default defineConfig({ + plugins: [freeMocapBridge({ projectRoot: import.meta.dirname })], server: { port: 5174, open: true }, // Multi-page: main game + character studio both ship as real HTML entries. build: { @@ -13,6 +15,7 @@ export default defineConfig({ input: { main: resolve(import.meta.dirname, 'index.html'), character: resolve(import.meta.dirname, 'character.html'), + animation: resolve(import.meta.dirname, 'animation.html'), }, }, },