@kieransimkin/dance-rudiments 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +58 -0
- package/CONTRIBUTING.md +8 -0
- package/README.md +204 -57
- package/dist/recording/recording.wasm +0 -0
- package/dist/recording/recording.wasm.json +5 -0
- package/dist/typescript/index.d.ts +2 -1
- package/dist/typescript/index.js +1 -0
- package/dist/typescript/motion-packages.d.ts +125 -0
- package/dist/typescript/motion-packages.js +625 -0
- package/docs/blender-motion-import.md +228 -0
- package/docs/mesh-demos.md +163 -0
- package/docs/midi-harness.md +180 -0
- package/docs/motion-packages.md +324 -0
- package/docs/release-demo.md +232 -0
- package/docs/rig-motion.md +285 -0
- package/docs/rigify-catalogue.md +246 -0
- package/examples/motion-packages/inspect_package.py +21 -0
- package/examples/motion-packages/page-motion.mjs +168 -0
- package/package.json +25 -4
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call mountCharacterMotion only when entering the character's page/scene.
|
|
3
|
+
* There is no module-level native initialization, fetch, or motion registration.
|
|
4
|
+
*
|
|
5
|
+
* The host supplies:
|
|
6
|
+
* - rig.target: {typeUri, sha256, dimensions}, from the actual saved scene rig;
|
|
7
|
+
* do not manufacture this compatibility target from the package manifest.
|
|
8
|
+
* - rig.applyChannels(profile, physicalValues): the scene's rig evaluator.
|
|
9
|
+
* Rigify IK/constraints need a capable evaluator; bone-name copying is not one.
|
|
10
|
+
* - clock.currentPip()/subscribe(callback): absolute integer positions at 64
|
|
11
|
+
* pips per quarter note, derived from the host transport's absolute clock.
|
|
12
|
+
* For a time-only preview, this instead means floor(elapsedSeconds * 64).
|
|
13
|
+
* - wasmUrl: the trusted, separately hosted package `./recording-wasm` asset.
|
|
14
|
+
* Configure its public URL in the app's build; no motion assets are bundled.
|
|
15
|
+
*
|
|
16
|
+
* Example route lifecycle:
|
|
17
|
+
* const route = new AbortController();
|
|
18
|
+
* const playback = mountCharacterMotion({
|
|
19
|
+
* manifestUrl, packageId, wasmUrl, rig, clock, signal: route.signal,
|
|
20
|
+
* selection: {sourceClipId: selectedSourceHash}, timing: 'seconds-preview',
|
|
21
|
+
* onError: error => showError(error.message),
|
|
22
|
+
* });
|
|
23
|
+
* await playback.ready;
|
|
24
|
+
* // On route exit: route.abort(); playback.dispose();
|
|
25
|
+
*
|
|
26
|
+
* `timing: 'music'` requires an explicitly beat/phase-annotated motion. A
|
|
27
|
+
* time-only 60 BPM coordinate does not establish musical tempo or alignment;
|
|
28
|
+
* compile source beat annotations first. No clock or interpolation runs here.
|
|
29
|
+
*/
|
|
30
|
+
export function mountCharacterMotion({
|
|
31
|
+
manifestUrl, packageId, wasmUrl, rig, clock, selection, timing,
|
|
32
|
+
signal, pipOffset = 0, manifestSha256, fetchAsset = globalThis.fetch,
|
|
33
|
+
onError = error => console.error(error),
|
|
34
|
+
}) {
|
|
35
|
+
if (!Number.isSafeInteger(pipOffset)) throw Error('pipOffset must be an integer');
|
|
36
|
+
if (!['music','seconds-preview'].includes(timing)) throw Error('Choose music or seconds-preview timing');
|
|
37
|
+
if (!selection || Boolean(selection.clipId) === Boolean(selection.sourceClipId))
|
|
38
|
+
throw Error('Select either one clipId or one complete sourceClipId');
|
|
39
|
+
const lifetime = new AbortController();
|
|
40
|
+
let loader, unsubscribe, chunks = [], closed = false, generation = 0;
|
|
41
|
+
let active, pending, desiredPip, failedId;
|
|
42
|
+
const aborted = () => { throw new DOMException('Character page was closed','AbortError'); };
|
|
43
|
+
const checkOpen = () => { if (closed || lifetime.signal.aborted) aborted(); };
|
|
44
|
+
const report = error => { if (!closed && error?.name !== 'AbortError') onError(error); };
|
|
45
|
+
|
|
46
|
+
function releaseCurrent() {
|
|
47
|
+
generation++;
|
|
48
|
+
if (pending) {
|
|
49
|
+
const old = pending; pending = undefined;
|
|
50
|
+
old.controller.abort(); loader?.unload(old.id);
|
|
51
|
+
}
|
|
52
|
+
if (active) { const old = active; active = undefined; loader?.unload(old.id); }
|
|
53
|
+
}
|
|
54
|
+
function dispose() {
|
|
55
|
+
if (closed) return;
|
|
56
|
+
closed = true;
|
|
57
|
+
lifetime.abort();
|
|
58
|
+
signal?.removeEventListener('abort',dispose);
|
|
59
|
+
unsubscribe?.(); unsubscribe = undefined;
|
|
60
|
+
releaseCurrent(); loader?.dispose(); loader = undefined;
|
|
61
|
+
}
|
|
62
|
+
signal?.addEventListener('abort',dispose,{once:true});
|
|
63
|
+
if (signal?.aborted) dispose();
|
|
64
|
+
|
|
65
|
+
function descriptorAt(pip) {
|
|
66
|
+
// Metadata search only; this does not read any other chunk's samples.
|
|
67
|
+
let low=0, high=chunks.length;
|
|
68
|
+
while(low<high) {
|
|
69
|
+
const middle=Math.floor((low+high)/2), item=chunks[middle];
|
|
70
|
+
if(pip<item.start_pip) high=middle;
|
|
71
|
+
else if(pip>=item.start_pip+item.sample_count) low=middle+1;
|
|
72
|
+
else return item;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function applyLatest(motion, token) {
|
|
76
|
+
if (closed || token !== generation) return;
|
|
77
|
+
const pip=desiredPip;
|
|
78
|
+
if(pip<motion.startPip || pip>=motion.startPip+motion.sampleCount) return;
|
|
79
|
+
// Native sampler reads the absolute pip. Only channel-unit decoding is JS.
|
|
80
|
+
rig.applyChannels(motion.profile,motion.sample(pip));
|
|
81
|
+
}
|
|
82
|
+
function update(hostPip) {
|
|
83
|
+
if (closed) return Promise.resolve();
|
|
84
|
+
if (!Number.isSafeInteger(hostPip)) {
|
|
85
|
+
releaseCurrent(); report(Error('Host clock must supply an integer pip'));return Promise.resolve();
|
|
86
|
+
}
|
|
87
|
+
desiredPip=hostPip-pipOffset;
|
|
88
|
+
const descriptor=descriptorAt(desiredPip);
|
|
89
|
+
if (!descriptor) {
|
|
90
|
+
// Recordings do not loop or hold an invented endpoint outside their range.
|
|
91
|
+
if(active || pending) releaseCurrent();
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
}
|
|
94
|
+
if(active?.id===descriptor.id) {
|
|
95
|
+
try {applyLatest(active.motion,generation);} catch(error) {releaseCurrent();failedId=descriptor.id;report(error);}
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
}
|
|
98
|
+
// Frame ticks within one pending chunk update desiredPip without restarting
|
|
99
|
+
// its request. Its completion will sample the latest host position.
|
|
100
|
+
if(pending?.id===descriptor.id) return pending.promise;
|
|
101
|
+
if(failedId===descriptor.id) return Promise.resolve(); // explicit retry below
|
|
102
|
+
releaseCurrent();
|
|
103
|
+
const token=generation, controller=new AbortController();
|
|
104
|
+
const request={id:descriptor.id,controller,promise:undefined};
|
|
105
|
+
pending=request;
|
|
106
|
+
request.promise=loader.load(descriptor.id,{signal:controller.signal}).then(motion=>{
|
|
107
|
+
// Seeking/unmounting invalidates this generation before releasing its
|
|
108
|
+
// handle, so a late network/native completion cannot apply a stale pose.
|
|
109
|
+
if(closed || token!==generation) return;
|
|
110
|
+
pending=undefined;
|
|
111
|
+
if(timing==='music' && motion.clock.phase_status==='unverified') {
|
|
112
|
+
loader.unload(descriptor.id);
|
|
113
|
+
throw Error('Musical playback requires an explicitly annotated source beat phase');
|
|
114
|
+
}
|
|
115
|
+
active={id:descriptor.id,motion};failedId=undefined;
|
|
116
|
+
applyLatest(motion,token);
|
|
117
|
+
}).catch(error=>{
|
|
118
|
+
if(closed || token!==generation) return;
|
|
119
|
+
pending=undefined;failedId=descriptor.id;
|
|
120
|
+
loader.unload(descriptor.id);active=undefined;report(error);
|
|
121
|
+
});
|
|
122
|
+
return request.promise;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const ready=(async()=>{
|
|
126
|
+
checkOpen();
|
|
127
|
+
// Dynamic import is deliberately inside this route-level operation.
|
|
128
|
+
const {createMotionPackageLoader,createRecordingWasmAdapter} =
|
|
129
|
+
await import('@kieransimkin/dance-rudiments/motion-packages');
|
|
130
|
+
checkOpen();
|
|
131
|
+
const response=await fetchAsset(wasmUrl,{signal:lifetime.signal,credentials:'omit'});
|
|
132
|
+
if(!response.ok) throw Error(`Recording WASM HTTP ${response.status}`);
|
|
133
|
+
const module=await WebAssembly.compile(await response.arrayBuffer());
|
|
134
|
+
checkOpen();
|
|
135
|
+
loader=createMotionPackageLoader({manifestUrl,packageId,expectedProfile:rig.target,
|
|
136
|
+
nativeFactory:createRecordingWasmAdapter(module),fetch:fetchAsset,manifestSha256});
|
|
137
|
+
const manifest=await loader.catalogue({signal:lifetime.signal});
|
|
138
|
+
checkOpen();
|
|
139
|
+
chunks=selection.clipId
|
|
140
|
+
? manifest.clips.filter(item=>item.id===selection.clipId)
|
|
141
|
+
: manifest.clips.filter(item=>(item.metadata?.original_clip_id ?? item.metadata?.clip_id)===selection.sourceClipId);
|
|
142
|
+
chunks.sort((a,b)=>a.start_pip-b.start_pip);
|
|
143
|
+
if(!chunks.length) throw Error('Selected motion is not in this compatible package');
|
|
144
|
+
if(selection.sourceClipId) {
|
|
145
|
+
const total=chunks.reduce((sum,item)=>sum+item.sample_count,0);
|
|
146
|
+
chunks.forEach((item,index)=>{
|
|
147
|
+
if(item.metadata?.chunk_index!==index || item.metadata?.chunk_count!==chunks.length ||
|
|
148
|
+
item.metadata?.total_sample_count!==total ||
|
|
149
|
+
(index && item.start_pip!==chunks[index-1].start_pip+chunks[index-1].sample_count))
|
|
150
|
+
throw Error('Selected source motion must have its complete contiguous chunk manifest');
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
for(const item of chunks) {
|
|
154
|
+
const kind=item.metadata?.timing_kind, mode=item.metadata?.timing_mode;
|
|
155
|
+
if(timing==='music' && (kind==='time-only' || mode==='time-only' || mode==='tempo-only'))
|
|
156
|
+
throw Error('Time-only/tempo-only motion needs explicit source tempo and beat phase before musical playback');
|
|
157
|
+
if(timing==='seconds-preview' && kind!=='time-only' && mode!=='time-only')
|
|
158
|
+
throw Error('Seconds-preview is reserved for explicitly labelled time-only motion');
|
|
159
|
+
}
|
|
160
|
+
unsubscribe=clock.subscribe(pip=>{void update(pip);});
|
|
161
|
+
await update(clock.currentPip());
|
|
162
|
+
})().catch(error=>{report(error);dispose();throw error;});
|
|
163
|
+
|
|
164
|
+
return Object.freeze({
|
|
165
|
+
ready, dispose,
|
|
166
|
+
retry() {if(!closed && loader) {failedId=undefined;return update(clock.currentPip());}return Promise.resolve();},
|
|
167
|
+
});
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kieransimkin/dance-rudiments",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Deterministic rhythmic position functions with C++, Python, TypeScript/WASM and C# bindings. https://kieransimkin.co.uk/my-songs/",
|
|
5
5
|
"license": "MIT AND BSD-3-Clause AND CC0-1.0 AND CC-BY-4.0",
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
"./collections/atlas": "./python/dancerudiments_authoring/packs/atlas.json",
|
|
18
18
|
"./collections/continuum": "./python/dancerudiments_authoring/packs/continuum.json",
|
|
19
19
|
"./collections/club": "./python/dancerudiments_authoring/packs/club.json",
|
|
20
|
-
"./collections/dancefloor": "./python/dancerudiments_authoring/packs/dancefloor.json"
|
|
20
|
+
"./collections/dancefloor": "./python/dancerudiments_authoring/packs/dancefloor.json",
|
|
21
|
+
"./motion-packages": {
|
|
22
|
+
"types": "./dist/typescript/motion-packages.d.ts",
|
|
23
|
+
"import": "./dist/typescript/motion-packages.js"
|
|
24
|
+
},
|
|
25
|
+
"./recording-wasm": "./dist/recording/recording.wasm"
|
|
21
26
|
},
|
|
22
27
|
"files": [
|
|
23
28
|
"dist/typescript/",
|
|
@@ -47,7 +52,21 @@
|
|
|
47
52
|
"bindings/csharp/README.md",
|
|
48
53
|
"docs/branding/",
|
|
49
54
|
"AGENTS.md",
|
|
50
|
-
"CONTRIBUTING.md"
|
|
55
|
+
"CONTRIBUTING.md",
|
|
56
|
+
"dist/recording/",
|
|
57
|
+
"docs/motion-packages.md",
|
|
58
|
+
"examples/motion-packages/",
|
|
59
|
+
"docs/rig-motion.md",
|
|
60
|
+
"docs/rigify-catalogue.md",
|
|
61
|
+
"docs/blender-motion-import.md",
|
|
62
|
+
"docs/mesh-demos.md",
|
|
63
|
+
"docs/release-demo.md",
|
|
64
|
+
"docs/midi-harness.md",
|
|
65
|
+
"!**/__pycache__/**",
|
|
66
|
+
"!**/*.pyc",
|
|
67
|
+
"!**/*.pyo",
|
|
68
|
+
"!motion-packs/**",
|
|
69
|
+
"!optional-motion-packs/**"
|
|
51
70
|
],
|
|
52
71
|
"repository": {
|
|
53
72
|
"type": "git",
|
|
@@ -61,10 +80,12 @@
|
|
|
61
80
|
},
|
|
62
81
|
"scripts": {
|
|
63
82
|
"test:initial": "node --test tests/typescript/initial-wasm.test.mjs",
|
|
64
|
-
"build:harness": "tsc -p tsconfig.json",
|
|
83
|
+
"build:harness": "tsc -p tsconfig.json && node tools/package_recording_runtime.mjs",
|
|
65
84
|
"test": "npm run build:harness && node --test --test-isolation=none tests/typescript/*.test.js"
|
|
66
85
|
},
|
|
67
86
|
"devDependencies": {
|
|
87
|
+
"esbuild": "0.28.2",
|
|
88
|
+
"three": "0.186.1",
|
|
68
89
|
"typescript": "^5.9.0"
|
|
69
90
|
},
|
|
70
91
|
"author": {
|