demo/gif_to_mp4.sh28 of 28 statements covered (100.00%).
coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.
| 1 | #!/usr/bin/env bash | |
| 2 | # ───────────────────────────────────────────────────────────────────────────── | |
| 3 | # Convert a demo GIF to an MP4 for embedding outside GitHub | |
| 4 | # ───────────────────────────────────────────────────────────────────────────── | |
| 5 | # GitHub renders the committed GIFs directly, but most other surfaces | |
| 6 | # (LinkedIn, Slack, blog posts, internal wikis) treat native video far | |
| 7 | # better than GIFs — autoplay, scrubbing, and a fraction of the bytes. | |
| 8 | # This helper converts any of the demo recordings (or any GIF) into a | |
| 9 | # widely-compatible H.264 MP4: | |
| 10 | # | |
| 11 | # - yuv420p pixel format (the one every player/platform accepts) | |
| 12 | # - +faststart (moov atom up front, so streaming starts immediately) | |
| 13 | # - even-dimension scaling (H.264 requires width/height % 2 == 0; | |
| 14 | # agg output is often odd-height) | |
| 15 | # | |
| 16 | # MP4s are deliberately NOT committed to the repository — demo/*.mp4 is | |
| 17 | # gitignored, and the tracked-media policy in | |
| 18 | # .github/scripts/validate_demo_gifs.py governs GIFs only. Generate the | |
| 19 | # MP4 locally whenever you need one. | |
| 20 | # | |
| 21 | # Usage: | |
| 22 | # bash demo/gif_to_mp4.sh <input.gif> [output.mp4] | |
| 23 | # | |
| 24 | # bash demo/gif_to_mp4.sh demo/autopilot-claude-code.gif | |
| 25 | # bash demo/gif_to_mp4.sh demo/live_demo.gif /tmp/live_demo.mp4 | |
| 26 | # | |
| 27 | # Options (via environment variables): | |
| 28 | # MP4_FPS=12 Output frame rate (default: 12 — plenty for terminal | |
| 29 | # recordings, keeps files small) | |
| 30 | # | |
| 31 | # Prerequisites: | |
| 32 | # - ffmpeg: brew install ffmpeg (macOS) or apt install ffmpeg (Linux) | |
| 33 | # | |
| 34 | # An existing output file is replaced (conversions are cheap and | |
| 35 | # deterministic; the GIF remains the source of truth). | |
| 36 | # ───────────────────────────────────────────────────────────────────────────── | |
| 37 | ||
| 38 | 11 | set -euo pipefail |
| 39 | ||
| 40 | usage() { | |
| 41 | 6 | grep '^#' "$0" | sed -n '2,40p' | sed 's/^# \{0,1\}//' |
| 42 | } | |
| 43 | ||
| 44 | 31 | if [ "$#" -lt 1 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then |
| 45 | 2 | usage |
| 46 | 2 | exit 2 |
| 47 | fi | |
| 48 | ||
| 49 | 9 | INPUT="$1" |
| 50 | 9 | OUTPUT="${2:-${INPUT%.gif}.mp4}" |
| 51 | 9 | FPS="${MP4_FPS:-12}" |
| 52 | ||
| 53 | 9 | if ! command -v ffmpeg >/dev/null 2>&1; then |
| 54 | 1 | echo "error: ffmpeg is not installed." >&2 |
| 55 | 1 | echo " brew install ffmpeg (macOS)" >&2 |
| 56 | 1 | echo " apt install ffmpeg (Linux)" >&2 |
| 57 | 1 | exit 1 |
| 58 | fi | |
| 59 | ||
| 60 | 8 | if [ ! -f "$INPUT" ]; then |
| 61 | 1 | echo "error: input GIF not found: $INPUT" >&2 |
| 62 | 1 | exit 1 |
| 63 | fi | |
| 64 | ||
| 65 | 7 | case "$INPUT" in |
| 66 | 6 | *.gif) : ;; |
| 67 | 2 | *) echo "error: input must be a .gif file, got: $INPUT" >&2; exit 1 ;; |
| 68 | esac | |
| 69 | ||
| 70 | 6 | if [ "$INPUT" = "$OUTPUT" ]; then |
| 71 | 1 | echo "error: input and output are the same path: $INPUT" >&2 |
| 72 | 1 | exit 1 |
| 73 | fi | |
| 74 | ||
| 75 | 5 | case "$FPS" in |
| 76 | 2 | ''|*[!0-9]*) echo "error: MP4_FPS must be a positive integer, got: $FPS" >&2; exit 1 ;; |
| 77 | esac | |
| 78 | ||
| 79 | # trunc(n/2)*2 rounds each dimension down to even, which H.264 + yuv420p | |
| 80 | # require; lanczos keeps terminal text crisp through the (at most 1px) | |
| 81 | # rescale. | |
| 82 | 4 | ffmpeg -hide_banner -loglevel error -y \ |
| 83 | -i "$INPUT" \ | |
| 84 | -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos" \ | |
| 85 | -pix_fmt yuv420p \ | |
| 86 | -movflags +faststart \ | |
| 87 | -r "$FPS" \ | |
| 88 | "$OUTPUT" | |
| 89 | ||
| 90 | 12 | SIZE=$(du -h "$OUTPUT" | cut -f1) |
| 91 | 4 | echo "✓ MP4 written: ${OUTPUT} (${SIZE}, ${FPS} fps)" |
| 92 | 4 | echo " Note: MP4s are gitignored on purpose — the committed GIF stays the source of truth." |