971 lines
32 KiB
Bash
Executable File
971 lines
32 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# ============================================================================
|
|
# QUAD HORIZONTAL COMPILATION CREATOR
|
|
# ============================================================================
|
|
# Creates a 4K compilation with:
|
|
# - Four 1920x1080 horizontal videos in a 2x2 grid (perfectly tiles 4K)
|
|
# - Center horizontal overlay with crossfade transitions
|
|
# - Smart duration filling to reach target length
|
|
#
|
|
# Canvas: 3840x2160 (4K)
|
|
# Grid: 2x2 of 1920x1080 videos (no gaps, perfect tile)
|
|
# Positions: TL (0,0), TR (1920,0), BL (0,1080), BR (1920,1080)
|
|
# Middle overlay: Centered on top, 800px wide
|
|
# ============================================================================
|
|
|
|
# Enable strict error handling
|
|
set -o pipefail # Catch failures in piped commands
|
|
|
|
# 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
|
|
}
|
|
|
|
# Quadrant dimensions and positions (2x2 grid, perfectly tiles 4K canvas)
|
|
QUAD_WIDTH=1920
|
|
QUAD_HEIGHT=1080
|
|
QUAD_TL_X=0
|
|
QUAD_TL_Y=0
|
|
QUAD_TR_X=1920
|
|
QUAD_TR_Y=0
|
|
QUAD_BL_X=0
|
|
QUAD_BL_Y=1080
|
|
QUAD_BR_X=1920
|
|
QUAD_BR_Y=1080
|
|
|
|
# Middle overlay settings (sits on top of the 4 quadrants)
|
|
MIDDLE_WIDTH=800
|
|
CROSSFADE_DURATION=1 # seconds
|
|
|
|
# Diversity settings
|
|
MAX_VIDEOS_PER_PERFORMER=1 # Max videos from same performer per quadrant
|
|
|
|
# Canvas
|
|
CANVAS_WIDTH=3840
|
|
CANVAS_HEIGHT=2160
|
|
|
|
# ============================================================================
|
|
# UTILITY FUNCTIONS
|
|
# ============================================================================
|
|
|
|
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() {
|
|
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
|
|
}
|
|
|
|
get_duration_from_filename() {
|
|
local filename="$1"
|
|
local basename=$(basename "$filename")
|
|
if [[ "$basename" =~ \[([0-9]+):([0-9]{2})\] ]]; then
|
|
local hours="${BASH_REMATCH[1]}"
|
|
local minutes="${BASH_REMATCH[2]}"
|
|
echo $(( 10#$hours * 3600 + 10#$minutes * 60 ))
|
|
return 0
|
|
fi
|
|
if [[ "$basename" =~ \[([0-9]+)s\] ]]; then
|
|
echo $(( 10#${BASH_REMATCH[1]} ))
|
|
return 0
|
|
fi
|
|
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$filename" 2>/dev/null | cut -d. -f1
|
|
}
|
|
|
|
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_duration() {
|
|
local seconds="$1"
|
|
local hours=$((seconds / 3600))
|
|
local minutes=$(((seconds % 3600) / 60))
|
|
printf "%d:%02d" "$hours" "$minutes"
|
|
}
|
|
|
|
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" ]]
|
|
}
|
|
|
|
is_valid_video() {
|
|
local file="$1"
|
|
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
|
|
if ! timeout 10 ffmpeg -nostdin -v error -i "$file" -vframes 5 -f null - 2>/dev/null; then
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ============================================================================
|
|
# VIDEO INVENTORY FUNCTIONS
|
|
# ============================================================================
|
|
|
|
# Find all horizontal [H] videos from the main library (not gifs)
|
|
find_library_horizontal_videos() {
|
|
local -n result_array=$1
|
|
local skipped=0
|
|
local checked=0
|
|
|
|
echo " Scanning library for horizontal [H] videos..."
|
|
|
|
while IFS= read -r -d '' file; do
|
|
((checked++))
|
|
printf "\r Checking file %d..." "$checked"
|
|
|
|
if ! has_audio "$file"; then
|
|
((skipped++))
|
|
continue
|
|
fi
|
|
|
|
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 "*\[H\].mp4" ! -path "*/gifs/*" ! -path "*/scripts/*" -print0 2>/dev/null | filter_excluded_null)
|
|
|
|
echo ""
|
|
echo " Found ${#result_array[@]} valid horizontal videos in library (skipped $skipped)"
|
|
}
|
|
|
|
# Find all horizontal gif videos
|
|
find_gif_horizontal_videos() {
|
|
local -n result_array=$1
|
|
local skipped=0
|
|
local checked=0
|
|
local total_files=$(find "$GIFS_DIR" -path "*/horizontal/*.mp4" -type f 2>/dev/null | filter_excluded | wc -l)
|
|
|
|
echo " Scanning for horizontal 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
|
|
|
|
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 "*/horizontal/*.mp4" -type f -print0 2>/dev/null | filter_excluded_null)
|
|
|
|
echo ""
|
|
echo " Found ${#result_array[@]} valid horizontal gif clips (skipped $skipped)"
|
|
}
|
|
|
|
# Find horizontal videos in a specific creator folder (for middle overlay)
|
|
find_creator_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
|
|
|
|
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
|
|
# ============================================================================
|
|
|
|
USED_VIDEOS_FILE=""
|
|
|
|
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_used() {
|
|
local filepath="$1"
|
|
echo "$filepath" >> "$USED_VIDEOS_FILE"
|
|
sync
|
|
}
|
|
|
|
# Extract performer name from filename (format: "Studio - Performer - Title [metadata].mp4")
|
|
get_performer_from_filename() {
|
|
local filepath="$1"
|
|
local basename=$(basename "$filepath" .mp4)
|
|
|
|
# Try to extract performer from "Studio - Performer - Title" format
|
|
if [[ "$basename" =~ ^[^-]+-[[:space:]]*([^-]+)[[:space:]]*- ]]; then
|
|
local performer="${BASH_REMATCH[1]}"
|
|
# Trim whitespace and normalize
|
|
performer=$(echo "$performer" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]')
|
|
echo "$performer"
|
|
return 0
|
|
fi
|
|
|
|
# Fallback: use first part before any dash or bracket
|
|
local fallback=$(echo "$basename" | sed 's/[[-].*//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]')
|
|
echo "$fallback"
|
|
}
|
|
|
|
# Select videos to fill a target duration for one quadrant
|
|
select_videos_for_quadrant() {
|
|
local target_seconds=$1
|
|
local -n library_videos=$2
|
|
local -n gif_videos=$3
|
|
local quadrant_name=$4
|
|
|
|
local library_selected=()
|
|
local gif_selected=()
|
|
local library_duration=0
|
|
local gif_duration=0
|
|
local min_gap=30
|
|
|
|
local library_target=$((target_seconds / 2))
|
|
local gif_target=$((target_seconds / 2))
|
|
|
|
# Track performer counts for diversity
|
|
declare -A performer_counts
|
|
|
|
# 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
|
|
for entry in "${shuffled_library[@]}"; do
|
|
local duration="${entry%%:*}"
|
|
local filepath="${entry#*:}"
|
|
|
|
if is_video_used "$filepath"; then
|
|
continue
|
|
fi
|
|
|
|
# Check performer diversity limit
|
|
local performer=$(get_performer_from_filename "$filepath")
|
|
local current_count=${performer_counts[$performer]:-0}
|
|
if [[ "$current_count" -ge "$MAX_VIDEOS_PER_PERFORMER" ]]; 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))
|
|
performer_counts[$performer]=$((current_count + 1))
|
|
fi
|
|
done
|
|
|
|
# Select gif videos
|
|
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
|
|
|
|
# Check performer diversity limit
|
|
local performer=$(get_performer_from_filename "$filepath")
|
|
local current_count=${performer_counts[$performer]:-0}
|
|
if [[ "$current_count" -ge "$MAX_VIDEOS_PER_PERFORMER" ]]; 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))
|
|
performer_counts[$performer]=$((current_count + 1))
|
|
fi
|
|
done
|
|
|
|
# Interleave library and gif videos
|
|
local final_videos=()
|
|
local lib_idx=0
|
|
local gif_idx=0
|
|
local lib_count=${#library_selected[@]}
|
|
local gif_count=${#gif_selected[@]}
|
|
|
|
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
|
|
if [[ $lib_idx -lt $lib_count ]]; then
|
|
local entry="${library_selected[$lib_idx]}"
|
|
final_videos+=("${entry#*:}")
|
|
((lib_idx++))
|
|
fi
|
|
|
|
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))
|
|
|
|
local unique_performers=${#performer_counts[@]}
|
|
echo " Quadrant $quadrant_name: ${#final_videos[@]} videos from $unique_performers performers, $(format_duration $total_duration) total (${lib_pct}% library, $((100-lib_pct))% gifs)" >&2
|
|
|
|
printf '%s\n' "${final_videos[@]}"
|
|
}
|
|
|
|
# ============================================================================
|
|
# VIDEO PROCESSING FUNCTIONS
|
|
# ============================================================================
|
|
|
|
# Create a single quadrant video using concat filter
|
|
create_quadrant_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 quadrant"
|
|
return 1
|
|
fi
|
|
|
|
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..."
|
|
|
|
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 to quadrant size (horizontal 16:9), normalize fps/SAR
|
|
filter_v="$filter_v[$i:v]scale=${QUAD_WIDTH}:${QUAD_HEIGHT}:force_original_aspect_ratio=decrease,pad=${QUAD_WIDTH}:${QUAD_HEIGHT}:(ow-iw)/2:(oh-ih)/2,fps=30,setsar=1,setpts=PTS-STARTPTS[v$i];"
|
|
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
|
|
|
|
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 quadrant (single-pass concat filter)..."
|
|
|
|
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
|
|
|
|
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 " Quadrant 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
|
|
|
|
local scaled_height=$((MIDDLE_WIDTH * 9 / 16)) # 16:9 aspect ratio
|
|
local temp_dir=$(mktemp -d)
|
|
|
|
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,setsar=1" \
|
|
-c:v $ENCODER $ENCODER_OPTS \
|
|
-af "aresample=48000" \
|
|
-c:a aac -b:a 128k -ar 48000 \
|
|
-loglevel error \
|
|
-y "$output_file" 2>/dev/null
|
|
rm -rf "$temp_dir"
|
|
return 0
|
|
fi
|
|
|
|
# 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,setsar=1,setpts=PTS-STARTPTS" \
|
|
-c:v $ENCODER $ENCODER_OPTS \
|
|
-af "aresample=48000,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=""
|
|
|
|
for i in "${!prepared_files[@]}"; do
|
|
inputs="$inputs -i \"${prepared_files[$i]}\""
|
|
done
|
|
|
|
local durations=()
|
|
for prep_file in "${prepared_files[@]}"; do
|
|
local dur=$(get_duration_ffprobe "$prep_file")
|
|
durations+=("$dur")
|
|
done
|
|
|
|
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
|
|
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
|
|
|
|
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
|
|
|
|
local xfade_duration=$(get_duration_ffprobe "$xfade_output")
|
|
|
|
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
|
|
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 4 quadrants and middle overlay
|
|
create_final_composite() {
|
|
local output_file="$1"
|
|
local quad_tl="$2"
|
|
local quad_tr="$3"
|
|
local quad_bl="$4"
|
|
local quad_br="$5"
|
|
local middle_overlay="$6"
|
|
local target_duration="$7"
|
|
|
|
local middle_height=$((MIDDLE_WIDTH * 9 / 16))
|
|
local middle_x=$(( (CANVAS_WIDTH - MIDDLE_WIDTH) / 2 ))
|
|
local middle_y=$(( (CANVAS_HEIGHT - middle_height) / 2 ))
|
|
|
|
echo " Compositing final video..."
|
|
|
|
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];
|
|
[4:v]setpts=PTS-STARTPTS[v4];
|
|
[base][v0]overlay=${QUAD_TL_X}:${QUAD_TL_Y}:eof_action=repeat[tmp1];
|
|
[tmp1][v1]overlay=${QUAD_TR_X}:${QUAD_TR_Y}:eof_action=repeat[tmp2];
|
|
[tmp2][v2]overlay=${QUAD_BL_X}:${QUAD_BL_Y}:eof_action=repeat[tmp3];
|
|
[tmp3][v3]overlay=${QUAD_BR_X}:${QUAD_BR_Y}:eof_action=repeat[tmp4];
|
|
[tmp4][v4]overlay=${middle_x}:${middle_y}:eof_action=repeat[vout];
|
|
[0:a][1:a][2:a][3:a][4:a]amix=inputs=5:duration=first:dropout_transition=2[aout]
|
|
"
|
|
|
|
ffmpeg -nostdin \
|
|
-i "$quad_tl" \
|
|
-i "$quad_tr" \
|
|
-i "$quad_bl" \
|
|
-i "$quad_br" \
|
|
-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 ""
|
|
|
|
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
|
|
|
|
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 " QUAD HORIZONTAL COMPILATION CREATOR"
|
|
echo "=============================================="
|
|
echo ""
|
|
|
|
check_dependencies
|
|
setup_encoder
|
|
|
|
# Log excluded directories if any
|
|
if [[ -n "$EXCLUDED_DIRS" ]]; then
|
|
echo ""
|
|
echo "Excluding directories: $EXCLUDED_DIRS"
|
|
fi
|
|
|
|
select_target_duration
|
|
select_middle_overlay_source
|
|
|
|
local current_date=$(date +%d-%m-%Y)
|
|
local start_time=$(date +%s)
|
|
|
|
echo ""
|
|
echo "Building Video Inventory"
|
|
echo ""
|
|
|
|
USED_VIDEOS_FILE=$(mktemp)
|
|
export USED_VIDEOS_FILE
|
|
|
|
declare -a LIBRARY_VIDEOS
|
|
declare -a GIF_VIDEOS
|
|
declare -a MIDDLE_VIDEOS
|
|
|
|
find_library_horizontal_videos LIBRARY_VIDEOS
|
|
find_gif_horizontal_videos GIF_VIDEOS
|
|
find_creator_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 horizontal 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 "Selecting Videos for Quadrants"
|
|
echo ""
|
|
|
|
mapfile -t QUAD_TL_VIDEOS < <(select_videos_for_quadrant "$TARGET_DURATION" LIBRARY_VIDEOS GIF_VIDEOS "Top-Left")
|
|
mapfile -t QUAD_TR_VIDEOS < <(select_videos_for_quadrant "$TARGET_DURATION" LIBRARY_VIDEOS GIF_VIDEOS "Top-Right")
|
|
mapfile -t QUAD_BL_VIDEOS < <(select_videos_for_quadrant "$TARGET_DURATION" LIBRARY_VIDEOS GIF_VIDEOS "Bottom-Left")
|
|
mapfile -t QUAD_BR_VIDEOS < <(select_videos_for_quadrant "$TARGET_DURATION" LIBRARY_VIDEOS GIF_VIDEOS "Bottom-Right")
|
|
|
|
echo ""
|
|
echo "Preview Selection"
|
|
echo ""
|
|
echo "Top-Left (${#QUAD_TL_VIDEOS[@]} videos):"
|
|
for v in "${QUAD_TL_VIDEOS[@]:0:3}"; do
|
|
echo " $(basename "$v")"
|
|
done
|
|
[[ ${#QUAD_TL_VIDEOS[@]} -gt 3 ]] && echo " ... and $((${#QUAD_TL_VIDEOS[@]} - 3)) more"
|
|
echo ""
|
|
echo "Top-Right (${#QUAD_TR_VIDEOS[@]} videos):"
|
|
for v in "${QUAD_TR_VIDEOS[@]:0:3}"; do
|
|
echo " $(basename "$v")"
|
|
done
|
|
[[ ${#QUAD_TR_VIDEOS[@]} -gt 3 ]] && echo " ... and $((${#QUAD_TR_VIDEOS[@]} - 3)) more"
|
|
echo ""
|
|
echo "Bottom-Left (${#QUAD_BL_VIDEOS[@]} videos):"
|
|
for v in "${QUAD_BL_VIDEOS[@]:0:3}"; do
|
|
echo " $(basename "$v")"
|
|
done
|
|
[[ ${#QUAD_BL_VIDEOS[@]} -gt 3 ]] && echo " ... and $((${#QUAD_BL_VIDEOS[@]} - 3)) more"
|
|
echo ""
|
|
echo "Bottom-Right (${#QUAD_BR_VIDEOS[@]} videos):"
|
|
for v in "${QUAD_BR_VIDEOS[@]:0:3}"; do
|
|
echo " $(basename "$v")"
|
|
done
|
|
[[ ${#QUAD_BR_VIDEOS[@]} -gt 3 ]] && echo " ... and $((${#QUAD_BR_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
|
|
|
|
TEMP_DIR=$(mktemp -d)
|
|
trap "rm -rf '$TEMP_DIR'; rm -f '$USED_VIDEOS_FILE'" EXIT
|
|
|
|
echo ""
|
|
echo "Processing Quadrants"
|
|
echo ""
|
|
|
|
echo " Processing Top-Left (${#QUAD_TL_VIDEOS[@]} videos)..."
|
|
create_quadrant_video "$TEMP_DIR/quad_tl.mp4" "$TARGET_DURATION" "${QUAD_TL_VIDEOS[@]}"
|
|
|
|
echo " Processing Top-Right (${#QUAD_TR_VIDEOS[@]} videos)..."
|
|
create_quadrant_video "$TEMP_DIR/quad_tr.mp4" "$TARGET_DURATION" "${QUAD_TR_VIDEOS[@]}"
|
|
|
|
echo " Processing Bottom-Left (${#QUAD_BL_VIDEOS[@]} videos)..."
|
|
create_quadrant_video "$TEMP_DIR/quad_bl.mp4" "$TARGET_DURATION" "${QUAD_BL_VIDEOS[@]}"
|
|
|
|
echo " Processing Bottom-Right (${#QUAD_BR_VIDEOS[@]} videos)..."
|
|
create_quadrant_video "$TEMP_DIR/quad_br.mp4" "$TARGET_DURATION" "${QUAD_BR_VIDEOS[@]}"
|
|
|
|
echo ""
|
|
echo "Processing Middle Overlay"
|
|
echo ""
|
|
|
|
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 "Creating Final Composite"
|
|
echo ""
|
|
|
|
local temp_output="$TEMP_DIR/final.mp4"
|
|
create_final_composite "$temp_output" \
|
|
"$TEMP_DIR/quad_tl.mp4" \
|
|
"$TEMP_DIR/quad_tr.mp4" \
|
|
"$TEMP_DIR/quad_bl.mp4" \
|
|
"$TEMP_DIR/quad_br.mp4" \
|
|
"$TEMP_DIR/middle.mp4" \
|
|
"$TARGET_DURATION"
|
|
|
|
if [[ -f "$temp_output" ]]; then
|
|
local actual_duration=$(get_duration_ffprobe "$temp_output")
|
|
local duration_formatted=$(format_duration "$actual_duration")
|
|
|
|
# Create final filename with versioning
|
|
local base_filename="Quad Comp [${current_date}] [${duration_formatted}] [4K] [E] [H]"
|
|
local final_filename="${base_filename}.mp4"
|
|
local final_path="$OUTPUT_DIR/$final_filename"
|
|
|
|
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
|
|
|
|
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 " QUAD COMPILATION COMPLETE!"
|
|
echo "=============================================="
|
|
echo " Time elapsed: ${hours}h ${minutes}m ${seconds}s"
|
|
echo "=============================================="
|
|
echo ""
|
|
}
|
|
|
|
main "$@"
|