duration=INTERVAL in the concat file made a 0.1fps raw video, which x264 couldn't speed up 4000x cleanly and produced 20s of the last frame instead. The original script used plain 'file path' entries with no duration, letting ffmpeg default to 25fps internally. The speed_factor calculation against raw_duration compensates correctly either way, but only the standard-fps raw video survives the extreme setpts transform. Also removed the coverage_pct check which was based on images*INTERVAL and became meaningless without duration. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
183 lines
8.9 KiB
Bash
Executable File
183 lines
8.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).
|
|
CAM_NAME="${1:-}"
|
|
if [ -z "$CAM_NAME" ]; then
|
|
echo "Usage: $0 <camera-name>"
|
|
echo " e.g. $0 east or $0 north"
|
|
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 ────────────────────────────────────────────────────────────────
|
|
yesterday=$(date --date="yesterday" +%Y-%m-%d)
|
|
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 (suntime 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, corrupt JPEGs, and optionally grey/uniform frames.
|
|
# SEASONS_GREY_STDDEV_MIN=0 (default) disables the grey filter.
|
|
# Set to e.g. 5 in sky-cam.conf to drop uniform grey frames (bad-signal captures).
|
|
GREY_MIN="${SEASONS_GREY_STDDEV_MIN:-0}"
|
|
GREY_DARK_FLOOR="${SEASONS_GREY_DARK_FLOOR:-30}" # frames darker than this are never skipped
|
|
images=(); bad=0; grey=0
|
|
for img in "${all_images[@]}"; do
|
|
# Empty or unreadable
|
|
if [ ! -r "$img" ] || [ ! -s "$img" ]; then
|
|
echo "WARNING: skipping empty/missing frame: $(basename "$img")"
|
|
((bad++)) || true; continue
|
|
fi
|
|
# Corrupt JPEG — ffprobe can't identify any video stream
|
|
if ! ffprobe -v quiet -select_streams v:0 \
|
|
-show_entries stream=codec_name -of csv=p=0 "$img" 2>/dev/null \
|
|
| grep -q .; then
|
|
echo "WARNING: skipping corrupt frame: $(basename "$img")"
|
|
((bad++)) || true; continue
|
|
fi
|
|
# Optional: skip uniform-grey bad-signal frames.
|
|
# A frame is bad-signal if it is BOTH uniform (low stddev) AND not dark.
|
|
# Night frames are dark so they pass even with low stddev.
|
|
if [ "$GREY_MIN" != "0" ] && command -v convert &>/dev/null; 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")
|
|
if [ "${stddev:-255}" -lt "$GREY_MIN" ] && [ "${mean:-0}" -gt "$GREY_DARK_FLOOR" ]; then
|
|
((grey++)) || true; continue
|
|
fi
|
|
fi
|
|
images+=("$img")
|
|
done
|
|
[ "$bad" -gt 0 ] && echo "WARNING: skipped $bad corrupt/empty frame(s) of ${#all_images[@]}"
|
|
[ "$grey" -gt 0 ] && echo "INFO: skipped $grey grey/uniform frame(s) (SEASONS_GREY_STDDEV_MIN=${GREY_MIN})"
|
|
|
|
if [ "${#images[@]}" -eq 0 ]; then
|
|
echo "ERROR: no valid images remain after filtering — skipping $yesterday."
|
|
exit 0
|
|
fi
|
|
|
|
for img in "${images[@]}"; do
|
|
printf "file '%s'\n" "$img"
|
|
done > "$temp_file"
|
|
|
|
# ── Step 1: Build raw video ───────────────────────────────────────────────────
|
|
echo "Step 1/2: encoding raw video from ${#images[@]} frames..."
|
|
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..."
|
|
|
|
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" \
|
|
-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
|