31ba2911df
Skills (.claude/skills/): animation-creation, iclone-video-mocap, pose-estimation (MediaPipe models via LFS), retarget-animations (deprecated). Tools: cc/mixamo/kevin/mocap retargeters + composite baker. Plans: animation-gen-pipeline + skills-adoption. exchange/: incoming-fbx, converted-glb, reference-video (LFS for fbx/glb/mp4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
281 lines
14 KiB
JavaScript
281 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
// Bake a combined "Run_Punch" animation: legs from Jog_Fwd_Loop + upper body from Punch_Jab.
|
||
//
|
||
// No Blender required — edits the glTF JSON chunk only. The new animation reuses the source
|
||
// GLB's accessors/buffer verbatim (same skeleton); arm-lift and wind-up append fresh keyframes
|
||
// to the binary chunk.
|
||
//
|
||
// --mode arms-only : spine+neck+head+legs from RUN (torso upright, gaze forward), arms from PUNCH
|
||
// --arm-lift DEG : pitch both upperarms up (Y axis, mirrored per arm) → fist at eye level
|
||
// --windup DEG : synthesize a wind-up on the PUNCHING (left) upperarm: neutral → cocked
|
||
// (pulled back DEG on Z axis) → extended → recovered. Adds anticipation.
|
||
// --windup-dur S : wind-up (pull-back) duration in seconds (default 0.18)
|
||
//
|
||
// Usage: node tools/bake_run_punch.mjs --mode arms-only --arm-lift 35 --windup 45
|
||
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
|
||
const argv = process.argv.slice(2);
|
||
const getOpt = (name, fallback) => {
|
||
const i = argv.indexOf(name);
|
||
return i >= 0 ? argv[i + 1] : fallback;
|
||
};
|
||
const mode = getOpt("--mode", "arms-only");
|
||
const armLiftDeg = parseFloat(getOpt("--arm-lift", "0"));
|
||
const windupDeg = parseFloat(getOpt("--windup", "0"));
|
||
const windupDur = parseFloat(getOpt("--windup-dur", "0.18"));
|
||
|
||
const SRC = "assets/quaternius/anim-lib-1/Universal Animation Library[Standard]/Unreal-Godot/UAL1_Standard.glb";
|
||
const OUT = "assets/quaternius/anim-combined/Run_Punch.glb";
|
||
const RUN_NAME = "Jog_Fwd_Loop";
|
||
const PUNCH_NAME = "Punch_Jab";
|
||
const OUT_CLIP = "Run_Punch";
|
||
const THROW_DUR = 0.15; // cocked → extended (the fast thrust)
|
||
|
||
function parseGlb(buf) {
|
||
if (buf.toString("ascii", 0, 4) !== "glTF") throw new Error("not a GLB");
|
||
const length = buf.readUInt32LE(8);
|
||
let o = 12, json = null, bin = null;
|
||
while (o < length) {
|
||
const clen = buf.readUInt32LE(o);
|
||
const ctype = buf.toString("ascii", o + 4, o + 8);
|
||
const data = buf.subarray(o + 8, o + 8 + clen);
|
||
if (ctype === "JSON") json = JSON.parse(data.toString("utf8"));
|
||
else if (ctype.startsWith("BIN")) bin = Buffer.from(data);
|
||
o += 8 + clen;
|
||
}
|
||
return { json, bin };
|
||
}
|
||
|
||
function writeGlb(jsonObj, binBuf, outPath) {
|
||
const jsonBytes = Buffer.from(JSON.stringify(jsonObj), "utf8");
|
||
const jsonPad = (4 - (jsonBytes.length % 4)) % 4;
|
||
const jsonPadded = Buffer.concat([jsonBytes, Buffer.alloc(jsonPad, 0x20)]);
|
||
const binPad = (4 - (binBuf.length % 4)) % 4;
|
||
const binPadded = Buffer.concat([binBuf, Buffer.alloc(binPad, 0x00)]);
|
||
const total = 12 + 8 + jsonPadded.length + 8 + binPadded.length;
|
||
const out = Buffer.alloc(total);
|
||
let p = 0;
|
||
out.write("glTF", p, "ascii"); p += 4;
|
||
out.writeUInt32LE(2, p); p += 4;
|
||
out.writeUInt32LE(total, p); p += 4;
|
||
out.writeUInt32LE(jsonPadded.length, p); p += 4; out.write("JSON", p, "ascii"); p += 4;
|
||
jsonPadded.copy(out, p); p += jsonPadded.length;
|
||
out.writeUInt32LE(binPadded.length, p); p += 4; out.write("BIN\0", p, "ascii"); p += 4;
|
||
binPadded.copy(out, p); p += binPadded.length;
|
||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||
fs.writeFileSync(outPath, out);
|
||
return total;
|
||
}
|
||
|
||
// ── Quaternion + vector math (xyzw) ──
|
||
function qMul(a, b) {
|
||
const [ax, ay, az, aw] = a, [bx, by, bz, bw] = b;
|
||
return [aw * bx + ax * bw + ay * bz - az * by,
|
||
aw * by - ax * bz + ay * bw + az * bx,
|
||
aw * bz + ax * by - ay * bx + az * bw,
|
||
aw * bw - ax * bx - ay * by - az * bz];
|
||
}
|
||
function qAxis(axis, deg) {
|
||
const sign = deg < 0 ? -1 : 1;
|
||
const r = Math.abs(deg) * Math.PI / 180, h = Math.sin(r / 2) * sign;
|
||
const v = [0, 0, 0]; v[axis] = h;
|
||
return [v[0], v[1], v[2], Math.cos(r / 2)];
|
||
}
|
||
function qSlerp(a, b, t) {
|
||
let [ax, ay, az, aw] = a;
|
||
let [bx, by, bz, bw] = b;
|
||
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
||
if (dot < 0) { bx = -bx; by = -by; bz = -bz; bw = -bw; dot = -dot; }
|
||
if (dot > 0.9995) {
|
||
return [ax + t * (bx - ax), ay + t * (by - ay), az + t * (bz - az), aw + t * (bw - aw)].map(v => v);
|
||
}
|
||
const theta = Math.acos(dot), sinT = Math.sin(theta);
|
||
const s0 = Math.sin((1 - t) * theta) / sinT, s1 = Math.sin(t * theta) / sinT;
|
||
return [s0 * ax + s1 * bx, s0 * ay + s1 * by, s0 * az + s1 * bz, s0 * aw + s1 * bw];
|
||
}
|
||
// Lift quaternion per arm side (Y axis, mirrored: right +, left −). Raises the fist.
|
||
const qLiftFor = (bone, deg) => bone.endsWith("_l") ? qAxis(1, -deg) : qAxis(1, deg);
|
||
// Retract (wind-up) quaternion per arm side (Z axis, mirrored). Pulls the fist backward.
|
||
const qRetractFor = (bone, deg) => bone.endsWith("_l") ? qAxis(2, -deg) : qAxis(2, deg);
|
||
|
||
const isLowerBody = b => !b ? false : b === "root" || b === "pelvis" || /thigh|calf|foot|ball/i.test(b);
|
||
const isArm = b => !b ? false : /clavicle|upperarm|lowerarm|^hand_|hand_l|hand_r|index_|middle_|pinky_|ring_|thumb_/i.test(b);
|
||
const fromRun = mode === "arms-only" ? (b => !isArm(b))
|
||
: mode === "no-head" ? (b => isLowerBody(b) || b === "neck_01" || b === "Head")
|
||
: isLowerBody;
|
||
|
||
const { json, bin } = parseGlb(fs.readFileSync(SRC));
|
||
const nodes = json.nodes;
|
||
const anims = json.animations;
|
||
const accessors = json.accessors;
|
||
const bufferViews = json.bufferViews;
|
||
const run = anims.find(a => (a.name || "") === RUN_NAME);
|
||
const punch = anims.find(a => (a.name || "") === PUNCH_NAME);
|
||
if (!run || !punch) throw new Error(`source clips not found`);
|
||
|
||
// parent map (glTF uses children arrays)
|
||
const parentOf = new Array(nodes.length).fill(-1);
|
||
for (let i = 0; i < nodes.length; i++) if (nodes[i].children) for (const c of nodes[i].children) parentOf[c] = i;
|
||
const ni = nm => nodes.findIndex(n => (n.name || "") === nm);
|
||
|
||
const newSamplers = [];
|
||
const newChannels = [];
|
||
const runBones = new Set(), punchBones = new Set();
|
||
|
||
function addChannels(srcAnim, include, set) {
|
||
for (const ch of srcAnim.channels) {
|
||
const bone = nodes[ch.target.node]?.name;
|
||
if (!include(bone)) continue;
|
||
set.add(bone);
|
||
const ss = srcAnim.samplers[ch.sampler];
|
||
newSamplers.push({ input: ss.input, output: ss.output, interpolation: ss.interpolation });
|
||
newChannels.push({ sampler: newSamplers.length - 1, target: { node: ch.target.node, path: ch.target.path } });
|
||
}
|
||
}
|
||
addChannels(run, fromRun, runBones);
|
||
addChannels(punch, b => !fromRun(b), punchBones);
|
||
|
||
// ── Binary-append helpers (append float data + create bufferView + accessor) ──
|
||
let outBin = bin;
|
||
function appendFloats(floats, type, count) {
|
||
const comps = type === "SCALAR" ? 1 : type === "VEC3" ? 3 : 4;
|
||
const byteOffset = outBin.length;
|
||
const buf = Buffer.alloc(count * comps * 4);
|
||
for (let i = 0; i < floats.length; i++) buf.writeFloatLE(floats[i], i * 4);
|
||
outBin = Buffer.concat([outBin, buf]);
|
||
const bvIdx = bufferViews.length;
|
||
bufferViews.push({ buffer: 0, byteOffset, byteLength: count * comps * 4 });
|
||
const accIdx = accessors.length;
|
||
const min = new Array(comps).fill(Infinity), max = new Array(comps).fill(-Infinity);
|
||
for (let i = 0; i < count; i++) for (let c = 0; c < comps; c++) {
|
||
const v = floats[i * comps + c];
|
||
if (v < min[c]) min[c] = v; if (v > max[c]) max[c] = v;
|
||
}
|
||
accessors.push({ bufferView: bvIdx, componentType: 5126, count, type, min, max });
|
||
return accIdx;
|
||
}
|
||
|
||
// ── --arm-lift: pitch both upperarms up (mirrored Y) ──
|
||
if (armLiftDeg !== 0) {
|
||
for (const ch of newChannels) {
|
||
const bone = nodes[ch.target.node]?.name;
|
||
if ((bone !== "upperarm_l" && bone !== "upperarm_r") || ch.target.path !== "rotation") continue;
|
||
const sampler = newSamplers[ch.sampler];
|
||
const outAcc = accessors[sampler.output];
|
||
const bv = bufferViews[outAcc.bufferView];
|
||
const off = (bv.byteOffset || 0) + (outAcc.byteOffset || 0);
|
||
const count = outAcc.count;
|
||
const lift = qLiftFor(bone, armLiftDeg);
|
||
const floats = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const q = [bin.readFloatLE(off + (i * 4) * 4), bin.readFloatLE(off + (i * 4 + 1) * 4),
|
||
bin.readFloatLE(off + (i * 4 + 2) * 4), bin.readFloatLE(off + (i * 4 + 3) * 4)];
|
||
const r = qMul(lift, q);
|
||
floats.push(...r);
|
||
}
|
||
sampler.output = appendFloats(floats, "VEC4", count);
|
||
}
|
||
}
|
||
|
||
// ── --windup: synthesize neutral → cocked → extended → recovered on the punching (left) upperarm ──
|
||
let windupApplied = false;
|
||
if (windupDeg !== 0) {
|
||
const bone = "upperarm_l";
|
||
const boneIdx = ni(bone);
|
||
const ch = newChannels.find(c => c.target.node === boneIdx && c.target.path === "rotation");
|
||
if (ch) {
|
||
const sampler = newSamplers[ch.sampler];
|
||
const outAcc = accessors[sampler.output];
|
||
const inAcc = accessors[sampler.input];
|
||
const bvO = bufferViews[outAcc.bufferView], bvI = bufferViews[inAcc.bufferView];
|
||
const offO = (bvO.byteOffset || 0) + (outAcc.byteOffset || 0);
|
||
const offI = (bvI.byteOffset || 0) + (inAcc.byteOffset || 0);
|
||
const count = outAcc.count;
|
||
const rots = [], times = [];
|
||
for (let i = 0; i < count; i++) {
|
||
rots.push([outBin.readFloatLE(offO + (i * 4) * 4), outBin.readFloatLE(offO + (i * 4 + 1) * 4),
|
||
outBin.readFloatLE(offO + (i * 4 + 2) * 4), outBin.readFloatLE(offO + (i * 4 + 3) * 4)]);
|
||
times.push(outBin.readFloatLE(offI + i * 4));
|
||
}
|
||
// Note: rots/times read from outBin (post-lift) so the lift is preserved in neutral/extended/recover.
|
||
const L = times[times.length - 1]; // clip length (~0.93s, matches run)
|
||
const neutral = rots[0];
|
||
const recovered = rots[rots.length - 1];
|
||
// find the extension key = max forward (Z) hand via FK on the left arm chain
|
||
const chain = [];
|
||
{ let i = boneIdx; while (i >= 0) { chain.unshift(i); i = parentOf[i]; } }
|
||
function handZ(upperarmRot) {
|
||
let M = [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]];
|
||
for (const idx of chain) {
|
||
const nm = nodes[idx].name;
|
||
let rot = nodes[idx].rotation ? [...nodes[idx].rotation] : [0,0,0,1];
|
||
if (nm === bone && upperarmRot) rot = upperarmRot;
|
||
else {
|
||
const pch = punch.channels.find(c => c.target.node === idx && c.target.path === "rotation");
|
||
if (pch) rot = rots0(pch); // falls back below
|
||
}
|
||
const r = rot, t = nodes[idx].translation || [0,0,0];
|
||
const q2m = q => { const[x,y,z,w]=q,x2=x*x,y2=y*y,z2=z*z,xy=x*y,xz=x*z,yz=y*z,wx=w*x,wy=w*y,wz=w*z;
|
||
return[[1-2*(y2+z2),2*(xy-wz),2*(xz+wy)],[2*(xy+wz),1-2*(x2+z2),2*(yz-wx)],[2*(xz-wy),2*(yz+wx),1-2*(x2+y2)]]; };
|
||
const m = q2m(r);
|
||
M = mm(M, [[m[0][0],m[0][1],m[0][2],t[0]],[m[1][0],m[1][1],m[1][2],t[1]],[m[2][0],m[2][1],m[2][2],t[2]],[0,0,0,1]]);
|
||
}
|
||
return M[2][3]; // hand world Z (forward)
|
||
}
|
||
function mm(a,b){const r=[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];for(let i=0;i<4;i++)for(let j=0;j<4;j++){let s=0;for(let k=0;k<4;k++)s+=a[i][k]*b[k][j];r[i][j]=s;}return r;}
|
||
function rots0(pch){return null;} // placeholder — replaced by per-key lookup below
|
||
// Proper FK: find extension key by scanning punch upperarm keys
|
||
let extK = 0, extZ = -Infinity;
|
||
for (let k = 0; k < count; k++) {
|
||
// build chain with this key's upperarm rotation
|
||
let M = [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]];
|
||
for (const idx of chain) {
|
||
const nm = nodes[idx].name;
|
||
let rot = nodes[idx].rotation ? [...nodes[idx].rotation] : [0,0,0,1];
|
||
if (nm === bone) rot = rots[k];
|
||
else {
|
||
const pch = punch.channels.find(c => c.target.node === idx && c.target.path === "rotation");
|
||
if (pch) {
|
||
const ps = punch.samplers[pch.sampler];
|
||
const pa = accessors[ps.output]; const pbv = bufferViews[pa.bufferView];
|
||
const poff = (pbv.byteOffset||0)+(pa.byteOffset||0);
|
||
rot = [bin.readFloatLE(poff+(k*4)*4),bin.readFloatLE(poff+(k*4+1)*4),bin.readFloatLE(poff+(k*4+2)*4),bin.readFloatLE(poff+(k*4+3)*4)];
|
||
}
|
||
}
|
||
const q2m = q => { const[x,y,z,w]=q,x2=x*x,y2=y*y,z2=z*z,xy=x*y,xz=x*z,yz=y*z,wx=w*x,wy=w*y,wz=w*z;
|
||
return[[1-2*(y2+z2),2*(xy-wz),2*(xz+wy)],[2*(xy+wz),1-2*(x2+z2),2*(yz-wx)],[2*(xz-wy),2*(yz+wx),1-2*(x2+y2)]]; };
|
||
const m = q2m(rot), t = nodes[idx].translation||[0,0,0];
|
||
M = mm(M, [[m[0][0],m[0][1],m[0][2],t[0]],[m[1][0],m[1][1],m[1][2],t[1]],[m[2][0],m[2][1],m[2][2],t[2]],[0,0,0,1]]);
|
||
}
|
||
const z = M[2][3];
|
||
if (z > extZ) { extZ = z; extK = k; }
|
||
}
|
||
const extended = rots[extK];
|
||
// cocked = neutral pulled back (retract in bone-local space, pre-multiply)
|
||
const retract = qRetractFor(bone, windupDeg);
|
||
const cocked = qMul(retract, neutral);
|
||
// 4-key synthesized curve: neutral@0 → cocked@windUpDur → extended@(+throw) → recovered@L
|
||
const extT = windupDur + THROW_DUR;
|
||
const sTimes = [0, windupDur, extT, L];
|
||
const sRots = [neutral, cocked, extended, recovered];
|
||
const floats = [];
|
||
for (const q of sRots) floats.push(...q);
|
||
sampler.input = appendFloats(sTimes, "SCALAR", 4);
|
||
sampler.output = appendFloats(floats, "VEC4", 4);
|
||
sampler.interpolation = "LINEAR";
|
||
windupApplied = true;
|
||
console.log(` wind-up: punching arm '${bone}' → neutral@0 → cocked@${windupDur}s → extended@${extT}s (key ${extK}) → recovered@${L.toFixed(3)}s (retract Z ${windupDeg}°)`);
|
||
}
|
||
}
|
||
|
||
json.buffers[0].byteLength = outBin.length;
|
||
const runPunch = { name: OUT_CLIP, channels: newChannels, samplers: newSamplers };
|
||
const outJson = { ...json, animations: [runPunch] };
|
||
const bytes = writeGlb(outJson, outBin, OUT);
|
||
|
||
const combinedLen = Math.max(...newSamplers.map(s => accessors[s.input]?.max?.[0] ?? 0));
|
||
console.log(`baked ${OUT} [mode=${mode}, arm-lift=${armLiftDeg}°, windup=${windupDeg}°]`);
|
||
console.log(` from RUN(${runBones.size}) + PUNCH(${punchBones.size}) = ${runBones.size + punchBones.size} bones, len=${combinedLen.toFixed(3)}s, ${(bytes/1024/1024).toFixed(2)}MB`);
|