Merge pull request #1 from outis1one/claude/seasonal-sunrise-montage-nn268

Claude/seasonal sunrise montage nn268
This commit is contained in:
Outis
2026-04-17 23:48:13 -04:00
committed by GitHub
4 changed files with 468 additions and 255 deletions
Regular → Executable
+81 -159
View File
@@ -1,182 +1,104 @@
#!/bin/bash #!/bin/bash
# 4-seasons.sh — create one daily clip from yesterday's images, sized to its
# share of the corresponding Vivaldi movement's music duration.
#
# On the last day of a movement period, automatically runs montage-mvt.sh to
# compile the full movement montage.
#
# Run once per day from cron (e.g. 01:00 daily).
set -euo pipefail
set -x set -x
# Get yesterday's date SCRIPT_DIR="$(dirname "$(realpath "$0")")"
yesterday=$(date --date="yesterday" +%Y-%m-%d)
current_year=$(date +%Y)
day_of_year=$(date --date="$yesterday" +%j) # Day of the year (1 to 366)
# Define base directory and music directory # ── Configuration ──────────────────────────────────────────────────────────────
base_dir="/home/motion/drives/local-2tb" base_dir="/home/motion/drives/local-2tb"
music_base_dir="/home/motion/drives/local-2tb/music/4 Seasons" music_base_dir="$base_dir/music/4 Seasons"
[ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music"
# Determine the season based on the day of the year # ── Date setup ────────────────────────────────────────────────────────────────
if [ "$day_of_year" -ge 80 ] && [ "$day_of_year" -le 172 ]; then yesterday=$(date --date="yesterday" +%Y-%m-%d)
season="Spring" echo "Processing: $yesterday"
season_start_date=80
season_end_date=172
days_in_season=92
elif [ "$day_of_year" -ge 173 ] && [ "$day_of_year" -le 264 ]; then
season="Summer"
season_start_date=173
season_end_date=264
days_in_season=92
elif [ "$day_of_year" -ge 265 ] && [ "$day_of_year" -le 355 ]; then
season="Autumn"
season_start_date=265
season_end_date=355
days_in_season=91
else
season="Winter"
season_start_date=356
season_end_date=79
days_in_season=90
fi
# Calculate the day of the season (from 1 to days_in_season) # ── Season / movement info from astronomical calculation ──────────────────────
if [ "$season" == "Winter" ]; then eval "$(python3 "$SCRIPT_DIR/season_info.py" "$yesterday")"
if [ "$day_of_year" -ge 355 ]; then # Provides: SEASON MVT_NUM DAY_OF_MVT DAYS_IN_MVT MVT_START MVT_END IS_LAST_DAY
# Winter season starts at day 1 on Dec 21st echo "Season=$SEASON Mvt=$MVT_NUM Day=$DAY_OF_MVT/$DAYS_IN_MVT LastDay=$IS_LAST_DAY"
day_of_season=$(($day_of_year - 354)) # Day 1 of Winter starts on Dec 21st
else
# Before Dec 21st, Winter starts on Dec 21st in the previous year
day_of_season=$((365 - 354 + $day_of_year)) # Adjust for the 365th day (Dec 20th) in the previous year
fi
else
day_of_season=$(($day_of_year - $season_start_date + 1))
fi
# Determine the movement based on the day of the season # ── Locate music file and get its duration ────────────────────────────────────
if [ "$season" == "Winter" ]; then music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*Mvt*${MVT_NUM}*" | sort | head -n 1)
# Winter typically has three movements, so we calculate the movement number if [ -z "$music_file" ] || [ ! -f "$music_file" ]; then
if [ "$day_of_season" -le 30 ]; then echo "ERROR: Music file not found for $SEASON Mvt $MVT_NUM in $music_base_dir"
movement_num=1
elif [ "$day_of_season" -le 60 ]; then
movement_num=2
else
movement_num=3
fi
else
# Other seasons follow the same calculation for movement
if [ "$day_of_season" -le 30 ]; then
movement_num=1
elif [ "$day_of_season" -le 60 ]; then
movement_num=2
else
movement_num=3
fi
fi
# Determine the music file based on the season and movement
music_file=$(find "$music_base_dir" -type f -iname "*$season Mvt $movement_num*" | head -n 1)
# Check if the music file exists
if [ ! -f "$music_file" ]; then
echo "Music file for $season, Movement $movement_num not found!"
exit 1 exit 1
fi fi
music_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$music_file")
echo "Music: $(basename "$music_file") ($music_duration s)"
# Get the duration of the music file in seconds # Each daily clip covers music_duration / DAYS_IN_MVT seconds so that
music_duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$music_file") # concatenating all clips for the movement sums to the exact music duration.
echo "Music duration: $music_duration seconds" # Printing the music duration for verification target_per_clip=$(echo "scale=6; $music_duration / $DAYS_IN_MVT" | bc)
echo "Target clip duration: $target_per_clip s ($DAYS_IN_MVT days in movement)"
# Define the directory where images are stored (using yesterday's date) # ── Locate yesterday's images ─────────────────────────────────────────────────
first_word_of_script_folder=$(basename "$(dirname "$(realpath "$0")")" | cut -d'-' -f1) script_name=$(basename "$SCRIPT_DIR" | cut -d'-' -f1)
image_dir="$base_dir/$first_word_of_script_folder/$yesterday" image_dir="$base_dir/$script_name/$yesterday"
# Check if the image directory exists
if [ ! -d "$image_dir" ]; then if [ ! -d "$image_dir" ]; then
echo "Error: Image directory $image_dir not found for $yesterday!" echo "WARNING: Image directory $image_dir not found — skipping $yesterday."
echo "Skipping this day's video generation."
# Log the missing day
echo "No images for $yesterday" >> /path/to/missing_days.log
# Skip to the next day
exit 0 exit 0
fi fi
# Get today's date in yyyy-mm format for output directory total_images=$(find "$image_dir" -type f -name "*.jpg" | wc -l)
output_date=$(date +"%Y-%m") # yyyy-mm format if [ "$total_images" -lt 1 ]; then
echo "WARNING: No images in $image_dir — skipping $yesterday."
exit 0
fi
echo "Images found: $total_images"
# Dynamically generate the output directory based on season and movement # ── Prepare output directory ──────────────────────────────────────────────────
output_dir="$base_dir/movies/$first_word_of_script_folder/$season/Mvt$movement_num" output_dir="$base_dir/movies/$script_name/$SEASON/Mvt$MVT_NUM"
mkdir -p "$output_dir" mkdir -p "$output_dir"
# Count the total number of images (JPEG files) # ── Build sorted image list ───────────────────────────────────────────────────
total_images=$(find "$image_dir" -type f -name "*.jpg" | wc -l) temp_file=$(mktemp --suffix=.txt)
temp_video="$output_dir/${yesterday}_Mvt${MVT_NUM}-temp.mp4"
cleanup() { rm -f "$temp_file" "$temp_video" 2>/dev/null || true; }
trap cleanup EXIT
# If there are no images, log it and move to the next day find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do
if [ "$total_images" -lt 1 ]; then echo "file '$img'"
echo "Error: No images found in $image_dir for $yesterday." done > "$temp_file"
echo "Skipping this day's video generation."
# Log the missing day # ── Step 1: Build raw video at default frame rate ─────────────────────────────
echo "No images for $yesterday" >> /path/to/missing_days.log echo "Step 1/2: encoding raw video..."
# Skip to the next day ffmpeg -loglevel warning \
exit 0 -f concat -safe 0 -i "$temp_file" \
-c:v libx264 -pix_fmt yuv420p -crf 28 -vsync 2 -an \
-y "$temp_video"
raw_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$temp_video")
echo "Raw duration: $raw_duration s"
# ── Step 2: Speed-adjust to target clip duration ──────────────────────────────
speed_factor=$(echo "scale=6; $raw_duration / $target_per_clip" | bc)
echo "Step 2/2: speed factor $speed_factor..."
final_file="$output_dir/${yesterday}_Mvt${MVT_NUM}-Day${DAY_OF_MVT}of${DAYS_IN_MVT}-final.mp4"
ffmpeg -loglevel warning \
-i "$temp_video" \
-vf "setpts=PTS/$speed_factor" \
-c:v libx264 -pix_fmt yuv420p -crf 26 \
-t "$target_per_clip" -an \
-y "$final_file"
adjusted=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_file")
echo "Daily clip: $final_file ($adjusted s)"
# ── Auto-trigger montage on last day of movement ──────────────────────────────
if [ "$IS_LAST_DAY" = "true" ]; then
echo "Last day of $SEASON Mvt $MVT_NUM — triggering montage compilation..."
# Pass $yesterday so montage-mvt.sh looks up the correct movement
# (running the next morning, "today" would already be the next movement).
"$SCRIPT_DIR/montage-mvt.sh" "$yesterday"
fi fi
# Create a temporary text file with the sorted list of image paths
temp_file=$(mktemp)
find "$image_dir" -type f -name "*.jpg" | sort -n | while read image; do
echo "file '$image'" >> "$temp_file"
done
# Calculate the number of images
num_images=$(wc -l < "$temp_file")
echo "Total images found for the video: $num_images"
# Calculate the target total duration (music duration + 3 seconds for transitions)
target_total_duration=$(echo "$music_duration + 3" | bc)
echo "Target total video duration (music + 3 seconds): $target_total_duration seconds"
# Calculate the target duration per video (based on number of days in the season)
target_duration_per_video=$(echo "$target_total_duration / $days_in_season" | bc -l)
echo "Each day's target video length: $target_duration_per_video seconds"
# Check if the daily video length is too short (less than 1.67 seconds)
if (( $(echo "$target_duration_per_video < 1.67" | bc -l) )); then
echo "Daily video length is less than 1.67 seconds. Doubling the music duration."
music_duration=$(echo "$music_duration * 2" | bc)
target_total_duration=$(echo "$music_duration + 3" | bc)
target_duration_per_video=$(echo "$target_total_duration / $days_in_season" | bc -l)
echo "New target total video duration (after doubling music): $target_total_duration seconds"
echo "New target duration per video: $target_duration_per_video seconds"
fi
# First, create the video using default frame rate (no speed-up or slow-down yet)
temp_video="$output_dir/${yesterday}_Mvt${movement_num}-temp.mp4"
echo "Creating video at default frame rate..."
ffmpeg -f concat -safe 0 -i "$temp_file" -c:v libx264 -pix_fmt yuv420p -crf 28 -vsync 2 -y "$temp_video"
# Check the duration of the created video
video_duration=$(ffmpeg -i "$temp_video" 2>&1 | grep "Duration" | cut -d ' ' -f 4 | sed s/,//)
echo "Initial video duration: $video_duration"
# Get the video duration in seconds (from HH:MM:SS format)
IFS=':' read -r hours minutes seconds <<< $(echo $video_duration | cut -d '.' -f1) # Extract time
total_seconds=$(($hours * 3600 + $minutes * 60 + $seconds)) # Convert to total seconds
# Calculate the speed-up factor to make the video fit into the target duration
speed_up_factor=$(echo "scale=3; $total_seconds / $target_duration_per_video" | bc)
echo "Calculated speed-up factor: $speed_up_factor"
# Adjust the playback speed using the setpts filter to make the video fit into the target duration
final_file_name="${yesterday}_Mvt${movement_num}-Day${day_of_season}of${days_in_season}-final.mp4"
final_video="$output_dir/$final_file_name"
ffmpeg -i "$temp_video" -filter:v "setpts=PTS/$speed_up_factor" -y "$final_video"
# Check the duration of the adjusted video
adjusted_duration=$(ffmpeg -i "$final_video" 2>&1 | grep "Duration" | cut -d ' ' -f 4 | sed s/,//)
echo "Adjusted video duration: $adjusted_duration"
# Check for ffmpeg success
if [ $? -ne 0 ]; then
echo "Error: ffmpeg failed to create the final video."
exit 1
fi
# Inform user the video has been created
echo "Video created at $final_video"
# Clean up the temporary file
rm "$temp_file"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
# fullday-video.sh — create a natural-speed timelapse of yesterday's full day of
# images at a fixed frame rate. Files older than RETENTION_DAYS are deleted
# automatically. Sunrise 10-second uploads and montage videos are NOT touched.
#
# Run once per day from cron (e.g. 02:00 daily, after 4-seasons.sh).
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
# ── Configuration ──────────────────────────────────────────────────────────────
base_dir="/home/motion/drives/local-2tb"
FULLDAY_FPS=5 # timelapse frame rate — adjust to taste
RETENTION_DAYS=10 # full-day videos older than this are deleted
# ── Date / paths ──────────────────────────────────────────────────────────────
yesterday=$(date --date="yesterday" +%Y-%m-%d)
script_name=$(basename "$SCRIPT_DIR" | cut -d'-' -f1)
image_dir="$base_dir/$script_name/$yesterday"
output_dir="$base_dir/movies/$script_name/fullday"
mkdir -p "$output_dir"
# ── Purge old full-day videos ─────────────────────────────────────────────────
echo "Removing full-day videos older than $RETENTION_DAYS days..."
find "$output_dir" -maxdepth 1 -name "*-fullday.mp4" -mtime "+$RETENTION_DAYS" -delete
# ── Sanity checks ─────────────────────────────────────────────────────────────
if [ ! -d "$image_dir" ]; then
echo "WARNING: Image directory $image_dir not found — skipping."
exit 0
fi
num_images=$(find "$image_dir" -type f -name "*.jpg" | wc -l)
if [ "$num_images" -lt 1 ]; then
echo "WARNING: No images in $image_dir — skipping."
exit 0
fi
frame_dur=$(echo "scale=6; 1 / $FULLDAY_FPS" | bc)
approx_min=$(echo "scale=1; $num_images / $FULLDAY_FPS / 60" | bc)
echo "Images: $num_images at ${FULLDAY_FPS} fps ≈ ${approx_min} min"
# ── Build concat list with per-frame duration ─────────────────────────────────
# The concat demuxer needs an explicit duration per image; the last file is
# repeated without a duration to correctly anchor the final frame's pts.
temp_list=$(mktemp --suffix=.txt)
trap 'rm -f "$temp_list"' EXIT
find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do
printf "file '%s'\nduration %s\n" "$img" "$frame_dur"
done > "$temp_list"
# Append last image again (required by ffmpeg concat demuxer for images)
last_img=$(find "$image_dir" -type f -name "*.jpg" | sort | tail -n 1)
printf "file '%s'\n" "$last_img" >> "$temp_list"
# ── Encode — native camera resolution, no audio ───────────────────────────────
output_file="$output_dir/${yesterday}-fullday.mp4"
echo "Creating: $output_file"
ffmpeg -loglevel warning \
-f concat -safe 0 -i "$temp_list" \
-c:v libx264 -pix_fmt yuv420p -crf 28 \
-an -y "$output_file"
actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$output_file")
echo "Done: $output_file (${actual_dur}s / $(echo "scale=1; $actual_dur/60" | bc) min)"
Regular → Executable
+147 -96
View File
@@ -1,108 +1,159 @@
#!/bin/bash #!/bin/bash
# montage-mvt.sh — compile all daily clips for the current Vivaldi movement into
# a single montage that exactly matches the music duration, with 2-second
# audio/video fade in/out and a 7-second attribution card appended.
#
# Called automatically by 4-seasons.sh on the last day of each movement.
# Can also be run manually (e.g. to build a partial mid-season preview).
#
# Music: "The Four Seasons" by Antonio Vivaldi
# Performed by John Harrison with the Wichita State University Chamber Players
# Source: Free Music Archive — freemusicarchive.org
# License: CC BY-SA 3.0 — creativecommons.org/licenses/by-sa/3.0
# Set parameters dynamically based on the current date set -euo pipefail
current_date=$(date +%Y-%m-%d)
current_year=$(date +%Y)
day_of_year=$(date +%j) # The day of the year (e.g., 1-365)
# Define season and movement parameters based on the current date SCRIPT_DIR="$(dirname "$(realpath "$0")")"
if [ $day_of_year -ge 265 ] && [ $day_of_year -le 355 ]; then
season="Autumn"
movement_num=3
season_start_date=265
season_end_date=355
days_in_season=91
day_of_season=$((day_of_year - season_start_date + 1)) # Calculate the day of the season
elif [ $day_of_year -ge 1 ] && [ $day_of_year -le 79 ]; then
season="Winter"
movement_num=1
season_start_date=1
season_end_date=79
days_in_season=79
day_of_season=$((day_of_year - season_start_date + 1))
elif [ $day_of_year -ge 80 ] && [ $day_of_year -le 171 ]; then
season="Spring"
movement_num=2
season_start_date=80
season_end_date=171
days_in_season=92
day_of_season=$((day_of_year - season_start_date + 1))
elif [ $day_of_year -ge 172 ] && [ $day_of_year -le 264 ]; then
season="Summer"
movement_num=4
season_start_date=172
season_end_date=264
days_in_season=93
day_of_season=$((day_of_year - season_start_date + 1))
else
echo "Season not found for the current date"
exit 1
fi
# Debugging info: Print dynamic parameters # ── Configuration ──────────────────────────────────────────────────────────────
echo "Season: $season" base_dir="/home/motion/drives/local-2tb"
echo "Movement: $movement_num" music_base_dir="$base_dir/music/4 Seasons"
echo "Day of Year: $day_of_year" [ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music"
# Set the directory for the videos dynamically based on the season and movement FADE_DUR=2.0 # seconds for video + audio fade in/out
video_base_dir="/home/motion/drives/local-2tb/movies/sunrise/$season/Mvt$movement_num" ATTR_DUR=7 # duration of attribution card in seconds
echo "Looking for video files in: $video_base_dir"
# Ensure the video directory exists # ── Season / movement info ────────────────────────────────────────────────────
if [ ! -d "$video_base_dir" ]; then # Optional first argument: YYYY-MM-DD of the last day of the movement to compile.
echo "Video directory $video_base_dir not found" # Used when called automatically from 4-seasons.sh (which processes yesterday's
exit 1 # images the next morning, so "today" would already be the next movement).
fi # When run manually with no argument, uses today — correct for same-day runs.
DATE_ARG="${1:-}"
eval "$(python3 "$SCRIPT_DIR/season_info.py" ${DATE_ARG:+"$DATE_ARG"})"
echo "Season=$SEASON Mvt=$MVT_NUM Day=$DAY_OF_MVT/$DAYS_IN_MVT (ref date: ${DATE_ARG:-today})"
# Find the music file for the current season and movement # ── Locate files ──────────────────────────────────────────────────────────────
music_file=$(find "/home/motion/drives/local-2tb/music/4 Seasons" -type f -iname "*$season Mvt $movement_num*" | head -n 1) script_name=$(basename "$SCRIPT_DIR" | cut -d'-' -f1)
video_dir="$base_dir/movies/$script_name/$SEASON/Mvt$MVT_NUM"
# Ensure the music file exists output_dir="$video_dir/montage"
if [ ! -f "$music_file" ]; then
echo "Music file for $season Mvt $movement_num not found"
exit 1
fi
# Get the duration of the music file in seconds
music_duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$music_file")
echo "Music duration: $music_duration seconds"
# Find all video files ending in "-final.mp4" using ls and grep
video_files=()
echo "Looking for video files in: $video_base_dir"
for video_file in $(ls "$video_base_dir" | grep -i "Mvt${movement_num}.*-final.mp4"); do
full_path="$video_base_dir/$video_file"
if [ -f "$full_path" ]; then
video_files+=("$full_path")
fi
done
# Check if video files were found
if [ ${#video_files[@]} -eq 0 ]; then
echo "No video files found for $season Mvt$movement_num in $video_base_dir"
exit 1
else
echo "Found ${#video_files[@]} video files for $season Mvt$movement_num"
fi
# Prepare a temporary file for concatenation
temp_file=$(mktemp)
for video_file in "${video_files[@]}"; do
echo "file '$video_file'" >> "$temp_file"
done
# Set the output directory and file for the montage
output_dir="$video_base_dir/$current_year-$month"
mkdir -p "$output_dir" mkdir -p "$output_dir"
output_file="$output_dir/$(date +"%Y-%m-%d")_Mvt$movement_num-Montage.mp4"
# Create the final montage by concatenating the video files with the music music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*Mvt*${MVT_NUM}*" | sort | head -n 1)
echo "Creating montage for $season Mvt $movement_num..." if [ -z "$music_file" ]; then
echo "ERROR: Music file not found for $SEASON Mvt $MVT_NUM"
exit 1
fi
music_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$music_file")
echo "Music: $(basename "$music_file") ($music_duration s)"
# Use ffmpeg to concatenate the video files and overlay the music # ── Collect sorted daily clips ─────────────────────────────────────────────────
ffmpeg -f concat -safe 0 -i "$temp_file" -i "$music_file" -c:v libx264 -pix_fmt yuv420p -c:a aac -strict experimental -shortest "$output_file" mapfile -d '' daily_clips < <(find "$video_dir" -maxdepth 1 -name "*-final.mp4" -print0 | sort -z)
if [ "${#daily_clips[@]}" -eq 0 ]; then
echo "ERROR: No *-final.mp4 clips in $video_dir"
exit 1
fi
echo "Found ${#daily_clips[@]} daily clips"
# Clean up temporary file # ── Detect source resolution from first clip ──────────────────────────────────
rm "$temp_file" # The attribution card is created at this same resolution so nothing is resized.
VIDEO_W=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "${daily_clips[0]}")
VIDEO_H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "${daily_clips[0]}")
echo "Source resolution: ${VIDEO_W}x${VIDEO_H}"
echo "Montage created: $output_file" # ── Temp-file tracking ────────────────────────────────────────────────────────
TMPFILES=()
cleanup() { rm -f "${TMPFILES[@]}" 2>/dev/null || true; }
trap cleanup EXIT
concat_list=$(mktemp --suffix=.txt); TMPFILES+=("$concat_list")
printf "file '%s'\n" "${daily_clips[@]}" > "$concat_list"
# ── Step 1: Concatenate clips, normalising to source resolution ───────────────
# Normalise in case any clip differs (e.g. camera settings changed on one day).
temp_concat=$(mktemp --suffix=.mp4); TMPFILES+=("$temp_concat")
echo "Step 1/5: concatenating ${#daily_clips[@]} clips..."
ffmpeg -loglevel warning \
-f concat -safe 0 -i "$concat_list" \
-vf "scale=${VIDEO_W}:${VIDEO_H}:force_original_aspect_ratio=decrease,\
pad=${VIDEO_W}:${VIDEO_H}:(ow-iw)/2:(oh-ih)/2,setsar=1" \
-c:v libx264 -pix_fmt yuv420p -crf 26 -an \
-y "$temp_concat"
# ── Step 2: Speed-adjust to match music duration exactly ──────────────────────
concat_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$temp_concat")
speed_factor=$(echo "scale=6; $concat_dur / $music_duration" | bc)
echo "Step 2/5: speed factor $speed_factor (raw=${concat_dur}s music=${music_duration}s)"
temp_sped=$(mktemp --suffix=.mp4); TMPFILES+=("$temp_sped")
ffmpeg -loglevel warning \
-i "$temp_concat" \
-vf "setpts=PTS/$speed_factor" \
-c:v libx264 -pix_fmt yuv420p -crf 26 -an \
-t "$music_duration" -y "$temp_sped"
rm "$temp_concat"
# ── Step 3: Merge with music + 2-second video/audio fade in/out ───────────────
fade_out_start=$(echo "scale=3; $music_duration - $FADE_DUR" | bc)
echo "Step 3/5: adding music and fades..."
temp_main=$(mktemp --suffix=.mp4); TMPFILES+=("$temp_main")
ffmpeg -loglevel warning \
-i "$temp_sped" -i "$music_file" \
-filter_complex \
"[0:v]fade=t=in:st=0:d=${FADE_DUR},fade=t=out:st=${fade_out_start}:d=${FADE_DUR}[vout];\
[1:a]afade=t=in:st=0:d=${FADE_DUR},afade=t=out:st=${fade_out_start}:d=${FADE_DUR}[aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 192k \
-t "$music_duration" -y "$temp_main"
rm "$temp_sped"
# ── Step 4: Create 7-second attribution card at source resolution ─────────────
# Resolution matches the video — no resizing of the main montage required.
echo "Step 4/5: creating attribution card at ${VIDEO_W}x${VIDEO_H}..."
FONT=""
FONT_BOLD=""
if command -v fc-match &>/dev/null; then
FONT=$(fc-match "DejaVu Sans:style=Regular" --format="%{file}" 2>/dev/null || true)
FONT_BOLD=$(fc-match "DejaVu Sans:style=Bold" --format="%{file}" 2>/dev/null || true)
fi
if [ -z "$FONT" ]; then
for f in \
/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf \
/usr/share/fonts/dejavu/DejaVuSans.ttf \
/usr/share/fonts/TTF/DejaVuSans.ttf \
/usr/share/fonts/truetype/freefont/FreeSans.ttf; do
[ -f "$f" ] && { FONT="$f"; break; }
done
fi
[ -z "$FONT_BOLD" ] && FONT_BOLD="$FONT"
# Font sizes are proportional to frame height so they look right at any resolution.
# Colons inside drawtext text values must be escaped as \: (\\: in bash double-quotes).
DT="drawtext=fontfile='${FONT_BOLD}':text='The Four Seasons - Antonio Vivaldi':fontcolor=white:fontsize=h/19:x=(w-text_w)/2:y=h*0.15"
DT="${DT},drawtext=fontfile='${FONT}':text='Performed by John Harrison':fontcolor=0xDDDDDD:fontsize=h/26:x=(w-text_w)/2:y=h*0.32"
DT="${DT},drawtext=fontfile='${FONT}':text='with the Wichita State University Chamber Players':fontcolor=0xDDDDDD:fontsize=h/30:x=(w-text_w)/2:y=h*0.43"
DT="${DT},drawtext=fontfile='${FONT}':text='Source\\: Free Music Archive':fontcolor=white:fontsize=h/32:x=(w-text_w)/2:y=h*0.57"
DT="${DT},drawtext=fontfile='${FONT}':text='freemusicarchive.org':fontcolor=0xFFFF88:fontsize=h/32:x=(w-text_w)/2:y=h*0.66"
DT="${DT},drawtext=fontfile='${FONT}':text='License\\: CC BY-SA 3.0':fontcolor=0xCCCCCC:fontsize=h/34:x=(w-text_w)/2:y=h*0.76"
DT="${DT},fade=t=in:st=0:d=1,fade=t=out:st=$((ATTR_DUR-1)):d=1"
temp_attr=$(mktemp --suffix=.mp4); TMPFILES+=("$temp_attr")
ffmpeg -loglevel warning \
-f lavfi -i "color=c=black:s=${VIDEO_W}x${VIDEO_H}:r=25:d=${ATTR_DUR}" \
-f lavfi -i "anullsrc=r=44100:cl=stereo" \
-vf "$DT" \
-c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 192k \
-t "$ATTR_DUR" -y "$temp_attr"
# ── Step 5: Join montage + attribution card ───────────────────────────────────
echo "Step 5/5: joining montage and attribution card..."
final_list=$(mktemp --suffix=.txt); TMPFILES+=("$final_list")
printf "file '%s'\n" "$temp_main" "$temp_attr" > "$final_list"
output_file="$output_dir/$(date +%Y-%m-%d)_${SEASON}_Mvt${MVT_NUM}-Montage.mp4"
ffmpeg -loglevel warning \
-f concat -safe 0 -i "$final_list" \
-c copy -y "$output_file"
echo "Done: $output_file"
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
season_info.py — output shell variables describing the current (or given) date's
astronomical season and movement within that season.
Season boundaries are the actual equinoxes/solstices computed via the Jean Meeus
approximation (Astronomical Algorithms, Ch. 27 Table 27.a) and converted to the
LOCAL timezone so the calendar date matches what the camera sees on the ground.
DST transitions and year-to-year variation in the exact equinox moment are both
handled correctly.
Usage:
eval "$(python3 season_info.py [YYYY-MM-DD] [Timezone])"
Arguments (both optional):
YYYY-MM-DD Date to evaluate; defaults to today.
Timezone IANA timezone name, e.g. America/New_York.
Can also be set via the TIMEZONE environment variable.
Defaults to America/New_York (matches the sunrise scripts).
Outputs (shell-sourceable):
SEASON Spring | Summer | Autumn | Winter
MVT_NUM 1 | 2 | 3
DAY_OF_MVT day number within the movement (1-based)
DAYS_IN_MVT total days in this movement
MVT_START YYYY-MM-DD of movement start (local date)
MVT_END YYYY-MM-DD of movement end (local date)
SEASON_START YYYY-MM-DD of season start (local date)
SEASON_END YYYY-MM-DD of season end (local date)
IS_LAST_DAY true | false
"""
import sys
import os
import datetime
DEFAULT_TZ = "America/New_York"
# ---------------------------------------------------------------------------
# Timezone helpers — prefer pytz (already installed for sunrise.py),
# fall back to zoneinfo (Python ≥ 3.9), then to UTC with a warning.
# ---------------------------------------------------------------------------
def _load_tz(name: str):
try:
import pytz
return pytz.timezone(name)
except ImportError:
pass
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name)
except ImportError:
pass
print(f"WARNING: neither pytz nor zoneinfo available; using UTC instead of {name}", file=sys.stderr)
return datetime.timezone.utc
def _utc_datetime_from_jde(jde: float) -> datetime.datetime:
"""Convert Julian Day Number to a UTC datetime (including time of day)."""
jde_s = jde + 0.5 # shift so integer part = calendar day
Z = int(jde_s)
F = jde_s - Z # fractional day = fraction of 24 h after midnight
if Z >= 2299161:
alpha = int((Z - 1867216.25) / 36524.25)
A = Z + 1 + alpha - alpha // 4
else:
A = Z
B = A + 1524
C = int((B - 122.1) / 365.25)
D = int(365.25 * C)
E = int((B - D) / 30.6001)
day_frac = B - D - int(30.6001 * E) + F
day = int(day_frac)
frac_hours = (day_frac - day) * 24
hour = int(frac_hours)
frac_mins = (frac_hours - hour) * 60
minute = int(frac_mins)
second = int((frac_mins - minute) * 60)
month = E - 1 if E < 14 else E - 13
year = C - 4716 if month > 2 else C - 4715
return datetime.datetime(year, month, day, hour, minute, second,
tzinfo=datetime.timezone.utc)
def _season_starts_local(year: int, tz) -> dict:
"""
Return the LOCAL calendar date (not UTC date) of each equinox/solstice
for the given year. 'tz' is a pytz/zoneinfo timezone object.
"""
y = (year - 2000) / 1000.0
jdes = {
"Spring": 2451623.80984 + 365242.37404*y + 0.05169*y**2 - 0.00411*y**3 - 0.00057*y**4,
"Summer": 2451716.56767 + 365241.62603*y + 0.00325*y**2 + 0.00888*y**3 - 0.00030*y**4,
"Autumn": 2451810.21715 + 365242.01767*y - 0.11575*y**2 + 0.00337*y**3 + 0.00078*y**4,
"Winter": 2451900.05952 + 365242.74049*y - 0.06223*y**2 - 0.00823*y**3 + 0.00032*y**4,
}
result = {}
for name, jde in jdes.items():
utc_dt = _utc_datetime_from_jde(jde)
local_dt = utc_dt.astimezone(tz)
result[name] = local_dt.date()
return result
def get_info(date: datetime.date, tz_name: str = DEFAULT_TZ) -> dict:
tz = _load_tz(tz_name)
year = date.year
cur = _season_starts_local(year, tz)
prev = _season_starts_local(year - 1, tz)
nxt = _season_starts_local(year + 1, tz)
# Determine season and its inclusive start/end dates.
# Winter straddles the year boundary (Dec solstice → next Mar equinox).
if date < cur["Spring"]:
season, s_start, s_end = "Winter", prev["Winter"], cur["Spring"] - datetime.timedelta(days=1)
elif date < cur["Summer"]:
season, s_start, s_end = "Spring", cur["Spring"], cur["Summer"] - datetime.timedelta(days=1)
elif date < cur["Autumn"]:
season, s_start, s_end = "Summer", cur["Summer"], cur["Autumn"] - datetime.timedelta(days=1)
elif date < cur["Winter"]:
season, s_start, s_end = "Autumn", cur["Autumn"], cur["Winter"] - datetime.timedelta(days=1)
else:
season, s_start, s_end = "Winter", cur["Winter"], nxt["Spring"] - datetime.timedelta(days=1)
days_in_season = (s_end - s_start).days + 1
# Split season into 3 movements. Mvt 1 and 2 get floor(n/3) days;
# Mvt 3 takes the remainder so no days are lost to integer rounding.
m1 = days_in_season // 3
m2 = days_in_season // 3
m3 = days_in_season - m1 - m2
m1_end = s_start + datetime.timedelta(days=m1 - 1)
m2_end = m1_end + datetime.timedelta(days=m2)
m3_end = s_end
if date <= m1_end:
mvt, mvt_start, mvt_end, days_in_mvt = 1, s_start, m1_end, m1
elif date <= m2_end:
mvt, mvt_start, mvt_end, days_in_mvt = 2, m1_end + datetime.timedelta(1), m2_end, m2
else:
mvt, mvt_start, mvt_end, days_in_mvt = 3, m2_end + datetime.timedelta(1), m3_end, m3
return {
"SEASON": season,
"MVT_NUM": mvt,
"DAY_OF_MVT": (date - mvt_start).days + 1,
"DAYS_IN_MVT": days_in_mvt,
"MVT_START": mvt_start.isoformat(),
"MVT_END": mvt_end.isoformat(),
"SEASON_START": s_start.isoformat(),
"SEASON_END": s_end.isoformat(),
"IS_LAST_DAY": str(date == mvt_end).lower(),
}
if __name__ == "__main__":
args = [a for a in sys.argv[1:] if not a.startswith("-")]
date_s = args[0] if len(args) > 0 else None
tz_name = args[1] if len(args) > 1 else os.environ.get("TIMEZONE", DEFAULT_TZ)
date = datetime.date.fromisoformat(date_s) if date_s else datetime.date.today()
for k, v in get_info(date, tz_name).items():
print(f'{k}="{v}"')