Fix season dates, resolution, attribution, retention policies, montage trigger

season_info.py (new):
- Computes actual equinox/solstice dates via Jean Meeus approximation
- Converts to LOCAL timezone (pytz / zoneinfo) so the calendar date reflects
  what the camera sees on the ground, including DST transitions
- Timezone defaults to America/New_York; override via $TIMEZONE env or 2nd arg
- Divides each season into 3 equal movements; handles Winter's year boundary
- Outputs IS_LAST_DAY=true on the final day of each movement

4-seasons.sh:
- Uses season_info.py for all date/season logic (no hardcoded day-of-year)
- Passes $yesterday to season_info.py so astronomical dates are correct
- Removed forced 1280x720 scale; clips stay at native camera resolution
- Auto-triggers montage-mvt.sh "$yesterday" on last day of movement so the
  correct movement (not the next day's) is compiled

montage-mvt.sh:
- Accepts optional YYYY-MM-DD argument (passed by 4-seasons.sh) so it
  resolves the correct season/movement when run the morning after last day
- Detects source resolution from first daily clip via ffprobe; attribution
  card is generated at that exact resolution — no resizing of main video
- Font sizes proportional to frame height so text looks right at any res
- License corrected to CC BY-SA 3.0 (was CC BY-NC-SA 4.0)

fullday-video.sh:
- Removed 30-minute target; video length is natural (num_images / FPS)
- Default FULLDAY_FPS=5 (configurable at top of script)
- Retains RETENTION_DAYS=10 auto-delete for full-day videos only
- Sunrise 10s uploads and montage videos are never auto-deleted

https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
This commit is contained in:
Claude
2026-04-18 03:36:34 +00:00
parent 009f6b4b64
commit 9403e3ca46
4 changed files with 305 additions and 178 deletions
+47 -66
View File
@@ -1,123 +1,104 @@
#!/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.
# Run once per day (e.g. from cron at 01:00).
#
# 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
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
# ── Configuration ──────────────────────────────────────────────────────────────
base_dir="/home/motion/drives/local-2tb"
music_base_dir="$base_dir/music/4 Seasons"
# Fallback: use music/ directory next to this script (repo copy)
[ ! -d "$music_base_dir" ] && music_base_dir="$(dirname "$(realpath "$0")")/music"
TARGET_W=1280
TARGET_H=720
[ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music"
# ── Date setup ────────────────────────────────────────────────────────────────
yesterday=$(date --date="yesterday" +%Y-%m-%d)
day_of_year=$(date --date="$yesterday" +%j | sed 's/^0*//') # strip leading zeros
echo "Processing: $yesterday"
# ── Season / movement detection ────────────────────────────────────────────────
if [ "$day_of_year" -ge 80 ] && [ "$day_of_year" -le 172 ]; then
season="Spring"; season_start=80; days_in_season=93
elif [ "$day_of_year" -ge 173 ] && [ "$day_of_year" -le 264 ]; then
season="Summer"; season_start=173; days_in_season=92
elif [ "$day_of_year" -ge 265 ] && [ "$day_of_year" -le 355 ]; then
season="Autumn"; season_start=265; days_in_season=91
else
season="Winter"; days_in_season=90
if [ "$day_of_year" -ge 356 ]; then season_start=356; else season_start=0; fi
fi
# ── Season / movement info from astronomical calculation ──────────────────────
eval "$(python3 "$SCRIPT_DIR/season_info.py" "$yesterday")"
# Provides: SEASON MVT_NUM DAY_OF_MVT DAYS_IN_MVT MVT_START MVT_END IS_LAST_DAY
echo "Season=$SEASON Mvt=$MVT_NUM Day=$DAY_OF_MVT/$DAYS_IN_MVT LastDay=$IS_LAST_DAY"
# Day within the season (1-based)
if [ "$season" = "Winter" ] && [ "$day_of_year" -lt 80 ]; then
day_of_season=$(( day_of_year + 365 - 355 ))
elif [ "$season" = "Winter" ]; then
day_of_season=$(( day_of_year - 355 ))
else
day_of_season=$(( day_of_year - season_start + 1 ))
fi
# Each season: three movements of ~30 days; Mvt 3 takes the remainder
if [ "$day_of_season" -le 30 ]; then movement_num=1; days_in_movement=30
elif [ "$day_of_season" -le 60 ]; then movement_num=2; days_in_movement=30
else movement_num=3; days_in_movement=$(( days_in_season - 60 ))
fi
echo "Date=$yesterday Season=$season Mvt=$movement_num DayOfSeason=$day_of_season DaysInMvt=$days_in_movement"
# ── Locate music file ─────────────────────────────────────────────────────────
music_file=$(find "$music_base_dir" -type f -iname "*${season}*Mvt*${movement_num}*" | sort | head -n 1)
# ── Locate music file and get its duration ────────────────────────────────────
music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*Mvt*${MVT_NUM}*" | sort | head -n 1)
if [ -z "$music_file" ] || [ ! -f "$music_file" ]; then
echo "ERROR: Music file not found for $season Mvt $movement_num in $music_base_dir"
echo "ERROR: Music file not found for $SEASON Mvt $MVT_NUM in $music_base_dir"
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)"
# Each daily clip should cover music_duration / days_in_movement seconds so that
# the full movement montage sums to exactly the music duration.
target_duration_per_video=$(echo "scale=6; $music_duration / $days_in_movement" | bc)
echo "Target duration per daily clip: $target_duration_per_video s"
# Each daily clip covers music_duration / DAYS_IN_MVT seconds so that
# concatenating all clips for the movement sums to the exact music duration.
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)"
# ── Locate images ─────────────────────────────────────────────────────────────
script_name=$(basename "$(dirname "$(realpath "$0")")" | cut -d'-' -f1)
# ── Locate yesterday's images ─────────────────────────────────────────────────
script_name=$(basename "$SCRIPT_DIR" | cut -d'-' -f1)
image_dir="$base_dir/$script_name/$yesterday"
if [ ! -d "$image_dir" ]; then
echo "WARNING: Image directory $image_dir not found for $yesterday — skipping."
echo "WARNING: Image directory $image_dir not found — skipping $yesterday."
exit 0
fi
total_images=$(find "$image_dir" -type f -name "*.jpg" | wc -l)
if [ "$total_images" -lt 1 ]; then
echo "WARNING: No images in $image_dir for $yesterday — skipping."
echo "WARNING: No images in $image_dir — skipping $yesterday."
exit 0
fi
echo "Images found: $total_images"
# ── Prepare output directory ──────────────────────────────────────────────────
output_dir="$base_dir/movies/$script_name/$season/Mvt$movement_num"
output_dir="$base_dir/movies/$script_name/$SEASON/Mvt$MVT_NUM"
mkdir -p "$output_dir"
# ── Build sorted image list ───────────────────────────────────────────────────
# ── Build sorted image list ───────────────────────────────────────────────────
temp_file=$(mktemp --suffix=.txt)
trap 'rm -f "$temp_file" "$temp_video"' EXIT
temp_video="$output_dir/${yesterday}_Mvt${MVT_NUM}-temp.mp4"
cleanup() { rm -f "$temp_file" "$temp_video" 2>/dev/null || true; }
trap cleanup EXIT
find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do
echo "file '$img'"
done > "$temp_file"
num_images=$(wc -l < "$temp_file")
echo "Images: $num_images"
# ── Step 1: Build raw video at default frame rate ─────────────────────────────
temp_video="$output_dir/${yesterday}_Mvt${movement_num}-temp.mp4"
echo "Creating raw video..."
echo "Step 1/2: encoding raw video..."
ffmpeg -loglevel warning \
-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 video duration: $raw_duration s"
echo "Raw duration: $raw_duration s"
# ── Step 2: Speed-adjust to target duration + normalise resolution ─────────────
speed_factor=$(echo "scale=6; $raw_duration / $target_duration_per_video" | bc)
echo "Speed factor: $speed_factor"
# ── 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${movement_num}-Day${day_of_season}of${days_in_movement}-final.mp4"
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,\
scale=${TARGET_W}:${TARGET_H}:force_original_aspect_ratio=decrease,\
pad=${TARGET_W}:${TARGET_H}:(ow-iw)/2:(oh-ih)/2,setsar=1" \
-vf "setpts=PTS/$speed_factor" \
-c:v libx264 -pix_fmt yuv420p -crf 26 \
-t "$target_duration_per_video" \
-t "$target_per_clip" -an \
-y "$final_file"
rm "$temp_video"
adjusted=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_file")
echo "Daily clip: $final_file ($adjusted s)"
adjusted_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_file")
echo "Daily clip created: $final_file ($adjusted_duration 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
+22 -36
View File
@@ -1,24 +1,22 @@
#!/bin/bash
# fullday-video.sh — create an ~30-minute timelapse of yesterday's full day of
# images. Files older than 10 days are deleted automatically to save space.
# Run once per day (e.g. from cron at 02:00, after 4-seasons.sh).
# 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"
script_name=$(basename "$(dirname "$(realpath "$0")")" | cut -d'-' -f1)
TARGET_DURATION=1800 # target video length in seconds (~30 min)
MIN_FRAME_DUR=0.016667 # max 60 fps
MAX_FRAME_DUR=10.0 # min 0.1 fps (10 s per frame)
RETENTION_DAYS=10
TARGET_W=1280
TARGET_H=720
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"
@@ -35,48 +33,36 @@ fi
num_images=$(find "$image_dir" -type f -name "*.jpg" | wc -l)
if [ "$num_images" -lt 1 ]; then
echo "WARNING: No images found in $image_dir — skipping."
echo "WARNING: No images in $image_dir — skipping."
exit 0
fi
echo "Images: $num_images"
# ── Calculate per-frame duration to hit TARGET_DURATION ───────────────────────
frame_dur=$(echo "scale=6; $TARGET_DURATION / $num_images" | bc)
# Clamp to [MIN_FRAME_DUR, MAX_FRAME_DUR]
if (( $(echo "$frame_dur < $MIN_FRAME_DUR" | bc -l) )); then frame_dur=$MIN_FRAME_DUR; fi
if (( $(echo "$frame_dur > $MAX_FRAME_DUR" | bc -l) )); then frame_dur=$MAX_FRAME_DUR; fi
fps=$(echo "scale=4; 1 / $frame_dur" | bc)
approx_min=$(echo "scale=1; $num_images * $frame_dur / 60" | bc)
echo "Frame duration: ${frame_dur}s (fps=${fps}) ≈ ${approx_min} min"
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 requires the last file to be repeated without a 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
last_img=""
find "$image_dir" -type f -name "*.jpg" | sort | while read -r img; do
echo "file '$img'"
echo "duration $frame_dur"
last_img="$img"
printf "file '%s'\nduration %s\n" "$img" "$frame_dur"
done > "$temp_list"
# Append last image again (required by concat demuxer to set final frame pts)
# Append last image again (required by ffmpeg concat demuxer for images)
last_img=$(find "$image_dir" -type f -name "*.jpg" | sort | tail -n 1)
echo "file '$last_img'" >> "$temp_list"
printf "file '%s'\n" "$last_img" >> "$temp_list"
# ── Encode ────────────────────────────────────────────────────────────────────
# ── Encode — native camera resolution, no audio ───────────────────────────────
output_file="$output_dir/${yesterday}-fullday.mp4"
echo "Creating full-day video: $output_file"
echo "Creating: $output_file"
ffmpeg -loglevel warning \
-f concat -safe 0 -i "$temp_list" \
-vf "scale=${TARGET_W}:${TARGET_H}:force_original_aspect_ratio=decrease,\
pad=${TARGET_W}:${TARGET_H}:(ow-iw)/2:(oh-ih)/2,setsar=1" \
-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 "Done: $output_file (${actual_dur}s / $(echo "scale=1; $actual_dur/60" | bc) min)"
+64 -76
View File
@@ -1,101 +1,88 @@
#!/bin/bash
# montage-mvt.sh — compile daily sunrise clips into a seasonal movement montage.
# Output: daily clips concatenated, speed-adjusted to match music duration,
# with 2-second video/audio fade in/out, followed by a 7-second attribution card.
# 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-NC-SA 4.0 — verify at the URL above before redistribution.
# Source: Free Music Archive freemusicarchive.org
# License: CC BY-SA 3.0 — creativecommons.org/licenses/by-sa/3.0
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
# ── Configuration ──────────────────────────────────────────────────────────────
base_dir="/home/motion/drives/local-2tb"
script_name=$(basename "$(dirname "$(realpath "$0")")" | cut -d'-' -f1)
music_base_dir="$base_dir/music/4 Seasons"
# Fallback: use music/ directory next to this script (repo copy)
[ ! -d "$music_base_dir" ] && music_base_dir="$(dirname "$(realpath "$0")")/music"
[ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music"
TARGET_W=1280
TARGET_H=720
FADE_DUR=2.0
ATTR_DUR=7
FADE_DUR=2.0 # seconds for video + audio fade in/out
ATTR_DUR=7 # duration of attribution card in seconds
# ── Season / movement detection ────────────────────────────────────────────────
day_of_year=$(date +%j | sed 's/^0*//') # strip leading zeros for arithmetic
# ── Season / movement info ────────────────────────────────────────────────────
# Optional first argument: YYYY-MM-DD of the last day of the movement to compile.
# Used when called automatically from 4-seasons.sh (which processes yesterday's
# images the next morning, so "today" would already be the next movement).
# 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})"
if [ "$day_of_year" -ge 80 ] && [ "$day_of_year" -le 172 ]; then
season="Spring"; season_start=80; days_in_season=93
elif [ "$day_of_year" -ge 173 ] && [ "$day_of_year" -le 264 ]; then
season="Summer"; season_start=173; days_in_season=92
elif [ "$day_of_year" -ge 265 ] && [ "$day_of_year" -le 355 ]; then
season="Autumn"; season_start=265; days_in_season=91
else
season="Winter"; days_in_season=90
# Winter straddles the year boundary (day 35679)
if [ "$day_of_year" -ge 356 ]; then season_start=356; else season_start=0; fi
fi
# Day within the season (1-based)
if [ "$season" = "Winter" ] && [ "$day_of_year" -lt 80 ]; then
day_of_season=$(( day_of_year + 365 - 355 ))
elif [ "$season" = "Winter" ]; then
day_of_season=$(( day_of_year - 355 ))
else
day_of_season=$(( day_of_year - season_start + 1 ))
fi
# Each season split into three movements of ~30 days each
if [ "$day_of_season" -le 30 ]; then movement_num=1; days_in_movement=30
elif [ "$day_of_season" -le 60 ]; then movement_num=2; days_in_movement=30
else movement_num=3; days_in_movement=$(( days_in_season - 60 ))
fi
echo "Season=$season Mvt=$movement_num DayOfSeason=$day_of_season DaysInMvt=$days_in_movement"
# ── Locate input files ─────────────────────────────────────────────────────────
video_dir="$base_dir/movies/$script_name/$season/Mvt$movement_num"
# ── Locate files ──────────────────────────────────────────────────────────────
script_name=$(basename "$SCRIPT_DIR" | cut -d'-' -f1)
video_dir="$base_dir/movies/$script_name/$SEASON/Mvt$MVT_NUM"
output_dir="$video_dir/montage"
mkdir -p "$output_dir"
music_file=$(find "$music_base_dir" -type f -iname "*${season}*Mvt*${movement_num}*" | sort | head -n 1)
music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*Mvt*${MVT_NUM}*" | sort | head -n 1)
if [ -z "$music_file" ]; then
echo "ERROR: Music file not found for $season Mvt $movement_num in $music_base_dir"
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)"
# Collect sorted daily clips
# ── Collect sorted daily clips ─────────────────────────────────────────────────
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 found in $video_dir"
echo "ERROR: No *-final.mp4 clips in $video_dir"
exit 1
fi
echo "Found ${#daily_clips[@]} daily clips"
# ── Temp-file tracking for cleanup ────────────────────────────────────────────
# ── Detect source resolution from first clip ──────────────────────────────────
# 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}"
# ── Temp-file tracking ────────────────────────────────────────────────────────
TMPFILES=()
cleanup() { rm -f "${TMPFILES[@]}" 2>/dev/null || true; }
trap cleanup EXIT
concat_list=$(mktemp --suffix=.txt); TMPFILES+=("$concat_list")
concat_list=$(mktemp --suffix=.txt); TMPFILES+=("$concat_list")
printf "file '%s'\n" "${daily_clips[@]}" > "$concat_list"
# ── Step 1: Concatenate + normalise to TARGET_W x TARGET_H ────────────────────
# ── 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 at ${TARGET_W}x${TARGET_H}..."
echo "Step 1/5: concatenating ${#daily_clips[@]} clips..."
ffmpeg -loglevel warning \
-f concat -safe 0 -i "$concat_list" \
-vf "scale=${TARGET_W}:${TARGET_H}:force_original_aspect_ratio=decrease,\
pad=${TARGET_W}:${TARGET_H}:(ow-iw)/2:(oh-ih)/2,setsar=1" \
-c:v libx264 -pix_fmt yuv420p -crf 26 -an -y "$temp_concat"
-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 so total video == music duration ─────────────────────
# ── 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 target=${music_duration}s)"
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 \
@@ -105,9 +92,9 @@ ffmpeg -loglevel warning \
-t "$music_duration" -y "$temp_sped"
rm "$temp_concat"
# ── Step 3: Merge with music + video/audio fades ──────────────────────────────
# ── 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: merging music + fade in/out (fade=${FADE_DUR}s)..."
echo "Step 3/5: adding music and fades..."
temp_main=$(mktemp --suffix=.mp4); TMPFILES+=("$temp_main")
ffmpeg -loglevel warning \
@@ -120,15 +107,15 @@ ffmpeg -loglevel warning \
-t "$music_duration" -y "$temp_main"
rm "$temp_sped"
# ── Step 4: Create 7-second attribution card ──────────────────────────────────
echo "Step 4/5: building attribution card..."
# ── 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}..."
# Locate a usable font
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)
FONT_BOLD=$(fc-match "DejaVu Sans:style=Bold" --format="%{file}" 2>/dev/null || true)
fi
if [ -z "$FONT" ]; then
for f in \
@@ -141,29 +128,30 @@ if [ -z "$FONT" ]; then
fi
[ -z "$FONT_BOLD" ] && FONT_BOLD="$FONT"
# Build drawtext chain (colons in text values escaped as \: → \\: in bash string)
DT="drawtext=fontfile='${FONT_BOLD}':text='The Four Seasons - Antonio Vivaldi':fontcolor=white:fontsize=38:x=(w-text_w)/2:y=h*0.18"
DT="${DT},drawtext=fontfile='${FONT}':text='Performed by John Harrison':fontcolor=0xDDDDDD:fontsize=28:x=(w-text_w)/2:y=h*0.34"
DT="${DT},drawtext=fontfile='${FONT}':text='with the Wichita State University Chamber Players':fontcolor=0xDDDDDD:fontsize=22:x=(w-text_w)/2:y=h*0.44"
DT="${DT},drawtext=fontfile='${FONT}':text='Source\\: Free Music Archive':fontcolor=white:fontsize=21:x=(w-text_w)/2:y=h*0.59"
DT="${DT},drawtext=fontfile='${FONT}':text='freemusicarchive.org':fontcolor=0xFFFF88:fontsize=21:x=(w-text_w)/2:y=h*0.68"
DT="${DT},drawtext=fontfile='${FONT}':text='License\\: CC BY-NC-SA 4.0':fontcolor=0xCCCCCC:fontsize=19:x=(w-text_w)/2:y=h*0.78"
DT="${DT},fade=t=in:st=0:d=1,fade=t=out:st=$((ATTR_DUR - 1)):d=1"
# 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=${TARGET_W}x${TARGET_H}:r=25:d=${ATTR_DUR}" \
-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: Concatenate montage + attribution ─────────────────────────────────
echo "Step 5/5: appending attribution and writing final file..."
# ── 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${movement_num}-Montage.mp4"
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"
+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}"')