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);