Learn · Developers

How to speed ramp a video with Python and the Shotstack API

Contents

A speed ramp changes a clip’s playback speed gradually instead of cutting between speeds. The classic shape plays at normal speed, eases into slow motion for the key moment, then speeds back up. Sports highlights, product reveals and the velocity edits on short-form apps all use it.

The hard part is timing. Slow motion stretches the moment, so it lands later in the video than it sits in the source footage.

This article gives you a Python script that plans the speed changes and the trim, so a moment you choose plays in slow motion at the second you choose. It renders with Shotstack’s video editing API. We checked the timing frame by frame on rendered files.

TL;DR

  • A video asset’s speed takes an array of tweens, so one clip can play at several rates.
  • A ramp plays the average of its two rates. From that, the script works out how much source has played at any output second.
  • Set trim so the moment arrives exactly halfway through the slow section.
  • In our frame-coded test, the planned source frame played on the planned output frame with linear and easeInOut ramps.
  • Slow motion only shows the frames the source has. At 0.4x, 25 fps footage gives about 10 new frames a second.

What is a speed ramp?

A speed ramp is a gradual change in playback speed within one shot. Instead of a hard cut from normal speed to slow motion, the speed eases down over a fraction of a second, holds, then eases back up.

For one highlight, the ramp has five parts. This is the plan the example uses for a BMX jump:

PartSpeed
Lead-in1x (normal) for 0.5 s
Ramp down1x to 0.4x over 0.5 s
Hold0.4x for 1.5 s, centered on the top of the jump
Ramp up0.4x to 2x over 0.5 s
Tail2x until the footage ends

How speed works in the Edit API

In the Edit API, a clip keeps the length you set. The video asset’s speed decides how much of the source plays in that time, starting from trim. A single number plays the whole clip at one rate, as in our guide to speeding up and slowing down video.

An array of tweens changes the rate over time. Each tween has from and to rates, a start and length in seconds, and optional easing. The speed ramps section of the animations guide covers the rules, such as what plays before the first tween and after the last.

Plan a ramp around one moment

Two facts make the timing predictable:

  1. A constant rate r for d seconds plays r × d seconds of source.
  2. A ramp from rate a to rate b over d seconds plays d × (a + b) / 2 seconds of source, the average of its two rates.

With the example’s numbers, the 0.5 s lead-in plays 0.5 s of source. The ramp down plays 0.5 × (1 + 0.4) / 2 = 0.35 s, and the first half of the hold plays 0.4 × 0.75 = 0.3 s. That makes 1.15 s of source before the middle of the hold.

The top of the jump sits at 1.2 s in the source. To have it play at 1.75 s in the output, the clip must start 1.2 − 1.15 = 0.05 s into the source. That is the trim.

The same sums after the moment give the clip length, which ends on the footage’s last frames.

The second fact is exact for linear ramps. In our test renders, easeInOut ramps landed on the same frames as linear ones. An uneven easing such as easeIn does not: it put the moment one frame late.

Speed ramp a video with Python

The script uses only the Python standard library. Set SHOTSTACK_API_KEY to the API key from your Shotstack dashboard, then run it with Python 3.

Python
import json
import os
import sys
import time
import urllib.error
import urllib.request

API = "https://api.shotstack.io/edit/v1/render"
SOURCE = "https://shotstack-assets.s3.ap-southeast-2.amazonaws.com/footage/bmx.mp4"
SOURCE_LENGTH = 5.68  # seconds, from the Probe endpoint
FPS = 25  # output frame rate, the same as the source
MOMENT = 1.2  # source second to show in slow motion: the top of the jump
AT = 1.75  # output second where that moment should play
BEFORE, SLOW, AFTER = 1.0, 0.4, 2.0  # playback rates: 1 is normal speed
RAMP = 0.5  # seconds each speed change takes
HOLD = 1.5  # output seconds at the slow rate, centered on the moment
EASE = {"interpolation": "bezier", "easing": "easeInOut"}


def plan(moment, at, before, slow, after, ramp, hold, source_length, fps):
    """Return trim, clip length and speed tweens that put `moment` at `at`."""
    down = at - hold / 2 - ramp  # output second where the ramp down starts
    if down < 0:
        raise ValueError("AT is too early for this RAMP and HOLD")
    # A ramp plays the average of its two rates. That is exact for linear
    # ramps and for symmetric easings such as easeInOut.
    played = before * down + ramp * (before + slow) / 2 + slow * hold / 2
    trim = round(moment - played, 3)
    if trim < 0:
        raise ValueError(f"MOMENT is {-trim:.2f}s too early: lower AT or BEFORE")
    up = at + hold / 2  # output second where the ramp up starts
    used = trim + played + slow * hold / 2 + ramp * (slow + after) / 2
    if used > source_length:
        raise ValueError("the source ends before the ramp up finishes")
    tail = (source_length - used) / after
    length = int((up + ramp + tail) * fps) / fps  # whole frames, no frozen end
    tweens = [
        {"from": before, "to": before, "start": 0, "length": down},
        {"from": before, "to": slow, "start": down, "length": ramp, **EASE},
        {"from": slow, "to": slow, "start": down + ramp, "length": hold},
        {"from": slow, "to": after, "start": up, "length": ramp, **EASE},
    ]
    for tween in tweens:
        tween["start"], tween["length"] = (
            round(tween["start"], 3),
            round(tween["length"], 3),
        )
    return trim, length, tweens


def build_edit(trim, length, tweens):
    asset = {"type": "video", "src": SOURCE, "trim": trim, "speed": tweens}
    return {
        "timeline": {
            "tracks": [{"clips": [{"asset": asset, "start": 0, "length": length}]}]
        },
        "output": {"format": "mp4", "resolution": "hd", "fps": FPS},
    }


def call(url, body=None):
    data = json.dumps(body).encode() if body else None
    headers = {
        "x-api-key": os.environ["SHOTSTACK_API_KEY"],
        "Content-Type": "application/json",
    }
    try:
        with urllib.request.urlopen(
            urllib.request.Request(url, data, headers)
        ) as response:
            return json.load(response)["response"]
    except urllib.error.HTTPError as error:
        sys.exit(f"HTTP {error.code}: {error.read().decode()[:300]}")


if __name__ == "__main__":
    trim, length, tweens = plan(
        MOMENT, AT, BEFORE, SLOW, AFTER, RAMP, HOLD, SOURCE_LENGTH, FPS
    )
    edit = build_edit(trim, length, tweens)
    if "--dry-run" in sys.argv:
        print(json.dumps(edit, indent=2))
        sys.exit()
    if not os.environ.get("SHOTSTACK_API_KEY"):
        sys.exit("Set SHOTSTACK_API_KEY first.")
    print(f"trim {trim}s, length {length}s")
    render_id = call(API, edit)["id"]
    print("Render queued:", render_id)
    while True:
        time.sleep(5)
        status = call(f"{API}/{render_id}")
        if status["status"] == "done":
            print(status["url"])
            break
        if status["status"] == "failed":
            sys.exit(f"Render failed: {status.get('error')}")

Run python speed_ramp.py. It prints the planned trim and length, then the URL of the rendered video:

Text
trim 0.05s, length 4.76s
Render queued: 93ac0611-6184-4eb7-b7de-b3fc6cc6293d
https://shotstack-api-v1-output.s3-ap-southeast-2.amazonaws.com/35tqpmb0ya/93ac0611-6184-4eb7-b7de-b3fc6cc6293d.mp4

This is the rendered video. The rider leaves the ramp at normal speed, floats through the top of the jump at 0.4x, then rides away at 2x:

Run python speed_ramp.py --dry-run to print the edit without rendering. This is the speed array it builds:

JSON
[
  {
    "from": 1.0,
    "to": 1.0,
    "start": 0,
    "length": 0.5
  },
  {
    "from": 1.0,
    "to": 0.4,
    "start": 0.5,
    "length": 0.5,
    "interpolation": "bezier",
    "easing": "easeInOut"
  },
  {
    "from": 0.4,
    "to": 0.4,
    "start": 1.0,
    "length": 1.5
  },
  {
    "from": 0.4,
    "to": 2.0,
    "start": 2.5,
    "length": 0.5,
    "interpolation": "bezier",
    "easing": "easeInOut"
  }
]

How the script works

  • plan() turns the moment into output times. It finds where the ramp down must start (down), adds up the source each part plays, and sets trim so the moment arrives at AT.
  • It raises an error when the plan can’t work: AT too early for the ramp and hold, a moment too close to the start of the source, or footage that ends before the ramp up finishes.
  • The clip length is rounded down to whole frames. When a clip outlasts its source, the last frame holds on screen, so ending a frame early avoids a frozen tail.
  • The constant parts are tweens with the same from and to. Every part of the plan stays visible in the JSON when you tune it.
  • build_edit() puts the clip on one track and renders at 25 fps, the source’s frame rate.

What the rendered frames show

To check the timing, we made a 12 second test clip at 25 fps whose frames carry their own number as a row of black and white blocks. We planned a ramp that puts source frame 125 (5.0 s) at 4.0 s in the output, rendered it, and read the number back from every output frame.

Ramp easingSource frame at 4.0 s (planned: 125)Largest gap from the plan
easeInOut (the script’s default)125under 1 frame
Linear125under 1 frame
easeInOut, clip placed 1 s into the timeline125under 1 frame
easeIn1262 frames
  • Tween start times count from the clip’s own start. Moving the clip 1 s along the timeline didn’t change which frames played.
  • During the 0.4x hold, the 25 fps source showed 16 different frames in 1.5 seconds, about 10 a second. Each stayed on screen for 2 or 3 output frames.
  • The last output frame was source frame 297 of 299, so the rounded length ended just before the footage did.

Tune the ramp

  • Slow rate and frame rate. Slow motion repeats frames the source already has, which looks steppy on fast motion. For smoother slow motion, use 60 fps footage and set FPS = 60, as the animations guide recommends.
  • Ramp length. RAMP sets how sudden the change feels. At 0.5 s, each ramp spans about 12 output frames at 25 fps.
  • Speed after the moment. AFTER = 2 wins back the time the slow motion used. The example turns 5.68 s of footage into a 4.76 s clip.
  • Audio. Sound follows the speed, so its pitch changes with every ramp. The BMX clip has no sound. For footage with sound, set the video asset’s volume to 0 and put music or a voice-over on another track.

Land every highlight on cue with Shotstack

You now have a script that turns one timestamp into a speed ramp. It plans the speed tweens, sets the trim so the moment lands where you want it, and renders the clip with the Edit API. Feed it the goals, jumps or product reveals in your own footage, and each one plays in slow motion right on cue.

Create a free Shotstack account to get your API key and render your first speed ramp.

Frequently asked questions (FAQs)

How do I find the timestamp of the moment?

Scrub the video in any player to find the rough second, then pull frames around it to pick the exact one. Our guide to extracting frames from a video does that with the Edit API. The Probe endpoint returns the frame rate, so you can turn a frame number into seconds.

Can I slow the moment down to a freeze frame?

Yes: a speed of 0 pauses playback on the current frame. In plan() terms, a hold at 0 plays no source, so the moment stays on screen for the whole hold. A ramp from 1x down to 0 plays half its length in source.

Can one clip have more than one ramp?

Yes, add more tweens to the speed array. Each slow section pushes later moments further back in the output, so add up the source each part plays, in order, as plan() does. For two highlights far apart in the footage, two clips with their own trim and ramp are easier to time.

Keep reading

Start free

  • Unlimited developer sandbox
  • No credit card required