#!/bin/bash
#
# Author: Twily 2026
# Descriptiong: Turn images into a moving slideshow video
# Website: twily.info
# --- CONFIGURATION ---
IMAGE_DIR="./twitter" # Folder containing your source images
#MUSIC_FILE="background.mp3" # Path to your audio track
MUSIC_FILE="/media/amalie/4E7035FC7035EB7B1/slideshowvideo/background_anime.mp3"
#OUTPUT_FILE="slideshow.mp4" # Final output video file
#CLIP_DIR="./tmp_clips" # Temporary folder for video processing
OUTPUT_FILE="/media/amalie/4E7035FC7035EB7B1/slideshowvideo/slideshowvideo8.mp4"
CLIP_DIR="/media/amalie/4E7035FC7035EB7B1/slideshowvideo/tmp_clips8"
WIDTH=1920
HEIGHT=1080
FPS=30
# 2h 1m 5s = 7265
# 7265 / 1256 = 5.784 (+1s fade?)
# 7265 / 1257 = 5.779 (+1 image credit)
# 5.779-.5 = 5.279
CHUNK_DIR="/media/amalie/4E7035FC7035EB7B1/slideshowvideo/tmp_chunks8"
CONCAT_FILE="chunks_list8.txt"
# Total display time per image (seconds)
TRANSITION_DURATION=1 # Overlap blend time (seconds)
# jsonmap use for re using random effects, either decipher after or have it write out the numbers to file (not currently setup)
# jsonmap format
# {
# "chunk0": {
# "zoom": [0,1,0,0,1,1,0,0,0,1],
# "fade": [6,3,3,5,9,7,7,8,7]
# },
# "chunk1": {
# "zoom": [0,1,1,0,0,0,1,0,1,1],
# "fade": [1,1,1,1,6,5,9,0,2]
# },
# ...
# where zoom 0 is left to right or top to bottom, 1 is opposite
JSONMAP="./mapslideshowdata.json"
CHUNK_SIZE=10
CHUNK_NAME=""
LAST_CHUNK_NAME=""
MAX_CHUNK_IDX=125
CHUNK_POS=0
# List of cinematic transitions
EFFECTS=("fade" "slideleft" "slideright" "slideup" "slidedown" "circlecrop" "wipeleft" "wiperight" "dissolve" "pixelize")
# --- INITIAL SANITY CHECKS ---
#rm -rf "$CLIP_DIR" && mkdir -p "$CLIP_DIR"
if [ ! -d "$IMAGE_DIR" ]; then
echo "Error: Image directory '$IMAGE_DIR' not found." && exit 1
fi
# Gather and sort all images naturally (handles spaces and various extensions)
shopt -s nullglob extglob
images=("$IMAGE_DIR"/*.@(jpg|jpeg|png|JPG|JPEG|PNG))
num_images=${#images[@]}
if [ "$num_images" -lt 2 ]; then
echo "Error: You need at least 2 images to create a slideshow." && exit 1
fi
# Calculate clip duration from total images and match song duration
# Add +3 for credit if credit clip est 3x a clip duration length
CHUNK_NUM=$(echo "($num_images + 3) / $CHUNK_SIZE" | bc)
CLIP_DURATION=$(echo "scale=2; (((60 * 60 * 2) + 65) / ($num_images + 3)) + 0.9" | bc -l)
# +.9 for fade time 1s but across 9/10, chunk does not transition
#CLIP_DURATION=$(echo "scale=2; $CLIP_DURATION - (($CHUNK_NUM / $CLIP_DURATION))" | bc -l)
echo "CLIP_DURATION=$CLIP_DURATION"
#CLIP_DURATION=6.7289 # 6.779s ? default 7
ZOOMD_DURATION=$(echo "$CLIP_DURATION - .5" | bc) # 6.279s ? default 6.5
ZOOMU_DURATION=$(echo "$CLIP_DURATION") # 6.279s ? default 6.5
# to test zoom_duration
# zoom duration equal clip duration when going up (dir=1)
# zoom duration clip duration -.5 when going down (dir=0)
# more likely to go past head going up could apply to only portrait
echo "Found $num_images images. Starting phase 1..."
#echo "Found $num_images images. Skipping phase 1 jumping to phase 2"
# comment out if doing parts only
rm -rf "$CLIP_DIR" && mkdir -p "$CLIP_DIR"
# --- PHASE 1: PRE-RENDER INDIVIDUAL TEMPORARY CLIPS WITH DYNAMIC PANNING ---
count=0
for img in "${images[@]}"; do
printf -v pad_count "%04d" "$count"
out_clip="$CLIP_DIR/clip_${pad_count}.mp4"
CHUNK_IDX=$(( $count/$CHUNK_SIZE ))
CHUNK_NAME="chunk$CHUNK_IDX"
echo "image $count : $CHUNK_NAME pos $CHUNK_POS"
if [ "$LAST_CHUNK_NAME" != "$CHUNK_NAME" ]; then
LAST_CHUNK_NAME=$CHUNK_NAME
CHUNK_POS=0
eval "$(jq -r --arg c "$CHUNK_NAME" '.[$c] | "zoom=(\(.zoom | map(@sh) | join(" ")))" , "fade=(\(.fade | map(@sh) | join(" ")))"' $JSONMAP)"
# post "error" when not found, can ignore
fi
USERAND=1
if [ $CHUNK_IDX -le $MAX_CHUNK_IDX ]; then
USERAND=0
fi
#if [ $count -eq 865 ]; then
echo "Processing [$((count+1))/$num_images]: $(basename "$img")"
# 1. Grab raw image dimensions via ffprobe
dimensions=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "$img")
img_w=$(echo "$dimensions" | cut -d',' -f1)
img_h=$(echo "$dimensions" | cut -d',' -f2)
# Calculate aspect ratio (multiplied by 100)
img_ratio=$(( (img_w * 100) / img_h ))
#total_frames=$(( ZOOM_DURATION * FPS ))
total_frames=$(echo "$ZOOMD_DURATION * $FPS" | bc)
if [ $USERAND -eq 1 ]; then
direction=$(( RANDOM % 2 ))
if [ "$img_ratio" -lt 177 ]; then
# portrait only up custom zoom_duration
total_frames=$(echo "$ZOOMU_DURATION * $FPS" | bc)
fi
else
direction=${zoom[$CHUNK_POS]}
fi
PI="3.1415926535"
# 2. Unified Easing Calculation (0 to 1 or 1 to 0)
if [ "$direction" -eq 0 ] ; then
# left to right or top to bottom
ease_expr="(1-cos($PI*n/$total_frames))/2"
else
# right to left or bottom to top
ease_expr="(1+cos($PI*n/$total_frames))/2"
fi
# 3. Process based on image aspect ratio relative to 16:9 (roughly 177)
if [ "$img_ratio" -ge 177 ]; then
# --- LANDSCAPE IMAGE (Wider than 16:9, e.g. panoramas) ---
# Fixed: Loop the image, set duration to CLIP_DURATION, map the crop math safely.
if [ "$img_ratio" -eq 177 ]; then
# Bulletproof fallback for small/weird rounding landscape images
ffmpeg -y -v error -loop 1 -t "$CLIP_DURATION" -i "$img" \
-vf "scale=$WIDTH:$HEIGHT:force_original_aspect_ratio=increase,crop=$WIDTH:$HEIGHT,setsar=1,fps=$FPS" \
-c:v libx264 -pix_fmt yuv420p "$out_clip"
else
ffmpeg -y -v error -loop 1 -t "$CLIP_DURATION" -i "$img" \
-vf "scale=-1:$HEIGHT,setsar=1,crop=$WIDTH:$HEIGHT:(iw-1920)*$ease_expr:0,fps=$FPS" \
-c:v libx264 -pix_fmt yuv420p "$out_clip"
fi
else
# --- PORTRAIT / SQUARE / 4:3 IMAGES (Taller than 16:9, e.g. 1424x1000 & mobile shots) ---
# Fixed: Loop the image, set duration to CLIP_DURATION, map the crop math safely.
ffmpeg -y -v error -loop 1 -t "$CLIP_DURATION" -i "$img" \
-vf "scale=$WIDTH:-1,setsar=1,crop=$WIDTH:$HEIGHT:0:(ih-1080)*$ease_expr,fps=$FPS" \
-c:v libx264 -pix_fmt yuv420p "$out_clip"
fi
#exit
#fi
((count++))
((CHUNK_POS++))
done
# comment out if doing parts as above
rm -rf "$CHUNK_DIR" && mkdir -p "$CHUNK_DIR"
rm -f "$CONCAT_FILE"
echo "Phase 1 verification complete. Beginning chunked compilation..."
# add credits clip
((num_images++))
# --- PHASE 2: COMPILE SUB-CHUNKS OF 100 CLIPS ---
chunk_index=0
CHUNK_POS=0
for ((start_idx=0; start_idx<num_images; start_idx+=CHUNK_SIZE)); do
end_idx=$((start_idx + CHUNK_SIZE))
if [ "$end_idx" -gt "$num_images" ]; then
end_idx=$num_images
fi
current_chunk_clips=$((end_idx - start_idx))
if [ "$current_chunk_clips" -lt 2 ] && [ "$start_idx" -ne 0 ]; then
echo "Skipping final single trailing clip..."
break
fi
echo "Compiling Sub-Chunk #$chunk_index (Clips $start_idx to $((end_idx-1)))..."
input_args=""
filter_graph=""
# 1. Map the inputs safely
for ((i=start_idx; i<end_idx; i++)); do
printf -v pad_count "%04d" "$i"
input_args+="-i $CLIP_DIR/clip_${pad_count}.mp4 "
done
CHUNK_NAME="chunk$chunk_index"
echo "image $count : $CHUNK_NAME pos $CHUNK_POS"
#if [ "$LAST_CHUNK_NAME" != "$CHUNK_NAME" ]; then
LAST_CHUNK_NAME=$CHUNK_NAME
CHUNK_POS=1
eval "$(jq -r --arg c "$CHUNK_NAME" '.[$c] | "zoom=(\(.zoom | map(@sh) | join(" ")))" , "fade=(\(.fade | map(@sh) | join(" ")))"' $JSONMAP)"
#fi
USERAND=1
if [ $chunk_index -le $MAX_CHUNK_IDX ]; then
USERAND=0
fi
# 2. Build the cascading filter graph without stray trailing characters
# Anchor the first node crossover
if [ $USERAND -eq 1 ]; then
rand_effect=${EFFECTS[$RANDOM % ${#EFFECTS[@]}]}
else
rand_effect=${EFFECTS[${fade[0]}]}
fi
offset=$(echo "$CLIP_DURATION - $TRANSITION_DURATION" | bc)
filter_graph+="[0:v][1:v]xfade=transition=${rand_effect}:duration=${TRANSITION_DURATION}:offset=${offset}[vfx1]"
# Cascade the remaining elements (notice the semicolon is placed at the START of the new loop iteration)
for ((l=2; l<current_chunk_clips; l++)); do
prev_vfx=$((l-1))
#offset=$((l * (CLIP_DURATION - TRANSITION_DURATION)))
offset=$(echo "$l * ($CLIP_DURATION - $TRANSITION_DURATION)" | bc)
if [ $USERAND -eq 1 ]; then
rand_effect=${EFFECTS[$RANDOM % ${#EFFECTS[@]}]}
else
rand_effect=${EFFECTS[${fade[$CHUNK_POS]}]}
fi
filter_graph+="; [vfx${prev_vfx}][$l:v]xfade=transition=${rand_effect}:duration=${TRANSITION_DURATION}:offset=${offset}[vfx${l}]"
((CHUNK_POS++))
done
# Correctly locate the final dynamic output node string
last_vfx=$((current_chunk_clips-1))
chunk_output="$CHUNK_DIR/chunk_${chunk_index}.mp4"
# Render out the completed individual chunk block
ffmpeg -y $input_args -filter_complex "$filter_graph" -map "[vfx$last_vfx]" -c:v libx264 -pix_fmt yuv420p "$chunk_output"
echo "file '$chunk_output'" >> "$CONCAT_FILE"
((chunk_index++))
done
# --- PHASE 3: MASTER CONCAT STITCH & AUDIO MIX ---
echo "All chunks compiled. Running master stitch pass..."
if [ -f "$MUSIC_FILE" ]; then
# We stitch the chunks via concat demuxer AND pull in the music file simultaneously
ffmpeg -y -f concat -safe 0 -i "$CONCAT_FILE" -i "$MUSIC_FILE" \
-c:v libx264 -pix_fmt yuv420p \
-map 0:v -map 1:a -c:a aac -shortest "$OUTPUT_FILE"
else
echo "Warning: Music file missing. Outputting silent master presentation..."
ffmpeg -y -f concat -safe 0 -i "$CONCAT_FILE" \
-c:v libx264 -pix_fmt yuv420p "$OUTPUT_FILE"
fi
# --- OPTIONAL CLEANUP ---
# Kept commented out as per your workflow safety preference!
# rm -rf "$CHUNK_DIR" "$CONCAT_FILE"
echo "MASTER SUCCESS! Final video generated successfully at: $OUTPUT_FILE"
# compressing video after 3GB 1080p to 900MB 720p or 540p
# H264
# Pass 1
# $ ffmpeg -y -i slideshowvideo2.mp4 -c:v libx264 -b:v 900k -pass 1 -vf scale=-2:720 -an -f null /dev/null
# Pass 2
# $ ffmpeg -i slideshowvideo2.mp4 -c:v libx264 -b:v 900k -pass 2 -vf scale=-2:720 -c:a copy slideshowvideo2_720p_h264.mp4
#
# H265
# Pass 1
# $ ffmpeg -y -i slideshowvideo2.mp4 -c:v libx265 -b:v 850k -x265-params pass=1 -vf scale=-2:540 -an -f null /dev/null
# Pass 2
# $ ffmpeg -i slideshowvideo2.mp4 -c:v libx265 -b:v 850k -x265-params pass=2 -vf scale=-2:540 -c:a copy slideshowvideo2_540p_h265.mp4
Top