copy first keyframe button

This commit is contained in:
2026-09-29 13:29:42 +02:00
parent 94b3145254
commit 9920f1da4c
2 changed files with 192 additions and 9 deletions
+58 -9
View File
@@ -20,6 +20,7 @@
let automaticImportAction;
let packFoldersAction;
let previousStagePoseAction;
let loopPoseAction;
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_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
@@ -2501,21 +2502,54 @@
}
}
function loopPoseUnavailableReason() {
if (typeof Format === 'undefined' || Format.id !== 'geckolib_model') return 'Open a GeckoLib animation project first.';
const animation = Animation.selected;
if (!animation) return 'Select an animation clip first.';
if (!Number.isFinite(animation.length) || animation.length <= 0) return 'Set a positive animation clip length first.';
return null;
}
function refreshLoopPoseButton() {
if (!loopPoseAction) return;
const reason = loopPoseUnavailableReason();
const description = reason || 'Copy the first pose of all actors to the clip endpoint for looping. Existing endpoint transforms are replaced.';
loopPoseAction.description = description;
for (const node of loopPoseAction.nodes || []) {
node.classList.toggle('disabled', !!reason);
node.setAttribute('aria-disabled', String(!!reason));
const label = node.querySelector('.tooltip_description');
if (label) label.textContent = description;
}
}
function copyFirstPoseToEnd() {
const reason = loopPoseUnavailableReason();
if (reason) { Blockbench.showQuickMessage(reason, 2500); return; }
const target = Animation.selected;
copyActorPose(target, 0, target, target.length, 'Copy first pose to clip end', true);
}
function copyPreviousStagePose() {
const { source, reason } = previousStagePoseSource();
if (reason) { Blockbench.showQuickMessage(reason, 2500); return; }
const target = Animation.selected, poses = [];
copyActorPose(source, source.length, Animation.selected, 0, 'Copy previous stage pose');
}
function copyActorPose(source, sourceTime, target, targetTime, label, keyedChannelsOnly = false) {
const poses = [];
const originalTime = Timeline.time;
try {
Timeline.time = source.length;
Timeline.time = sourceTime;
for (const group of Group.all) {
if (readActorIndexFromName(group.name) === null || isBlockRequirementGuide(group)) continue;
const animator = source.animators[group.uuid];
Animator.resetLastValues();
for (const channel of ['position', 'rotation', 'scale']) {
if (keyedChannelsOnly && !animator?.[channel]?.length) continue;
const value = animator?.[channel]?.length ? animator.interpolate(channel, false) : null;
const values = value || (channel === 'scale' ? [1, 1, 1] : [0, 0, 0]);
if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error(`Cannot evaluate ${group.name} ${channel} at the previous stage endpoint.`);
if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error(`Cannot evaluate ${group.name} ${channel} at time ${sourceTime}.`);
poses.push({ group, channel, values });
}
}
@@ -2523,20 +2557,21 @@
Timeline.time = originalTime;
Animator.resetLastValues();
}
if (!poses.length) { Blockbench.showQuickMessage('There are no actor bones to copy.', 2500); return; }
const keyframes = poses.flatMap(({ group, channel }) => (target.animators[group.uuid]?.[channel] || []).filter((frame) => frame.time === 0));
if (!poses.length) { Blockbench.showQuickMessage('There are no actor transforms to copy.', 2500); return; }
const keyframes = poses.flatMap(({ group, channel }) => (target.animators[group.uuid]?.[channel] || []).filter((frame) => frame.time === targetTime));
Undo.initEdit({ animations: [target], keyframes });
for (const { group, channel, values } of poses) {
const animator = target.getBoneAnimator(group);
const frame = animator[channel].find((frame) => frame.time === 0);
const frame = animator[channel].find((frame) => frame.time === targetTime);
const data = { interpolation: 'linear', uniform: false, data_points: [{ x: values[0], y: values[1], z: values[2] }] };
if (frame) frame.extend(data);
else keyframes.push(animator.createKeyframe(data, 0, channel, false, false));
// Preserve the exact clip endpoint, even when it falls between timeline grid steps.
else keyframes.push(animator.addKeyframe({ ...data, channel, time: targetTime }));
}
Undo.finishEdit('Copy previous stage pose', { animations: [target], keyframes });
Undo.finishEdit(label, { animations: [target], keyframes });
Animator.preview();
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
Blockbench.showQuickMessage('Copied previous stage pose to time 0.', 2500);
Blockbench.showQuickMessage(`Copied actor pose to time ${targetTime}.`, 2500);
}
function cleanSoundPreviewFields(clip) {
@@ -5795,14 +5830,26 @@ You can import or export complete AFW animation IDs as one file containing all s
catch (error) { Blockbench.showMessageBox({ title: 'Copy Previous Stage Pose', message: error.message }); }
},
});
loopPoseAction = new Action('animationdirector_first_pose_to_end', {
name: 'Copy First Pose to End', icon: 'step_over', color: '#b8a6cc', category: 'animation',
description: 'Copy the first actor pose to the clip endpoint for looping.',
click() {
try { copyFirstPoseToEnd(); }
catch (error) { Blockbench.showMessageBox({ title: 'Copy First Pose to End', message: error.message }); }
},
});
if (typeof Toolbars !== 'undefined' && Toolbars.timeline) {
const toolbar = Toolbars.timeline;
const marker = toolbar.children.findIndex((item) => item === 'add_marker' || item?.id === 'add_marker');
toolbar.add(previousStagePoseAction, marker >= 0 ? marker + 1 : 0);
toolbar.add(loopPoseAction, toolbar.children.indexOf(previousStagePoseAction) + 1);
}
for (const event of POSE_BUTTON_EVENTS) Blockbench.on(event, refreshPreviousStagePoseButton);
previousStagePoseAction.node?.addEventListener('mouseenter', refreshPreviousStagePoseButton);
refreshPreviousStagePoseButton();
for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.on(event, refreshLoopPoseButton);
loopPoseAction.node?.addEventListener('mouseenter', refreshLoopPoseButton);
refreshLoopPoseButton();
if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') {
soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, {
exposed: false, condition: (point) => point.keyframe.channel === 'sound',
@@ -6026,6 +6073,8 @@ You can import or export complete AFW animation IDs as one file containing all s
MenuBar.update();
},
onunload() {
for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.removeListener(event, refreshLoopPoseButton);
loopPoseAction?.delete(); loopPoseAction = undefined;
for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton);
previousStagePoseAction?.delete(); previousStagePoseAction = undefined;
Blockbench.removeListener('timeline_play', figuraSoundPlayListener);
+134
View File
@@ -0,0 +1,134 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
function harness() {
const context = {
Plugin: { register() {} }, Format: { id: 'geckolib_model' }, Project: {},
Animation: { all: [], selected: null }, Group: { all: [] }, Timeline: { time: 0.7 },
Animator: { resetLastValues() {}, preview() {} },
edits: [], Undo: { initEdit(edit) { context.edits.push(edit); }, finishEdit(label) { context.label = label; } },
Blockbench: { showQuickMessage(message) { context.message = message; } }, console,
};
const code = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
.replace(' Plugin.register(PLUGIN_ID, {', ' globalThis.api = { previousStagePoseSource, copyPreviousStagePose, copyFirstPoseToEnd, loopPoseUnavailableReason, refreshPreviousStagePoseButton, setButton(button) { previousStagePoseAction = button; } };\n Plugin.register(PLUGIN_ID, {');
vm.runInNewContext(code, context);
return context;
}
function animation(name, length = 2) {
return { name, length, animators: {}, getBoneAnimator(group) {
return this.animators[group.uuid] ||= animator();
} };
}
function animator() {
return { position: [], rotation: [], scale: [], addKeyframe(data) {
return this.createKeyframe(data, data.time, data.channel);
}, createKeyframe(data, time, channel) {
const frame = { ...data, time, channel, extend(data) { Object.assign(this, data); } };
this[channel].push(frame); return frame;
} };
}
test('previous pose button stays visible and explains why it cannot be used', () => {
const h = harness(), states = [], label = {};
const button = { nodes: [{ classList: { toggle(name, value) { states.push(value); } }, setAttribute() {}, querySelector() { return label; } }] };
h.api.setButton(button);
h.api.refreshPreviousStagePoseButton();
assert.match(label.textContent, /Select an animation stage/);
h.Animation.selected = animation('example.p1'); h.Animation.all = [h.Animation.selected];
h.api.refreshPreviousStagePoseButton();
assert.match(label.textContent, /no previous stage/);
assert.deepEqual(states, [true, true]);
});
test('previous stage uses numeric ordering, same animation ID and saved reused stages', () => {
const h = harness(), first = animation('example.p1'), current = animation('example.p3');
h.Animation.selected = current;
h.Animation.all = [animation('unrelated.p2'), current, first];
h.Project.animationdirector_animdef_definitions = JSON.stringify({ example: { stages: [{ stage: 1 }, { stage: 2, use_stage: 1 }, { stage: 3 }] } });
assert.equal(h.api.previousStagePoseSource().source, first);
assert.equal(h.api.previousStagePoseSource().previous, 2);
h.Project.animationdirector_animdef_definitions = JSON.stringify({ example: { stages: [{ stage: 2, use_stage: 9 }] } });
assert.match(h.api.previousStagePoseSource().reason, /missing animation p9/);
});
test('copies evaluated endpoint for all actors, resets absent channels and preserves later frames and effects', () => {
const h = harness(), source = animation('example.p1'), target = animation('example.p2');
h.Animation.selected = target; h.Animation.all = [target, source];
const groups = [{ uuid: 'a', name: 'actor1_body' }, { uuid: 'b', name: 'actor2_head' }, { uuid: 'guide', name: '__ad_block_requirement_x' }];
h.Group.all = groups;
source.animators.a = animator();
source.animators.a.position = [{ time: 1 }];
source.animators.a.interpolate = (channel, allowExpression) => {
assert.equal(h.Timeline.time, 2); assert.equal(allowExpression, false);
return [4, 5, 6];
};
target.animators.a = animator();
const initial = target.animators.a.createKeyframe({ data_points: [{ x: 99 }] }, 0, 'position');
const later = target.animators.a.createKeyframe({ data_points: [{ x: 77 }] }, 1, 'position');
target.animators.effects = { sound: [{ time: 0, effect: 'test' }] };
h.api.copyPreviousStagePose();
assert.equal(h.Timeline.time, 0.7);
assert.equal(initial.data_points[0].x, 4);
assert.equal(later.data_points[0].x, 77);
assert.equal(target.animators.b.scale[0].data_points[0].x, 1);
assert.equal(target.animators.b.rotation[0].data_points[0].x, 0);
assert.equal(target.animators.effects.sound[0].effect, 'test');
assert.equal(target.animators.guide, undefined);
assert.equal(h.edits.length, 1);
});
test('failed endpoint evaluation restores playhead and leaves target unmodified', () => {
const h = harness(), source = animation('example.p1'), target = animation('example.p2');
h.Animation.selected = target; h.Animation.all = [source, target];
h.Group.all = [{ uuid: 'a', name: 'actor1_body' }];
source.animators.a = { position: [{}], interpolate() { return [NaN, 0, 0]; } };
assert.throws(() => h.api.copyPreviousStagePose(), /Cannot evaluate/);
assert.equal(h.Timeline.time, 0.7);
assert.equal(h.edits.length, 0);
assert.deepEqual(target.animators, {});
});
test('loop pose copies time zero to exact endpoint without modifying intermediate keys or source pose', () => {
const h = harness(), clip = animation('example.p1', 2.1234);
h.Animation.selected = clip; h.Animation.all = [clip];
const group = { uuid: 'a', name: 'actor1_body' };
h.Group.all = [group, { uuid: 'empty', name: 'actor2_head' }];
const bone = clip.animators.a = animator();
const first = bone.createKeyframe({ data_points: [{ x: 5, y: 6, z: 7 }] }, 0, 'position');
const middle = bone.createKeyframe({ data_points: [{ x: 99 }] }, 1, 'position');
const last = bone.createKeyframe({ data_points: [{ x: 88 }] }, clip.length, 'position');
bone.interpolate = () => { assert.equal(h.Timeline.time, 0); return [5, 6, 7]; };
h.api.copyFirstPoseToEnd();
assert.equal(first.data_points[0].x, 5);
assert.equal(middle.data_points[0].x, 99);
assert.equal(last.data_points[0].x, 5);
assert.equal(bone.scale.length, 0);
assert.equal(bone.rotation.length, 0);
assert.equal(clip.animators.empty, undefined);
assert.equal(clip.length, 2.1234);
assert.equal(h.Timeline.time, 0.7);
assert.equal(h.edits.length, 1);
});
test('loop pose on an unkeyed clip does not create animators or an undo edit', () => {
const h = harness(), clip = animation('example.p1');
h.Animation.selected = clip; h.Animation.all = [clip];
h.Group.all = [{ uuid: 'a', name: 'actor1_body' }];
h.api.copyFirstPoseToEnd();
assert.deepEqual(clip.animators, {});
assert.equal(h.edits.length, 0);
});
test('loop pose is unavailable without a clip or with a zero length', () => {
const h = harness();
assert.match(h.api.loopPoseUnavailableReason(), /Select an animation/);
h.Animation.selected = animation('example.p1', 0);
h.api.copyFirstPoseToEnd();
assert.match(h.message, /positive animation clip length/);
assert.equal(h.edits.length, 0);
});