copy first keyframe button

This commit is contained in:
2026-09-29 13:45:53 +02:00
parent 9920f1da4c
commit 4735c33e6e
3 changed files with 129 additions and 0 deletions
+77
View File
@@ -21,6 +21,8 @@
let packFoldersAction; let packFoldersAction;
let previousStagePoseAction; let previousStagePoseAction;
let loopPoseAction; let loopPoseAction;
let autoplayStagesAction, autoplayLoopOriginal, autoplayLoopWrapper;
let autoplayTransitioning = false;
const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'add_animation', 'remove_animation']; const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'add_animation', 'remove_animation'];
const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews'; const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' }; const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
@@ -2489,6 +2491,63 @@
return { source: matches[0], previous }; return { source: matches[0], previous };
} }
function nextAutoplayStage(animation) {
const current = parseAfwStageAnimationName(animation);
if (!current) return null;
const stages = Animation.all.map(parseAfwStageAnimationName)
.filter((stage) => stage?.id === current.id && stage.stageNumber > current.stageNumber)
.sort((a, b) => a.stageNumber - b.stageNumber);
if (!stages.length || stages.filter((stage) => stage.stageNumber === stages[0].stageNumber).length !== 1) return null;
return stages[0].animation;
}
function updateAutoplayIconColor() {
if (!autoplayStagesAction) return;
const color = autoplayStagesAction.value ? '#000000' : '#b8a6cc';
autoplayStagesAction.color = color;
if (autoplayStagesAction.icon_node) autoplayStagesAction.icon_node.style.color = color;
for (const node of autoplayStagesAction.nodes || []) {
const icon = node.querySelector('.icon');
if (icon) icon.style.color = color;
}
}
function installStageAutoplay() {
autoplayLoopOriginal = Timeline.loop;
// Native playback resets once-clips before emitting pause. Observe the completed
// playback step instead of treating a user's pause as a stage transition.
autoplayLoopWrapper = function (...args) {
// start() invokes loop() immediately. Defer that nested step to the next
// normal playback frame, but render the new pose synchronously below.
if (autoplayTransitioning) return;
const animation = Animation.selected;
const eligible = autoplayStagesAction?.value && Format.id === 'geckolib_model'
&& Timeline.playing && animation?.length > 0 && animation.loop !== 'loop'
&& !(Timeline.custom_range?.[0] || Timeline.custom_range?.[1]);
const result = autoplayLoopOriginal.apply(this, args);
if (eligible && autoplayStagesAction?.value && Animation.selected === animation && !Timeline.playing) {
const next = nextAutoplayStage(animation);
if (next) {
autoplayTransitioning = true;
try {
Timeline.setTime(0);
next.select();
Timeline.start();
Animator.preview(true);
} finally {
autoplayTransitioning = false;
}
}
}
return result;
};
Timeline.loop = autoplayLoopWrapper;
}
function removeStageAutoplay() {
if (Timeline.loop === autoplayLoopWrapper) Timeline.loop = autoplayLoopOriginal;
}
function refreshPreviousStagePoseButton() { function refreshPreviousStagePoseButton() {
if (!previousStagePoseAction) return; if (!previousStagePoseAction) return;
const { reason } = previousStagePoseSource(); const { reason } = previousStagePoseSource();
@@ -5822,6 +5881,22 @@ You can import or export complete AFW animation IDs as one file containing all s
version: '2.0.0', version: '2.0.0',
variant: 'both', variant: 'both',
onload() { onload() {
autoplayStagesAction = new Toggle('animationdirector_autoplay_stages', {
name: 'Autoplay Stages', icon: 'resume', color: '#b8a6cc', category: 'animation',
description: 'Automatically play the next stage when a non-looping stage finishes. Looping stages stay on their current clip.',
default: false, save_on_restart: true,
onChange: updateAutoplayIconColor,
condition: () => typeof Format !== 'undefined' && Format.id === 'geckolib_model',
});
if (typeof Toolbars !== 'undefined') {
const toolbar = Toolbars.animations;
if (toolbar) {
const length = toolbar.children.findIndex((item) => item === 'slider_animation_length' || item?.id === 'slider_animation_length');
toolbar.add(autoplayStagesAction, length >= 0 ? length + 1 : 0);
}
}
updateAutoplayIconColor();
installStageAutoplay();
previousStagePoseAction = new Action('animationdirector_previous_stage_pose', { previousStagePoseAction = new Action('animationdirector_previous_stage_pose', {
name: 'Copy Previous Stage Pose', icon: 'move_down', color: '#b8a6cc', category: 'animation', name: 'Copy Previous Stage Pose', icon: 'move_down', color: '#b8a6cc', category: 'animation',
description: 'Copy the final actor pose of the previous stage into time 0.', description: 'Copy the final actor pose of the previous stage into time 0.',
@@ -6073,6 +6148,8 @@ You can import or export complete AFW animation IDs as one file containing all s
MenuBar.update(); MenuBar.update();
}, },
onunload() { onunload() {
removeStageAutoplay();
autoplayStagesAction?.delete(); autoplayStagesAction = undefined;
for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.removeListener(event, refreshLoopPoseButton); for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.removeListener(event, refreshLoopPoseButton);
loopPoseAction?.delete(); loopPoseAction = undefined; loopPoseAction?.delete(); loopPoseAction = undefined;
for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton); for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton);
+2
View File
@@ -48,6 +48,8 @@ function harness() {
const context = { const context = {
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } }, Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
Action: class { constructor(id, options) { Object.assign(this, options, { id }); } }, Action: class { constructor(id, options) { Object.assign(this, options, { id }); } },
Toggle: class { constructor(id, options) { Object.assign(this, options, { id, value: false }); } },
Timeline: { loop() {} },
Panel: class { Panel: class {
constructor(id, options) { constructor(id, options) {
this.options = options; this.options = options;
+50
View File
@@ -0,0 +1,50 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function harness() {
const h = { Plugin: { register() {} }, Animation: { selected: null, all: [] }, Format: { id: 'geckolib_model' },
Animator: { preview() { h.rendered = h.Animation.selected; } },
Timeline: { playing: true, custom_range: [0, 0], loop() { this.playing = false; }, setTime(time) { this.time = time; }, start() { this.playing = true; this.loop(); } } };
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(' Plugin.register(PLUGIN_ID, {',
' globalThis.api = { installStageAutoplay, removeStageAutoplay, nextAutoplayStage, setEnabled(value) { autoplayStagesAction = { value }; } };\n Plugin.register(PLUGIN_ID, {');
vm.runInNewContext(source, h);
h.clip = (name, loop = 'once') => ({ name, length: 2, loop, select() { h.Animation.selected = this; } });
h.api.setEnabled(true); h.api.installStageAutoplay();
return h;
}
test('autoplay advances completed non-looping stages by number within the same ID', () => {
const h = harness(), first = h.clip('example.p1'), next = h.clip('example.p2');
h.Animation.selected = first; h.Animation.all = [h.clip('other.p2'), h.clip('example.p10'), next, first];
h.Timeline.loop();
assert.equal(h.Animation.selected, next);
assert.equal(h.Timeline.time, 0);
assert.equal(h.Timeline.playing, true);
assert.equal(h.rendered, next);
});
test('autoplay leaves looping stages, custom ranges, disabled toggle and final stage alone', () => {
for (const mode of ['loop', 'range', 'disabled', 'final']) {
const h = harness(), first = h.clip('example.p1', mode === 'loop' ? 'loop' : 'hold');
h.Animation.selected = first; h.Animation.all = mode === 'final' ? [first] : [first, h.clip('example.p2')];
if (mode === 'range') h.Timeline.custom_range = [0, 1];
if (mode === 'disabled') h.api.setEnabled(false);
h.Timeline.loop();
assert.equal(h.pending, undefined);
assert.equal(h.Animation.selected, first);
}
});
test('manual playback stops do not advance and unload restores playback', () => {
const h = harness(), first = h.clip('example.p1'), next = h.clip('example.p2');
h.Animation.selected = first; h.Animation.all = [first, next];
h.Timeline.playing = false; h.Timeline.loop();
assert.equal(h.pending, undefined);
assert.equal(h.Animation.selected, first);
assert.equal(h.Timeline.playing, false);
const wrapper = h.Timeline.loop; h.api.removeStageAutoplay();
assert.notEqual(h.Timeline.loop, wrapper);
});