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
240 lines
12 KiB
Bash
Executable File
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
|