Files
sky-cam/montage-mvt.sh
T
Claude 2f15ce47c9 sunrise.py: replace suntime with skyfield for accurate sunrise calculation
The suntime library uses a simplified approximation that degrades at high
latitudes as sunrise approaches very early hours near the summer solstice.
The computed sunrise drifted progressively earlier than the actual event,
shrinking the capture window until today it ended before the sun rose.

Switch to skyfield's sunrise_sunset almanac (DE421 ephemeris), which is
accurate to within a second at any latitude — the same library already
used by moon_phase.py for sun events. The search window is anchored to
local midnight→midnight UTC so the correct calendar-day sunrise is
always returned regardless of timezone offset.

Also promote skyfield to the core pip install in bootstrap.sh (dropping
suntime, which is no longer needed) and tidy the stale "suntime" hints
in 4-seasons.sh and montage-mvt.sh error messages.

https://claude.ai/code/session_01S4oUbU9xNj91cbV5bj5NRn
2026-05-14 03:17:45 +00:00

240 lines
12 KiB
Bash
Executable File

#!/bin/bash
# montage-mvt.sh — compile one Vivaldi movement's daily clips into a montage.
#
# Duration matches the movement's music exactly. Attribution is overlaid as a
# translucent bar across the top of the video for the first ATTR_DUR seconds
# (fade in/out) — no separate card appended, so year-end joining is a plain
# concat with no duplicate attribution concerns.
#
# Called automatically by 4-seasons.sh on the last day of each movement.
# Can also be run manually for a mid-season preview.
#
# On the last day of Autumn Mvt 3, automatically triggers year-end-join.sh.
#
# 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
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
source "$SCRIPT_DIR/sky-cam.conf"
export TIMEZONE # make it visible to season_info.py subprocess
# Args: <camera-name> [date]
# Camera name: required first argument.
# Date: optional second argument (defaults to today; pass yesterday's date when
# running the morning after the last day of a movement).
CAM_NAME="${1:-}"
DATE_ARG="${2:-}"
if [ -z "$CAM_NAME" ]; then
echo "Usage: $0 <camera-name> [YYYY-MM-DD]"
echo " e.g. $0 east 2026-04-19"
exit 1
fi
# ── Configuration ──────────────────────────────────────────────────────────────
base_dir="$BASE_DIR"
music_base_dir="$MUSIC_DIR"
[ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music"
# Fade/overlay durations come from sky-cam.conf (MONTAGE_FADE_DUR,
# MONTAGE_ATTR_DUR, MONTAGE_ATTR_FADE). Alias for shorter local use.
FADE_DUR="$MONTAGE_FADE_DUR"
ATTR_DUR="$MONTAGE_ATTR_DUR"
ATTR_FADE="$MONTAGE_ATTR_FADE"
# ── Season / movement info ────────────────────────────────────────────────────
_season_info="$(python3 "$SCRIPT_DIR/season_info.py" ${DATE_ARG:+"$DATE_ARG"})" || {
echo "Error: season_info.py failed — check Python dependencies (pytz)"
exit 1
}
eval "$_season_info"
# Provides: SEASON MVT_NUM DAY_OF_MVT DAYS_IN_MVT MVT_START MVT_END
# IS_LAST_DAY ASTRO_YEAR
echo "Season=$SEASON Mvt=$MVT_NUM Year=$ASTRO_YEAR (ref: ${DATE_ARG:-today})"
# ── Locate files ──────────────────────────────────────────────────────────────
video_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM"
output_dir="$video_dir/montage"
mkdir -p "$output_dir"
music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*" \
| grep -iE "Mvt[^0-9]*${MVT_NUM}[^0-9]" | sort | head -n 1)
if [ -z "$music_file" ]; then
"$SCRIPT_DIR/notify.sh" "FAILED: montage $SEASON Mvt$MVT_NUM ($ASTRO_YEAR)" \
"Music file not found in $music_base_dir" || true
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)"
loops_var="MONTAGE_MUSIC_LOOPS_${SEASON}_${MVT_NUM}"
loops="${!loops_var:-${MONTAGE_MUSIC_LOOPS:-1}}"
effective_duration=$(echo "scale=3; $music_duration * $loops" | bc)
[ "$loops" -gt 1 ] && echo "Music loops: ${loops}x (effective ${effective_duration}s)"
# ── 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
"$SCRIPT_DIR/notify.sh" "FAILED: montage $SEASON Mvt$MVT_NUM ($ASTRO_YEAR)" \
"No *-final.mp4 clips found in $video_dir" || true
echo "ERROR: No *-final.mp4 clips in $video_dir"
exit 1
fi
echo "Found ${#daily_clips[@]} daily clips"
# ── Validate clip duration sum against music ──────────────────────────────────
total_clip_dur=0
for clip in "${daily_clips[@]}"; do
d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$clip")
total_clip_dur=$(echo "scale=3; $total_clip_dur + $d" | bc)
done
clip_drift=$(echo "scale=3; $total_clip_dur - $music_duration" | bc | sed 's/^-//')
echo "Sum of clips: ${total_clip_dur}s Music: ${music_duration}s Drift: ${clip_drift}s"
if awk "BEGIN{exit !($clip_drift > 2.0)}"; then
echo "WARNING: clip total differs from music by ${clip_drift}s — speed pass will compensate"
"$SCRIPT_DIR/notify.sh" \
"WARNING: $SEASON Mvt$MVT_NUM clip drift ${clip_drift}s ($ASTRO_YEAR)" \
"Clips sum ${total_clip_dur}s vs music ${music_duration}s" || true
fi
# ── Source resolution ─────────────────────────────────────────────────────────
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}"
# ── Font detection ────────────────────────────────────────────────────────────
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"
# ── Temp files ────────────────────────────────────────────────────────────────
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 + normalise resolution ────────────────────────────────
temp_concat=$(mktemp --suffix=.mp4); TMPFILES+=("$temp_concat")
echo "Step 1/3: 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 -preset "$ENCODE_PRESET" -crf "$CRF_MONTAGE" -an \
-y "$temp_concat"
# ── Step 2: Speed-adjust — saved permanently so music/overlay failure is recoverable ─
# Deleted automatically if step 3 succeeds.
concat_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$temp_concat")
speed_factor=$(echo "scale=6; $concat_dur / $effective_duration" | bc)
echo "Step 2/3: speed $speed_factor (raw=${concat_dur}s → target=${effective_duration}s)"
sped_file="$output_dir/${CAM_NAME}-${MVT_START}_${SEASON}_Mvt${MVT_NUM}-Sped.mp4"
if ! ffmpeg -loglevel warning \
-i "$temp_concat" \
-vf "setpts=PTS/$speed_factor" \
-c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_MONTAGE" -an \
-t "$effective_duration" -y "$sped_file"; then
rm -f "$sped_file"
"$SCRIPT_DIR/notify.sh" "FAILED: montage step 2/3 (speed) $SEASON Mvt$MVT_NUM ($ASTRO_YEAR)" \
"speed-adjust failed — concat temp lost; re-run montage-mvt.sh" || true
exit 1
fi
rm "$temp_concat"
echo "Speed-adjusted: $sped_file (saved — music+overlay still pending)"
# ── Step 3: Music + fades + attribution overlay ───────────────────────────────
# Attribution overlay: semi-transparent bar across the top for the first
# ATTR_DUR seconds, text fades in over ATTR_FADE s and out over ATTR_FADE s.
# alpha expression: 0→1 over first ATTR_FADE s, 1 in the middle, 1→0 over
# the last ATTR_FADE s before ATTR_DUR.
ATTR_FADE_END=$(echo "$ATTR_DUR - $ATTR_FADE" | bc)
ALPHA="if(lt(t,${ATTR_FADE}),t/${ATTR_FADE},if(gt(t,${ATTR_FADE_END}),(${ATTR_DUR}-t)/${ATTR_FADE},1))"
ENABLE="between(t,0,${ATTR_DUR})"
fade_out_start=$(echo "scale=3; $effective_duration - $FADE_DUR" | bc)
echo "Step 3/3: music + fades + attribution overlay..."
# Build attribution drawtext filters (box=1 gives a per-line background).
# Colons inside text values are escaped as \: (\\: in bash double-quoted strings).
DT="drawtext=fontfile='${FONT_BOLD}'"
DT="${DT}:text='The Four Seasons \: Antonio Vivaldi'"
DT="${DT}:fontcolor=white:fontsize=h/22:x=(w-text_w)/2:y=h*0.012"
DT="${DT}:box=1:boxcolor=black@0.55:boxborderw=8"
DT="${DT}:alpha='${ALPHA}':enable='${ENABLE}'"
DT="${DT},drawtext=fontfile='${FONT}'"
DT="${DT}:text='John Harrison / Wichita State University Chamber Players'"
DT="${DT}:fontcolor=0xEEEEEE:fontsize=h/30:x=(w-text_w)/2:y=h*0.068"
DT="${DT}:box=1:boxcolor=black@0.55:boxborderw=6"
DT="${DT}:alpha='${ALPHA}':enable='${ENABLE}'"
DT="${DT},drawtext=fontfile='${FONT}'"
DT="${DT}:text='Free Music Archive \: freemusicarchive.org \: CC BY-SA 3.0'"
DT="${DT}:fontcolor=0xCCCCCC:fontsize=h/36:x=(w-text_w)/2:y=h*0.113"
DT="${DT}:box=1:boxcolor=black@0.55:boxborderw=5"
DT="${DT}:alpha='${ALPHA}':enable='${ENABLE}'"
output_file="$output_dir/${CAM_NAME}-${MVT_START}_${SEASON}_Mvt${MVT_NUM}-Montage.mp4"
# If music/overlay fails, the sped video is promoted to the output path so
# year-end-join can still include this movement (as a silent, no-overlay clip).
if ffmpeg -loglevel warning \
-i "$sped_file" -stream_loop $((loops - 1)) -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},${DT}[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 libx265 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_MONTAGE_FINAL" \
-tag:v hvc1 -c:a aac -b:a "$AUDIO_BITRATE" \
-t "$effective_duration" -y "$output_file"; then
rm -f "$sped_file"
echo "Done: $output_file"
# ── Post-build checks ─────────────────────────────────────────────────────
actual_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$output_file")
dur_delta=$(echo "scale=1; $actual_dur - $effective_duration" | bc | sed 's/^-//')
stream_types=$(ffprobe -v error -show_entries stream=codec_type -of csv=p=0 "$output_file" 2>/dev/null)
has_video=$(echo "$stream_types" | grep -c "video" || echo 0)
has_audio=$(echo "$stream_types" | grep -c "audio" || echo 0)
checks="duration=${actual_dur}s (drift ${dur_delta}s)"
[ "$has_video" -gt 0 ] && checks="$checks video=ok" || checks="$checks video=MISSING"
[ "$has_audio" -gt 0 ] && checks="$checks audio=ok" || checks="$checks audio=MISSING"
echo "Checks: $checks"
"$SCRIPT_DIR/notify.sh" \
"Montage ready: $SEASON Mvt $MVT_NUM ($ASTRO_YEAR) [$CAM_NAME]" \
"$(basename "$output_file") | $checks | $output_file | verify: ./verify-mvt.sh $ASTRO_YEAR $SEASON $MVT_NUM $CAM_NAME" \
|| true
else
mv "$sped_file" "$output_file"
echo "Music+overlay failed — silent video promoted to: $output_file"
"$SCRIPT_DIR/notify.sh" \
"WARNING: montage audio+overlay failed — $SEASON Mvt$MVT_NUM ($ASTRO_YEAR) [$CAM_NAME]" \
"Silent video saved as $(basename "$output_file") — run: ./verify-mvt.sh $ASTRO_YEAR $SEASON $MVT_NUM $CAM_NAME" || true
fi
# ── Trigger year-end join on last Autumn movement ────────────────────────────
if [ "$SEASON" = "Autumn" ] && [ "$MVT_NUM" = "3" ]; then
echo "Last movement of astronomical year $ASTRO_YEAR — triggering year-end join..."
"$SCRIPT_DIR/year-end-join.sh" "$ASTRO_YEAR" "$CAM_NAME"
fi