animations
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+132
-40
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+138
-25
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -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,
|
||||
|
||||
+41
-5
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user