From 55d559380417482ecba5a45ebb2e87bb4ed9fbe8 Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Tue, 29 Sep 2026 01:07:15 +0200 Subject: [PATCH] sound library --- AnimationDirector.js | 322 ++++++++++++++++++++++++++++++-- README.md | 28 +++ test/automatic-import.test.js | 18 +- test/block-requirements.test.js | 2 +- test/uv-normalization.test.js | 28 ++- 5 files changed, 378 insertions(+), 20 deletions(-) diff --git a/AnimationDirector.js b/AnimationDirector.js index 6c24fa9..17623ae 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -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: `
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.
+{{error}}
{{warnings}}
+