Created
January 10, 2026 13:06
-
-
Save ifthenelse/46cd74668760dcd943a91023563b3b7f to your computer and use it in GitHub Desktop.
Zsh script to generate YouTube Shorts (1080×1920) from a static image with optional audio; supports custom output path, duration, CRF/preset, audio bitrate, overwrite and dry-run.
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
| #!/usr/bin/env zsh | |
| # create-youtube-short.zsh | |
| # Create a YouTube Short (1080x1920) from a static image | |
| # Audio is OPTIONAL: if missing, the video will be muted. | |
| set -euo pipefail | |
| SCRIPT_NAME="${0:t}" | |
| VERSION="1.1.0" | |
| CRF_DEFAULT=18 | |
| PRESET_DEFAULT="slow" | |
| AUDIO_BITRATE_DEFAULT="192k" | |
| DURATION_DEFAULT="30" # Default duration in seconds when no audio is provided | |
| print_help() { | |
| cat <<'EOF' | |
| Create a YouTube Short (vertical 9:16) from a static image. | |
| Audio is optional. | |
| Usage: | |
| create-youtube-short.zsh [options] <image> [audio] | |
| create-youtube-short.zsh [options] <file1> <file2> [...] | |
| If multiple files are provided (e.g. from Finder): | |
| - the first image found is used | |
| - the first audio found is used (optional) | |
| Options: | |
| -o, --output <path> Output file path | |
| -d, --duration <sec> Force duration in seconds (default: 30) | |
| -q, --crf <int> H.264 quality (default: 18) | |
| -p, --preset <name> x264 preset (default: slow) | |
| -b, --audio-bitrate <k> Audio bitrate (default: 192k) | |
| -y, --overwrite Overwrite output if it exists | |
| -n, --dry-run Print ffmpeg command only | |
| -h, --help Show this help | |
| -v, --version Print version | |
| Notes: | |
| - If NO audio is provided, duration defaults to 30 seconds unless overridden with --duration. | |
| - Output format is YouTube Shorts compatible (1080x1920, H.264 + AAC). | |
| Dependencies: | |
| ffmpeg (brew install ffmpeg) | |
| EOF | |
| } | |
| die() { print -r -- "Error: $*" >&2; exit 1; } | |
| need_cmd() { | |
| command -v "$1" >/dev/null 2>&1 || die "Missing dependency: $1 (brew install ffmpeg)" | |
| } | |
| is_image() { | |
| local ext="${1:e:l}" | |
| [[ "$ext" == jpg || "$ext" == jpeg || "$ext" == png || "$ext" == webp || "$ext" == heic || "$ext" == tiff ]] | |
| } | |
| is_audio() { | |
| local ext="${1:e:l}" | |
| [[ "$ext" == mp3 || "$ext" == wav || "$ext" == m4a || "$ext" == aac || "$ext" == flac || "$ext" == ogg || "$ext" == opus ]] | |
| } | |
| sanitize_filename() { | |
| print -r -- "${1//[^A-Za-z0-9._-]/_}" | |
| } | |
| # ------------------------- | |
| # Parse args | |
| # ------------------------- | |
| OVERWRITE=0 | |
| DRY_RUN=0 | |
| CRF="$CRF_DEFAULT" | |
| PRESET="$PRESET_DEFAULT" | |
| AUDIO_BR="$AUDIO_BITRATE_DEFAULT" | |
| OUT_PATH="" | |
| FORCE_DURATION="$DURATION_DEFAULT" | |
| ARGS=() | |
| while (( $# > 0 )); do | |
| case "$1" in | |
| -h|--help) print_help; exit 0 ;; | |
| -v|--version) print -r -- "$SCRIPT_NAME v$VERSION"; exit 0 ;; | |
| -y|--overwrite) OVERWRITE=1; shift ;; | |
| -n|--dry-run) DRY_RUN=1; shift ;; | |
| -q|--crf) shift; CRF="$1"; shift ;; | |
| -p|--preset) shift; PRESET="$1"; shift ;; | |
| -b|--audio-bitrate) shift; AUDIO_BR="$1"; shift ;; | |
| -d|--duration) shift; FORCE_DURATION="$1"; shift ;; | |
| -o|--output) shift; OUT_PATH="$1"; shift ;; | |
| --) shift; ARGS+=("$@"); break ;; | |
| -*) die "Unknown option: $1" ;; | |
| *) ARGS+=("$1"); shift ;; | |
| esac | |
| done | |
| (( ${#ARGS[@]} >= 1 )) || die "You must provide at least one image file." | |
| # ------------------------- | |
| # Dependencies | |
| # ------------------------- | |
| need_cmd ffmpeg | |
| # ------------------------- | |
| # Pick image + optional audio | |
| # ------------------------- | |
| IMAGE="" | |
| AUDIO="" | |
| for f in "${ARGS[@]}"; do | |
| [[ -e "$f" ]] || continue | |
| if [[ -z "$IMAGE" ]] && is_image "$f"; then | |
| IMAGE="$f" | |
| continue | |
| fi | |
| if [[ -z "$AUDIO" ]] && is_audio "$f"; then | |
| AUDIO="$f" | |
| continue | |
| fi | |
| done | |
| [[ -n "$IMAGE" ]] || die "No image file found." | |
| # ------------------------- | |
| # Output path | |
| # ------------------------- | |
| IMAGE_DIR="${IMAGE:h}" | |
| BASE="$(sanitize_filename "${IMAGE:t:r}")" | |
| # If no output provided, default to image dir with _short suffix | |
| if [[ -z "$OUT_PATH" ]]; then | |
| OUT_PATH="${IMAGE_DIR}/${BASE}_short.mp4" | |
| else | |
| # If OUT_PATH is an existing directory, place file inside it | |
| if [[ -d "$OUT_PATH" ]]; then | |
| OUT_PATH="${OUT_PATH%/}/${BASE}_short.mp4" | |
| fi | |
| # If OUT_PATH has no extension, append .mp4 | |
| if [[ "${OUT_PATH##*/}" != *.* ]]; then | |
| OUT_PATH="${OUT_PATH}.mp4" | |
| fi | |
| fi | |
| # If target exists and not overwriting, add timestamp before extension | |
| if [[ -e "$OUT_PATH" && $OVERWRITE -eq 0 ]]; then | |
| OUT_PATH="${OUT_PATH:r}_$(date +%Y%m%d_%H%M%S).mp4" | |
| fi | |
| # ------------------------- | |
| # Duration logic | |
| # ------------------------- | |
| if [[ -z "$AUDIO" && -z "$FORCE_DURATION" ]]; then | |
| die "No audio provided. You MUST specify --duration <seconds>." | |
| fi | |
| FILTER='scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2' | |
| FFMPEG_Y=() | |
| (( OVERWRITE == 1 )) && FFMPEG_Y=(-y) | |
| FFMPEG_T=() | |
| [[ -n "$FORCE_DURATION" ]] && FFMPEG_T=(-t "$FORCE_DURATION") | |
| # ------------------------- | |
| # Build command | |
| # ------------------------- | |
| CMD=(ffmpeg "${FFMPEG_Y[@]}" -loop 1 -i "$IMAGE") | |
| if [[ -n "$AUDIO" ]]; then | |
| CMD+=(-i "$AUDIO" -c:a aac -b:a "$AUDIO_BR" -shortest) | |
| else | |
| CMD+=(-an) | |
| fi | |
| CMD+=( | |
| -c:v libx264 | |
| -preset "$PRESET" | |
| -crf "$CRF" | |
| -tune stillimage | |
| -pix_fmt yuv420p | |
| -profile:v high | |
| -level 4.2 | |
| "${FFMPEG_T[@]}" | |
| -movflags +faststart | |
| -vf "$FILTER" | |
| "$OUT_PATH" | |
| ) | |
| # ------------------------- | |
| # Execute | |
| # ------------------------- | |
| if (( DRY_RUN == 1 )); then | |
| print -r -- "Dry run:" | |
| print -r -- "${(q)CMD[@]}" | |
| exit 0 | |
| fi | |
| print -r -- "Image : $IMAGE" | |
| [[ -n "$AUDIO" ]] && print -r -- "Audio : $AUDIO" || print -r -- "Audio : (none, muted)" | |
| print -r -- "Output: $OUT_PATH" | |
| "${CMD[@]}" | |
| print -r -- "Done ✔" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment