Last active
September 1, 2026 14:39
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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