changemaker.lite/media-manager/scripts/create_mega_compilation.sh

1036 lines
36 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# MEGA COMPILATION CREATOR
# ============================================================================
# Creates a 4K compilation with:
# - Three vertical video columns (longer library videos + gif padding)
# - Center horizontal overlay with crossfade transitions
# - Smart duration filling to reach target length
#
# Canvas: 3840x2160 (4K)
# Column positions: Left (150,120), Center (1380,120), Right (2610,120)
# Middle overlay: Centered, ~600px wide
# ============================================================================
# Don't exit on error - we'll handle errors explicitly
# set -e
# Use environment variables if set (for Docker), otherwise use default paths
GIFS_DIR="${GIFS_PATH:-/media/bunker-admin/Internal/plex/xxx/media/local/gifs}"
LIBRARY_DIR="${SOURCE_LIBRARY:-/media/bunker-admin/Internal/plex/xxx/media/local/studios}"
OUTPUT_DIR="${INBOX_PATH:-/media/bunker-admin/Internal/plex/xxx/media/local/inbox}"
# Directory exclusion (colon-separated list of directory names to exclude)
EXCLUDED_DIRS="${EXCLUDED_DIRS:-}"
# Filter function for excluding directories (handles null-terminated input)
filter_excluded_null() {
if [[ -z "$EXCLUDED_DIRS" ]]; then
cat # Pass through unchanged
else
local pattern
pattern=$(echo "$EXCLUDED_DIRS" | sed 's/:/|/g')
grep -zEv "/($pattern)/"
fi
}
# Filter function for excluding directories (handles newline-terminated input)
filter_excluded() {
if [[ -z "$EXCLUDED_DIRS" ]]; then
cat # Pass through unchanged
else
local pattern
pattern=$(echo "$EXCLUDED_DIRS" | sed 's/:/|/g')
grep -Ev "/($pattern)/"
fi
}
# Column dimensions and positions
COLUMN_WIDTH=1080
COLUMN_HEIGHT=1920
COL_LEFT_X=150
COL_CENTER_X=1380
COL_RIGHT_X=2610
COL_Y=120
# Middle overlay settings
MIDDLE_WIDTH=600
CROSSFADE_DURATION=1 # seconds
# Canvas
CANVAS_WIDTH=3840
CANVAS_HEIGHT=2160
# ============================================================================
# UTILITY FUNCTIONS
# ============================================================================
# Check dependencies
check_dependencies() {
local missing=()
if ! command -v ffmpeg &> /dev/null; then
missing+=("ffmpeg")
fi
if ! command -v ffprobe &> /dev/null; then
missing+=("ffprobe")
fi
if [[ ${#missing[@]} -gt 0 ]]; then
echo "Error: Missing required tools: ${missing[*]}"
exit 1
fi
}
# Setup encoder (NVENC or fallback to CPU)
setup_encoder() {
if ! ffmpeg -hide_banner -encoders 2>/dev/null | grep -q h264_nvenc; then
echo "Warning: NVENC encoder not available. Using CPU encoding."
ENCODER="libx264"
ENCODER_OPTS="-preset medium -crf 23"
else
local enc_usage=$(nvidia-smi dmon -c 1 2>/dev/null | tail -1 | awk '{print $7}')
if [[ "$enc_usage" -gt 50 ]]; then
if [[ "${NONINTERACTIVE:-0}" == "1" ]]; then
echo "GPU encoder in use (${enc_usage}%), proceeding anyway in non-interactive mode"
ENCODER="h264_nvenc"
ENCODER_OPTS="-preset p4 -cq 23 -rc vbr -gpu 0 -b_ref_mode 0"
else
echo ""
echo "⚠️ WARNING: GPU encoder is currently in use (${enc_usage}% utilization)"
echo ""
echo " Options:"
echo " 1) Close other apps and press Enter to continue with GPU"
echo " 2) Type 'cpu' to use CPU encoding instead (slower)"
echo " 3) Press Ctrl+C to cancel"
echo ""
read -p "Choice: " enc_choice
if [[ "$enc_choice" == "cpu" ]]; then
ENCODER="libx264"
ENCODER_OPTS="-preset medium -crf 23"
else
ENCODER="h264_nvenc"
ENCODER_OPTS="-preset p4 -cq 23 -rc vbr -gpu 0 -b_ref_mode 0"
fi
fi
else
echo "✓ NVENC GPU encoder detected and available"
ENCODER="h264_nvenc"
ENCODER_OPTS="-preset p4 -cq 23 -rc vbr -gpu 0 -b_ref_mode 0"
fi
fi
}
# Extract duration from filename tag [H:MM] or [Xs] - returns seconds
get_duration_from_filename() {
local filename="$1"
local basename=$(basename "$filename")
# Try [H:MM] format first (e.g., [1:23] = 1 hour 23 minutes)
if [[ "$basename" =~ \[([0-9]+):([0-9]{2})\] ]]; then
local hours="${BASH_REMATCH[1]}"
local minutes="${BASH_REMATCH[2]}"
# Use 10# prefix to force base-10 (avoids octal issues with 08, 09)
echo $(( 10#$hours * 3600 + 10#$minutes * 60 ))
return 0
fi
# Try [Xs] format (e.g., [45s] = 45 seconds)
if [[ "$basename" =~ \[([0-9]+)s\] ]]; then
# Use 10# prefix to force base-10
echo $(( 10#${BASH_REMATCH[1]} ))
return 0
fi
# Fallback: probe the file
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$filename" 2>/dev/null | cut -d. -f1
}
# Get actual duration using ffprobe
get_duration_ffprobe() {
local file="$1"
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null | cut -d. -f1
}
# Format seconds as H:MM
format_duration() {
local seconds="$1"
local hours=$((seconds / 3600))
local minutes=$(((seconds % 3600) / 60))
printf "%d:%02d" "$hours" "$minutes"
}
# Check if video has audio stream
has_audio() {
local file="$1"
local audio_streams=$(ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "$file" 2>/dev/null)
[[ -n "$audio_streams" ]]
}
# Validate video file is not corrupted (quick check)
is_valid_video() {
local file="$1"
# Check if ffprobe can read the file (timeout after 5 seconds)
if ! timeout 5 ffprobe -v error -select_streams v:0 -show_entries stream=width,height,duration -of csv=p=0 "$file" &>/dev/null; then
return 1
fi
# Try to decode first few frames (timeout after 10 seconds)
if ! timeout 10 ffmpeg -nostdin -v error -i "$file" -vframes 5 -f null - 2>/dev/null; then
return 1
fi
return 0
}
# Show progress bar
show_progress() {
local current=$1
local total=$2
local label=$3
local width=40
local percentage=$((current * 100 / total))
local filled=$((width * current / total))
local empty=$((width - filled))
printf "\r → %s %d/%d [" "$label" "$current" "$total"
printf "%${filled}s" | tr ' ' '█'
printf "%${empty}s" | tr ' ' '░'
printf "] %3d%%" "$percentage"
}
# ============================================================================
# VIDEO INVENTORY FUNCTIONS
# ============================================================================
# Find all vertical [V] videos from the main library (not gifs)
find_library_vertical_videos() {
local -n result_array=$1
local skipped=0
local checked=0
echo " → Scanning library for vertical [V] videos..."
# Find all MP4 files with [V] tag, excluding gifs directory
while IFS= read -r -d '' file; do
((checked++))
printf "\r → Checking file %d..." "$checked"
# Skip if no audio
if ! has_audio "$file"; then
((skipped++))
continue
fi
# Validate video is not corrupted
if ! is_valid_video "$file"; then
echo ""
echo " ⚠ Skipping corrupted: $(basename "$file")"
((skipped++))
continue
fi
local duration=$(get_duration_from_filename "$file")
if [[ -n "$duration" ]] && [[ "$duration" -gt 0 ]]; then
result_array+=("$duration:$file")
fi
done < <(find "$LIBRARY_DIR" -type f -name "*\[V\].mp4" ! -path "*/gifs/*" ! -path "*/scripts/*" -print0 2>/dev/null | filter_excluded_null)
echo ""
echo " → Found ${#result_array[@]} valid vertical videos in library (skipped $skipped)"
}
# Find all vertical gif videos
find_gif_vertical_videos() {
local -n result_array=$1
local skipped=0
local checked=0
local total_files=$(find "$GIFS_DIR" -path "*/vertical/*.mp4" -type f 2>/dev/null | filter_excluded | wc -l)
echo " → Scanning for vertical gif content ($total_files files)..."
while IFS= read -r -d '' file; do
((checked++))
printf "\r → Validating gif %d/%d..." "$checked" "$total_files"
if ! has_audio "$file"; then
((skipped++))
continue
fi
# Quick validation (less thorough for gifs since there are many)
if ! timeout 3 ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$file" &>/dev/null; then
((skipped++))
continue
fi
local duration=$(get_duration_from_filename "$file")
if [[ -n "$duration" ]] && [[ "$duration" -gt 0 ]]; then
result_array+=("$duration:$file")
fi
done < <(find "$GIFS_DIR" -path "*/vertical/*.mp4" -type f -print0 2>/dev/null | filter_excluded_null)
echo ""
echo " → Found ${#result_array[@]} valid vertical gif clips (skipped $skipped)"
}
# Find horizontal videos in a specific creator folder
find_horizontal_videos() {
local creator_dir="$1"
local -n result_array=$2
local skipped=0
while IFS= read -r -d '' file; do
if ! has_audio "$file"; then
((skipped++))
continue
fi
# Quick validation
if ! timeout 3 ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$file" &>/dev/null; then
((skipped++))
continue
fi
result_array+=("$file")
done < <(find "$creator_dir/horizontal" -type f -name "*.mp4" -print0 2>/dev/null)
}
# ============================================================================
# VIDEO SELECTION ALGORITHM
# ============================================================================
# File to track used videos across columns (persists across subshells)
USED_VIDEOS_FILE=""
# Check if video is already used
is_video_used() {
local filepath="$1"
if [[ -f "$USED_VIDEOS_FILE" ]] && [[ -s "$USED_VIDEOS_FILE" ]]; then
grep -qF "$filepath" "$USED_VIDEOS_FILE"
return $?
fi
return 1
}
# Mark video as used
mark_video_used() {
local filepath="$1"
echo "$filepath" >> "$USED_VIDEOS_FILE"
sync # Ensure write is flushed to disk for subshells
}
# Select videos to fill a target duration for one column
# Returns newline-separated list of video paths
# Ensures ~50/50 split between library and gif content, interleaved
select_videos_for_column() {
local target_seconds=$1
local -n library_videos=$2
local -n gif_videos=$3
local column_name=$4
local library_selected=()
local gif_selected=()
local library_duration=0
local gif_duration=0
local min_gap=30
# Target ~50% for each type
local library_target=$((target_seconds / 2))
local gif_target=$((target_seconds / 2))
# Shuffle both pools
local shuffled_library=()
while IFS= read -r line; do
[[ -n "$line" ]] && shuffled_library+=("$line")
done < <(printf '%s\n' "${library_videos[@]}" | shuf)
local shuffled_gifs=()
while IFS= read -r line; do
[[ -n "$line" ]] && shuffled_gifs+=("$line")
done < <(printf '%s\n' "${gif_videos[@]}" | shuf)
# Select library videos (up to ~50% of target)
for entry in "${shuffled_library[@]}"; do
local duration="${entry%%:*}"
local filepath="${entry#*:}"
if is_video_used "$filepath"; then
continue
fi
local remaining=$((library_target - library_duration))
if [[ "$remaining" -lt "$min_gap" ]]; then
break
fi
if [[ "$duration" -le "$remaining" ]]; then
library_selected+=("$duration:$filepath")
mark_video_used "$filepath"
library_duration=$((library_duration + duration))
fi
done
# Select gif videos (up to ~50% of target, or more if library didn't fill)
local gif_remaining_target=$((target_seconds - library_duration))
for entry in "${shuffled_gifs[@]}"; do
local duration="${entry%%:*}"
local filepath="${entry#*:}"
if is_video_used "$filepath"; then
continue
fi
local remaining=$((gif_remaining_target - gif_duration))
if [[ "$remaining" -lt "$min_gap" ]]; then
break
fi
if [[ "$duration" -le "$remaining" ]]; then
gif_selected+=("$duration:$filepath")
mark_video_used "$filepath"
gif_duration=$((gif_duration + duration))
fi
done
# Now interleave: alternate library video, then gif(s), then library, etc.
local final_videos=()
local lib_idx=0
local gif_idx=0
local lib_count=${#library_selected[@]}
local gif_count=${#gif_selected[@]}
# Calculate how many gifs to insert between each library video
local gifs_per_gap=1
if [[ $lib_count -gt 0 ]]; then
gifs_per_gap=$(( (gif_count + lib_count - 1) / lib_count ))
[[ $gifs_per_gap -lt 1 ]] && gifs_per_gap=1
fi
while [[ $lib_idx -lt $lib_count ]] || [[ $gif_idx -lt $gif_count ]]; do
# Add one library video
if [[ $lib_idx -lt $lib_count ]]; then
local entry="${library_selected[$lib_idx]}"
final_videos+=("${entry#*:}")
((lib_idx++))
fi
# Add some gifs after the library video
local gifs_added=0
while [[ $gif_idx -lt $gif_count ]] && [[ $gifs_added -lt $gifs_per_gap ]]; do
local entry="${gif_selected[$gif_idx]}"
final_videos+=("${entry#*:}")
((gif_idx++))
((gifs_added++))
done
done
local total_duration=$((library_duration + gif_duration))
local lib_pct=0
[[ $total_duration -gt 0 ]] && lib_pct=$((library_duration * 100 / total_duration))
echo " → Column $column_name: ${#final_videos[@]} videos, $(format_duration $total_duration) total (${lib_pct}% library, $((100-lib_pct))% gifs)" >&2
# Output the selected videos
printf '%s\n' "${final_videos[@]}"
}
# ============================================================================
# VIDEO PROCESSING FUNCTIONS
# ============================================================================
# Create a single column video from a list of video files
# Uses FFmpeg concat FILTER (not demuxer) for perfect timestamp continuity
create_column_video() {
local output_file="$1"
local target_duration="$2"
shift 2
local video_files=("$@")
if [[ ${#video_files[@]} -eq 0 ]]; then
echo " Error: No videos provided for column"
return 1
fi
# Validate all input files exist
local valid_files=()
for video in "${video_files[@]}"; do
if [[ -f "$video" ]]; then
valid_files+=("$video")
else
echo " Warning: File not found: $video"
fi
done
if [[ ${#valid_files[@]} -eq 0 ]]; then
echo " Error: No valid video files found"
return 1
fi
local num_videos=${#valid_files[@]}
echo " Building concat filter for $num_videos videos..."
# Build FFmpeg inputs and filter_complex
local inputs=""
local filter_v=""
local filter_a=""
local concat_inputs_v=""
local concat_inputs_a=""
for i in "${!valid_files[@]}"; do
local video="${valid_files[$i]}"
inputs="$inputs -i \"$video\""
# Scale video, pad to column size, normalize fps/SAR, set pts
# setsar=1 is critical - concat filter requires identical SAR across all inputs
filter_v="$filter_v[$i:v]scale=${COLUMN_WIDTH}:${COLUMN_HEIGHT}:force_original_aspect_ratio=decrease,pad=${COLUMN_WIDTH}:${COLUMN_HEIGHT}:(ow-iw)/2:(oh-ih)/2,fps=30,setsar=1,setpts=PTS-STARTPTS[v$i];"
# Normalize audio sample rate and set pts
filter_a="$filter_a[$i:a]aresample=48000,asetpts=PTS-STARTPTS[a$i];"
concat_inputs_v="$concat_inputs_v[v$i]"
concat_inputs_a="$concat_inputs_a[a$i]"
done
# Build the concat filter
local filter_complex="${filter_v}${filter_a}${concat_inputs_v}concat=n=${num_videos}:v=1:a=0[vout];${concat_inputs_a}concat=n=${num_videos}:v=0:a=1[aout]"
echo " Encoding column (single-pass concat filter)..."
# Execute FFmpeg with concat filter - single pass, no intermediate files
local ffmpeg_cmd="ffmpeg -nostdin $inputs -filter_complex \"$filter_complex\" -map \"[vout]\" -map \"[aout]\" -c:v $ENCODER $ENCODER_OPTS -c:a aac -b:a 128k -ar 48000 -loglevel error -y \"$output_file\""
eval "$ffmpeg_cmd" 2>&1
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
echo " Error: FFmpeg concat filter failed (exit code: $exit_code)"
return 1
fi
# Verify output was created and has matching durations
if [[ ! -f "$output_file" ]]; then
echo " Error: Output file was not created"
return 1
fi
local video_dur=$(ffprobe -v error -select_streams v:0 -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 "$output_file" 2>/dev/null | cut -d. -f1)
local audio_dur=$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 "$output_file" 2>/dev/null | cut -d. -f1)
if [[ -n "$video_dur" ]] && [[ -n "$audio_dur" ]]; then
local diff=$((video_dur > audio_dur ? video_dur - audio_dur : audio_dur - video_dur))
if [[ $diff -gt 5 ]]; then
echo " ⚠ Warning: Duration mismatch (video: ${video_dur}s, audio: ${audio_dur}s)"
else
echo " ✓ Durations match (${video_dur}s)"
fi
fi
echo " Column video created successfully"
}
# Create the middle overlay video with crossfades
create_middle_overlay() {
local output_file="$1"
local target_duration="$2"
shift 2
local video_files=("$@")
if [[ ${#video_files[@]} -eq 0 ]]; then
echo "Error: No videos provided for middle overlay"
return 1
fi
# Calculate scaled height maintaining aspect ratio (1920x1080 -> 600x?)
local scaled_height=$((MIDDLE_WIDTH * 1080 / 1920)) # 337.5 -> 338
local temp_dir=$(mktemp -d)
# If only one video, just scale and loop it
if [[ ${#video_files[@]} -eq 1 ]]; then
ffmpeg -nostdin -stream_loop -1 -i "${video_files[0]}" \
-t "$target_duration" \
-vf "scale=${MIDDLE_WIDTH}:${scaled_height},fps=30" \
-c:v $ENCODER $ENCODER_OPTS \
-af "aresample=async=1" \
-c:a aac -b:a 128k -ar 48000 \
-loglevel error \
-y "$output_file" 2>/dev/null
rm -rf "$temp_dir"
return 0
fi
# For multiple videos, create crossfade transitions
# First, prepare all videos to same format
local prepared_files=()
local idx=0
for video in "${video_files[@]}"; do
local prep_file="$temp_dir/prep_$(printf "%05d" $idx).mp4"
ffmpeg -nostdin -i "$video" \
-vf "scale=${MIDDLE_WIDTH}:${scaled_height},fps=30,setpts=PTS-STARTPTS" \
-c:v $ENCODER $ENCODER_OPTS \
-af "aresample=async=1,asetpts=PTS-STARTPTS" \
-c:a aac -b:a 128k -ar 48000 \
-loglevel error \
-y "$prep_file" 2>/dev/null
prepared_files+=("$prep_file")
((idx++))
done
# Build xfade filter chain
local num_videos=${#prepared_files[@]}
local inputs=""
local filter_complex=""
local current_offset=0
for i in "${!prepared_files[@]}"; do
inputs="$inputs -i \"${prepared_files[$i]}\""
done
# Calculate offsets and build filter
# Each video plays, then crossfades to next
local durations=()
for prep_file in "${prepared_files[@]}"; do
local dur=$(get_duration_ffprobe "$prep_file")
durations+=("$dur")
done
# Build the xfade chain
if [[ $num_videos -eq 2 ]]; then
local offset=$((${durations[0]} - CROSSFADE_DURATION))
filter_complex="[0:v][1:v]xfade=transition=fade:duration=${CROSSFADE_DURATION}:offset=${offset}[vout];[0:a][1:a]acrossfade=d=${CROSSFADE_DURATION}[aout]"
else
# For 3+ videos, chain the xfades
local offset=$((${durations[0]} - CROSSFADE_DURATION))
filter_complex="[0:v][1:v]xfade=transition=fade:duration=${CROSSFADE_DURATION}:offset=${offset}[v1];[0:a][1:a]acrossfade=d=${CROSSFADE_DURATION}[a1]"
for ((i=2; i<num_videos; i++)); do
offset=$((offset + ${durations[$((i-1))]} - CROSSFADE_DURATION))
local prev_v="v$((i-1))"
local prev_a="a$((i-1))"
if [[ $i -eq $((num_videos - 1)) ]]; then
filter_complex="$filter_complex;[$prev_v][$i:v]xfade=transition=fade:duration=${CROSSFADE_DURATION}:offset=${offset}[vout];[$prev_a][$i:a]acrossfade=d=${CROSSFADE_DURATION}[aout]"
else
filter_complex="$filter_complex;[$prev_v][$i:v]xfade=transition=fade:duration=${CROSSFADE_DURATION}:offset=${offset}[v$i];[$prev_a][$i:a]acrossfade=d=${CROSSFADE_DURATION}[a$i]"
fi
done
fi
# Build and execute the ffmpeg command
local xfade_output="$temp_dir/xfade_output.mp4"
eval "ffmpeg -nostdin $inputs \
-filter_complex \"$filter_complex\" \
-map \"[vout]\" -map \"[aout]\" \
-c:v $ENCODER $ENCODER_OPTS \
-c:a aac -b:a 128k \
-loglevel error \
-y \"$xfade_output\"" 2>/dev/null
# Get duration of xfade output
local xfade_duration=$(get_duration_ffprobe "$xfade_output")
# Validate xfade output was created and has valid duration
if [[ ! -f "$xfade_output" ]] || [[ -z "$xfade_duration" ]] || [[ "$xfade_duration" -eq 0 ]]; then
echo " Error: xfade output failed or has zero duration"
rm -rf "$temp_dir"
return 1
fi
# If we need more duration, loop the content
if [[ "$xfade_duration" -lt "$target_duration" ]]; then
local loop_count=$(( (target_duration / xfade_duration) + 1 ))
ffmpeg -nostdin -stream_loop "$loop_count" -i "$xfade_output" \
-t "$target_duration" \
-c:v $ENCODER $ENCODER_OPTS \
-c:a aac -b:a 128k \
-loglevel error \
-y "$output_file" 2>/dev/null
else
# Trim to target duration
ffmpeg -nostdin -i "$xfade_output" \
-t "$target_duration" \
-c copy \
-loglevel error \
-y "$output_file" 2>/dev/null
fi
rm -rf "$temp_dir"
}
# Final composite: combine columns and middle overlay
create_final_composite() {
local output_file="$1"
local col_left="$2"
local col_center="$3"
local col_right="$4"
local middle_overlay="$5"
local target_duration="$6"
# Calculate middle overlay position (centered)
local middle_height=$((MIDDLE_WIDTH * 1080 / 1920))
local middle_x=$(( (CANVAS_WIDTH - MIDDLE_WIDTH) / 2 ))
local middle_y=$(( (CANVAS_HEIGHT - middle_height) / 2 ))
echo " → Compositing final video..."
# Complex filter to:
# 1. Create black canvas
# 2. Overlay three columns
# 3. Overlay middle section
# 4. Mix all audio
# Use eof_action=repeat to hold last frame if any input ends early
# This prevents video freezing due to timestamp issues in inputs
# The black canvas duration controls final video length
local filter_complex="
color=black:s=${CANVAS_WIDTH}x${CANVAS_HEIGHT}:d=${target_duration}:r=30[base];
[0:v]setpts=PTS-STARTPTS[v0];
[1:v]setpts=PTS-STARTPTS[v1];
[2:v]setpts=PTS-STARTPTS[v2];
[3:v]setpts=PTS-STARTPTS[v3];
[base][v0]overlay=${COL_LEFT_X}:${COL_Y}:eof_action=repeat[tmp1];
[tmp1][v1]overlay=${COL_CENTER_X}:${COL_Y}:eof_action=repeat[tmp2];
[tmp2][v2]overlay=${COL_RIGHT_X}:${COL_Y}:eof_action=repeat[tmp3];
[tmp3][v3]overlay=${middle_x}:${middle_y}:eof_action=repeat[vout];
[0:a][1:a][2:a][3:a]amix=inputs=4:duration=first:dropout_transition=2[aout]
"
ffmpeg -nostdin \
-i "$col_left" \
-i "$col_center" \
-i "$col_right" \
-i "$middle_overlay" \
-filter_complex "$filter_complex" \
-map "[vout]" -map "[aout]" \
-c:v $ENCODER $ENCODER_OPTS \
-c:a aac -b:a 192k \
-movflags +faststart \
-loglevel error \
-y "$output_file"
}
# ============================================================================
# INTERACTIVE MENU
# ============================================================================
select_target_duration() {
# Non-interactive mode: use TARGET_DURATION from environment (in seconds)
if [[ "${NONINTERACTIVE:-0}" == "1" ]]; then
TARGET_DURATION="${TARGET_DURATION:-3600}" # Default 60 minutes
echo "Non-interactive mode: Target duration $(format_duration $TARGET_DURATION)"
return
fi
echo ""
echo "Select target compilation duration:"
echo " 1) 30 minutes"
echo " 2) 45 minutes"
echo " 3) 60 minutes (1 hour)"
echo " 4) 90 minutes (1.5 hours)"
echo " 5) 120 minutes (2 hours)"
echo " 6) Custom duration"
echo ""
read -p "Enter choice [1-6] (default: 3): " duration_choice
case "$duration_choice" in
1) TARGET_DURATION=$((30 * 60)) ;;
2) TARGET_DURATION=$((45 * 60)) ;;
4) TARGET_DURATION=$((90 * 60)) ;;
5) TARGET_DURATION=$((120 * 60)) ;;
6)
read -p "Enter duration in minutes: " custom_minutes
TARGET_DURATION=$((custom_minutes * 60))
;;
*) TARGET_DURATION=$((60 * 60)) ;;
esac
echo ""
echo "Target duration: $(format_duration $TARGET_DURATION)"
}
select_middle_overlay_source() {
# Non-interactive mode: use OVERLAY_SOURCE from environment
if [[ "${NONINTERACTIVE:-0}" == "1" ]]; then
if [[ -n "$OVERLAY_SOURCE" ]]; then
MIDDLE_SOURCE_NAME="$OVERLAY_SOURCE"
MIDDLE_SOURCE_DIR="$GIFS_DIR/$MIDDLE_SOURCE_NAME"
if [[ ! -d "$MIDDLE_SOURCE_DIR/horizontal" ]]; then
echo "Error: Overlay source '$OVERLAY_SOURCE' not found or has no horizontal content"
exit 1
fi
echo "Non-interactive mode: Using overlay source '$MIDDLE_SOURCE_NAME'"
return
else
echo "Error: OVERLAY_SOURCE environment variable required in non-interactive mode"
exit 1
fi
fi
echo ""
echo "Select source for middle horizontal overlay:"
echo ""
# Find all creator folders with horizontal content
local creators=()
while IFS= read -r -d '' dir; do
if [[ -d "$dir/horizontal" ]]; then
local count=$(find "$dir/horizontal" -type f -name "*.mp4" 2>/dev/null | wc -l)
if [[ $count -gt 0 ]]; then
local name=$(basename "$dir")
creators+=("$name:$count")
fi
fi
done < <(find "$GIFS_DIR" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | filter_excluded_null | sort -z)
if [[ ${#creators[@]} -eq 0 ]]; then
echo "Error: No horizontal content found in gifs directory"
exit 1
fi
# Display menu
local idx=1
for entry in "${creators[@]}"; do
local name="${entry%%:*}"
local count="${entry#*:}"
printf " %2d) %s (%d videos)\n" "$idx" "$name" "$count"
((idx++))
done
echo ""
read -p "Enter choice [1-${#creators[@]}]: " creator_choice
if [[ -z "$creator_choice" ]] || [[ "$creator_choice" -lt 1 ]] || [[ "$creator_choice" -gt ${#creators[@]} ]]; then
echo "Invalid choice"
exit 1
fi
local selected="${creators[$((creator_choice - 1))]}"
MIDDLE_SOURCE_NAME="${selected%%:*}"
MIDDLE_SOURCE_DIR="$GIFS_DIR/$MIDDLE_SOURCE_NAME"
echo ""
echo "Selected: $MIDDLE_SOURCE_NAME"
}
# ============================================================================
# MAIN
# ============================================================================
main() {
echo ""
echo "=============================================="
echo " MEGA COMPILATION CREATOR"
echo "=============================================="
echo ""
# Check dependencies
check_dependencies
# Setup encoder
setup_encoder
# Log excluded directories if any
if [[ -n "$EXCLUDED_DIRS" ]]; then
echo ""
echo "Excluding directories: $EXCLUDED_DIRS"
fi
# Get user selections
select_target_duration
select_middle_overlay_source
local current_date=$(date +%d-%m-%Y)
local start_time=$(date +%s)
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Building Video Inventory"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Reset used videos tracker
USED_VIDEOS=()
# Build video inventories
declare -a LIBRARY_VIDEOS
declare -a GIF_VIDEOS
declare -a MIDDLE_VIDEOS
find_library_vertical_videos LIBRARY_VIDEOS
find_gif_vertical_videos GIF_VIDEOS
find_horizontal_videos "$MIDDLE_SOURCE_DIR" MIDDLE_VIDEOS
echo " → Found ${#MIDDLE_VIDEOS[@]} horizontal videos for middle overlay"
if [[ ${#LIBRARY_VIDEOS[@]} -eq 0 ]] && [[ ${#GIF_VIDEOS[@]} -eq 0 ]]; then
echo ""
echo "Error: No vertical videos found"
exit 1
fi
if [[ ${#MIDDLE_VIDEOS[@]} -eq 0 ]]; then
echo ""
echo "Error: No horizontal videos found for middle overlay"
exit 1
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Selecting Videos for Columns"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Initialize temp file to track used videos across subshells
USED_VIDEOS_FILE=$(mktemp)
export USED_VIDEOS_FILE # Export so subshells can see it
# Select videos for each column (unique videos per column)
mapfile -t COL_LEFT_VIDEOS < <(select_videos_for_column "$TARGET_DURATION" LIBRARY_VIDEOS GIF_VIDEOS "Left")
mapfile -t COL_CENTER_VIDEOS < <(select_videos_for_column "$TARGET_DURATION" LIBRARY_VIDEOS GIF_VIDEOS "Center")
mapfile -t COL_RIGHT_VIDEOS < <(select_videos_for_column "$TARGET_DURATION" LIBRARY_VIDEOS GIF_VIDEOS "Right")
# Show preview and confirm
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Preview Selection"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Left column (${#COL_LEFT_VIDEOS[@]} videos):"
for v in "${COL_LEFT_VIDEOS[@]:0:3}"; do
echo "$(basename "$v")"
done
[[ ${#COL_LEFT_VIDEOS[@]} -gt 3 ]] && echo " ... and $((${#COL_LEFT_VIDEOS[@]} - 3)) more"
echo ""
echo "Center column (${#COL_CENTER_VIDEOS[@]} videos):"
for v in "${COL_CENTER_VIDEOS[@]:0:3}"; do
echo "$(basename "$v")"
done
[[ ${#COL_CENTER_VIDEOS[@]} -gt 3 ]] && echo " ... and $((${#COL_CENTER_VIDEOS[@]} - 3)) more"
echo ""
echo "Right column (${#COL_RIGHT_VIDEOS[@]} videos):"
for v in "${COL_RIGHT_VIDEOS[@]:0:3}"; do
echo "$(basename "$v")"
done
[[ ${#COL_RIGHT_VIDEOS[@]} -gt 3 ]] && echo " ... and $((${#COL_RIGHT_VIDEOS[@]} - 3)) more"
echo ""
echo "Middle overlay: $MIDDLE_SOURCE_NAME (${#MIDDLE_VIDEOS[@]} horizontal videos)"
echo ""
echo "Target duration: $(format_duration $TARGET_DURATION)"
echo ""
# Skip confirmation in non-interactive mode
if [[ "${NONINTERACTIVE:-0}" != "1" ]]; then
read -p "Proceed with encoding? [Y/n]: " confirm
if [[ "$confirm" =~ ^[Nn] ]]; then
echo "Cancelled."
exit 0
fi
fi
# Create temp directory for intermediate files
TEMP_DIR=$(mktemp -d)
trap "rm -rf '$TEMP_DIR'; rm -f '$USED_VIDEOS_FILE'" EXIT
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Processing Columns"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Create column videos
echo " → Processing left column (${#COL_LEFT_VIDEOS[@]} videos)..."
create_column_video "$TEMP_DIR/col_left.mp4" "$TARGET_DURATION" "${COL_LEFT_VIDEOS[@]}"
echo " → Processing center column (${#COL_CENTER_VIDEOS[@]} videos)..."
create_column_video "$TEMP_DIR/col_center.mp4" "$TARGET_DURATION" "${COL_CENTER_VIDEOS[@]}"
echo " → Processing right column (${#COL_RIGHT_VIDEOS[@]} videos)..."
create_column_video "$TEMP_DIR/col_right.mp4" "$TARGET_DURATION" "${COL_RIGHT_VIDEOS[@]}"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Processing Middle Overlay"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Shuffle and limit middle videos
mapfile -t SHUFFLED_MIDDLE < <(printf '%s\n' "${MIDDLE_VIDEOS[@]}" | shuf | head -20)
echo " → Creating middle overlay with ${#SHUFFLED_MIDDLE[@]} videos and crossfades..."
create_middle_overlay "$TEMP_DIR/middle.mp4" "$TARGET_DURATION" "${SHUFFLED_MIDDLE[@]}"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Creating Final Composite"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Create final composite
local temp_output="$TEMP_DIR/final.mp4"
create_final_composite "$temp_output" \
"$TEMP_DIR/col_left.mp4" \
"$TEMP_DIR/col_center.mp4" \
"$TEMP_DIR/col_right.mp4" \
"$TEMP_DIR/middle.mp4" \
"$TARGET_DURATION"
if [[ -f "$temp_output" ]]; then
# Get actual duration
local actual_duration=$(get_duration_ffprobe "$temp_output")
local duration_formatted=$(format_duration "$actual_duration")
# Create final filename with versioning to prevent overwrites
local base_filename="Mega Comp [${current_date}] [${duration_formatted}] [4K] [E] [H]"
local final_filename="${base_filename}.mp4"
local final_path="$OUTPUT_DIR/$final_filename"
# Check if file exists and add version number if needed
local version=1
while [[ -f "$final_path" ]]; do
final_filename="${base_filename} v${version}.mp4"
final_path="$OUTPUT_DIR/$final_filename"
((version++))
done
mv "$temp_output" "$final_path"
local file_size=$(du -h "$final_path" | cut -f1)
echo ""
echo " ✓ Success! Created: $final_filename ($file_size)"
else
echo ""
echo " ✗ Failed to create compilation"
exit 1
fi
# Calculate elapsed time
local end_time=$(date +%s)
local elapsed=$((end_time - start_time))
local hours=$((elapsed / 3600))
local minutes=$(((elapsed % 3600) / 60))
local seconds=$((elapsed % 60))
echo ""
echo ""
echo "=============================================="
echo " MEGA COMPILATION COMPLETE!"
echo "=============================================="
echo " Time elapsed: ${hours}h ${minutes}m ${seconds}s"
echo "=============================================="
echo ""
}
# Run main
main "$@"