tweaks and improvements

This commit is contained in:
2026-09-29 01:50:32 +02:00
parent 474f244adb
commit 61df6bb0d3
3 changed files with 239 additions and 50 deletions
+98 -15
View File
@@ -26,12 +26,15 @@
const propSources = new Map(); const propSources = new Map();
let propPreviewSignature = null, propPreviewGeneration = 0; let propPreviewSignature = null, propPreviewGeneration = 0;
const SOUND_SOURCE_KEY = 'animationdirector_sound_source'; 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_PREVIEW_PROPERTY = 'animationdirector_audio_preview';
const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection']; const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection'];
let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition; let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition;
let soundLibrary, soundLibraryGeneration = 0, soundPreviewMode = 'dry'; let soundLibrary, soundLibraryGeneration = 0;
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap(); const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
let soundAudioBytes = 0; let soundAudioBytes = 0;
const figuraSoundPatches = [], figuraSoundMedia = new Set();
let figuraSoundCompatEnabled = true, figuraSoundPlayListener;
const SYSTEM_TAG_OPTIONS = { const SYSTEM_TAG_OPTIONS = {
'': 'None (normal animation)', '': 'None (normal animation)',
manualpeak: 'Manual peak', manualpeak: 'Manual peak',
@@ -5410,11 +5413,11 @@
return soundLibrary; return soundLibrary;
} }
function soundCueId(cue, mode = soundPreviewMode) { function soundCueId(cue) {
const text = String(cue || '').trim(); const text = String(cue || '').trim();
const bare = text.replace(/^(?:needsofnature|minecraft):/, ''); const bare = text.replace(/^(?:needsofnature|minecraft):/, '');
if (bare.toLowerCase() === 'reactiveimpact_silent') return null; 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' }; impactdry: 'impactdry01', impactwet: 'impactwet01', birth: 'pop' };
if (semantic[bare.toLowerCase()]) return `needsofnature:${semantic[bare.toLowerCase()]}`; if (semantic[bare.toLowerCase()]) return `needsofnature:${semantic[bare.toLowerCase()]}`;
const id = text.includes(':') ? text : `needsofnature:${text}`; const id = text.includes(':') ? text : `needsofnature:${text}`;
@@ -5468,22 +5471,17 @@
for (const keyframe of animation?.animators?.effects?.sound || []) { for (const keyframe of animation?.animators?.effects?.sound || []) {
for (const point of keyframe.data_points || []) { for (const point of keyframe.data_points || []) {
if (point.file && point.file !== point[SOUND_PREVIEW_PROPERTY]) continue; 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; if (soundMappedPoints.get(point) === signature) continue;
const audio = resolveSoundAudio(point.effect) || (point.sound ? resolveSoundAudio(point.sound) : null); const audio = resolveSoundAudio(point.effect) || (point.sound ? resolveSoundAudio(point.sound) : null);
point.file = audio || ''; point.file = audio || '';
point[SOUND_PREVIEW_PROPERTY] = point.file; 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) { function applyLibrarySound(id, insert = false) {
const animation = typeof Animation === 'undefined' ? null : Animation.selected; const animation = typeof Animation === 'undefined' ? null : Animation.selected;
if (!animation) throw new Error('Select an animation first.'); 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, soundLibraryDialog = new Dialog({ id: 'animationdirector_sound_library', title: 'Sound Library', width: 700,
buttons: ['Close'], onConfirm: stopSoundAudition, onCancel: stopSoundAudition, buttons: ['Close'], onConfirm: stopSoundAudition, onCancel: stopSoundAudition,
component: { 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: '' }; }, ids: [...soundLibrary.events.keys()].sort(), warnings: soundLibrary.warnings.join('\n'), error: '' }; },
computed: { computed: {
rows() { rows() {
const cues = ['reactiveimpact', 'reactiveimpact_silent', 'impactdry', 'impactwet', 'birth']; const cues = ['reactiveimpact', 'reactiveimpact_silent', 'impactdry', 'impactwet', 'birth'];
return [...cues.map((id) => ({ id, category: 'Gameplay cues' })), ...this.ids.map((id) => ({ id, category: 'Sound events' }))] 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); }, visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); },
}, },
@@ -5558,16 +5557,14 @@
}, },
stop: stopSoundAudition, stop: stopSoundAudition,
apply(insert) { try { applyLibrarySound(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } }, apply(insert) { try { applyLibrarySound(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } },
modeChanged() { stopSoundAudition(); setSoundPreviewMode(this.mode); },
}, },
template: `<div style="padding:12px"> template: `<div style="padding:12px">
<div class="bar flex" style="gap:6px"><button @click="jar">Select NoN JAR / ZIP</button><button @click="folder">Select Assets Folder</button><button @click="reload">Refresh</button></div> <div class="bar flex" style="gap:6px"><button @click="jar">Select NoN JAR / ZIP</button><button @click="folder">Select Assets Folder</button><button @click="reload">Refresh</button></div>
<p class="subtle">Uses the selected sound source and remembered Pack Folders. Previews never change exported cue IDs.</p> <p class="subtle">Uses the selected sound source and remembered Pack Folders. Previews never change exported cue IDs.</p>
<div class="bar flex" style="gap:8px;margin:10px 0"><input v-model="query" placeholder="Search sound IDs" style="flex:1" aria-label="Search sound IDs"><select v-model="category"><option value="all">All sounds</option><option>Sound events</option><option>Gameplay cues</option></select></div> <div class="bar flex" style="gap:8px;margin:10px 0"><input v-model="query" placeholder="Search sound IDs" style="flex:1" aria-label="Search sound IDs"><select v-model="category"><option value="all">All sounds</option><option>Sound events</option><option>Gameplay cues</option></select></div>
<label>Reactive preview <select v-model="mode" @change="modeChanged"><option value="dry">Dry</option><option value="wet">Wet</option><option value="full">Full</option><option value="peak">Peak</option></select></label>
<p class="subtle">Gameplay cues and random events use representative audio, not a gameplay simulation.</p> <p class="subtle">Gameplay cues and random events use representative audio, not a gameplay simulation.</p>
<div style="height:260px;overflow-y:auto;margin:10px 0;border:1px solid var(--color-border)"> <div style="height:260px;overflow-y:auto;margin:10px 0;border:1px solid var(--color-border)">
<div v-for="row in visibleRows" :key="row.category+row.id" @click="selected=row.id;stop()" @dblclick="selected=row.id;preview()" :style="{background:selected===row.id?'var(--color-accent)':'',padding:'6px 10px',cursor:'pointer'}"><span>{{row.id}}</span><small style="float:right;opacity:.6">{{row.category}}</small></div> <div v-for="row in visibleRows" :key="row.category+row.id" @click="selected=row.id;preview()" :style="{background:selected===row.id?'var(--color-accent)':'',padding:'6px 10px',cursor:'pointer'}"><span>{{row.id}}</span><small style="float:right;opacity:.6">{{row.category}}</small></div>
</div> </div>
<div class="bar flex" style="gap:6px"><button @click="page--" :disabled="page===0">Previous</button><span>{{rows.length}} results</span><button @click="page++" :disabled="(page+1)*100>=rows.length">Next</button></div> <div class="bar flex" style="gap:6px"><button @click="page--" :disabled="page===0">Previous</button><span>{{rows.length}} results</span><button @click="page++" :disabled="(page+1)*100>=rows.length">Next</button></div>
<div class="bar flex" style="gap:6px;margin-top:12px"><button @click="preview" :disabled="!selected">Play</button><button @click="stop">Stop</button><button @click="apply(false)" :disabled="!selected">Apply to Selected / Insert</button><button @click="apply(true)" :disabled="!selected">Insert at Playhead</button></div> <div class="bar flex" style="gap:6px;margin-top:12px"><button @click="preview" :disabled="!selected">Play</button><button @click="stop">Stop</button><button @click="apply(false)" :disabled="!selected">Apply to Selected / Insert</button><button @click="apply(true)" :disabled="!selected">Insert at Playhead</button></div>
@@ -5578,7 +5575,88 @@
soundLibraryDialog.show(); 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() { function refreshSoundLibraryPanel() {
installFiguraSoundCompat();
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const panel = document.getElementById('panel_keyframe'); const panel = document.getElementById('panel_keyframe');
if (panel && !document.getElementById('animationdirector_sound_library_button')) { 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; soundLibraryListener = refreshSoundLibraryPanel;
Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener); Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener);
figuraSoundCompatEnabled = true;
figuraSoundPlayListener = beginFiguraSoundPlayback;
Blockbench.on('timeline_play', figuraSoundPlayListener);
refreshSoundLibraryPanel(); refreshSoundLibraryPanel();
if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) { if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) {
if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) { 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(); MenuBar.update();
}, },
onunload() { onunload() {
Blockbench.removeListener('timeline_play', figuraSoundPlayListener);
removeFiguraSoundCompat();
for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener); for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener);
if (typeof document !== 'undefined') document.getElementById('animationdirector_sound_library_button')?.remove(); if (typeof document !== 'undefined') document.getElementById('animationdirector_sound_library_button')?.remove();
soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete(); soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete();
-28
View File
@@ -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.
+141 -7
View File
@@ -9,8 +9,9 @@ function harness() {
const storage = new Map(), blobs = [], revoked = [], undo = []; const storage = new Map(), blobs = [], revoked = [], undo = [];
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8') const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
.replace(' Plugin.register(PLUGIN_ID, {', ` .replace(' Plugin.register(PLUGIN_ID, {', `
globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds, setSoundPreviewMode, globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds,
applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY }; applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY,
installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper };
Plugin.register(PLUGIN_ID, {`); Plugin.register(PLUGIN_ID, {`);
class EffectAnimator { class EffectAnimator {
constructor(animation) { this.animation = animation; this.sound = []; } constructor(animation) { this.animation = animation; this.sound = []; }
@@ -28,11 +29,37 @@ function harness() {
Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] }, Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] },
Undo: { initEdit(aspects) { undo.push(aspects.keyframes.slice()); }, finishEdit() {} }, Undo: { initEdit(aspects) { undo.push(aspects.keyframes.slice()); }, finishEdit() {} },
Dialog: class { constructor(options) { context.dialog = options; } show() {} delete() {} }, 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); vm.runInNewContext(source, context);
return { ...context.api, context, storage, blobs, revoked, undo }; 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 = {}) { function assets(t, json, files = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-sounds-')); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-sounds-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true })); 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.blobs.length, 1);
assert.equal(h.resolveSoundAudio('reactiveimpact_silent'), null); assert.equal(h.resolveSoundAudio('reactiveimpact_silent'), null);
assert.equal(h.resolveSoundAudio('not_a_sound'), null); assert.equal(h.resolveSoundAudio('not_a_sound'), null);
h.setSoundPreviewMode('peak');
assert.equal(h.resolveSoundAudio('reactiveimpact'), h.resolveSoundAudio('shot_in01'));
h.loadSoundLibrary(); h.loadSoundLibrary();
assert(h.revoked.includes(audio)); 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[1].file, 'custom.ogg');
assert.equal(points[2].file, ''); assert.equal(points[2].file, '');
assert.equal(points[3].file, ''); assert.equal(points[3].file, '');
h.setSoundPreviewMode('peak'); h.loadSoundLibrary();
h.mapAnimationSounds([animation]);
assert.notEqual(points[0].file, original); assert.notEqual(points[0].file, original);
points[0].effect = 'reactiveimpact_silent'; points[0].effect = 'reactiveimpact_silent';
h.mapAnimationSounds([animation]); 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'); 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(); const h = harness();
h.showSoundLibrary(); h.showSoundLibrary();
const ui = h.context.dialog.component; const ui = h.context.dialog.component;
assert.match(ui.template, /Search sound IDs/); assert.match(ui.template, /Search sound IDs/);
assert.match(ui.template, /Insert at Playhead/); 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.preview, 'function');
assert.equal(typeof ui.methods.reload, '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);
});