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();
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: `<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>
<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>
<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>
<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 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>
@@ -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();