mirror of
https://github.com/JannisX11/blockbench.git
synced 2025-01-30 15:42:42 +08:00
Basic bezier keyframe support
This commit is contained in:
parent
7533db12c7
commit
bbedda916b
@ -1071,6 +1071,34 @@
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.keyframe_bezier_handle {
|
||||||
|
background-color: var(--color-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
margin: 1px;
|
||||||
|
margin-top: 3px;
|
||||||
|
cursor: move;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.keyframe_bezier_handle:hover, .keyframe_bezier_handle:active {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.keyframe_bezier_handle::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--color-grid);
|
||||||
|
height: 2px;
|
||||||
|
width: var(--length);
|
||||||
|
transform-origin: left;
|
||||||
|
rotate: var(--angle);
|
||||||
|
top: 4px;
|
||||||
|
left: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
border-left: 5px solid var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
#timeline_header {
|
#timeline_header {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -196,6 +196,45 @@ class Keyframe {
|
|||||||
|
|
||||||
return curve.getPoint(time).y;
|
return curve.getPoint(time).y;
|
||||||
}
|
}
|
||||||
|
getBezierLerp(before, after, axis, alpha) {
|
||||||
|
let val_before = before.calc(axis, 1);
|
||||||
|
let val_after = after.calc(axis, 0);
|
||||||
|
let axis_num = getAxisNumber(axis);
|
||||||
|
let vectors = [
|
||||||
|
new THREE.Vector2(before.time, val_before),
|
||||||
|
|
||||||
|
new THREE.Vector2(before.time + before.bezier_right_time[axis_num] || 0, val_before + before.bezier_right_value[axis_num] || 0),
|
||||||
|
new THREE.Vector2(after.time + after.bezier_left_time[axis_num] || 0, val_after + after.bezier_left_value[axis_num] || 0),
|
||||||
|
|
||||||
|
new THREE.Vector2(after.time, val_after),
|
||||||
|
];
|
||||||
|
|
||||||
|
let curve = new THREE.CubicBezierCurve(...vectors);
|
||||||
|
let time = before.time + (after.time - before.time) * alpha;
|
||||||
|
|
||||||
|
let points = curve.getPoints(200);
|
||||||
|
let closest;
|
||||||
|
let closest_diff = Infinity;
|
||||||
|
points.forEach(point => {
|
||||||
|
let diff = Math.abs(point.x - time);
|
||||||
|
if (diff < closest_diff) {
|
||||||
|
closest_diff = diff;
|
||||||
|
closest = point;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let second_closest;
|
||||||
|
closest_diff = Infinity;
|
||||||
|
points.forEach(point => {
|
||||||
|
if (point == closest) return;
|
||||||
|
let diff = Math.abs(point.x - time);
|
||||||
|
if (diff < closest_diff) {
|
||||||
|
closest_diff = diff;
|
||||||
|
second_closest = closest;
|
||||||
|
second_closest = point;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Math.lerp(closest.y, second_closest.y, Math.clamp(Math.getLerp(closest.x, second_closest.x, time), 0, 1));
|
||||||
|
}
|
||||||
getArray(data_point = 0) {
|
getArray(data_point = 0) {
|
||||||
var arr = [
|
var arr = [
|
||||||
this.get('x', data_point),
|
this.get('x', data_point),
|
||||||
@ -468,6 +507,7 @@ class Keyframe {
|
|||||||
'_',
|
'_',
|
||||||
'keyframe_uniform',
|
'keyframe_uniform',
|
||||||
'keyframe_interpolation',
|
'keyframe_interpolation',
|
||||||
|
'keyframe_bezier_linked',
|
||||||
{name: 'menu.cube.color', icon: 'color_lens', children() {
|
{name: 'menu.cube.color', icon: 'color_lens', children() {
|
||||||
return [
|
return [
|
||||||
{icon: 'bubble_chart', name: 'generic.unset', click: function(kf) {kf.forSelected(kf2 => {kf2.color = -1}, 'change color')}},
|
{icon: 'bubble_chart', name: 'generic.unset', click: function(kf) {kf.forSelected(kf2 => {kf2.color = -1}, 'change color')}},
|
||||||
@ -488,10 +528,16 @@ class Keyframe {
|
|||||||
new Property(Keyframe, 'number', 'color', {default: -1})
|
new Property(Keyframe, 'number', 'color', {default: -1})
|
||||||
new Property(Keyframe, 'boolean', 'uniform', {condition: keyframe => keyframe.channel == 'scale', default: settings.uniform_keyframe.value})
|
new Property(Keyframe, 'boolean', 'uniform', {condition: keyframe => keyframe.channel == 'scale', default: settings.uniform_keyframe.value})
|
||||||
new Property(Keyframe, 'string', 'interpolation', {default: 'linear'})
|
new Property(Keyframe, 'string', 'interpolation', {default: 'linear'})
|
||||||
|
new Property(Keyframe, 'vector2', 'bezier_linked', {condition: keyframe => keyframe.interpolation == 'bezier', default: true})
|
||||||
|
new Property(Keyframe, 'vector2', 'bezier_left_time', {default: [-0.1, -0.1, -0.1]});
|
||||||
|
new Property(Keyframe, 'vector2', 'bezier_left_value');
|
||||||
|
new Property(Keyframe, 'vector2', 'bezier_right_time', {default: [0.1, 0.1, 0.1]});
|
||||||
|
new Property(Keyframe, 'vector2', 'bezier_right_value');
|
||||||
Keyframe.selected = [];
|
Keyframe.selected = [];
|
||||||
Keyframe.interpolation = {
|
Keyframe.interpolation = {
|
||||||
linear: 'linear',
|
linear: 'linear',
|
||||||
catmullrom: 'catmullrom',
|
catmullrom: 'catmullrom',
|
||||||
|
bezier: 'bezier',
|
||||||
step: 'step',
|
step: 'step',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -819,18 +865,35 @@ BARS.defineActions(function() {
|
|||||||
updateKeyframeSelection();
|
updateKeyframeSelection();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
new Toggle('keyframe_bezier_linked', {
|
||||||
|
icon: 'fa-bezier-curve',
|
||||||
|
category: 'animation',
|
||||||
|
condition: () => Animator.open && Timeline.selected.length && !Timeline.selected.find(kf => kf.interpolation !== 'bezier'),
|
||||||
|
onChange(value) {
|
||||||
|
let keyframes = Timeline.selected;
|
||||||
|
Undo.initEdit({keyframes})
|
||||||
|
keyframes.forEach((kf) => {
|
||||||
|
kf.bezier_linked = value;
|
||||||
|
})
|
||||||
|
Undo.finishEdit('Change keyframes bezier link option');
|
||||||
|
updateKeyframeSelection();
|
||||||
|
}
|
||||||
|
})
|
||||||
new BarSelect('keyframe_interpolation', {
|
new BarSelect('keyframe_interpolation', {
|
||||||
category: 'animation',
|
category: 'animation',
|
||||||
condition: () => Animator.open && Timeline.selected.length && Timeline.selected.find(kf => kf.transform),
|
condition: () => Animator.open && Timeline.selected.length && Timeline.selected.find(kf => kf.transform),
|
||||||
options: {
|
options: {
|
||||||
linear: true,
|
linear: true,
|
||||||
catmullrom: true,
|
catmullrom: true,
|
||||||
|
bezier: true,
|
||||||
step: true,
|
step: true,
|
||||||
},
|
},
|
||||||
onChange: function(sel, event) {
|
onChange: function(sel, event) {
|
||||||
Undo.initEdit({keyframes: Timeline.selected})
|
Undo.initEdit({keyframes: Timeline.selected})
|
||||||
Timeline.selected.forEach((kf) => {
|
Timeline.selected.forEach((kf) => {
|
||||||
if (kf.transform) kf.interpolation = sel.value;
|
if (kf.transform) {
|
||||||
|
kf.interpolation = sel.value;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
Undo.finishEdit('Change keyframes interpolation')
|
Undo.finishEdit('Change keyframes interpolation')
|
||||||
updateKeyframeSelection();
|
updateKeyframeSelection();
|
||||||
|
@ -698,6 +698,14 @@ Interface.definePanels(() => {
|
|||||||
max = Math.max(max, value);
|
max = Math.max(max, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
keyframes.forEach(kf => {
|
||||||
|
if (kf.interpolation === 'bezier') {
|
||||||
|
min = Math.min(min, kf.display_value + kf.bezier_left_value[this.graph_editor_axis_number]);
|
||||||
|
max = Math.max(max, kf.display_value + kf.bezier_left_value[this.graph_editor_axis_number]);
|
||||||
|
min = Math.min(min, kf.display_value + kf.bezier_right_value[this.graph_editor_axis_number]);
|
||||||
|
max = Math.max(max, kf.display_value + kf.bezier_right_value[this.graph_editor_axis_number]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Timeline.time = original_time;
|
Timeline.time = original_time;
|
||||||
|
|
||||||
@ -722,6 +730,9 @@ Interface.definePanels(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return string;
|
return string;
|
||||||
|
},
|
||||||
|
graph_editor_axis_number() {
|
||||||
|
return getAxisNumber(this.graph_editor_axis)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -824,6 +835,7 @@ Interface.definePanels(() => {
|
|||||||
},
|
},
|
||||||
dragKeyframes(clicked, e1) {
|
dragKeyframes(clicked, e1) {
|
||||||
convertTouchEvent(e1);
|
convertTouchEvent(e1);
|
||||||
|
if (e1.target.classList.contains('keyframe_bezier_handle')) return;
|
||||||
let dragging_range;
|
let dragging_range;
|
||||||
let dragging_restriction;
|
let dragging_restriction;
|
||||||
let originalValue;
|
let originalValue;
|
||||||
@ -996,6 +1008,123 @@ Interface.definePanels(() => {
|
|||||||
addEventListeners(document, 'mousemove touchmove', slide, {passive: false});
|
addEventListeners(document, 'mousemove touchmove', slide, {passive: false});
|
||||||
addEventListeners(document, 'mouseup touchend', off);
|
addEventListeners(document, 'mouseup touchend', off);
|
||||||
},
|
},
|
||||||
|
dragBezierHandle(clicked, side, e1) {
|
||||||
|
convertTouchEvent(e1);
|
||||||
|
let values_changed;
|
||||||
|
let is_setup = false;
|
||||||
|
let axis_number = getAxisNumber(this.graph_editor_axis);
|
||||||
|
let old_values = {};
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
|
||||||
|
if (!clicked.selected && !e1.shiftKey && !Pressing.overrides.shift && Timeline.selected.length != 0) {
|
||||||
|
clicked.select()
|
||||||
|
} else if (clicked && !clicked.selected) {
|
||||||
|
clicked.select({shiftKey: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
Keyframe.selected.forEach(kf => {
|
||||||
|
if (kf.interpolation == 'bezier') {
|
||||||
|
old_values[kf.uuid] = {
|
||||||
|
bezier_left_time: kf.bezier_left_time.slice(),
|
||||||
|
bezier_left_value: kf.bezier_left_value.slice(),
|
||||||
|
bezier_right_time: kf.bezier_right_time.slice(),
|
||||||
|
bezier_right_value: kf.bezier_right_value.slice(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Undo.initEdit({keyframes: Timeline.selected});
|
||||||
|
Timeline.dragging_keyframes = true;
|
||||||
|
|
||||||
|
is_setup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slide(e2) {
|
||||||
|
convertTouchEvent(e2);
|
||||||
|
e2.preventDefault();
|
||||||
|
let offset = [
|
||||||
|
e2.clientX - e1.clientX,
|
||||||
|
e2.clientY - e1.clientY,
|
||||||
|
]
|
||||||
|
if (!is_setup) {
|
||||||
|
if (Math.pow(offset[0], 2) + Math.pow(offset[1], 2) > 20) {
|
||||||
|
setup();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var difference_time = Math.clamp(offset[0] / Timeline.vue._data.size, -256, 256);
|
||||||
|
var difference_value = Math.clamp(-offset[1] / Timeline.vue.graph_size, -256, 256);
|
||||||
|
|
||||||
|
|
||||||
|
for (var kf of Timeline.selected) {
|
||||||
|
if (kf.interpolation == 'bezier') {
|
||||||
|
|
||||||
|
kf.bezier_left_time.V3_set(old_values[kf.uuid].bezier_left_time);
|
||||||
|
kf.bezier_left_value.V3_set(old_values[kf.uuid].bezier_left_value);
|
||||||
|
kf.bezier_right_time.V3_set(old_values[kf.uuid].bezier_right_time);
|
||||||
|
kf.bezier_right_value.V3_set(old_values[kf.uuid].bezier_right_value);
|
||||||
|
|
||||||
|
if (side === 'left') {
|
||||||
|
kf.bezier_left_time[axis_number] = Math.min(0, old_values[kf.uuid].bezier_left_time[axis_number] + difference_time);
|
||||||
|
kf.bezier_left_value[axis_number] = old_values[kf.uuid].bezier_left_value[axis_number] + difference_value;
|
||||||
|
if (kf.bezier_linked) {
|
||||||
|
kf.bezier_right_time[axis_number] = -kf.bezier_left_time[axis_number];
|
||||||
|
kf.bezier_right_value[axis_number] = -kf.bezier_left_value[axis_number];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (side === 'right') {
|
||||||
|
kf.bezier_right_time[axis_number] = Math.max(0, old_values[kf.uuid].bezier_right_time[axis_number] + difference_time);
|
||||||
|
kf.bezier_right_value[axis_number] = old_values[kf.uuid].bezier_right_value[axis_number] + difference_value;
|
||||||
|
if (kf.bezier_linked) {
|
||||||
|
kf.bezier_left_time[axis_number] = -kf.bezier_right_time[axis_number];
|
||||||
|
kf.bezier_left_value[axis_number] = -kf.bezier_right_value[axis_number];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = `${trimFloatNumber(difference_time)} ⨉ ${trimFloatNumber(difference_value)}`;
|
||||||
|
Blockbench.setStatusBarText(text);
|
||||||
|
|
||||||
|
Timeline.vue.show_zero_line = !Timeline.vue.show_zero_line;
|
||||||
|
Timeline.vue.show_zero_line = !Timeline.vue.show_zero_line;
|
||||||
|
Animator.showMotionTrail()
|
||||||
|
Animator.preview()
|
||||||
|
}
|
||||||
|
function off() {
|
||||||
|
removeEventListeners(document, 'mousemove touchmove', slide);
|
||||||
|
removeEventListeners(document, 'mouseup touchend', off);
|
||||||
|
|
||||||
|
if (is_setup) {
|
||||||
|
Blockbench.setStatusBarText();
|
||||||
|
if (values_changed) {
|
||||||
|
Undo.finishEdit('Adjust keyframe bezier handles');
|
||||||
|
} else {
|
||||||
|
Undo.cancelEdit();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
Timeline.dragging_keyframes = false;
|
||||||
|
}, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEventListeners(document, 'mousemove touchmove', slide, {passive: false});
|
||||||
|
addEventListeners(document, 'mouseup touchend', off);
|
||||||
|
},
|
||||||
|
getBezierHandleStyle(keyframe, side) {
|
||||||
|
let axis_number = getAxisNumber(this.graph_editor_axis);
|
||||||
|
let x_offset = -keyframe[`bezier_${side}_time`][axis_number] * this.size;
|
||||||
|
let y_offset = -keyframe[`bezier_${side}_value`][axis_number] * this.graph_size;
|
||||||
|
let length = Math.sqrt(Math.pow(x_offset, 2) + Math.pow(y_offset, 2));
|
||||||
|
let angle = Math.atan2(-y_offset, x_offset);
|
||||||
|
return {
|
||||||
|
right: x_offset + 'px',
|
||||||
|
top: y_offset + 'px',
|
||||||
|
'--length': Math.max(length - 6, 0) + 'px',
|
||||||
|
'--angle': Math.radToDeg(angle) + 'deg',
|
||||||
|
}
|
||||||
|
},
|
||||||
clamp: Math.clamp,
|
clamp: Math.clamp,
|
||||||
trimFloatNumber
|
trimFloatNumber
|
||||||
},
|
},
|
||||||
@ -1149,6 +1278,17 @@ Interface.definePanels(() => {
|
|||||||
<i class="material-icons keyframe_icon_smaller" v-if="keyframe.interpolation == 'catmullrom'">lens</i>
|
<i class="material-icons keyframe_icon_smaller" v-if="keyframe.interpolation == 'catmullrom'">lens</i>
|
||||||
<i class="material-icons keyframe_icon_step" v-else-if="keyframe.interpolation == 'step'">eject</i>
|
<i class="material-icons keyframe_icon_step" v-else-if="keyframe.interpolation == 'step'">eject</i>
|
||||||
<i :class="keyframe.data_points.length == 1 ? 'icon-keyframe' : 'icon-keyframe_discontinuous'" v-else></i>
|
<i :class="keyframe.data_points.length == 1 ? 'icon-keyframe' : 'icon-keyframe_discontinuous'" v-else></i>
|
||||||
|
|
||||||
|
<template v-if="keyframe.interpolation == 'bezier'">
|
||||||
|
<div class="keyframe_bezier_handle"
|
||||||
|
:style="getBezierHandleStyle(keyframe, 'left')"
|
||||||
|
@mousedown="dragBezierHandle(keyframe, 'left', $event)" @touchstart="dragBezierHandle('left', $event)"
|
||||||
|
></div>
|
||||||
|
<div class="keyframe_bezier_handle"
|
||||||
|
:style="getBezierHandleStyle(keyframe, 'right')"
|
||||||
|
@mousedown="dragBezierHandle(keyframe, 'right', $event)" @touchstart="dragBezierHandle('right', $event)"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -393,13 +393,15 @@ class BoneAnimator extends GeneralAnimator {
|
|||||||
} else {
|
} else {
|
||||||
let no_interpolations = Blockbench.hasFlag('no_interpolations')
|
let no_interpolations = Blockbench.hasFlag('no_interpolations')
|
||||||
let alpha = Math.getLerp(before.time, after.time, time)
|
let alpha = Math.getLerp(before.time, after.time, time)
|
||||||
|
|
||||||
|
|
||||||
if (no_interpolations || (before.interpolation !== Keyframe.interpolation.catmullrom && after.interpolation !== Keyframe.interpolation.catmullrom)) {
|
if (no_interpolations || (before.interpolation === Keyframe.interpolation.linear && after.interpolation === Keyframe.interpolation.linear)) {
|
||||||
if (no_interpolations) {
|
if (no_interpolations) {
|
||||||
alpha = Math.round(alpha)
|
alpha = Math.round(alpha)
|
||||||
}
|
}
|
||||||
return mapAxes(axis => before.getLerp(after, axis, alpha, allow_expression));
|
return mapAxes(axis => before.getLerp(after, axis, alpha, allow_expression));
|
||||||
} else {
|
|
||||||
|
} else if (before.interpolation === Keyframe.interpolation.catmullrom || after.interpolation === Keyframe.interpolation.catmullrom) {
|
||||||
|
|
||||||
let sorted = this[channel].slice().sort((kf1, kf2) => (kf1.time - kf2.time));
|
let sorted = this[channel].slice().sort((kf1, kf2) => (kf1.time - kf2.time));
|
||||||
let before_index = sorted.indexOf(before);
|
let before_index = sorted.indexOf(before);
|
||||||
@ -407,6 +409,10 @@ class BoneAnimator extends GeneralAnimator {
|
|||||||
let after_plus = sorted[before_index+2];
|
let after_plus = sorted[before_index+2];
|
||||||
|
|
||||||
return mapAxes(axis => before.getCatmullromLerp(before_plus, before, after, after_plus, axis, alpha));
|
return mapAxes(axis => before.getCatmullromLerp(before_plus, before, after, after_plus, axis, alpha));
|
||||||
|
|
||||||
|
} else if (before.interpolation === Keyframe.interpolation.bezier || after.interpolation === Keyframe.interpolation.bezier) {
|
||||||
|
// Bezier
|
||||||
|
return mapAxes(axis => before.getBezierLerp(before, after, axis, alpha));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result && result instanceof Keyframe) {
|
if (result && result instanceof Keyframe) {
|
||||||
|
@ -42,7 +42,7 @@ function buildAnimationTracks(do_quaternions = true) {
|
|||||||
// Sampling non-linear and math-based values
|
// Sampling non-linear and math-based values
|
||||||
let contains_script
|
let contains_script
|
||||||
for (var kf of keyframes) {
|
for (var kf of keyframes) {
|
||||||
if (kf.interpolation == Keyframe.interpolation.catmullrom) {
|
if (kf.interpolation == Keyframe.interpolation.catmullrom || kf.interpolation == Keyframe.interpolation.bezier) {
|
||||||
contains_script = true; break;
|
contains_script = true; break;
|
||||||
}
|
}
|
||||||
for (var data_point of kf.data_points) {
|
for (var data_point of kf.data_points) {
|
||||||
|
@ -1470,9 +1470,12 @@
|
|||||||
"action.keyframe_interpolation.desc": "Select the keyframe interpolation mode",
|
"action.keyframe_interpolation.desc": "Select the keyframe interpolation mode",
|
||||||
"action.keyframe_interpolation.linear": "Linear",
|
"action.keyframe_interpolation.linear": "Linear",
|
||||||
"action.keyframe_interpolation.catmullrom": "Smooth",
|
"action.keyframe_interpolation.catmullrom": "Smooth",
|
||||||
|
"action.keyframe_interpolation.bezier": "Bézier",
|
||||||
"action.keyframe_interpolation.step": "Step",
|
"action.keyframe_interpolation.step": "Step",
|
||||||
"action.keyframe_uniform": "Uniform Scaling",
|
"action.keyframe_uniform": "Uniform Scaling",
|
||||||
"action.keyframe_uniform.desc": "Enable uniform scaling on the selected keyframes",
|
"action.keyframe_uniform.desc": "Enable uniform scaling on the selected keyframes",
|
||||||
|
"action.keyframe_bezier_linked": "Link Bézier Handles",
|
||||||
|
"action.keyframe_bezier_linked.desc": "Connect the right and left keyframe bezier handle",
|
||||||
"action.change_keyframe_file": "Select Keyframe File",
|
"action.change_keyframe_file": "Select Keyframe File",
|
||||||
"action.change_keyframe_file.desc": "Select an audio or particle file to preview an effect",
|
"action.change_keyframe_file.desc": "Select an audio or particle file to preview an effect",
|
||||||
"action.reset_keyframe": "Reset Keyframe",
|
"action.reset_keyframe": "Reset Keyframe",
|
||||||
|
Loading…
Reference in New Issue
Block a user