~scripts
66 itemsDownload ./*

..
ai
display
dzen2
nginx
tiling
vbs
vosh
4cdl
4trips
alarm
ambient
battery-monitor
btctl.sh
checkit
cleverbot.py
clock
color-gen
colors-hex
colorscheme.sh
compton.sh
dailywall
div2pad
dmenu
esp32-cam-ffmpeg-gst-uvc.sh
ffthumb
fixfnkey.sh
grid
gridmacro
gridmacro.kde
help
importw
indexer
install-all.sh
install-twily.sh
installng
makeslideshowvideo.sh
mic-test-playback.sh
mp3ogg
netspeed.sh
orage
pipes
pipes.x
pipewire_bt.sh
ports
rain
randwall
readystart.sh
rotate.sh
scan.sh
screencast
screencast2
scrot
scrotw
search
skull
slocker
startblender.sh
starwars
streamit
sumnum
synctimedate.sh
tty-colorize
usrmount
ytp
ytplay
zombie-restart.sh


scriptsmakeslideshowvideo.sh
11 KB• 7•  1 day ago•  DownloadRawClose
1 day ago•  7

{}
#!/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
©twily.info 2013 - 2026
twily at twily dot info



2 815 407 visits
... ^ v