(—) Encode

Encode for smooth scrubbing.

Ordinary exports keep a keyframe every few seconds, so every seek has to decode from far back and the picture jumps. This script fixes that in one line.

./flythrough-encode.sh input.mp4 --mobile
./flythrough-encode.sh input.mp4 --mobile

Writes input.mp4 with a keyframe every 6 frames, a 720 × 1280 phone version and a poster. Save the script below as flythrough-encode.sh and run chmod +x flythrough-encode.sh once. Needs ffmpeg (brew install ffmpeg).

The script

#!/usr/bin/env bash
# flythrough-encode.sh - encode a clip for smooth scroll scrubbing (Flythrough for Framer).
# Free to use and share. Needs ffmpeg + ffprobe (brew install ffmpeg).
#
#   ./flythrough-encode.sh input.mp4 [options]
#   ./flythrough-encode.sh "frames/%04d.png" --name approach --mobile-src "frames-portrait/%04d.png"
#
# Options
#   --name NAME        output base name (default: input file name)
#   --gop N            keyframe every N frames (default 6). A seek decodes at most N-1 extra frames.
#   --max-smooth       every frame a keyframe (-g 1): smoothest seeking, about 2.4–4× the size
#   --fps N            frame rate (default 30)
#   --width N          desktop width (default 1920); height follows the source aspect
#   --mobile           also write NAME-mobile.mp4: 720x1280 portrait, centre-cropped from the input
#   --focus F          horizontal crop position for --mobile, 0 = left, 0.5 = centre, 1 = right
#   --mobile-src FILE  a separate portrait source for the mobile clip (better than cropping)
#   --mobile-size WxH  mobile size (default 720x1280)
#   --reverse          also write reversed clips (NAME-reverse.mp4, NAME-mobile-reverse.mp4) for Glide mode
#   --crf N            quality, lower = better and bigger (default 22)
#   --poster-at S      poster frame time in seconds (default 0)
#   --out DIR          output folder (default: current folder)
set -euo pipefail

die() { echo "error: $*" >&2; exit 1; }
command -v ffmpeg >/dev/null || die "ffmpeg not found (brew install ffmpeg)"

[ $# -ge 1 ] || { sed -n "2,22p" "$0"; exit 1; }
IN="$1"; shift
NAME=""; GOP=6; FPS=30; WIDTH=1920; MOBILE=0; FOCUS=0.5; MSRC=""; MSIZE="720x1280"
REVERSE=0; CRF=22; POSTER=0; OUT="."
# flags that take a value fail clearly when the value is missing
val() { [ $# -ge 2 ] && [ -n "$2" ] && [ "${2#--}" = "$2" ] || die "$1 needs a value"; }
while [ $# -gt 0 ]; do
  case "$1" in
    --name) val "$@"; NAME="$2"; shift 2;;
    --gop) val "$@"; GOP="$2"; shift 2;;
    --max-smooth) GOP=1; shift;;
    --fps) val "$@"; FPS="$2"; shift 2;;
    --width) val "$@"; WIDTH="$2"; shift 2;;
    --mobile) MOBILE=1; shift;;
    --focus) val "$@"; FOCUS="$2"; shift 2;;
    --mobile-src) val "$@"; MSRC="$2"; MOBILE=1; shift 2;;
    --mobile-size) val "$@"; MSIZE="$2"; shift 2;;
    --reverse) REVERSE=1; shift;;
    --crf) val "$@"; CRF="$2"; shift 2;;
    --poster-at) val "$@"; POSTER="$2"; shift 2;;
    --out) val "$@"; OUT="$2"; shift 2;;
    *) die "unknown option $1";;
  esac
done
[ -n "$NAME" ] || NAME="$(basename "${IN%.*}")"
mkdir -p "$OUT"
MW="${MSIZE%x*}"; MH="${MSIZE#*x}"

# arrays keep paths with spaces intact; image sequences (frames/%04d.png) need an input frame rate
input_args() { case "$1" in *%*) IA=(-framerate "$FPS" -i "$1");; *) IA=(-i "$1");; esac; }

X264=(-an -c:v libx264 -pix_fmt yuv420p -profile:v high -preset slow -crf "$CRF"
  -g "$GOP" -keyint_min "$GOP" -sc_threshold 0 -bf 0 -r "$FPS" -movflags +faststart)

enc() { # enc <input> <filter> <output>
  input_args "$1"
  ffmpeg -hide_banner -loglevel error -y "${IA[@]}" -vf "$2" "${X264[@]}" "$3"
}

DESK="$OUT/$NAME.mp4"
enc "$IN" "scale=$WIDTH:-2:flags=lanczos" "$DESK"
OUTS=("$DESK")

if [ "$MOBILE" = 1 ]; then
  MOB="$OUT/$NAME-mobile.mp4"
  if [ -n "$MSRC" ]; then
    enc "$MSRC" "scale=$MW:$MH:force_original_aspect_ratio=increase:flags=lanczos,crop=$MW:$MH" "$MOB"
  else
    enc "$IN" "scale=-2:$MH:flags=lanczos,crop=$MW:$MH:(in_w-$MW)*$FOCUS:0" "$MOB"
  fi
  OUTS+=("$MOB")
fi

if [ "$REVERSE" = 1 ]; then
  for f in "${OUTS[@]}"; do
    r="${f%.mp4}-reverse.mp4"
    ffmpeg -hide_banner -loglevel error -y -i "$f" -vf reverse "${X264[@]}" "$r"
    OUTS+=("$r")
  done
fi

ffmpeg -hide_banner -loglevel error -y -ss "$POSTER" -i "$DESK" -frames:v 1 -q:v 3 "$OUT/$NAME-poster.jpg"
[ "$MOBILE" = 1 ] && ffmpeg -hide_banner -loglevel error -y -ss "$POSTER" -i "$OUT/$NAME-mobile.mp4" -frames:v 1 -q:v 3 "$OUT/$NAME-mobile-poster.jpg"

size() { stat -f %z "$1" 2>/dev/null || stat -c %s "$1"; }
echo "keyframe every $GOP frames, crf $CRF, $FPS fps"
for f in "${OUTS[@]}"; do
  b=$(size "$f"); mb=$(awk "BEGIN{printf \"%.2f\", $b/1048576}")
  lim=12; case "$f" in *-mobile*) lim=6;; esac
  warn=""; awk "BEGIN{exit !($b/1048576 > $lim)}" && warn="  <- over $lim MB, try --crf 26 or a shorter clip"
  echo "  $f  $mb MB$warn"
done
echo "  $OUT/$NAME-poster.jpg"
[ "$MOBILE" = 1 ] && echo "  $OUT/$NAME-mobile-poster.jpg"
exit 0
#!/usr/bin/env bash
# flythrough-encode.sh - encode a clip for smooth scroll scrubbing (Flythrough for Framer).
# Free to use and share. Needs ffmpeg + ffprobe (brew install ffmpeg).
#
#   ./flythrough-encode.sh input.mp4 [options]
#   ./flythrough-encode.sh "frames/%04d.png" --name approach --mobile-src "frames-portrait/%04d.png"
#
# Options
#   --name NAME        output base name (default: input file name)
#   --gop N            keyframe every N frames (default 6). A seek decodes at most N-1 extra frames.
#   --max-smooth       every frame a keyframe (-g 1): smoothest seeking, about 2.4–4× the size
#   --fps N            frame rate (default 30)
#   --width N          desktop width (default 1920); height follows the source aspect
#   --mobile           also write NAME-mobile.mp4: 720x1280 portrait, centre-cropped from the input
#   --focus F          horizontal crop position for --mobile, 0 = left, 0.5 = centre, 1 = right
#   --mobile-src FILE  a separate portrait source for the mobile clip (better than cropping)
#   --mobile-size WxH  mobile size (default 720x1280)
#   --reverse          also write reversed clips (NAME-reverse.mp4, NAME-mobile-reverse.mp4) for Glide mode
#   --crf N            quality, lower = better and bigger (default 22)
#   --poster-at S      poster frame time in seconds (default 0)
#   --out DIR          output folder (default: current folder)
set -euo pipefail

die() { echo "error: $*" >&2; exit 1; }
command -v ffmpeg >/dev/null || die "ffmpeg not found (brew install ffmpeg)"

[ $# -ge 1 ] || { sed -n "2,22p" "$0"; exit 1; }
IN="$1"; shift
NAME=""; GOP=6; FPS=30; WIDTH=1920; MOBILE=0; FOCUS=0.5; MSRC=""; MSIZE="720x1280"
REVERSE=0; CRF=22; POSTER=0; OUT="."
# flags that take a value fail clearly when the value is missing
val() { [ $# -ge 2 ] && [ -n "$2" ] && [ "${2#--}" = "$2" ] || die "$1 needs a value"; }
while [ $# -gt 0 ]; do
  case "$1" in
    --name) val "$@"; NAME="$2"; shift 2;;
    --gop) val "$@"; GOP="$2"; shift 2;;
    --max-smooth) GOP=1; shift;;
    --fps) val "$@"; FPS="$2"; shift 2;;
    --width) val "$@"; WIDTH="$2"; shift 2;;
    --mobile) MOBILE=1; shift;;
    --focus) val "$@"; FOCUS="$2"; shift 2;;
    --mobile-src) val "$@"; MSRC="$2"; MOBILE=1; shift 2;;
    --mobile-size) val "$@"; MSIZE="$2"; shift 2;;
    --reverse) REVERSE=1; shift;;
    --crf) val "$@"; CRF="$2"; shift 2;;
    --poster-at) val "$@"; POSTER="$2"; shift 2;;
    --out) val "$@"; OUT="$2"; shift 2;;
    *) die "unknown option $1";;
  esac
done
[ -n "$NAME" ] || NAME="$(basename "${IN%.*}")"
mkdir -p "$OUT"
MW="${MSIZE%x*}"; MH="${MSIZE#*x}"

# arrays keep paths with spaces intact; image sequences (frames/%04d.png) need an input frame rate
input_args() { case "$1" in *%*) IA=(-framerate "$FPS" -i "$1");; *) IA=(-i "$1");; esac; }

X264=(-an -c:v libx264 -pix_fmt yuv420p -profile:v high -preset slow -crf "$CRF"
  -g "$GOP" -keyint_min "$GOP" -sc_threshold 0 -bf 0 -r "$FPS" -movflags +faststart)

enc() { # enc <input> <filter> <output>
  input_args "$1"
  ffmpeg -hide_banner -loglevel error -y "${IA[@]}" -vf "$2" "${X264[@]}" "$3"
}

DESK="$OUT/$NAME.mp4"
enc "$IN" "scale=$WIDTH:-2:flags=lanczos" "$DESK"
OUTS=("$DESK")

if [ "$MOBILE" = 1 ]; then
  MOB="$OUT/$NAME-mobile.mp4"
  if [ -n "$MSRC" ]; then
    enc "$MSRC" "scale=$MW:$MH:force_original_aspect_ratio=increase:flags=lanczos,crop=$MW:$MH" "$MOB"
  else
    enc "$IN" "scale=-2:$MH:flags=lanczos,crop=$MW:$MH:(in_w-$MW)*$FOCUS:0" "$MOB"
  fi
  OUTS+=("$MOB")
fi

if [ "$REVERSE" = 1 ]; then
  for f in "${OUTS[@]}"; do
    r="${f%.mp4}-reverse.mp4"
    ffmpeg -hide_banner -loglevel error -y -i "$f" -vf reverse "${X264[@]}" "$r"
    OUTS+=("$r")
  done
fi

ffmpeg -hide_banner -loglevel error -y -ss "$POSTER" -i "$DESK" -frames:v 1 -q:v 3 "$OUT/$NAME-poster.jpg"
[ "$MOBILE" = 1 ] && ffmpeg -hide_banner -loglevel error -y -ss "$POSTER" -i "$OUT/$NAME-mobile.mp4" -frames:v 1 -q:v 3 "$OUT/$NAME-mobile-poster.jpg"

size() { stat -f %z "$1" 2>/dev/null || stat -c %s "$1"; }
echo "keyframe every $GOP frames, crf $CRF, $FPS fps"
for f in "${OUTS[@]}"; do
  b=$(size "$f"); mb=$(awk "BEGIN{printf \"%.2f\", $b/1048576}")
  lim=12; case "$f" in *-mobile*) lim=6;; esac
  warn=""; awk "BEGIN{exit !($b/1048576 > $lim)}" && warn="  <- over $lim MB, try --crf 26 or a shorter clip"
  echo "  $f  $mb MB$warn"
done
echo "  $OUT/$NAME-poster.jpg"
[ "$MOBILE" = 1 ] && echo "  $OUT/$NAME-mobile-poster.jpg"
exit 0

Flags

Flag

Flag

Default

Default

What it does

What it does

--name NAME

--name NAME

input name

input name

Base name of the outputs.

Base name of the outputs.

--out DIR

--out DIR

.

.

Output folder.

Output folder.

--gop N

--gop N

6

6

A keyframe every N frames. A seek decodes at most N − 1 extra frames.

A keyframe every N frames. A seek decodes at most N − 1 extra frames.

--max-smooth

--max-smooth

off

off

Every frame a keyframe (-g 1). Smoothest; about 2.4–4× the size of -g 6, depending on the footage.

Every frame a keyframe (-g 1). Smoothest; about 2.4–4× the size of -g 6, depending on the footage.

--fps N

--fps N

30

30

Frame rate of the output; set Flythrough’s FPS to the same value.

Frame rate of the output; set Flythrough’s FPS to the same value.

--width N

--width N

1920

1920

Desktop width; the height follows the source.

Desktop width; the height follows the source.

--mobile

--mobile

off

off

Also writes name-mobile.mp4, 720 × 1280, centre-cropped.

Also writes name-mobile.mp4, 720 × 1280, centre-cropped.

--focus F

--focus F

0.5

0.5

Crop position for --mobile, 0 = left, 1 = right.

Crop position for --mobile, 0 = left, 1 = right.

--mobile-src FILE

--mobile-src FILE

—

—

Use a separate portrait source instead of cropping.

Use a separate portrait source instead of cropping.

--mobile-size WxH

--mobile-size WxH

720x1280

720x1280

Size of the phone version.

Size of the phone version.

--reverse

--reverse

off

off

Also writes reversed clips for the experimental Glide engine.

Also writes reversed clips for the experimental Glide engine.

--crf N

--crf N

22

22

Quality: lower is better and bigger.

Quality: lower is better and bigger.

--poster-at S

--poster-at S

0

0

Poster frame time in seconds, so a dark or white first frame is not the poster.

Poster frame time in seconds, so a dark or white first frame is not the poster.

Handbrake instead

Setting

Setting

Value

Value

Video encoder

Video encoder

H.264 (x264)

H.264 (x264)

Framerate

Framerate

Same as source, Constant

Same as source, Constant

Audio

Audio

None (remove the track)

None (remove the track)

Web Optimized

Web Optimized

On

On

Additional Options

Additional Options

the line below

the line below

keyint=6:min-keyint=6:scenecut=0:bframes=0
keyint=6:min-keyint=6:scenecut=0:bframes=0

Measured on the demo clip

Encode

Encode

Size

Size

Median seek

Median seek

Keyframe Check

Keyframe Check

Ordinary export (x264 default, keyframe every 250)

Ordinary export (x264 default, keyframe every 250)

1.53 MB

24–29 ms

Re-encode

Re-encode

flythrough-encode.sh (-g 6)

flythrough-encode.sh (-g 6)

2.07 MB

7 ms

Smooth

Smooth

flythrough-encode.sh --max-smooth (-g 1)

flythrough-encode.sh --max-smooth (-g 1)

5.18 MB

7–9 ms

Smooth

Smooth

Clip 1 of this demo: 6 s, 1920 × 1080, 30 fps, crf 22. Seek = median of 8 random seeks, 3 runs, headless Chrome on an M3 Max laptop. Keyframe Check compares random seeks with seeks next to the first keyframe on the same machine (ordinary export 3.4–4.3×, -g 6 0.7–0.9×), so slower computers show the same verdict with bigger numbers.

Why keyframes matter

A video stores a full picture only at keyframes; every other frame is a list of changes from the one before. To show second 4.2, the browser has to decode forward from the last keyframe, which in an ordinary export can be seconds away. Scrolling asks for a new time on almost every frame, so those decodes pile up and the picture stutters. With a keyframe every 6 frames, each seek decodes at most 5 frames, and the video follows your scroll.

Create a free website with Framer, the website builder loved by startups, designers and agencies.