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
202 lines
9.9 KiB
Bash
Executable File
202 lines
9.9 KiB
Bash
Executable File
#!/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
|
|
|
|
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
|
|
source "$SCRIPT_DIR/sky-cam.conf"
|
|
export TIMEZONE # make it visible to season_info.py subprocess
|
|
|
|
# Camera name: required first argument (systemd passes it via ExecStart).
|
|
# Date: optional second argument (YYYY-MM-DD); defaults to yesterday.
|
|
CAM_NAME="${1:-}"
|
|
DATE_ARG="${2:-}"
|
|
if [ -z "$CAM_NAME" ]; then
|
|
echo "Usage: $0 <camera-name> [YYYY-MM-DD]"
|
|
echo " e.g. $0 east"
|
|
echo " $0 east 2026-04-20"
|
|
exit 1
|
|
fi
|
|
|
|
# ── Configuration ──────────────────────────────────────────────────────────────
|
|
base_dir="$BASE_DIR"
|
|
music_base_dir="$MUSIC_DIR"
|
|
[ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music"
|
|
|
|
# ── Date setup ────────────────────────────────────────────────────────────────
|
|
if [ -n "$DATE_ARG" ]; then
|
|
yesterday=$(date --date="$DATE_ARG" +%Y-%m-%d)
|
|
else
|
|
yesterday=$(date --date="yesterday" +%Y-%m-%d)
|
|
fi
|
|
echo "Processing: $yesterday"
|
|
|
|
# ── Season / movement info from astronomical calculation ──────────────────────
|
|
_season_info="$(python3 "$SCRIPT_DIR/season_info.py" "$yesterday")" || {
|
|
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 Day=$DAY_OF_MVT/$DAYS_IN_MVT LastDay=$IS_LAST_DAY"
|
|
|
|
# ── Locate music file and get its duration ────────────────────────────────────
|
|
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" ] || [ ! -f "$music_file" ]; then
|
|
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 covers (music_duration * loops) / DAYS_IN_MVT seconds.
|
|
# Short movements use loops > 1 so each day gets more screen time.
|
|
loops_var="MONTAGE_MUSIC_LOOPS_${SEASON}_${MVT_NUM}"
|
|
loops="${!loops_var:-${MONTAGE_MUSIC_LOOPS:-1}}"
|
|
target_per_clip=$(echo "scale=6; $music_duration * $loops / $DAYS_IN_MVT" | bc)
|
|
echo "Target clip duration: $target_per_clip s ($DAYS_IN_MVT days in movement, ${loops}x music)"
|
|
|
|
# ── Locate yesterday's images ─────────────────────────────────────────────────
|
|
image_dir="$base_dir/$CAM_NAME/$yesterday"
|
|
|
|
if [ ! -d "$image_dir" ]; then
|
|
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 — skipping $yesterday."
|
|
exit 0
|
|
fi
|
|
echo "Images found: $total_images"
|
|
|
|
# ── Per-camera capture interval ───────────────────────────────────────────────
|
|
interval_var="CAPTURE_INTERVAL_${CAM_NAME}"
|
|
INTERVAL="${!interval_var:-${CAPTURE_INTERVAL:-10}}"
|
|
|
|
# ── Prepare output directory ──────────────────────────────────────────────────
|
|
output_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM"
|
|
mkdir -p "$output_dir"
|
|
|
|
# ── Build sorted image list ───────────────────────────────────────────────────
|
|
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
|
|
|
|
mapfile -t all_images < <(find "$image_dir" -type f -name "*.jpg" | sort)
|
|
|
|
# Validate each frame: skip empty files and optionally uniform-colour bad-signal frames.
|
|
# SEASONS_GREY_STDDEV_MIN=0 (default) disables the content check entirely.
|
|
# When enabled, large files (> SEASONS_LARGE_FRAME_BYTES, default 1 MB) are trusted
|
|
# as good without running the expensive ImageMagick check — only sub-1MB frames
|
|
# are inspected. This keeps the filter fast even on days with 40k+ images.
|
|
GREY_MIN="${SEASONS_GREY_STDDEV_MIN:-0}"
|
|
GREY_DARK_FLOOR="${SEASONS_GREY_DARK_FLOOR:-30}"
|
|
LARGE_BYTES="${SEASONS_LARGE_FRAME_BYTES:-1048576}" # files above this skip content check
|
|
total_frames=${#all_images[@]}
|
|
images=(); bad=0; grey=0; checked=0
|
|
if [ "$GREY_MIN" != "0" ]; then
|
|
echo "Validating $total_frames frames (grey-filter active: stddev<${GREY_MIN}, mean>${GREY_DARK_FLOOR})..."
|
|
else
|
|
echo "Validating $total_frames frames..."
|
|
fi
|
|
for img in "${all_images[@]}"; do
|
|
((checked++)) || true
|
|
if (( checked % 500 == 0 || checked == total_frames )); then
|
|
echo " $checked / $total_frames frames checked..."
|
|
fi
|
|
# Skip empty or unreadable files (0-byte writes from camera reconnects)
|
|
if [ ! -r "$img" ] || [ ! -s "$img" ]; then
|
|
echo "WARNING: skipping empty/missing frame: $(basename "$img")"
|
|
((bad++)) || true; continue
|
|
fi
|
|
# Optional: skip uniform-colour bad-signal frames (green, grey, etc.).
|
|
# Only runs ImageMagick on files below SEASONS_LARGE_FRAME_BYTES — large files
|
|
# are always good and skip the check entirely.
|
|
if [ "$GREY_MIN" != "0" ] && command -v convert &>/dev/null; then
|
|
fsize=$(stat -c%s "$img" 2>/dev/null || echo 0)
|
|
if [ "$fsize" -lt "$LARGE_BYTES" ]; then
|
|
read -r stddev mean < <(convert "$img" -colorspace Gray \
|
|
-format "%[fx:int(standard_deviation*255)] %[fx:int(mean*255)]" \
|
|
info: 2>/dev/null || echo "255 128") || true
|
|
if [ "${stddev:-255}" -lt "$GREY_MIN" ] && [ "${mean:-0}" -gt "$GREY_DARK_FLOOR" ]; then
|
|
((grey++)) || true; continue
|
|
fi
|
|
fi
|
|
fi
|
|
images+=("$img")
|
|
done
|
|
[ "$bad" -gt 0 ] && echo "WARNING: skipped $bad corrupt/empty frame(s) of ${#all_images[@]}" || true
|
|
[ "$grey" -gt 0 ] && echo "INFO: skipped $grey grey/uniform frame(s) (SEASONS_GREY_STDDEV_MIN=${GREY_MIN})" || true
|
|
|
|
if [ "${#images[@]}" -eq 0 ]; then
|
|
echo "ERROR: no valid images remain after filtering — skipping $yesterday."
|
|
exit 0
|
|
fi
|
|
echo "Validation done: ${#images[@]} valid frames"
|
|
|
|
for img in "${images[@]}"; do
|
|
printf "file '%s'\nduration 0.04\n" "$img"
|
|
done > "$temp_file"
|
|
# ffmpeg concat demuxer requires a final entry with no duration for image files
|
|
printf "file '%s'\n" "${images[-1]}" >> "$temp_file"
|
|
|
|
# ── Step 1: Build raw video ───────────────────────────────────────────────────
|
|
echo "Step 1/2: encoding raw video from ${#images[@]} frames (may take several minutes)..."
|
|
ffmpeg -loglevel warning \
|
|
-f concat -safe 0 -i "$temp_file" \
|
|
-c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SEASONS_RAW" -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}x → ${target_per_clip}s target..."
|
|
|
|
final_file="$output_dir/${CAM_NAME}-${yesterday}_Mvt${MVT_NUM}-Day${DAY_OF_MVT}of${DAYS_IN_MVT}-final.mp4"
|
|
if ! ffmpeg -loglevel warning \
|
|
-i "$temp_video" \
|
|
-vf "setpts=PTS/$speed_factor,fps=25" \
|
|
-c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SEASONS_FINAL" \
|
|
-t "$target_per_clip" -an \
|
|
-y "$final_file"; then
|
|
rm -f "$final_file"
|
|
"$SCRIPT_DIR/notify.sh" "FAILED: [$CAM_NAME] $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT speed-adjust ($yesterday)" \
|
|
"ffmpeg speed-adjust failed — re-run 4-seasons.sh $CAM_NAME while JPGs exist" || true
|
|
exit 1
|
|
fi
|
|
|
|
adjusted=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_file")
|
|
echo "Daily clip: $final_file ($adjusted s)"
|
|
|
|
# Warn if clip duration deviates more than 0.5 s from target (float drift check)
|
|
drift=$(echo "scale=3; $adjusted - $target_per_clip" | bc | sed 's/^-//')
|
|
if awk "BEGIN{exit !($drift > 0.5)}"; then
|
|
echo "WARNING: clip duration ${adjusted}s differs from target ${target_per_clip}s by ${drift}s"
|
|
"$SCRIPT_DIR/notify.sh" "WARNING: [$CAM_NAME] $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT duration drift ($yesterday)" \
|
|
"Clip ${adjusted}s vs target ${target_per_clip}s (drift ${drift}s)" || true
|
|
fi
|
|
|
|
"$SCRIPT_DIR/notify.sh" "[$CAM_NAME] $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT/$DAYS_IN_MVT saved ($yesterday)" \
|
|
"$(basename "$final_file") — ${adjusted}s | $final_file" || true
|
|
|
|
# ── 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" "$CAM_NAME" "$yesterday"
|
|
fi
|