AEJoy —— 使用 js 脚本创建非平滑抖动动画

255 阅读3分钟

前言

有时你并不想让所有事情都一帆风顺。这里有一个国外大佬 Zack 分享的用来创建自动 “略带蹒跚” 关键帧的 javascript (ExtendScript)小脚本。

它可以给 “太完美” 的关键帧一种非常自然的感觉。看起来打字效果也很不错。平滑抖动动画(脚本)

效果图

如紫色进度条所示:

6094a6f3c0125683c51a32a7_kODdw3vnDefSNqw5jbtliMtbYlp3wKJR0EE1c3ra26492.gif

完整脚本代码

/**
 * Takes a pair of keyframes and adds extra randomly stumbling, staggering keyframes between them.
 *
 * Helpful for making realistic progress bars, and probably not much else!
 *
 * Known Issues:
 *  - Sometimes, Bezier keyframes overshoot a lot causing reverse animation
 *  - The same speed/influence easing is used for all dimensions of a keyfrrame
 *
 * @author Zack Lovatt <zack@zacklovatt.com>
 * @version 0.1.3
 */
(function keyStumbler(thisObj) {
    var NUM_KEYS = 20;
    var MIN_GAP_FRAMES = 2;

    // The chance to have certain types of animation (out of 100)
    var HOLD_CHANCE_PERCENT = 5;
    var BEZIER_CHANCE_PERCENT = 50;

    // Only if IN and OUT are both bezier will these come into play:
    var AUTOBEZIER_CHANCE_PERCENT = 33;
    var CONTINUOUS_CHANCE_PERCENT = 33;

    /**
     * Draws UI
     *
     * @returns {Window} Created window
     */
    function createUI() {
        var win =
            thisObj instanceof Panel
                ? thisObj
                : new Window("palette", "Key Stumbler", undefined, {
                    resizeable: true,
                });
        win.alignChildren = ["fill", "top"];
        win.minimumSize = [50, 80];

        var pnlOptions = win.add("panel", undefined, "Options");
        pnlOptions.alignChildren = ["left", "top"];

        var grpNumKeys = pnlOptions.add("group");
        grpNumKeys.alignment = ["fill", "fill"];
        grpNumKeys.alignChildren = ["fill", "top"];

        var stNumKeys = grpNumKeys.add(
            "statictext",
            undefined,
            "Number of Keys to Create:"
        );
        var etNumKeys = grpNumKeys.add("edittext", undefined, "20");
        stNumKeys.helpTip = etNumKeys.helpTip =
            "How many keyframes should we make between your selected keys?";

        var grpGap = pnlOptions.add("group");
        grpGap.alignment = ["fill", "fill"];
        grpGap.alignChildren = ["fill", "top"];

        var stGap = grpGap.add(
            "statictext",
            undefined,
            "Minimum # Frames between Keys:"
        );
        var etGap = grpGap.add("edittext", undefined, "2");
        etGap.characters = 6;
        stGap.helpTip = etGap.helpTip =
            "What's the minimum number of frames between keyframes?";

        var pnlInterpolations = win.add("panel");
        pnlInterpolations.alignChildren = ["left", "top"];
        pnlInterpolations.add(
            "statictext",
            undefined,
            "Chance of keyframes being HOLD or BEZIER:"
        );

        var grpHoldChance = pnlInterpolations.add("group");
        grpHoldChance.alignment = ["fill", "fill"];
        grpHoldChance.alignChildren = ["fill", "top"];

        var stHoldChance = grpHoldChance.add(
            "statictext",
            undefined,
            "Hold Chance %:"
        );
        var etHoldChance = grpHoldChance.add("edittext", undefined, "5");
        stHoldChance.helpTip = etHoldChance.helpTip =
            "% chance for a keyframe to be HOLD";

        var grpBezierChance = pnlInterpolations.add("group");
        grpBezierChance.alignment = ["fill", "fill"];
        grpBezierChance.alignChildren = ["fill", "top"];

        var stBezierChance = grpBezierChance.add(
            "statictext",
            undefined,
            "Bezier Chance %:"
        );
        var etBezierChance = grpBezierChance.add("edittext", undefined, "50");
        stBezierChance.helpTip = etBezierChance.helpTip =
            "% chance for a keyframe to be BEZIER";

        var pnlBezierMode = win.add("panel");
        pnlBezierMode.alignChildren = ["left", "top"];
        pnlBezierMode.add(
            "statictext",
            undefined,
            "Chance of BEZIER keyframes being Autobezier or Continuous:"
        );

        var grpAutobezChance = pnlBezierMode.add("group");
        grpAutobezChance.alignment = ["fill", "fill"];
        grpAutobezChance.alignChildren = ["fill", "top"];

        var stAutobezChance = grpAutobezChance.add(
            "statictext",
            undefined,
            "Autobezier Chance %:"
        );
        var etAutobezChance = grpAutobezChance.add("edittext", undefined, "33");
        stAutobezChance.helpTip = etAutobezChance.helpTip =
            "% chance for a BEZIER keyframe to be AUTOBEZIER";

        var grpContChance = pnlBezierMode.add("group");
        grpContChance.alignment = ["fill", "fill"];
        grpContChance.alignChildren = ["fill", "top"];

        var stContChance = grpContChance.add(
            "statictext",
            undefined,
            "Continuous Chance %:"
        );
        var etContChance = grpContChance.add("edittext", undefined, "33");
        stContChance.helpTip = etContChance.helpTip =
            "% chance for a BEZIER keyframe to be CONTINUOUS";

        var grpBtns = win.add("group");
        grpBtns.orientation = "row";
        grpBtns.alignChildren = ["left", "top"];

        var btnStumble = grpBtns.add("button", undefined, "Stumble!");
        btnStumble.onClick = function () {
            var numKeysInput = parseInt(etNumKeys.text, 10);
            if (isNaN(numKeysInput)) {
                throw new Error("Enter a valid Number of Keys!");
            }

            var gapInput = parseInt(etGap.text, 10);
            if (isNaN(gapInput)) {
                throw new Error("Enter a valid Gap Frame Amount!");
            }

            // The chance to have certain types of animation (out of 100)
            var holdChanceInput = parseFloat(etHoldChance.text);
            if (isNaN(holdChanceInput)) {
                throw new Error("Enter a valid Hold Chance!");
            }

            var bezierChanceInput = parseFloat(etBezierChance.text);
            if (isNaN(bezierChanceInput)) {
                throw new Error("Enter a valid Bezier Chance!");
            }

            // Only if IN and OUT are both bezier will these come into play:
            var autobezChanceInput = parseFloat(etAutobezChance.text);
            if (isNaN(autobezChanceInput)) {
                throw new Error("Enter a valid Autobezier Chance!");
            }

            var contChanceInput = parseFloat(etContChance.text);
            if (isNaN(contChanceInput)) {
                throw new Error("Enter a valid Continuous Chance!");
            }

            NUM_KEYS = numKeysInput;
            MIN_GAP_FRAMES = gapInput;

            // The chance to have certain types of animation (out of 100)
            HOLD_CHANCE_PERCENT = holdChanceInput;
            BEZIER_CHANCE_PERCENT = bezierChanceInput;

            // Only if IN and OUT are both bezier will these come into play:
            AUTOBEZIER_CHANCE_PERCENT = autobezChanceInput;
            CONTINUOUS_CHANCE_PERCENT = contChanceInput;

            stumble();
        };

        win.layout.layout();

        win.onResizing = win.onResize = function () {
            this.layout.resize();
        };
        return win;
    }

    /**
     * Linear interpolation
     *
     * @param {number} t        Input
     * @param {number} tMin     Input min
     * @param {number} tMax     Input max
     * @param {number} valueMin Output min
     * @param {number} valueMax Output max
     * @returns {number}        Interpolated number
     */
    function _lerp(t, tMin, tMax, valueMin, valueMax) {
        return ((t - tMin) * (valueMax - valueMin)) / (tMax - tMin) + valueMin;
    }

    /**
     * Converts frame count to time.
     *
     * Pulled from aequery.
     *
     * @param  {number} frames    Frame count to convert
     * @param  {number} frameRate FPS to convert with
     * @return {number}           Frame count in time
     */
    function _framesToTime(frames, frameRate) {
        return frames / frameRate;
    }

    /**
     * Converts time to frame count.
     *
     * Pulled from aequery.
     *
     * @param  {number} time      Time to convert
     * @param  {number} frameRate FPS to convert with
     * @return {number}           Time in frames
     */
    function _timeToFrames(time, frameRate) {
        return time * frameRate;
    }

    /**
     * Checks whether a property is able to be stumbled
     *
     * @param {Property} prop Property to check
     * @returns {boolean}     Whether property is supported
     */
    function _isSupportedProperty(prop) {
        var supportedTypes = [
            PropertyValueType.COLOR.toString(),
            PropertyValueType.OneD.toString(),
            PropertyValueType.ThreeD.toString(),
            PropertyValueType.ThreeD_SPATIAL.toString(),
            PropertyValueType.TwoD.toString(),
            PropertyValueType.TwoD_SPATIAL.toString(),
        ].join("|");

        return supportedTypes.indexOf(prop.propertyValueType.toString()) > -1;
    }

    /**
     * Generates keyframe times from random sequence between existing keyframes
     *
     * @param {number[]} timeDeltas Array of time offsets
     * @param {object} timeData     Required info to generate times
     * @returns {number[]}          Randomized times
     */
    function _generateKeyTimes(timeDeltas, timeData) {
        var startTime = timeData.startTime;
        var endTime = timeData.endTime;
        var minGap = timeData.minGap;
        var frameRate = timeData.frameRate;

        var keyTimes = [];

        for (var ii = 0, il = timeDeltas.length; ii < il; ii++) {
            var timeDelta = timeDeltas[ii];

            var mappedTime = _lerp(
                timeDelta,
                timeDeltas[0],
                timeDeltas[timeDeltas.length - 1],
                startTime,
                endTime
            );

            var lastTime = startTime;
            if (keyTimes.length > 0) {
                lastTime = keyTimes[ii - 1];
            }

            var mappedInFrames = Math.floor(_timeToFrames(mappedTime, frameRate));
            var lastInFrames = Math.floor(_timeToFrames(lastTime, frameRate));
            var frameDelta = mappedInFrames - lastInFrames;

            if (frameDelta < minGap) {
                mappedInFrames = lastInFrames + minGap;
            }

            var mappedInSeconds = _framesToTime(mappedInFrames, frameRate);
            keyTimes.push(mappedInSeconds);
        }

        return keyTimes;
    }

    /**
     * Generates random keyframe values at provided times
     *
     * @param {number[]} valueDeltas Array of value offsets
     * @param {object} valueData     Required info to generate values
     * @returns {number[]}           Randomized values
     */
    function _generateKeyValues(valueDeltas, valueData) {
        var startValue = valueData.startValue;
        var endValue = valueData.endValue;
        var keyValues = [];

        for (var ii = 0, il = valueDeltas.length; ii < il; ii++) {
            var valueDelta = valueDeltas[ii];

            var mappedValue = _lerp(
                valueDelta,
                valueDeltas[0],
                valueDeltas[valueDeltas.length - 1],
                startValue,
                endValue
            );

            keyValues.push(mappedValue);
        }

        return keyValues;
    }

    /**
     * Generates X amount of incremental random numbers
     *
     * @param {number} numValues Number of values to generate
     * @returns {number[]}       Generated number sequence
     */
    function _generateIncrementalRandomNumbers(numValues) {
        var randomValues = [];
        var sum = 0;

        for (var ii = 0; ii <= numValues; ii++) {
            var num = generateRandomNumber();

            sum += num;
            randomValues.push(sum);
        }

        return randomValues;
    }

    /**
     * Randomly picks an interpolation method for keyframes
     *
     * @param {object} interpolationTypeChances Object of interpolation chance data
     * @returns {KeyframeInterplationType}      Chosen interpolation type
     */
    function _getInterpolationType(interpolationTypeChances) {
        var interpolationNumber = generateRandomNumber();

        var holdWeight = interpolationTypeChances.hold / 100;
        var bezierWeight = interpolationTypeChances.bezier / 100;

        if (interpolationNumber <= holdWeight) {
            return KeyframeInterpolationType.HOLD;
        }

        if (
            interpolationNumber > holdWeight &&
            interpolationNumber <= bezierWeight
        ) {
            return KeyframeInterpolationType.BEZIER;
        }

        return KeyframeInterpolationType.LINEAR;
    }

    /**
     * Generates a KeyfameEase array for a given property
     *
     * @todo Fix overshoot
     *
     * @param {Property} prop     Property to generate ease on
     * @param {object} keyIndices Start/end keyframe info
     * @returns {KeyframeEase[]}  Array of eases
     */
    function _generateBezierEase(prop, keyIndices) {
        var firstValue = prop.keyValue(keyIndices.start);
        var lastValue = prop.keyValue(keyIndices.end);

        var numDimensions = 1;

        if (prop.propertyValueType === PropertyValueType.OneD) {
            firstValue = [firstValue];
            lastValue = [lastValue];
        } else if (prop.propertyValueType === PropertyValueType.TwoD) {
            numDimensions = 2;
        } else if (prop.propertyValueType === PropertyValueType.ThreeD) {
            numDimensions = 3;
        }

        var ease = [];

        var animationDirection = firstValue[0] < lastValue[0] ? -1 : 1;
        var valueDelta =
            Math.abs(firstValue[0] - lastValue[0]) * animationDirection * -1;

        var speed = generateRandomNumber() * valueDelta;
        var influence = Math.max(generateRandomNumber() * 100, 0.1);

        for (var ii = 0, il = numDimensions; ii < il; ii++) {
            var dimensionEase = new KeyframeEase(speed, influence);
            ease.push(dimensionEase);
        }

        return ease;
    }

    /**
     * Sets eases for keyframes
     *
     * @param {Property} prop     Property to set eases on
     * @param {object} keyIndices Start/end keyframe info
     * @param {object} chances    Collection of probability values
     */
    function _setKeyEases(prop, keyIndices, chances) {
        var interpolationTypeChances = {
            hold: chances.hold,
            bezier: chances.bezier,
        };
        var autobezierChance = chances.autobezier;
        var continuousChance = chances.continuous;

        var startIndex = keyIndices.start;
        var endIndex = keyIndices.end;

        for (var ii = startIndex + 1; ii <= endIndex - 1; ii++) {
            var inType = _getInterpolationType(interpolationTypeChances);
            var outType = _getInterpolationType(interpolationTypeChances);

            var inEase = prop.keyInTemporalEase(ii);
            var outEase = prop.keyOutTemporalEase(ii);

            prop.setInterpolationTypeAtKey(ii, inType, outType);

            if (inType === KeyframeInterpolationType.BEZIER) {
                inEase = _generateBezierEase(prop, keyIndices);
            }

            if (outType === KeyframeInterpolationType.BEZIER) {
                outEase = _generateBezierEase(prop, keyIndices);
            }

            if (
                inType === KeyframeInterpolationType.BEZIER ||
                outType === KeyframeInterpolationType.BEZIER
            ) {
                prop.setTemporalEaseAtKey(ii, inEase, outEase);
            }

            if (
                inType === KeyframeInterpolationType.BEZIER &&
                outType === KeyframeInterpolationType.BEZIER
            ) {
                var temporalMode = generateRandomNumber();

                var autoBezierWeight = autobezierChance / 100;
                var continousWeight = continuousChance / 100;

                if (temporalMode <= autoBezierWeight) {
                    prop.setTemporalAutoBezierAtKey(ii, true);
                } else if (
                    temporalMode > autoBezierWeight &&
                    temporalMode <= continousWeight
                ) {
                    prop.setTemporalContinuousAtKey(ii, true);
                }
            }
        }
    }

    /**
     * Generates stumbled keyframes on the selected property
     *
     * @param {Property} prop  Property to generate keyframes on
     * @param {object} options Options about how to generate them
     * @param {object} chances Chances of different behaviours happening
     */
    function _generateStumbledKeys(prop, options, chances) {
        var selectedKeys = prop.selectedKeys;

        if (selectedKeys.length !== 2) {
            throw new Error("Select only 2 keyframes!");
        }

        var keyIndices = {
            start: selectedKeys[0],
            end: selectedKeys[1],
        };

        if (keyIndices.end - keyIndices.start !== 1) {
            throw new Error("Keyframes must be a sequence!");
        }

        var numKeys = options.numKeys;
        var minGap = options.minGap;

        var startTime = prop.keyTime(keyIndices.start);
        var endTime = prop.keyTime(keyIndices.end);
        var comp = prop.propertyGroup(prop.propertyDepth).containingComp;
        var frameRate = comp.frameRate;

        var spanFrameDuration = _timeToFrames(endTime - startTime, frameRate);
        var requiredFrameDuration = numKeys * minGap;

        if (requiredFrameDuration > spanFrameDuration) {
            throw new Error(
                "Not enough time between selected keys to create " +
                numKeys +
                " frames! Try extending span."
            );
        }

        var randomTimes = _generateIncrementalRandomNumbers(numKeys);
        var keyTimes = _generateKeyTimes(randomTimes, {
            startTime: startTime,
            endTime: endTime,
            minGap: minGap,
            frameRate: frameRate,
        });

        var whileCounter = 0;
        var whileLimit = 10;

        // Make sure our keyframes are only within the range
        while (
            whileCounter < whileLimit &&
            keyTimes[keyTimes.length - 1] > endTime
        ) {
            whileCounter++;

            randomTimes = _generateIncrementalRandomNumbers(numKeys);
            keyTimes = _generateKeyTimes(randomTimes, {
                startTime: startTime,
                endTime: endTime,
                minGap: minGap,
                frameRate: frameRate,
            });
        }

        if (whileCounter == whileLimit) {
            throw new Error(
                "Not enough time between selected keys to create " +
                numKeys +
                " frames! Try extending span."
            );
        }

        var randomValues = _generateIncrementalRandomNumbers(numKeys);
        var keyValues = _generateKeyValues(randomValues, {
            startValue: prop.keyValue(keyIndices.start),
            endValue: prop.keyValue(keyIndices.end),
        });

        prop.setValuesAtTimes(keyTimes, keyValues);

        keyIndices.end += options.numKeys;

        _setKeyEases(prop, keyIndices, chances);
    }

    function stumble() {
        var options = {
            numKeys: NUM_KEYS,
            minGap: MIN_GAP_FRAMES,
        };

        var interpolationChanceData = {
            hold: HOLD_CHANCE_PERCENT,
            bezier: BEZIER_CHANCE_PERCENT,
            autobezier: AUTOBEZIER_CHANCE_PERCENT,
            continuous: CONTINUOUS_CHANCE_PERCENT,
        };

        app.beginUndoGroup("Stumble Selected Keyframes");

        try {
            var comp = app.project.activeItem;

            if (!(comp && comp instanceof CompItem)) {
                throw new Error("Select an animated property!");
            }

            var selectedProps = comp.selectedProperties;

            for (var ii = 0, il = selectedProps.length; ii < il; ii++) {
                var prop = selectedProps[ii];

                if (!prop.canVaryOverTime) {
                    continue;
                }

                if (!_isSupportedProperty(prop)) {
                    throw new Error("Property " + prop.name + " is not supported!");
                }

                _generateStumbledKeys(prop, options, interpolationChanceData);
            }
        } catch (e) {
            alert(e);
        } finally {
            app.endUndoGroup();
        }
    }

    var ui = createUI();

    if (ui instanceof Window) {
        ui.show();
    } else {
        ui.layout.layout(true);
    }
})(this);