(—) 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.