From 2486c3cb6385e7e77c531d79befbacb4f9722ebc Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Mon, 28 Sep 2026 21:30:49 +0200 Subject: [PATCH] added a way to auto import full animations --- AnimationDirector.js | 1339 +++++++++++++++++-------------- test/automatic-import.test.js | 372 +++++++++ test/block-requirements.test.js | 142 +++- 3 files changed, 1254 insertions(+), 599 deletions(-) create mode 100644 test/automatic-import.test.js diff --git a/AnimationDirector.js b/AnimationDirector.js index 638da22..e3dfc0e 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -15,6 +15,9 @@ let blockRequirementPanel, blockRequirementPanelListener, blockRequirementPanelCss; let blockRequirementPendingTimer; let blockGuideProjectListener; + let importAnimationDefinitionAction; + let automaticImportAction; + let packFoldersAction; const SYSTEM_TAG_OPTIONS = { '': 'None (normal animation)', manualpeak: 'Manual peak', @@ -26,7 +29,7 @@ other: 'Other', }; - let importAction, replaceActorAction, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction; + let importAction, replaceActorAction, importConjoinedAnimationAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction; let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty; let actorTexturePreviewProjectListener; const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path']; @@ -519,15 +522,6 @@ return i; } - function getMaxActorIndexFromProject() { - let max = 0; - for (const g of Group.all) { - const idx = readActorIndexFromName(g?.name || ''); - if (idx && idx > max) max = idx; - } - return max; - } - function extractGeckoGeometryRoot(json) { return json?.['minecraft:geometry']?.[0] || null; } @@ -794,7 +788,7 @@ if (elements.length) Canvas.updateView({ elements, element_aspects: { faces: true, uv: true } }); } - function rebuildBlockRequirementGuides(animationId, entry) { + function rebuildBlockRequirementGuides(animationId, entry, importedDefinition, manageUndo = true) { const boxes = entry ? buildBlockRequirementGuideBoxes(entry) : []; const groupName = `${BLOCK_GUIDE_PREFIX}${animationId}`; const oldGroups = Group.all.filter((group) => group.name === groupName); @@ -805,8 +799,8 @@ else updated[animationId] = { requirement: null }; const createdElements = []; const createdGroups = []; - Undo.initEdit({ outliner: true, elements: oldElements, groups: oldGroups, - block_requirement_edit: true }); + if (manageUndo) Undo.initEdit({ outliner: true, elements: oldElements, groups: oldGroups, + block_requirement_edit: true, animdef_edit: importedDefinition !== undefined }); if (entry) { const root = oldGroups[0] || new Group({ name: groupName, export: false }); if (!oldGroups.length) { root.init(); root.addTo('root'); } @@ -828,19 +822,21 @@ for (const group of oldGroups) group.remove(); } Project[BLOCK_REQUIREMENTS_PROPERTY] = JSON.stringify(updated); + if (importedDefinition !== undefined) saveAnimdef(animationId, importedDefinition); hideInternalBlockGuideFaces(createdElements); // Rebuilding every cube would reset forced actor materials in single-texture formats. Canvas.updateView({ elements: createdElements, groups: createdGroups, element_aspects: { transform: true, geometry: true, faces: true, uv: true, visibility: true } }); for (const cube of createdElements) applyBlockRequirementGuideMaterial(cube); - Undo.finishEdit(entry ? 'Set block requirement' : 'Remove block requirement', - { outliner: true, elements: createdElements, groups: createdGroups, block_requirement_edit: true }); + if (manageUndo) Undo.finishEdit(importedDefinition !== undefined ? 'Import animation definition' : entry ? 'Set block requirement' : 'Remove block requirement', + { outliner: true, elements: createdElements, groups: createdGroups, block_requirement_edit: true, + animdef_edit: importedDefinition !== undefined }); Project.saved = false; } - function buildBlockRequirementForm(animationId) { + function buildBlockRequirementForm(animationId, importedRequirement) { const entry = getAuthoredBlockRequirement(animationId); - const req = (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) || {}; + const req = importedRequirement ?? (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) ?? {}; const wall = (result) => result?.type === 'wall'; const center = (result) => result?.type === 'center_support'; const directional = (result) => wall(result) || result?.placement !== 'surface_footprint'; @@ -1108,7 +1104,7 @@ return { rebound, missing }; } - function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false) { + function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false, manageUndo = true) { const bones = Array.isArray(geo?.bones) ? geo.bones : []; if (!bones.length) { Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500); @@ -1118,7 +1114,7 @@ const prefix = `actor${actorIndex}_`; const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null; - Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [], + if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [], animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] }); if (replaceExisting) removeActorGeometry(actorIndex); @@ -1205,15 +1201,17 @@ ? rebindActorAnimations(actorIndex, previousBindings) : { rebound: 0, missing: 0 }; - Canvas.updateAll(); - applyAllActorTexturePreviews(false); + if (manageUndo) { + Canvas.updateAll(); + applyAllActorTexturePreviews(false); + } if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select(); - Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`); + if (manageUndo) Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`); const animationNotice = replaceExisting && animationResult.rebound ? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}` : ''; - Blockbench.showQuickMessage(`${replaceExisting ? 'Replaced' : 'Imported'} actor with prefix "${prefix}"${animationNotice}`, 3000); + if (manageUndo) Blockbench.showQuickMessage(`${replaceExisting ? 'Replaced' : 'Imported'} actor with prefix "${prefix}"${animationNotice}`, 3000); if (replaceExisting && animationResult.missing) { Blockbench.showMessageBox({ @@ -2198,31 +2196,7 @@ return out; } - function buildActorAnimationOutputsForAnimation(anim, maxActor) { - if (typeof anim?.compileBedrockAnimation !== 'function') { - throw new Error(`Animation compiler not available for "${anim?.name || 'animation'}" (missing compileBedrockAnimation).`); - } - - const animKey = anim.name || 'animation.unnamed'; - const safeAnim = sanitizeFilePart(animKey); - - const compiled = anim.compileBedrockAnimation() || {}; - const baseFile = compiled.animations ? compiled : { format_version: '1.8.0', animations: { [animKey]: compiled } }; - - const outputs = []; - for (let i = 1; i <= maxActor; i++) { - const f = clone(baseFile); - stripPrefixFromAnimationFileForActor(f, i); - const safeActor = sanitizeFilePart(getActorExportLabel(i)) || `actor${i}`; - outputs.push({ - name: `${safeAnim}_${safeActor}.animation`, - content: stringifyInlineVec3(f, 2), - }); - } - return outputs; - } - - function exportActorAnimationOutputs(outputs, doneMessage) { + function exportAnimationOutputs(outputs, doneMessage) { let idx = 0; const next = () => { if (idx >= outputs.length) { @@ -2244,59 +2218,6 @@ next(); } - function exportSelectedActorAnimation() { - const anim = getSelectedAnimation(); - if (!anim) { - Blockbench.showQuickMessage('No animation selected.', 2500); - return; - } - - const maxActor = getMaxActorIndexFromProject(); - if (maxActor < 1) { - Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500); - return; - } - - try { - const outputs = buildActorAnimationOutputsForAnimation(anim, maxActor); - exportActorAnimationOutputs(outputs, `Exported ${outputs.length} actor animation files`); - } catch (e) { - console.error(e); - Blockbench.showMessageBox({ - title: 'Export Selected Animations (Split Files)', - message: `Export failed: ${e?.message || e}`, - buttons: ['OK'], - }); - } - } - - function exportAllActorAnimations() { - const animations = Array.isArray(Animation.all) ? Animation.all.filter((a) => a) : []; - if (!animations.length) { - Blockbench.showQuickMessage('No animations found.', 2500); - return; - } - - const maxActor = getMaxActorIndexFromProject(); - if (maxActor < 1) { - Blockbench.showQuickMessage('No actorN_ bones found in this project.', 2500); - return; - } - - try { - const outputs = []; - for (const anim of animations) outputs.push(...buildActorAnimationOutputsForAnimation(anim, maxActor)); - exportActorAnimationOutputs(outputs, `Exported ${outputs.length} actor animation files from ${animations.length} animations`); - } catch (e) { - console.error(e); - Blockbench.showMessageBox({ - title: 'Export All Animations (Split Files)', - message: `Export failed: ${e?.message || e}`, - buttons: ['OK'], - }); - } - } - function parseAfwStageAnimationName(animation) { const name = String(animation?.name || '').trim(); const match = /^([a-z0-9._-]+)\.p(\d+)$/.exec(name); @@ -2470,7 +2391,7 @@ function exportSelectedConjoinedAnimationGroups(groups) { try { const outputs = groups.map(buildConjoinedAnimationOutput); - exportActorAnimationOutputs( + exportAnimationOutputs( outputs, `Exported ${outputs.length} animation file${outputs.length === 1 ? '' : 's'}` ); @@ -2727,9 +2648,154 @@ ); } + const DEFINITION_GENERATOR_KEYS = new Set(['display_name', 'animators', 'system_tags', 'animation_tags', + 'content_tags', 'required_union_tags', 'join_after', 'match_content_on_join', 'weight', 'attack_eligible', + 'liquid_gain_multiplier', 'speed', 'water', 'position_anchor_actor', 'camera', 'manual_peak', 'tease', 'stuck', + 'actors', 'stages', 'block_requirements']); + const ACTOR_GENERATOR_KEYS = new Set(['label', 'entity_types', 'entity_variant', 'actor_tags', 'actor_tags_any', + 'activity', 'injector', 'receiver', 'injects_into', 'held_item']); + const STAGE_GENERATOR_KEYS = new Set(['stage', 'loop', 'cycle_seconds', 'allow_join', 'escapable', 'non_peak', + 'speed', 'use_stage', 'stage_seconds', 'stage_duration_multiplier', 'cycle_midpoint_offset_seconds']); + + function unmanagedAnimdefFields(source, keys) { + return Object.fromEntries(Object.entries(source || {}).filter(([key]) => !keys.has(key))); + } + + function prepareAnimationDefinitionImport(source, analysis) { + const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); + if (!isObject(source) || !Array.isArray(source.actors) || !source.actors.length + || !Array.isArray(source.stages) || !source.stages.length) { + throw new Error('Expected an animation definition JSON with non-empty actors and stages arrays (not an animation asset).'); + } + const definition = clone(source); + for (const key of ['animators', 'system_tags', 'animation_tags', 'content_tags', 'required_union_tags', 'join_after']) { + if (definition[key] !== undefined && (!Array.isArray(definition[key]) || definition[key].some((value) => typeof value !== 'string'))) { + throw new Error(`${key} must be an array of strings.`); + } + } + for (const key of ['camera', 'manual_peak', 'tease', 'stuck', 'block_requirements']) { + if (definition[key] !== undefined && !isObject(definition[key])) throw new Error(`${key} must be an object.`); + } + if (definition.system_tags === undefined && definition.animation_tags !== undefined) { + definition.system_tags = definition.animation_tags; + } + delete definition.animation_tags; + if (definition.actors.length !== analysis.actorIndices.length) { + throw new Error(`The definition has ${definition.actors.length} actors, but this animation has ${analysis.actorIndices.length}. Select an animation with the same actors.`); + } + const stageNumbers = new Set(analysis.stages.map((stage) => stage.stageNumber)); + const seenStages = new Set(); + for (const stage of definition.stages) { + if (!isObject(stage) || !Number.isInteger(stage.stage) || !stageNumbers.has(stage.stage) || seenStages.has(stage.stage)) { + throw new Error('Imported stages must have unique stage numbers matching the target animation.'); + } + seenStages.add(stage.stage); + } + if (seenStages.size !== stageNumbers.size) throw new Error('The definition must include every stage of the target animation.'); + + const roles = analysis.actorIndices.map((index) => analysis.actorLabels.get(index)); + const labels = new Set(); + for (const actor of definition.actors) { + if (!isObject(actor)) throw new Error('Every actor must be an object.'); + if (actor.label !== undefined && (typeof actor.label !== 'string' || !actor.label.trim() || labels.has(actor.label))) { + throw new Error('Explicit actor labels must be non-empty, unique strings.'); + } + if (actor.label) labels.add(actor.label); + for (const key of ['entity_types', 'actor_tags', 'actor_tags_any']) { + if (actor[key] !== undefined && (!Array.isArray(actor[key]) || actor[key].some((value) => typeof value !== 'string'))) { + throw new Error(`Actor ${key} must be an array of strings.`); + } + } + if (actor.held_item !== undefined && !isObject(actor.held_item)) throw new Error('Actor held_item must be an object.'); + } + // Reserve exact matches first; unmatched or unlabeled actors use remaining roles in order. + const assigned = definition.actors.map((actor) => roles.includes(actor.label) ? actor.label : null); + const available = roles.filter((role) => !assigned.includes(role)); + const labelMap = new Map(); + definition.actors.forEach((actor, index) => { + assigned[index] ||= available.shift(); + labelMap.set(actor.label || `actor${index + 1}`, assigned[index]); + }); + definition.actors.forEach((actor, index) => { + actor.label = assigned[index]; + if (actor.injects_into !== undefined) { + const targets = typeof actor.injects_into === 'string' ? [actor.injects_into] : actor.injects_into; + if (!Array.isArray(targets) || targets.some((target) => !labelMap.has(target))) { + throw new Error(`Actor ${actor.label} references an unknown injection target.`); + } + actor.injects_into = targets.map((target) => labelMap.get(target)); + if (actor.injects_into.includes(actor.label)) throw new Error('An actor cannot inject into itself.'); + } + }); + definition.actors.sort((a, b) => roles.indexOf(a.label) - roles.indexOf(b.label)); + if (definition.position_anchor_actor !== undefined) { + if (!labelMap.has(definition.position_anchor_actor)) throw new Error('The position anchor references an unknown actor.'); + definition.position_anchor_actor = labelMap.get(definition.position_anchor_actor); + } + return definition; + } + + function applyAnimationDefinitionImport(group, analysis, source) { + const definition = prepareAnimationDefinitionImport(source, analysis); + let entry = null; + if (definition.block_requirements) { + const req = definition.block_requirements; + if (!['wall', 'center_support'].includes(req.type) + || (req.facing && !['front', 'back', 'left', 'right'].includes(req.facing)) + || (req.support && !['full_block', 'slab', 'half_height', 'surface'].includes(req.support)) + || (req.placement && !['directional_clearance', 'surface_footprint'].includes(req.placement))) { + throw new Error('The block requirement has an unsupported type, support, placement or facing.'); + } + const form = buildBlockRequirementForm(group.id, req); + const values = Object.fromEntries(Object.entries(form).filter(([, field]) => field.type !== 'info').map(([key, field]) => [key, field.value])); + entry = readBlockRequirementForm(values); + buildBlockRequirementGuideBoxes(entry); + } + cancelBlockRequirementUpdate(); + rebuildBlockRequirementGuides(group.id, entry, definition); + if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id); + showAnimationDefinitionReviewDialog(group, analysis); + } + + function importAnimationDefinition() { + if (!Project) return Blockbench.showQuickMessage('Open an animation project first.', 2500); + const projectId = Project.uuid; + Filesystem.importFile({ type: 'AFW Animation Definition', extensions: ['json'], multiple: false, + readtype: 'text', title: 'Import Animation Definition' }, (files) => { + if (!files?.length) return; + const report = (error) => Blockbench.showMessageBox({ title: 'Import Animation Definition', message: error.message, buttons: ['OK'] }); + try { + if (!Project || Project.uuid !== projectId) throw new Error('The active project changed. Import the definition again.'); + const source = JSON.parse(String(files[0].content || '').replace(/^\uFEFF/, '')); + const groups = collectConjoinedAnimationGroups().groups; + if (!groups.length) throw new Error('Import your animation assets first (named .p), then import their definition.'); + const options = Object.fromEntries(groups.map((group) => [group.id, group.id])); + const fileId = String(files[0].name || files[0].path || '').split(/[\\/]/).pop().replace(/\.json$/i, ''); + const selected = parseAfwStageAnimationName(getSelectedAnimation()); + new Dialog({ id: 'animationdirector_import_animdef', title: 'Import Animation Definition', width: 520, + form: { + _info: { type: 'info', text: 'Choose the target animation. This replaces its saved definition and block guides. Actor labels are matched first; remaining actors are assigned in order. Keyframes and models are unchanged.' }, + animation_id: { label: 'Animation', type: 'select', options, + value: options[fileId] ? fileId : options[selected?.id] ? selected.id : groups[0].id }, + }, + onConfirm(result) { + try { + if (!Project || Project.uuid !== projectId) throw new Error('The active project changed. Import the definition again.'); + const group = collectConjoinedAnimationGroups().groups.find((candidate) => candidate.id === result.animation_id); + if (!group) throw new Error('The selected animation no longer exists.'); + applyAnimationDefinitionImport(group, analyzeAnimationDefinitionGroup(group), source); + return true; + } catch (e) { report(e); return false; } + }, + }).show(); + } catch (e) { report(e); } + }); + } + function showAnimationDefinitionReviewDialog(group, analysis) { const savedMetadata = getProjectActorAnimdefMetadata(); const saved = getSavedAnimdef(group.id); + const savedActors = new Map((Array.isArray(saved.actors) ? saved.actors : []).map((actor) => [actor.label, actor])); const savedStages = new Map((Array.isArray(saved.stages) ? saved.stages : []).map((stage) => [stage.stage, stage])); const savedSystemTags = uniqueTrimmedStrings((Array.isArray(saved.system_tags) ? saved.system_tags : []) .map((tag) => String(tag).toLowerCase())); @@ -2949,7 +3015,10 @@ form._actors = { type: 'info', text: '### Actor constraints' }; analysis.actorIndices.forEach((actorIndex, actorPosition) => { const label = analysis.actorLabels.get(actorIndex); - const metadata = actorAnimdefMetadataWithInference(actorIndex, savedMetadata[String(actorIndex)]); + const savedActor = savedActors.get(label); + const metadata = savedActor ? cleanActorAnimdefMetadata({ ...savedActor, + injects_into: typeof savedActor.injects_into === 'string' ? [savedActor.injects_into] : savedActor.injects_into }) + : actorAnimdefMetadataWithInference(actorIndex, savedMetadata[String(actorIndex)]); addActorAnimdefFormFields( form, 'stub_actor', @@ -3000,12 +3069,12 @@ type: 'select', options: { default: 'Use top-level default', true: 'Yes', false: 'No' }, value: savedStage.escapable == null - ? (isLastStage ? 'false' : 'default') : String(savedStage.escapable), + ? (savedStages.has(stage.stageNumber) ? 'default' : isLastStage ? 'false' : 'default') : String(savedStage.escapable), }; form[`${base}_peaked`] = { label: 'Peaked stage', type: 'checkbox', - value: savedStage.non_peak ?? isLastStage, + value: savedStages.has(stage.stageNumber) ? savedStage.non_peak === true : isLastStage, description: "Marks this as NoN's peaked stage.", }; form[`${base}_advanced`] = { @@ -3077,12 +3146,14 @@ const metadata = readActorAnimdefFormFields(result, 'stub_actor', actorIndex); actorMetadata[String(actorIndex)] = metadata; if (!metadata.entity_types?.length) unresolvedLabels.push(label); - actors.push(buildActorConstraint(label, metadata)); + actors.push({ ...unmanagedAnimdefFields(savedActors.get(label), ACTOR_GENERATOR_KEYS), + ...buildActorConstraint(label, metadata) }); } - const stages = analysis.stages.map((stage, index) => - readStageDefinitionFromForm(result, stage, index === analysis.stages.length - 1) - ); + const stages = analysis.stages.map((stage, index) => ({ + ...unmanagedAnimdefFields(savedStages.get(stage.stageNumber), STAGE_GENERATOR_KEYS), + ...readStageDefinitionFromForm(result, stage, index === analysis.stages.length - 1), + })); const actorsByLabel = new Map(actors.map((actor) => [actor.label, actor])); const explicitReceivers = actors.some((actor) => actor.receiver); const inferredReceivers = actors.some((actor) => !actor.injector); @@ -3108,7 +3179,7 @@ } const displayName = String(result?.display_name || '').trim(); const animators = normalizeAnimatorNames(String(result?.animators || '').split(',')); - const definition = {}; + const definition = unmanagedAnimdefFields(saved, DEFINITION_GENERATOR_KEYS); const authored = getAuthoredBlockRequirement(group.id); const requirement = authored ? authored.requirement : saved.block_requirements; if (requirement) definition.block_requirements = clone(requirement); @@ -3146,7 +3217,7 @@ if (water !== 'none') definition.water = water; const anchor = String(result?.position_anchor_actor || ''); if (anchor) definition.position_anchor_actor = anchor; - const camera = {}; + const camera = unmanagedAnimdefFields(saved.camera, new Set(['torso_tracking', 'block_requirement_collision'])); if (result?.camera_torso_tracking === 'continuous') camera.torso_tracking = 'continuous'; if (result?.camera_block_collision === 'ignore') camera.block_requirement_collision = 'ignore'; if (Object.keys(camera).length) definition.camera = camera; @@ -3156,7 +3227,9 @@ if (systemTags.includes('manualpeak')) { const priority = parseOptionalNumber(result?.manual_peak_priority, 'Manual peak priority', Number.isInteger); - if (priority !== null && priority !== 0) definition.manual_peak = { priority }; + const manualPeak = unmanagedAnimdefFields(saved.manual_peak, new Set(['priority'])); + if (priority !== null && priority !== 0) manualPeak.priority = priority; + if (Object.keys(manualPeak).length) definition.manual_peak = manualPeak; } if (systemTags.includes('tease')) { const multiplier = parseOptionalNumber( @@ -3165,7 +3238,7 @@ (value) => value >= 1 && value <= 20 ); if (multiplier === null) throw new Error('Nearby energy multiplier is required.'); - definition.tease = { near_animation_energy_multiplier: multiplier }; + definition.tease = { ...saved.tease, near_animation_energy_multiplier: multiplier }; } if (systemTags.includes('stuck')) { if (!contentTags.length) { @@ -3206,6 +3279,7 @@ throw new Error('Final escape difficulty cannot be harder than the initial difficulty.'); } definition.stuck = { + ...saved.stuck, initial_escape_difficulty_multiplier: initial, final_escape_difficulty_multiplier: finalDifficulty, ease_delay_seconds: delay, @@ -3303,73 +3377,6 @@ // ---------------------------- // Import Animations // ---------------------------- - function stripJsonSuffix(name) { - const s = String(name || ''); - return s.toLowerCase().endsWith('.json') ? s.slice(0, -5) : s; - } - - function stripAnimationSuffix(name) { - const s = String(name || ''); - return s.toLowerCase().endsWith('.animation') ? s.slice(0, -10) : s; - } - - function parseActorSuffix(base) { - const marker = '_actor'; - const at = base.lastIndexOf(marker); - if (at === -1) return null; - - let i = at + marker.length; - let digits = ''; - while (i < base.length) { - const c = base[i]; - if (c < '0' || c > '9') break; - digits += c; - i++; - } - - if (!digits) return null; - if (i !== base.length) return null; - - const idx = parseInt(digits, 10); - return Number.isFinite(idx) && idx > 0 ? idx : null; - } - - function splitWeakBaseAndLabel(base) { - const s = String(base || ''); - const last = s.lastIndexOf('_'); - if (last <= 0 || last >= s.length - 1) return { base: null, label: null }; - return { base: s.slice(0, last), label: s.slice(last + 1) }; - } - - function getFilenameParts(fileName) { - const rawBase = stripAnimationSuffix(stripJsonSuffix(fileName)); - - // Strong rule: ..._actorN - const actor = parseActorSuffix(rawBase); - if (actor) { - const marker = '_actor'; - const at = rawBase.lastIndexOf(marker); - const strictBaseName = at === -1 ? rawBase : rawBase.slice(0, at); - return { - rawBase, - strictActorIndex: actor, - strictBaseName, - weakBaseName: null, - weakLabel: null, - }; - } - - // Weak rule: split at last underscore: _