From 61df6bb0d3f48f517cb105e6ad91d3df28fbd05a Mon Sep 17 00:00:00 2001
From: L1Z0 <2grif484d@mozmail.com>
Date: Tue, 29 Sep 2026 01:50:32 +0200
Subject: [PATCH] tweaks and improvements
---
AnimationDirector.js | 113 ++++++++++++++++++++++++----
README.md | 28 -------
test/sound-library.test.js | 148 +++++++++++++++++++++++++++++++++++--
3 files changed, 239 insertions(+), 50 deletions(-)
diff --git a/AnimationDirector.js b/AnimationDirector.js
index 17623ae..f007b09 100644
--- a/AnimationDirector.js
+++ b/AnimationDirector.js
@@ -26,12 +26,15 @@
const propSources = new Map();
let propPreviewSignature = null, propPreviewGeneration = 0;
const SOUND_SOURCE_KEY = 'animationdirector_sound_source';
+ const SOUND_SELECTION_BLACKLIST = new Set(['needsofnature:a', 'needsofnature:funkytown']);
const SOUND_PREVIEW_PROPERTY = 'animationdirector_audio_preview';
const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection'];
let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition;
- let soundLibrary, soundLibraryGeneration = 0, soundPreviewMode = 'dry';
+ let soundLibrary, soundLibraryGeneration = 0;
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
let soundAudioBytes = 0;
+ const figuraSoundPatches = [], figuraSoundMedia = new Set();
+ let figuraSoundCompatEnabled = true, figuraSoundPlayListener;
const SYSTEM_TAG_OPTIONS = {
'': 'None (normal animation)',
manualpeak: 'Manual peak',
@@ -5410,11 +5413,11 @@
return soundLibrary;
}
- function soundCueId(cue, mode = soundPreviewMode) {
+ function soundCueId(cue) {
const text = String(cue || '').trim();
const bare = text.replace(/^(?:needsofnature|minecraft):/, '');
if (bare.toLowerCase() === 'reactiveimpact_silent') return null;
- const semantic = { reactiveimpact: { dry: 'impactdry01', wet: 'impactwet01', full: 'wet04', peak: 'shot_in01' }[mode] || 'impactdry01',
+ const semantic = { reactiveimpact: 'impactdry01',
impactdry: 'impactdry01', impactwet: 'impactwet01', birth: 'pop' };
if (semantic[bare.toLowerCase()]) return `needsofnature:${semantic[bare.toLowerCase()]}`;
const id = text.includes(':') ? text : `needsofnature:${text}`;
@@ -5468,22 +5471,17 @@
for (const keyframe of animation?.animators?.effects?.sound || []) {
for (const point of keyframe.data_points || []) {
if (point.file && point.file !== point[SOUND_PREVIEW_PROPERTY]) continue;
- const signature = `${soundLibraryGeneration}:${soundPreviewMode}:${point.effect}:${point.sound}:${point.file}`;
+ const signature = `${soundLibraryGeneration}:${point.effect}:${point.sound}:${point.file}`;
if (soundMappedPoints.get(point) === signature) continue;
const audio = resolveSoundAudio(point.effect) || (point.sound ? resolveSoundAudio(point.sound) : null);
point.file = audio || '';
point[SOUND_PREVIEW_PROPERTY] = point.file;
- soundMappedPoints.set(point, `${soundLibraryGeneration}:${soundPreviewMode}:${point.effect}:${point.sound}:${point.file}`);
+ soundMappedPoints.set(point, `${soundLibraryGeneration}:${point.effect}:${point.sound}:${point.file}`);
}
}
}
}
- function setSoundPreviewMode(mode) {
- soundPreviewMode = ['dry', 'wet', 'full', 'peak'].includes(mode) ? mode : 'dry';
- mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
- }
-
function applyLibrarySound(id, insert = false) {
const animation = typeof Animation === 'undefined' ? null : Animation.selected;
if (!animation) throw new Error('Select an animation first.');
@@ -5521,13 +5519,14 @@
soundLibraryDialog = new Dialog({ id: 'animationdirector_sound_library', title: 'Sound Library', width: 700,
buttons: ['Close'], onConfirm: stopSoundAudition, onCancel: stopSoundAudition,
component: {
- data() { return { query: '', category: 'all', selected: '', mode: soundPreviewMode, page: 0,
+ data() { return { query: '', category: 'all', selected: '', page: 0,
ids: [...soundLibrary.events.keys()].sort(), warnings: soundLibrary.warnings.join('\n'), error: '' }; },
computed: {
rows() {
const cues = ['reactiveimpact', 'reactiveimpact_silent', 'impactdry', 'impactwet', 'birth'];
return [...cues.map((id) => ({ id, category: 'Gameplay cues' })), ...this.ids.map((id) => ({ id, category: 'Sound events' }))]
- .filter((entry) => (this.category === 'all' || entry.category === this.category) && entry.id.toLowerCase().includes(this.query.toLowerCase()));
+ .filter((entry) => !SOUND_SELECTION_BLACKLIST.has(entry.id)
+ && (this.category === 'all' || entry.category === this.category) && entry.id.toLowerCase().includes(this.query.toLowerCase()));
},
visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); },
},
@@ -5558,16 +5557,14 @@
},
stop: stopSoundAudition,
apply(insert) { try { applyLibrarySound(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } },
- modeChanged() { stopSoundAudition(); setSoundPreviewMode(this.mode); },
},
template: `
Uses the selected sound source and remembered Pack Folders. Previews never change exported cue IDs.
-
Gameplay cues and random events use representative audio, not a gameplay simulation.
-
{{row.id}}{{row.category}}
+
{{row.id}}{{row.category}}
{{rows.length}} results
@@ -5578,7 +5575,88 @@
soundLibraryDialog.show();
}
+ function isBrokenFiguraSoundWrapper(callback) {
+ // Figura Format 0.1.5 discards in_loop. Do not patch native or corrected implementations.
+ return typeof callback === 'function' && /^function\s*\(\s*\)\s*\{\s*if\s*\(\s*Format\s*===\s*format\s*\)\s*return\s*;?\s*displayFrame\.call\(\s*this\s*\)\s*;?\s*\}$/.test(Function.prototype.toString.call(callback));
+ }
+
+ function removeFiguraSoundMedia(media) {
+ for (const list of [Timeline.playing_sounds, Timeline.paused_sounds]) {
+ const index = list?.indexOf(media) ?? -1;
+ if (index >= 0) list.splice(index, 1);
+ }
+ figuraSoundMedia.delete(media);
+ }
+
+ function playFiguraCompatSounds(animator, inLoop, states) {
+ const time = animator.animation?.time;
+ if (!Number.isFinite(time)) return;
+ const previous = states.get(animator);
+ states.set(animator, { time, includeStart: false });
+ if (!inLoop || animator.muted?.sound || !Timeline.playing) return;
+ const wrapped = previous && time < previous.time;
+ const rangeStart = Timeline.custom_range?.[0] || 0;
+ const rangeEnd = Math.min(animator.animation.length || Infinity, Timeline.custom_range?.[1] || Infinity);
+ for (const frame of animator.sound || []) {
+ const crossed = !previous ? frame.time === time : wrapped
+ ? (frame.time > previous.time && frame.time <= rangeEnd) || (frame.time >= rangeStart && frame.time <= time)
+ : frame.time <= time && (frame.time > previous.time || (previous.includeStart && frame.time === previous.time));
+ const file = frame.data_points?.[0]?.file;
+ if (!crossed || !file) continue;
+ const existing = Timeline.playing_sounds.filter((media) => media.keyframe_id === frame.uuid);
+ // A sound resumed by Blockbench's startPreviousSounds must not be played twice.
+ if (existing.length && !wrapped) continue;
+ for (const media of existing) { media.pause(); removeFiguraSoundMedia(media); }
+ try {
+ const media = new Audio(file);
+ media.keyframe_id = frame.uuid;
+ media.playbackRate = Math.max(0.1, Math.min(4, (Timeline.playback_speed || 100) / 100));
+ media.volume = Math.max(0, Math.min(1, typeof settings === 'undefined' ? 1 : (settings.volume?.value ?? 100) / 100));
+ media.onended = () => removeFiguraSoundMedia(media);
+ Timeline.playing_sounds.push(media); figuraSoundMedia.add(media);
+ media.play().catch((error) => { removeFiguraSoundMedia(media); console.warn('AnimationDirector sound preview failed:', error); });
+ } catch (error) { console.warn('AnimationDirector sound preview failed:', error); }
+ }
+ }
+
+ function installFiguraSoundCompat() {
+ if (typeof EffectAnimator === 'undefined') return;
+ const original = EffectAnimator.prototype.displayFrame;
+ if (!isBrokenFiguraSoundWrapper(original)) return;
+ const states = new WeakMap();
+ const wrapper = function (...args) {
+ const result = original.apply(this, args);
+ if (figuraSoundCompatEnabled && typeof Format !== 'undefined' && Format.id === 'geckolib_model') {
+ playFiguraCompatSounds(this, args[0], states);
+ }
+ return result;
+ };
+ figuraSoundPatches.push({ original, wrapper, states });
+ EffectAnimator.prototype.displayFrame = wrapper;
+ }
+
+ function beginFiguraSoundPlayback() {
+ installFiguraSoundCompat();
+ if (typeof Animation === 'undefined') return;
+ for (const animation of Animation.all) {
+ const effects = animation.animators?.effects;
+ if (effects) for (const patch of figuraSoundPatches) {
+ patch.states.set(effects, { time: animation.time, includeStart: true });
+ }
+ }
+ }
+
+ function removeFiguraSoundCompat() {
+ figuraSoundCompatEnabled = false;
+ for (const media of [...figuraSoundMedia]) { media.pause(); removeFiguraSoundMedia(media); }
+ for (const patch of figuraSoundPatches.slice().reverse()) {
+ if (EffectAnimator.prototype.displayFrame === patch.wrapper) EffectAnimator.prototype.displayFrame = patch.original;
+ }
+ figuraSoundPatches.length = 0;
+ }
+
function refreshSoundLibraryPanel() {
+ installFiguraSoundCompat();
if (typeof document !== 'undefined') {
const panel = document.getElementById('panel_keyframe');
if (panel && !document.getElementById('animationdirector_sound_library_button')) {
@@ -5616,6 +5694,9 @@ You can import or export complete AFW animation IDs as one file containing all s
});
soundLibraryListener = refreshSoundLibraryPanel;
Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener);
+ figuraSoundCompatEnabled = true;
+ figuraSoundPlayListener = beginFiguraSoundPlayback;
+ Blockbench.on('timeline_play', figuraSoundPlayListener);
refreshSoundLibraryPanel();
if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) {
if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) {
@@ -5820,6 +5901,8 @@ You can import or export complete AFW animation IDs as one file containing all s
MenuBar.update();
},
onunload() {
+ Blockbench.removeListener('timeline_play', figuraSoundPlayListener);
+ removeFiguraSoundCompat();
for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener);
if (typeof document !== 'undefined') document.getElementById('animationdirector_sound_library_button')?.remove();
soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete();
diff --git a/README.md b/README.md
index 5b172da..e69de29 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +0,0 @@
-# AnimationDirector
-
-## Sound Library
-
-Open **Sound Library...** in the keyframe panel or **Tools > AnimationDirector Anims**.
-Select a NoN mod JAR/ZIP or a folder containing `assets` once. The plugin remembers
-this source and also reads sound registries from your configured **Pack Folders**.
-Pack folders use their configured priority, followed by the selected sound source.
-No sound assets are downloaded or bundled into the plugin.
-
-Search sound IDs, select a result and press **Play** (or double-click it) to audition.
-**Apply to Selected / Insert** changes the first cue of selected sound keyframes,
-or inserts at the playhead when no sound keyframe is selected. **Insert at Playhead**
-always inserts; existing cues at that time are retained.
-
-Recognized sound cues are automatically linked to timeline audio when importing
-animations, opening projects or selecting animations. Select the sound source first,
-or select it afterwards to map already imported animations. **Refresh** reloads changed
-files. Manually assigned audio previews and unknown gameplay cues are preserved.
-
-**Reactive preview** selects representative Dry/Wet/Full/Peak audio for
-`reactiveimpact`. `reactiveimpact_silent` has no audio. Random sound events/pools use
-the first available sample. These previews do not simulate gameplay; exported
-animation cue IDs remain unchanged. Blockbench's native timeline player previews
-the first cue at a sound keyframe; additional simultaneous cues still export normally.
-
-Sound browsing requires Blockbench Desktop. Preview audio is loaded on demand,
-cached with a 64 MiB limit, and kept out of exported animation JSON.
diff --git a/test/sound-library.test.js b/test/sound-library.test.js
index 37ba2cf..36e9d2b 100644
--- a/test/sound-library.test.js
+++ b/test/sound-library.test.js
@@ -9,8 +9,9 @@ function harness() {
const storage = new Map(), blobs = [], revoked = [], undo = [];
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
.replace(' Plugin.register(PLUGIN_ID, {', `
- globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds, setSoundPreviewMode,
- applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY };
+ globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds,
+ applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY,
+ installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper };
Plugin.register(PLUGIN_ID, {`);
class EffectAnimator {
constructor(animation) { this.animation = animation; this.sound = []; }
@@ -28,11 +29,37 @@ function harness() {
Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] },
Undo: { initEdit(aspects) { undo.push(aspects.keyframes.slice()); }, finishEdit() {} },
Dialog: class { constructor(options) { context.dialog = options; } show() {} delete() {} },
+ Format: { id: 'geckolib_model' }, settings: { volume: { value: 80 } }, console,
+ played: [], coreCalls: [],
+ Audio: class {
+ constructor(file) { this.file = file; }
+ play() { context.played.push(this); return Promise.resolve(); }
+ pause() { this.paused = true; }
+ },
};
vm.runInNewContext(source, context);
return { ...context.api, context, storage, blobs, revoked, undo };
}
+function brokenFigura(h) {
+ vm.runInNewContext(`(() => {
+ const format = { id: 'figura' };
+ const displayFrame = function (in_loop) { coreCalls.push(in_loop); return 'original-result'; };
+ EffectAnimator.prototype.displayFrame = function () {
+ if (Format === format) return;
+ displayFrame.call(this);
+ };
+ })()`, h.context);
+ const animation = { time: 0, length: 2, animators: {}, playing: true };
+ const effects = new h.context.EffectAnimator(animation);
+ effects.muted = { sound: false };
+ effects.sound = [{ uuid: 'cue', time: 0.5, data_points: [{ file: 'manual.ogg' }] }];
+ animation.animators.effects = effects;
+ h.context.Animation.all = [animation];
+ Object.assign(h.context.Timeline, { playing: true, playback_speed: 100, custom_range: [0, 0], playing_sounds: [] });
+ return { animation, effects, original: h.context.EffectAnimator.prototype.displayFrame };
+}
+
function assets(t, json, files = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-sounds-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
@@ -54,8 +81,6 @@ test('sound library reads the real NoN sound registry, lazily maps semantic cues
assert.equal(h.blobs.length, 1);
assert.equal(h.resolveSoundAudio('reactiveimpact_silent'), null);
assert.equal(h.resolveSoundAudio('not_a_sound'), null);
- h.setSoundPreviewMode('peak');
- assert.equal(h.resolveSoundAudio('reactiveimpact'), h.resolveSoundAudio('shot_in01'));
h.loadSoundLibrary();
assert(h.revoked.includes(audio));
});
@@ -102,7 +127,8 @@ test('mapping retains cue IDs and manual previews, updates managed files and han
assert.equal(points[1].file, 'custom.ogg');
assert.equal(points[2].file, '');
assert.equal(points[3].file, '');
- h.setSoundPreviewMode('peak');
+ h.loadSoundLibrary();
+ h.mapAnimationSounds([animation]);
assert.notEqual(points[0].file, original);
points[0].effect = 'reactiveimpact_silent';
h.mapAnimationSounds([animation]);
@@ -140,13 +166,121 @@ test('editor preview fields never appear in exported sound cues', () => {
assert.equal(clip.sound_effects['0.0'][0].file, 'blob:test');
});
-test('library UI exposes search, semantic preview modes, audition and insertion controls', () => {
+test('library UI auditions on a single click without reactive preview controls', () => {
const h = harness();
h.showSoundLibrary();
const ui = h.context.dialog.component;
assert.match(ui.template, /Search sound IDs/);
assert.match(ui.template, /Insert at Playhead/);
- assert.match(ui.template, /Reactive preview/);
+ assert.doesNotMatch(ui.template, /Reactive preview|@dblclick/);
+ assert.match(ui.template, /@click="selected=row.id;preview\(\)"/);
assert.equal(typeof ui.methods.preview, 'function');
assert.equal(typeof ui.methods.reload, 'function');
});
+
+test('sound selection hides blacklisted events without disabling existing cue previews', () => {
+ const h = harness();
+ h.storage.set(h.SOUND_SOURCE_KEY, path.resolve(__dirname, '../../NON/src/main/resources'));
+ h.showSoundLibrary();
+ const ui = h.context.dialog.component;
+ const state = ui.data();
+ assert(state.ids.includes('needsofnature:a'));
+ assert(state.ids.includes('needsofnature:funkytown'));
+ for (const query of ['', 'needsofnature:a', 'funkytown']) {
+ const rows = ui.computed.rows.call({ ...state, query });
+ assert(!rows.some((row) => row.id === 'needsofnature:a' || row.id === 'needsofnature:funkytown'));
+ }
+ assert(h.resolveSoundAudio('needsofnature:a'));
+ assert(h.resolveSoundAudio('needsofnature:funkytown'));
+});
+
+test('Figura workaround detects only the known faulty wrapper and restores it on unload', () => {
+ const h = harness(), f = brokenFigura(h);
+ assert(h.isBrokenFiguraSoundWrapper(f.original));
+ h.installFiguraSoundCompat();
+ const patched = h.context.EffectAnimator.prototype.displayFrame;
+ assert.notEqual(patched, f.original);
+ h.installFiguraSoundCompat();
+ assert.equal(h.context.EffectAnimator.prototype.displayFrame, patched);
+ f.animation.time = 0.5;
+ f.effects.displayFrame(true);
+ h.removeFiguraSoundCompat();
+ assert.equal(h.context.EffectAnimator.prototype.displayFrame, f.original);
+ for (const corrected of [function (in_loop) {}, function (...args) { return args; }]) {
+ h.context.EffectAnimator.prototype.displayFrame = corrected;
+ h.installFiguraSoundCompat();
+ assert.equal(h.context.EffectAnimator.prototype.displayFrame, corrected);
+ }
+});
+
+test('Figura sound workaround triggers crossed keyframes even beyond the native 33ms window', () => {
+ const h = harness(), f = brokenFigura(h);
+ h.beginFiguraSoundPlayback();
+ f.animation.time = 0.45; f.effects.displayFrame(true);
+ f.animation.time = 0.6; f.effects.displayFrame(true);
+ assert.equal(h.context.played.length, 1);
+ assert.equal(h.context.played[0].file, 'manual.ogg');
+ assert.equal(h.context.played[0].volume, 0.8);
+ assert.equal(h.context.coreCalls.length, 2);
+ assert.equal(h.context.coreCalls[0], undefined);
+ f.animation.time = 0.7; f.effects.displayFrame(true);
+ assert.equal(h.context.played.length, 1);
+});
+
+test('Figura workaround plays start cues and restarts long sounds on subsequent loops', () => {
+ const h = harness(), f = brokenFigura(h);
+ f.effects.sound[0].time = 0;
+ h.beginFiguraSoundPlayback();
+ f.animation.time = 0.04; f.effects.displayFrame(true);
+ assert.equal(h.context.played.length, 1);
+ f.animation.time = 1.9; f.effects.displayFrame(true);
+ f.animation.time = 0; f.effects.displayFrame(true);
+ assert.equal(h.context.played.length, 2);
+ assert(h.context.played[0].paused);
+ assert.equal(h.context.Timeline.playing_sounds.length, 1);
+ h.removeFiguraSoundCompat();
+ assert(h.context.played[1].paused);
+ assert.equal(h.context.Timeline.playing_sounds.length, 0);
+});
+
+test('Figura workaround respects sound mute, scrubbing, other formats and resumed native audio', () => {
+ const h = harness(), f = brokenFigura(h);
+ h.beginFiguraSoundPlayback();
+ f.animation.time = 0.7; f.effects.displayFrame(false);
+ assert.equal(h.context.played.length, 0);
+ f.animation.time = 0; h.beginFiguraSoundPlayback();
+ f.effects.muted.sound = true;
+ f.animation.time = 0.6; f.effects.displayFrame(true);
+ assert.equal(h.context.played.length, 0);
+ f.effects.muted.sound = false;
+ f.animation.time = 0; h.beginFiguraSoundPlayback();
+ h.context.Format.id = 'figura';
+ f.animation.time = 0.6; f.effects.displayFrame(true);
+ assert.equal(h.context.played.length, 0);
+ h.context.Format.id = 'geckolib_model';
+ f.animation.time = 0; h.beginFiguraSoundPlayback();
+ h.context.Timeline.playing_sounds.push({ keyframe_id: 'cue' });
+ f.animation.time = 0.6; f.effects.displayFrame(true);
+ assert.equal(h.context.played.length, 0);
+});
+
+test('Figura workaround honors custom loop ranges and leaves later plugin patches installed', () => {
+ const h = harness(), f = brokenFigura(h);
+ Object.assign(h.context.Timeline, { custom_range: [0.3, 1] });
+ f.effects.sound = [{ uuid: 'before', time: 0.1, data_points: [{ file: 'excluded.ogg' }] },
+ { uuid: 'start', time: 0.3, data_points: [{ file: 'included.ogg' }] },
+ { uuid: 'after', time: 1.2, data_points: [{ file: 'excluded.ogg' }] }];
+ f.animation.time = 0.3; h.beginFiguraSoundPlayback();
+ f.animation.time = 0.35; f.effects.displayFrame(true);
+ f.animation.time = 0.95; f.effects.displayFrame(true);
+ f.animation.time = 0.3; f.effects.displayFrame(true);
+ assert.deepEqual(h.context.played.map((media) => media.file), ['included.ogg', 'included.ogg']);
+ const patched = h.context.EffectAnimator.prototype.displayFrame;
+ const later = function (...args) { return patched.apply(this, args); };
+ h.context.EffectAnimator.prototype.displayFrame = later;
+ h.removeFiguraSoundCompat();
+ assert.equal(h.context.EffectAnimator.prototype.displayFrame, later);
+ f.animation.time = 0.95; f.effects.displayFrame(true);
+ f.animation.time = 0.3; f.effects.displayFrame(true);
+ assert.equal(h.context.played.length, 2);
+});