1036 lines
36 KiB
Bash
Executable File
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 "$@"
|