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); +});