From a395f9babad8bd55d807140ce6b5151f73522199 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:27:57 +0000 Subject: [PATCH 1/3] Encoding: H.265 final montages, slow preset, sunrise retention - sky-cam.conf: add ENCODE_PRESET=slow (applied to all encodes); split CRF_MONTAGE (intermediates, H.264) from CRF_MONTAGE_FINAL=22 (archive montage, H.265); add SUNRISE_RETENTION_DAYS=10; improve CAPTURE_INTERVAL comment with storage/density table - montage-mvt.sh: steps 1+2 use preset; step 3 final switches to libx265 + CRF_MONTAGE_FINAL + -tag:v hvc1 for broad compatibility - 4-seasons.sh: both encode steps use ENCODE_PRESET - daily_sunrise_video.sh: all encode steps use ENCODE_PRESET; rolling retention deletes sunrise videos older than SUNRISE_RETENTION_DAYS https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- 4-seasons.sh | 4 ++-- daily_sunrise_video.sh | 16 +++++++++++++--- montage-mvt.sh | 7 ++++--- sky-cam.conf | 35 +++++++++++++++++++++++------------ 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/4-seasons.sh b/4-seasons.sh index a162db6..d362fbf 100755 --- a/4-seasons.sh +++ b/4-seasons.sh @@ -88,7 +88,7 @@ done > "$temp_file" echo "Step 1/2: encoding raw video..." ffmpeg -loglevel warning \ -f concat -safe 0 -i "$temp_file" \ - -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SEASONS_RAW" -vsync 2 -an \ + -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") @@ -102,7 +102,7 @@ final_file="$output_dir/${yesterday}_Mvt${MVT_NUM}-Day${DAY_OF_MVT}of${DAYS_IN_M if ! ffmpeg -loglevel warning \ -i "$temp_video" \ -vf "setpts=PTS/$speed_factor" \ - -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SEASONS_FINAL" \ + -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" diff --git a/daily_sunrise_video.sh b/daily_sunrise_video.sh index d5f15a9..d262b1f 100644 --- a/daily_sunrise_video.sh +++ b/daily_sunrise_video.sh @@ -110,7 +110,7 @@ raw_video=$(mktemp --suffix=.mp4) echo "Step 1/3: encoding raw video..." if ! ffmpeg -loglevel warning \ -f concat -safe 0 -i "$temp_list" \ - -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -vsync 2 -an \ + -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SUNRISE" -vsync 2 -an \ -y "$raw_video"; then "$SCRIPT_DIR/notify.sh" "FAILED: sunrise step 1/3 (encode) $current_date" \ "ffmpeg raw encode from images failed — check: journalctl -u sky-cam-sunrise.service" || true @@ -128,7 +128,7 @@ echo "Step 2/3: speed-adjust → $sped_video" if ! ffmpeg -loglevel warning \ -i "$raw_video" \ -vf "setpts=PTS/${speed_factor}" \ - -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" -an \ + -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SUNRISE" -an \ -y "$sped_video"; then rm -f "$sped_video" "$SCRIPT_DIR/notify.sh" "FAILED: sunrise step 2/3 (speed) $current_date" \ @@ -223,7 +223,7 @@ if [ -n "$DT" ]; then if ffmpeg -loglevel warning \ -i "$work_video" \ -vf "${DT}" \ - -c:v libx264 -pix_fmt yuv420p -crf "$CRF_SUNRISE" \ + -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SUNRISE" \ "${audio_out_flags[@]}" \ -y "$final_video"; then step3b_ok=true @@ -253,4 +253,14 @@ if $step3b_ok; then [ -f "$cam_audio" ] && rm -f "$cam_audio" "$SCRIPT_DIR/notify.sh" "Sunrise ready: $current_date" \ "$(basename "$final_video") — ${actual_dur}s, sunrise at ${SR_TIME}" || true + + # ── Rolling retention: delete sunrise videos older than SUNRISE_RETENTION_DAYS ─ + retain="${SUNRISE_RETENTION_DAYS:-10}" + if [ "$retain" -gt 0 ]; then + deleted_old=$(find "$MOVIES_DIR/$CAM_NAME" \ + -path "*/sunrise-only/*-daily-sunrise.mp4" \ + -mtime +"$retain" -print -delete 2>/dev/null | wc -l) + [ "$deleted_old" -gt 0 ] && \ + echo "Retention: deleted $deleted_old sunrise video(s) older than ${retain} days" + fi fi diff --git a/montage-mvt.sh b/montage-mvt.sh index c181583..e956178 100755 --- a/montage-mvt.sh +++ b/montage-mvt.sh @@ -133,7 +133,7 @@ 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 -crf "$CRF_MONTAGE" -an \ + -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 ─ @@ -146,7 +146,7 @@ sped_file="$output_dir/${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 -crf "$CRF_MONTAGE" -an \ + -c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_MONTAGE" -an \ -t "$music_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)" \ @@ -198,7 +198,8 @@ if ffmpeg -loglevel warning \ "[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 libx264 -pix_fmt yuv420p -c:a aac -b:a "$AUDIO_BITRATE" \ + -c:v libx265 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_MONTAGE_FINAL" \ + -tag:v hvc1 -c:a aac -b:a "$AUDIO_BITRATE" \ -t "$music_duration" -y "$output_file"; then rm -f "$sped_file" echo "Done: $output_file" diff --git a/sky-cam.conf b/sky-cam.conf index 87fb9d8..a1f719b 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -217,20 +217,28 @@ SUNRISE_TARGET_SECS=10 SUNRISE_OVERLAY_ENABLED=true # Opacity of the sunrise-time text (0.0 = invisible, 1.0 = fully opaque). SUNRISE_OVERLAY_OPACITY=0.45 +# Delete daily sunrise videos older than this many days (rolling window). +# Set 0 to keep forever. +SUNRISE_RETENTION_DAYS=10 # ── Video encoding quality ──────────────────────────────────────────────────── # FFmpeg CRF: lower = higher quality / larger files. Typical range 18–30. # 18 ≈ visually lossless 23 = ffmpeg default 28 = smaller/lower -# Different scripts use different defaults by design — intermediate/draft -# encodings use a higher CRF (smaller files, quality less critical), while -# final "polished" outputs use a lower CRF for better quality. -# Each is independent — changing one does NOT change the others. +# H.265 (libx265) CRF is not directly comparable — H.265 CRF 22 ≈ H.264 CRF 26 +# in visual quality but ~40-50% smaller file. Final montages use H.265; +# intermediates (deleted after use) stay H.264. # -CRF_SUNRISE=28 # daily_sunrise_video.sh — daily Mattermost upload -CRF_SEASONS_RAW=28 # 4-seasons.sh — raw concat before speed-adjust -CRF_SEASONS_FINAL=26 # 4-seasons.sh — final daily clip (goes into montage) -CRF_MONTAGE=26 # montage-mvt.sh — concat and speed-adjust and final -AUDIO_BITRATE=192k # montage-mvt.sh — music track on movement montages +# ENCODE_PRESET: slow = better compression, same CRF quality, ~2× encode time. +# Use 'medium' on slower machines, 'slow' or 'slower' on fast ones. +# All encodes run at night so 'slow' is the right default. +# +CRF_SUNRISE=28 # daily_sunrise_video.sh — daily Mattermost upload (H.264) +CRF_SEASONS_RAW=28 # 4-seasons.sh — raw concat before speed-adjust (H.264) +CRF_SEASONS_FINAL=26 # 4-seasons.sh — final daily clip (goes into montage) (H.264) +CRF_MONTAGE=28 # montage-mvt.sh — intermediate concat + sped steps (H.264) +CRF_MONTAGE_FINAL=22 # montage-mvt.sh — final archive montage (H.265) +ENCODE_PRESET=slow # ffmpeg preset for all encodes (slow/medium/fast) +AUDIO_BITRATE=192k # montage-mvt.sh — music track on movement montages # ── Montage attribution overlay ─────────────────────────────────────────────── # A translucent bar across the top of each movement montage, naming the @@ -254,12 +262,15 @@ MONTAGE_ATTR_FADE=1 # attribution fade-in and fade-out length (seconds) # CAM_RTSP_ goes in .env (see bottom of this file), not here. # Seconds between captured frames (applies to all cameras). -# 10 = one frame every 10 s → 8640 frames/day → good timelapse density. +# Lower = smoother timelapse + larger storage footprint: +# 10 s → 8,640 frames/day ~2.5 GB/day (default, good balance) +# 5 s → 17,280 frames/day ~5 GB/day (smoother motion) +# 2 s → 43,200 frames/day ~12 GB/day (very smooth, high storage) +# CAPTURE_STALE_SECS should always be at least 2× CAPTURE_INTERVAL. CAPTURE_INTERVAL=10 # Seconds without a new frame before the watchdog sends a stall notification. -# Should be at least 2× CAPTURE_INTERVAL. A recovery notification fires -# automatically when frames start arriving again. +# A recovery notification fires automatically when capture resumes. CAPTURE_STALE_SECS=30 # ── Sunrise audio capture ────────────────────────────────────────────────────── From d4e39a55541b8beb08c45345eebe561b733a3a35 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 18:17:20 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Add=20resize-temps.sh=20=E2=80=94=20re-size?= =?UTF-8?q?=20backup=20temp=20files=20to=20correct=20movement=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-processes *-temp.mp4 files from a backup location using season_info.py to compute the correct target duration per clip (music_duration / DAYS_IN_MVT), producing properly-sized *-final.mp4 files without touching the backup source. Skips finals that already exist. Fixes drift caused by old pipeline sizing clips to the wrong movement structure. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- resize-temps.sh | 161 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100755 resize-temps.sh diff --git a/resize-temps.sh b/resize-temps.sh new file mode 100755 index 0000000..426f99d --- /dev/null +++ b/resize-temps.sh @@ -0,0 +1,161 @@ +#!/bin/bash +# resize-temps.sh — re-size *-temp.mp4 files from a backup location into +# correctly-proportioned *-final.mp4 files for use with montage-mvt.sh. +# +# Reads source temp files from BACKUP_ROOT (never modifies them). +# Writes finals to MOVIES_DIR (from sky-cam.conf / .env). +# +# Usage: +# ./resize-temps.sh --cam [--dry-run] +# +# Example: +# ./resize-temps.sh --cam east /home/user/drives/data2/other-pictures/longterm/movies/movies +# ./resize-temps.sh --cam north /mnt/backup/movies --dry-run +# +# The backup_root should contain the old season directories: +# //Spring/ Winter/ Summer/ Autumn/ +# with files like: 2025-06-15_Mvt2-temp.mp4 +# +# Output goes to: +# MOVIES_DIR////Mvt/_Mvt-Dayof-final.mp4 +# +# Already-existing finals are skipped (never overwritten). + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +source "$SCRIPT_DIR/sky-cam.conf" +export TIMEZONE + +# ── Args ────────────────────────────────────────────────────────────────────── +CAM="" +BACKUP_ROOT="" +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --cam) CAM="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + -*) echo "Unknown option: $1"; exit 1 ;; + *) BACKUP_ROOT="$1"; shift ;; + esac +done + +if [ -z "$CAM" ] || [ -z "$BACKUP_ROOT" ]; then + echo "Usage: $0 --cam [--dry-run]" + echo " e.g. $0 --cam east /home/user/drives/data2/other-pictures/longterm/movies/movies" + exit 1 +fi + +if [ ! -d "$BACKUP_ROOT/$CAM" ]; then + echo "ERROR: $BACKUP_ROOT/$CAM not found" + exit 1 +fi + +music_base_dir="$MUSIC_DIR" +[ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music" + +echo "Camera: $CAM" +echo "Backup root: $BACKUP_ROOT/$CAM" +echo "Output root: $MOVIES_DIR/$CAM" +echo "Music dir: $music_base_dir" +$DRY_RUN && echo "DRY RUN — no files will be written" +echo "" + +# ── Find all temp files ─────────────────────────────────────────────────────── +mapfile -d '' temp_files < <( + find "$BACKUP_ROOT/$CAM" -type f -name '*-temp.mp4' -print0 | sort -z +) + +if [ "${#temp_files[@]}" -eq 0 ]; then + echo "No *-temp.mp4 files found under $BACKUP_ROOT/$CAM" + exit 0 +fi +echo "Found ${#temp_files[@]} temp file(s)" +echo "" + +skipped=0 +processed=0 +errors=0 + +for temp_file in "${temp_files[@]}"; do + # Extract date from filename: 2025-06-15_Mvt2-temp.mp4 → 2025-06-15 + filename="$(basename "$temp_file")" + date_str="${filename%%_*}" + + # Validate date format + if ! [[ "$date_str" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "SKIP (bad filename): $temp_file" + (( skipped++ )) || true + continue + fi + + # Get season/movement info for this date + _info="$(python3 "$SCRIPT_DIR/season_info.py" "$date_str" 2>/dev/null)" || { + echo "SKIP (season_info.py failed for $date_str): $filename" + (( skipped++ )) || true + continue + } + eval "$_info" + # Provides: SEASON MVT_NUM DAY_OF_MVT DAYS_IN_MVT MVT_START MVT_END + # IS_LAST_DAY ASTRO_YEAR + + # Locate music file + 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 + echo "SKIP (no music for $SEASON Mvt$MVT_NUM): $filename" + (( skipped++ )) || true + continue + fi + music_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$music_file") + target_per_clip=$(echo "scale=6; $music_duration / $DAYS_IN_MVT" | bc) + + # Determine output path + out_dir="$MOVIES_DIR/$CAM/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM" + final_file="$out_dir/${date_str}_Mvt${MVT_NUM}-Day${DAY_OF_MVT}of${DAYS_IN_MVT}-final.mp4" + + # Skip if final already exists + if [ -f "$final_file" ]; then + echo "EXISTS — skip: $(basename "$final_file")" + (( skipped++ )) || true + continue + fi + + raw_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$temp_file") + speed_factor=$(echo "scale=6; $raw_dur / $target_per_clip" | bc) + + echo "Processing: $filename" + echo " $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT/$DAYS_IN_MVT Year=$ASTRO_YEAR" + echo " raw=${raw_dur}s target=${target_per_clip}s speed=${speed_factor}x" + echo " → $(basename "$final_file")" + + if $DRY_RUN; then + (( processed++ )) || true + continue + fi + + mkdir -p "$out_dir" + + if ffmpeg -loglevel warning \ + -i "$temp_file" \ + -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 + actual=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_file") + echo " Done: ${actual}s" + (( processed++ )) || true + else + rm -f "$final_file" + echo " ERROR: ffmpeg failed — no output written" + (( errors++ )) || true + fi + echo "" +done + +echo "────────────────────────────────────────" +echo "Processed: $processed Skipped: $skipped Errors: $errors" +if $DRY_RUN; then + echo "(dry run — re-run without --dry-run to write files)" +fi From da35dc862cbe958b4df1414dba1f2868000ac8b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 18:39:15 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Add=20reorg-backup.sh=20=E2=80=94=20reorgan?= =?UTF-8?q?ize=20backup=20files=20into=20astro-YEAR=20folders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves Season/MvtN mp4 files from flat camera dirs into astro-YEAR//Season/MvtN/ using season_info.py for correct year assignment. Handles cross-year mixing in old backup structure. Dry-run support; never deletes files. https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ --- reorg-backup.sh | 117 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100755 reorg-backup.sh diff --git a/reorg-backup.sh b/reorg-backup.sh new file mode 100755 index 0000000..58ba053 --- /dev/null +++ b/reorg-backup.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# reorg-backup.sh — reorganize backup season files into astro-YEAR folders. +# +# Moves files from: +# //Season/MvtN/YYYY-MM-DD_* +# Into: +# /astro-YEAR//Season/MvtN/YYYY-MM-DD_* +# +# Uses season_info.py to determine the correct ASTRO_YEAR from each file's date. +# NEVER deletes anything — only moves files. +# +# Usage: +# ./reorg-backup.sh [--dry-run] +# +# Example: +# ./reorg-backup.sh ~/drives/data2-main --dry-run +# ./reorg-backup.sh ~/drives/data2-main + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +source "$SCRIPT_DIR/sky-cam.conf" +export TIMEZONE + +BACKUP_ROOT="" +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + -*) echo "Unknown option: $1"; exit 1 ;; + *) BACKUP_ROOT="$1"; shift ;; + esac +done + +if [ -z "$BACKUP_ROOT" ]; then + echo "Usage: $0 [--dry-run]" + echo " e.g. $0 ~/drives/data2-main --dry-run" + exit 1 +fi + +BACKUP_ROOT="$(realpath "$BACKUP_ROOT")" + +$DRY_RUN && echo "DRY RUN — no files will be moved" +echo "Backup root: $BACKUP_ROOT" +echo "" + +moved=0 +skipped=0 +errors=0 + +# Find all mp4 files in Season/MvtN directories (not already in astro-* dirs) +while IFS= read -r -d '' src_file; do + filename="$(basename "$src_file")" + + # Extract date: handles YYYY-MM-DD_ (underscore) prefix + date_str="${filename%%_*}" + if ! [[ "$date_str" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "SKIP (unrecognised filename): $src_file" + (( skipped++ )) || true + continue + fi + + # Get ASTRO_YEAR for this date + _info="$(python3 "$SCRIPT_DIR/season_info.py" "$date_str" 2>/dev/null)" || { + echo "SKIP (season_info.py failed for $date_str): $filename" + (( skipped++ )) || true + continue + } + eval "$_info" + + # Reconstruct target path: + # /astro-YEAR///Mvt/ + # Source path is: + # ///Mvt/ + rel_path="${src_file#$BACKUP_ROOT/}" # e.g. north/Winter/Mvt1/2025-01-06_... + cam_and_below="${rel_path#*/}" # e.g. Winter/Mvt1/2025-01-06_... (strip cam) + cam_name="${rel_path%%/*}" # e.g. north + + dest_dir="$BACKUP_ROOT/astro-${ASTRO_YEAR}/$cam_name/$(dirname "$cam_and_below")" + dest_file="$dest_dir/$filename" + + if [ "$src_file" = "$dest_file" ]; then + (( skipped++ )) || true + continue + fi + + if [ -f "$dest_file" ]; then + echo "EXISTS — skip: astro-${ASTRO_YEAR}/$cam_name/$cam_and_below" + (( skipped++ )) || true + continue + fi + + echo "MOVE astro-${ASTRO_YEAR}/$cam_name/$cam_and_below" + + if ! $DRY_RUN; then + mkdir -p "$dest_dir" + if mv "$src_file" "$dest_file"; then + (( moved++ )) || true + else + echo " ERROR: mv failed" + (( errors++ )) || true + fi + else + (( moved++ )) || true + fi + +done < <(find "$BACKUP_ROOT" \ + -not -path "*/astro-*" \ + -regextype posix-extended \ + -regex ".*/[A-Z][a-z]+/Mvt[0-9]+/[0-9]{4}-[0-9]{2}-[0-9]{2}_.*\.mp4" \ + -print0 | sort -z) + +echo "" +echo "────────────────────────────────────────" +echo "Moved: $moved Skipped: $skipped Errors: $errors" +$DRY_RUN && echo "(dry run — re-run without --dry-run to move files)"