sound library

This commit is contained in:
2026-09-29 01:07:15 +02:00
parent 6d4094e7c4
commit 55d5593804
5 changed files with 378 additions and 20 deletions
+309 -13
View File
@@ -25,6 +25,13 @@
const propMeshes = new Map();
const propSources = new Map();
let propPreviewSignature = null, propPreviewGeneration = 0;
const SOUND_SOURCE_KEY = 'animationdirector_sound_source';
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';
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
let soundAudioBytes = 0;
const SYSTEM_TAG_OPTIONS = {
'': 'None (normal animation)',
manualpeak: 'Manual peak',
@@ -136,7 +143,7 @@
function setActorUvBasis(actorIndex, width, height) {
const all = getActorUvBasis();
if (all[actorIndex]?.width === width && all[actorIndex]?.height === height) return;
all[actorIndex] = { width, height };
all[actorIndex] = { ...all[actorIndex], width, height };
Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all);
Project.saved = false;
}
@@ -606,20 +613,34 @@
return true;
}
function normalizeActorUvs(actorIndex, width, height, original = null) {
function normalizeCubeUvs(cube, actorIndex, width, height, original = null) {
width = validUvDimension(width); height = validUvDimension(height);
const basis = original || getActorUvBasis()[actorIndex] || {
const all = getActorUvBasis(), actor = all[actorIndex];
const key = cube.uuid || cube.name;
const basis = original || actor?.cubes?.[key] || actor || {
width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) };
const oldWidth = validUvDimension(basis.width), oldHeight = validUvDimension(basis.height);
expandProjectUvs(width, height);
const x = oldWidth / width, y = oldHeight / height;
if (x !== 1 || y !== 1) {
for (const cube of Cube.all) if (cubeBelongsToActor(cube, actorIndex)) scaleCubeUvs(cube, x, y);
scaleCubeUvs(cube, x, y);
if (actor?.cubes?.[key]?.width !== width || actor?.cubes?.[key]?.height !== height) {
all[actorIndex] = { ...(actor || basis), cubes: { ...actor?.cubes, [key]: { width, height } } };
Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all);
Project.saved = false;
}
setActorUvBasis(actorIndex, width, height);
return x !== 1 || y !== 1;
}
function normalizeActorUvs(actorIndex, width, height, original = null) {
let changed = false;
for (const cube of Cube.all) {
if (cubeBelongsToActor(cube, actorIndex)) changed = normalizeCubeUvs(cube, actorIndex, width, height, original) || changed;
}
expandProjectUvs(validUvDimension(width), validUvDimension(height));
setActorUvBasis(actorIndex, width, height);
return changed;
}
// ----------------------------
// Block requirement authoring
// ----------------------------
@@ -1843,6 +1864,11 @@
function applyTextureToCube(cube, texture) {
if (!cube || !texture) return;
const actorIndex = readActorIndexFromName(cube.name) || readActorIndexFromName(cube.parent?.name);
const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || texture.uv_width || 0;
const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || texture.uv_height || 0;
if (actorIndex && imageWidth > 0 && imageHeight > 0) normalizeCubeUvs(cube, actorIndex, imageWidth, imageHeight);
const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
const textureId = texture.uuid || texture.id || texture.name;
@@ -1869,14 +1895,13 @@
const texture = findTextureByActorTextureName(textureName);
if (!texture) return { applied: 0, missing: true };
const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || 0;
const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || 0;
if (imageWidth > 0 && imageHeight > 0) normalizeActorUvs(actorIndex, imageWidth, imageHeight);
let applied = 0;
const overrides = getProjectBoneTextureOverrides();
const cubes = Array.isArray(Cube.all) ? Cube.all : [];
for (const cube of cubes) {
if (!cubeBelongsToActor(cube, actorIndex)) continue;
const override = getNearestBoneTextureOverride(cube, overrides);
if (override?.texture && findTextureByActorTextureName(override.texture)) continue;
applyTextureToCube(cube, texture);
applied++;
}
@@ -2439,6 +2464,16 @@
return { groups, ignored };
}
function cleanSoundPreviewFields(clip) {
for (const cues of Object.values(clip?.sound_effects || {})) {
for (const cue of Array.isArray(cues) ? cues : [cues]) {
if (!cue || typeof cue !== 'object') continue;
delete cue.file; delete cue[SOUND_PREVIEW_PROPERTY];
}
}
return clip;
}
function compileConjoinedStage(stage) {
const animation = stage?.animation;
if (typeof animation?.compileBedrockAnimation !== 'function') {
@@ -2447,7 +2482,7 @@
const compiled = animation.compileBedrockAnimation() || {};
if (!compiled.animations) {
return { formatVersion: '1.8.0', clip: stripBlockRequirementGuideBones(clone(compiled)) };
return { formatVersion: '1.8.0', clip: cleanSoundPreviewFields(stripBlockRequirementGuideBones(clone(compiled))) };
}
let clip = compiled.animations[stage.name];
@@ -2460,7 +2495,7 @@
}
return {
formatVersion: compiled.format_version || '1.8.0',
clip: stripBlockRequirementGuideBones(clone(clip)),
clip: cleanSoundPreviewFields(stripBlockRequirementGuideBones(clone(clip))),
};
}
@@ -3936,6 +3971,7 @@
content: JSON.stringify(imported.fileObj, null, 2),
};
Animator.loadFile(fakeFile, imported.stageNames);
mapAnimationSounds(Animation.all);
Blockbench.showQuickMessage(
`Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
2500
@@ -5132,7 +5168,6 @@
const base = row.textures.get(row.defaultTexture);
if (base) {
textures[index] = loadedTextures.get(base.file || `${base.relative}:${row.defaultTexture}`);
if (base.dimensions) normalizeActorUvs(index, base.dimensions.width, base.dimensions.height);
}
for (const [bone, textureName] of Object.entries(row.json.afw_bone_textures || {})) {
const group = Group.all.find((candidate) => candidate.name === `actor${index}_${bone}`);
@@ -5147,6 +5182,7 @@
setProjectBoneTextureOverrides(overrides);
setProjectAnimdefAnimators(plan.definition.animators || []);
Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames);
mapAnimationSounds(Animation.all);
rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false);
if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries);
Canvas.updateAll();
@@ -5313,6 +5349,248 @@
}
}
// Sound IDs remain gameplay cues. Blob URLs are editor-only, lazily loaded audio previews.
function stopSoundAudition() {
if (soundAudition) { soundAudition.pause(); soundAudition = null; }
}
function clearSoundAudio() {
stopSoundAudition();
for (const url of soundAudioUrls.values()) URL.revokeObjectURL(url);
soundAudioUrls.clear(); soundAudioBytes = 0;
}
function soundJsonPaths(source, io) {
if (source.files) return [...source.files.keys()].filter((name) => /^assets\/[^/]+\/sounds\.json$/.test(name));
const root = io.path.basename(source.path) === 'assets' ? io.path.dirname(source.path) : source.path;
const assets = io.path.join(root, 'assets');
if (!io.fs.existsSync(assets)) return [];
return io.fs.readdirSync(assets, { withFileTypes: true }).slice(0, 256)
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && /^[a-z0-9_.-]+$/.test(entry.name))
.map((entry) => `assets/${entry.name}/sounds.json`);
}
function loadSoundLibrary(io = desktopFiles()) {
clearSoundAudio();
const events = new Map(), sources = [], warnings = [];
const selected = localStorage.getItem(SOUND_SOURCE_KEY);
const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : [])]);
for (const file of paths.slice(0, 64)) {
try {
const source = openPropSource(file, io);
sources.push(source);
for (const name of soundJsonPaths(source, io)) {
const raw = source.read(name);
if (!raw) continue;
const json = JSON.parse(raw.toString('utf8').replace(/^\uFEFF/, ''));
const namespace = name.split('/')[1];
for (const [key, value] of Object.entries(json)) {
const id = `${namespace}:${key}`;
if (!importResourceId(id) || !Array.isArray(value?.sounds) || events.size >= 10000) continue;
const entries = value.sounds.slice(0, 256);
if (!events.has(id)) events.set(id, { entries, replace: value.replace === true });
else if (!events.get(id).replace) {
events.get(id).entries.push(...entries);
events.get(id).replace = value.replace === true;
}
}
}
} catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); }
}
soundLibrary = { events, sources, warnings };
soundLibraryGeneration++;
return soundLibrary;
}
function ensureSoundLibrary() {
if (!soundLibrary) {
try { loadSoundLibrary(); }
catch (_) { soundLibrary = { events: new Map(), sources: [], warnings: ['Sound assets require Blockbench Desktop.'] }; }
}
return soundLibrary;
}
function soundCueId(cue, mode = soundPreviewMode) {
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',
impactdry: 'impactdry01', impactwet: 'impactwet01', birth: 'pop' };
if (semantic[bare.toLowerCase()]) return `needsofnature:${semantic[bare.toLowerCase()]}`;
const id = text.includes(':') ? text : `needsofnature:${text}`;
return importResourceId(id) ? id : null;
}
function resolveSoundAudio(cue, library = ensureSoundLibrary(), visiting = new Set()) {
if (/^random\(/i.test(String(cue).trim())) {
const match = String(cue).trim().match(/^random\((.*)\)$/i);
if (!match) return null;
for (const option of match[1].split(',').slice(0, 256)) {
const audio = resolveSoundAudio(option, library, visiting);
if (audio) return audio;
}
return null;
}
const id = soundCueId(cue);
if (!id || visiting.has(id) || visiting.size >= 16) return null;
const event = library.events.get(id);
if (!event) return null;
const next = new Set(visiting); next.add(id);
for (const entry of event.entries) {
const object = typeof entry === 'string' ? { name: entry } : entry;
if (!object || typeof object.name !== 'string') continue;
const name = object.name.includes(':') ? object.name : `minecraft:${object.name}`;
const resource = importResourceId(name);
if (!resource) continue;
if (object.type === 'event') {
const audio = resolveSoundAudio(name, library, next);
if (audio) return audio;
continue;
}
const asset = `assets/${resource.namespace}/sounds/${resource.resource}.ogg`;
if (soundAudioUrls.has(asset)) return soundAudioUrls.get(asset);
for (const source of library.sources) {
try {
const bytes = source.read(asset);
if (!bytes) continue;
if (soundAudioBytes + bytes.length > 64 * 1024 * 1024) return null;
const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/ogg' }));
soundAudioUrls.set(asset, url); soundAudioBytes += bytes.length;
return url;
} catch (_) { /* An unavailable asset must never prevent animation import. */ }
}
}
return null;
}
function mapAnimationSounds(animations) {
for (const animation of animations || []) {
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}`;
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}`);
}
}
}
}
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.');
const selected = (Timeline.selected || []).filter((keyframe) => keyframe.channel === 'sound' && keyframe.animator?.animation === animation);
if (!insert && selected.length) {
Undo.initEdit({ keyframes: selected });
for (const keyframe of selected) {
const point = keyframe.data_points[0];
point.effect = id; point.file = ''; point[SOUND_PREVIEW_PROPERTY] = '';
}
mapAnimationSounds([animation]);
Undo.finishEdit('Apply sound from library');
} else {
const effects = animation.animators.effects || (animation.animators.effects = new EffectAnimator(animation));
const keyframes = effects.sound.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001);
Undo.initEdit({ keyframes });
let keyframe = keyframes[0];
if (keyframe) {
keyframe.extend({ data_points: [...keyframe.data_points.map((point) => point.getUndoCopy()), { effect: id }] });
} else {
keyframe = effects.createKeyframe({ data_points: [{ effect: id }] }, Timeline.time, 'sound', false, false);
keyframes.push(keyframe);
}
mapAnimationSounds([animation]);
effects.select(); keyframe.select();
Undo.finishEdit('Insert sound from library', { keyframes });
}
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
}
function showSoundLibrary() {
stopSoundAudition();
ensureSoundLibrary();
soundLibraryDialog?.delete();
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,
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()));
},
visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); },
},
watch: { query() { this.page = 0; }, category() { this.page = 0; } },
methods: {
reload() {
try { loadSoundLibrary(); this.ids = [...soundLibrary.events.keys()].sort(); this.warnings = soundLibrary.warnings.join('\n');
this.error = ''; mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all); }
catch (error) { this.error = error.message; }
},
folder() {
try { const folder = Blockbench.pickDirectory({ title: 'Select folder containing assets', resource_id: SOUND_SOURCE_KEY });
if (folder) { localStorage.setItem(SOUND_SOURCE_KEY, folder); this.reload(); } }
catch (error) { this.error = error.message; }
},
jar() {
Filesystem.importFile({ type: 'Sound assets', extensions: ['jar', 'zip'], readtype: 'none', multiple: false }, (files) => {
if (files?.[0]?.path) { localStorage.setItem(SOUND_SOURCE_KEY, files[0].path); this.reload(); }
});
},
preview() {
stopSoundAudition(); this.error = '';
const file = resolveSoundAudio(this.selected);
if (!file) { this.error = this.selected === 'reactiveimpact_silent' ? 'Silent cue: gameplay effects only.' : 'No audio found. Select the NoN JAR or an extracted assets folder.'; return; }
soundAudition = new Audio(file);
soundAudition.volume = Math.max(0, Math.min(1, typeof settings === 'undefined' ? 1 : (settings.volume?.value ?? 100) / 100));
soundAudition.play().catch((error) => { this.error = error.message; });
},
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>
<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>
<p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
</div>`,
},
});
soundLibraryDialog.show();
}
function refreshSoundLibraryPanel() {
if (typeof document !== 'undefined') {
const panel = document.getElementById('panel_keyframe');
if (panel && !document.getElementById('animationdirector_sound_library_button')) {
const button = document.createElement('button');
button.id = 'animationdirector_sound_library_button'; button.textContent = 'Sound Library...';
button.style.cssText = 'margin:8px;width:calc(100% - 16px)';
button.addEventListener('click', showSoundLibrary); panel.appendChild(button);
}
}
mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]);
}
Plugin.register(PLUGIN_ID, {
title: 'AnimationDirector',
author: 'L1Z0',
@@ -5326,6 +5604,19 @@ You can import or export complete AFW animation IDs as one file containing all s
version: '1.1.0',
variant: 'both',
onload() {
if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') {
soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, {
exposed: false, condition: (point) => point.keyframe.channel === 'sound',
});
}
soundLibraryAction = new Action('animationdirector_sound_library', {
name: 'Sound Library...', icon: 'library_music', category: 'animation',
description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.',
click: showSoundLibrary,
});
soundLibraryListener = refreshSoundLibraryPanel;
Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener);
refreshSoundLibraryPanel();
if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) {
if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) {
actorNamesProjectProperty = new Property(ModelProject, 'string', ACTOR_NAMES_PROPERTY, {
@@ -5514,6 +5805,7 @@ You can import or export complete AFW animation IDs as one file containing all s
replaceActorAction,
blockRequirementAction,
propAction,
soundLibraryAction,
createAnimationDefinitionAction,
exportConjoinedAction,
],
@@ -5528,6 +5820,10 @@ You can import or export complete AFW animation IDs as one file containing all s
MenuBar.update();
},
onunload() {
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();
clearSoundAudio(); soundLibrary = undefined;
clearPropMeshes(); propSources.clear();
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete();
if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener);