Skip to content

Instantly share code, notes, and snippets.

@ifthenelse
Created January 10, 2026 13:06
Show Gist options
  • Select an option

  • Save ifthenelse/46cd74668760dcd943a91023563b3b7f to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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