Skip to content

Instantly share code, notes, and snippets.

@fabianros
Last active September 1, 2026 14:39
Show Gist options
  • Select an option

  • Save fabianros/6f97caa8c6315eb5e865c1f43081c8ae to your computer and use it in GitHub Desktop.

Select an option

Save fabianros/6f97caa8c6315eb5e865c1f43081c8ae to your computer and use it in GitHub Desktop.
Frame a screen recording into a square, Twitter-ready clip (native res, rounded corners, smooth). macOS + ffmpeg.
#!/bin/bash
#
# frame-recording.sh — turn a screen recording into a clean, Twitter-ready clip:
# native resolution kept, square off-white frame, rounded corners, smooth playback.
#
# ── Setup (once) ───────────────────────────────────────────────────────────────
# 1. Install ffmpeg: brew install ffmpeg
# 2. Save this file, then make it runnable: chmod +x frame-recording.sh
#
# ── Use ────────────────────────────────────────────────────────────────────────
# ./frame-recording.sh ~/Desktop/rec.mov --square
# → writes rec-framed.mp4 next to it, ready to post.
#
# ── Optional: a Finder right-click "Make ready for Twitter" ────────────────────
# Automator → New Quick Action → "receives movie files in Finder" →
# add "Run Shell Script" (Shell: /bin/zsh, Pass input: as arguments), paste:
# for f in "$@"; do ~/path/to/frame-recording.sh "$f" --square; done
# Save. Now right-click any recording → Quick Actions.
#
# ── Options ────────────────────────────────────────────────────────────────────
# --square square canvas (1:1), equal margin all round
# --pad N margin around the phone, px (default ~13% of width)
# --radius N corner radius, px (default ~16% of width)
# --bg RRGGBB background colour (default f5f5f7; ffffff = pure white)
# --trim A:B keep only seconds A to B, e.g. --trim 1.5:9
# --fps N force N fps; above the source it interpolates smooth in-between frames
# --fast smaller file (keyframe/second) instead of the all-keyframe default
# --cpu encode with libx264 instead of the media engine (slower, a touch higher quality)
# --lossless mathematically lossless (huge; Twitter re-compresses anyway)
#
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" # so Finder/Automator find ffmpeg
IN=""; OUT=""
PAD=""; RADIUS=""; BG="${BG:-f5f5f7}"; TRIM=""; PHONE_H=""; SQUARE=0; WANT_FPS=""; CRF="${CRF:-12}"; INTRA=1; LOSSLESS=0; GPU=1
while [ $# -gt 0 ]; do
case "$1" in
--pad) PAD="$2"; shift 2 ;;
--radius) RADIUS="$2"; shift 2 ;;
--bg) BG="$2"; shift 2 ;;
--trim) TRIM="$2"; shift 2 ;;
--height) PHONE_H="$2"; shift 2 ;;
--square) SQUARE=1; shift ;;
--fps) WANT_FPS="$2"; shift 2 ;;
--intra) INTRA=1; shift ;;
--fast) INTRA=0; shift ;;
--gpu) GPU=1; shift ;;
--cpu) GPU=0; shift ;;
--lossless) LOSSLESS=1; shift ;;
-*) echo "Unknown option: $1"; exit 1 ;;
*) if [ -z "$IN" ]; then IN="$1"; elif [ -z "$OUT" ]; then OUT="$1"; fi; shift ;;
esac
done
[ -n "$IN" ] || { echo "Usage: frame-recording.sh in.mov [out.mp4] [--square --pad N --radius N --bg RRGGBB --trim A:B --fps N]"; exit 1; }
[ -f "$IN" ] || { echo "No such file: $IN"; exit 1; }
OUT="${OUT:-${IN%.*}-framed.mp4}"
# Source dimensions and a sane fps (recordings report quirky variable rates; clamp it).
read -r IW IH RAW < <(ffprobe -v error -select_streams v:0 -show_entries stream=width,height,avg_frame_rate -of csv=p=0 "$IN" | tr ',' ' ')
SRC_FPS=$(awk -v r="$RAW" 'BEGIN{ n=split(r,a,"/"); f=(n==2 && a[2]>0)? a[1]/a[2] : a[1]+0; if(f<=0||f>240) f=60; printf "%d", (f+0.5) }')
FPS="${WANT_FPS:-$SRC_FPS}"
# Native height by default (never downscale). Radius/pad scale with width so any resolution looks the same.
PHONE_H="${PHONE_H:-$IH}"
SW=$(( (IW * PHONE_H / IH + 1) / 2 * 2 ))
RADIUS="${RADIUS:-$(( SW * 16 / 100 ))}"
PAD="${PAD:-$(( SW * 13 / 100 ))}"
if [ "$SQUARE" = 1 ]; then
SIDE=$(( PHONE_H + 2 * PAD )); [ $(( SW + 2 * PAD )) -gt "$SIDE" ] && SIDE=$(( SW + 2 * PAD ))
CW=$SIDE; CH=$SIDE
else
CW=$(( SW + 2 * PAD )); CH=$(( PHONE_H + 2 * PAD ))
fi
OX=$(( (CW - SW) / 2 )); OY=$(( (CH - PHONE_H) / 2 ))
SCALE=""; [ "$PHONE_H" != "$IH" ] && SCALE="scale=${SW}:${PHONE_H},"
TRIM_ARGS=(); [ -n "$TRIM" ] && TRIM_ARGS=(-ss "${TRIM%%:*}" -to "${TRIM##*:}")
# A rounded-rectangle alpha mask, baked once (a per-frame geq would be far too slow).
MASK="$(mktemp -t frame-mask).png"
trap 'rm -f "$MASK"' EXIT
ffmpeg -hide_banner -loglevel error -y -f lavfi -i "color=c=black:s=${SW}x${PHONE_H}" -frames:v 1 \
-vf "format=gray,geq=lum='255*clip(${RADIUS}+0.5-hypot(max(0\,${RADIUS}-min(X\,W-1-X))\,max(0\,${RADIUS}-min(Y\,H-1-Y)))\,0\,1)'" "$MASK"
# All-keyframe by default so pausing/scrubbing is always crisp; --fast = one keyframe/second.
GOP=(-g "$FPS" -keyint_min "$FPS")
[ "$INTRA" = 1 ] && GOP=(-g 1 -x264-params scenecut=0)
# Encode on the Mac's media engine (fast, small); --cpu or --lossless fall back to libx264.
CODEC=(-c:v libx264 -preset slow -profile:v high -pix_fmt yuv420p -crf "$CRF")
[ "$LOSSLESS" = 1 ] && { GPU=0; CODEC=(-c:v libx264 -preset slow -qp 0); }
[ "$GPU" = 1 ] && [ "$LOSSLESS" = 0 ] && CODEC=(-c:v h264_videotoolbox -q:v 65 -profile:v high -pix_fmt yuv420p)
# Round the corners, then place the recording on the canvas.
if [ "$FPS" -gt "$SRC_FPS" ]; then
# --fps above the source: interpolate to a constant rate (the canvas is the clock).
FILTER="color=c=0x${BG}:s=${CW}x${CH}:r=${FPS}[bg];
[0:v]minterpolate=fps=${FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir,${SCALE}format=rgba[v];
[v][1:v]alphamerge[ph];
[bg][ph]overlay=${OX}:${OY}:shortest=1:format=auto,format=yuv420p[out]"
TIMING=(-r "$FPS")
else
# Keep every frame at its own original time. The background is drawn FROM the video (split →
# fill) so it shares the exact frame timing and the overlay never resamples — otherwise a
# smooth variable-rate recording judders once re-encoded.
FILTER="[0:v]split[base][bgsrc];
[bgsrc]crop=2:2,scale=${CW}:${CH}:flags=neighbor,drawbox=w=iw:h=ih:color=0x${BG}:t=fill,format=rgb24[bg];
[base]${SCALE}format=rgba[v];
[v][1:v]alphamerge[ph];
[bg][ph]overlay=${OX}:${OY}:format=auto,format=yuv420p[out]"
TIMING=(-fps_mode passthrough)
fi
ffmpeg -hide_banner -loglevel error -y ${TRIM_ARGS[@]+"${TRIM_ARGS[@]}"} -i "$IN" -i "$MASK" -filter_complex "$FILTER" \
-map "[out]" -an "${TIMING[@]}" "${CODEC[@]}" "${GOP[@]}" -movflags +faststart "$OUT"
echo "→ $OUT ($(du -h "$OUT" | cut -f1), ${CW}x${CH})"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment