ff6a512630
Working tree (including .git) was lost to an rm. Rebuilt by replaying Write/Edit/ Read/attachment events from 25 Claude sessions and 22 successful codex apply_patch blocks into one timestamp-ordered timeline. Verified against ground truth recorded in the transcripts: wc -l on 10 files and ls -l on 5 files at 2026-07-18T13:13:44Z both match exactly; 18 files are byte-identical to their newest ~/.claude/file-history blob. See HANDOFF.md for sources, gaps, and how to rebuild .venv. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
3.1 KiB
Bash
Executable File
96 lines
3.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Turn a video into timestamped contact sheets and scene-change frames for visual analysis.
|
|
# Optional --window START:DURATION arguments create dense 10-fps sheets around transitions.
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
scripts/analyze_video_frames.sh VIDEO [OUTPUT_DIR] [--window START:DURATION ...]
|
|
|
|
Examples:
|
|
scripts/analyze_video_frames.sh chapter.mp4
|
|
scripts/analyze_video_frames.sh chapter.mp4 /tmp/chapter-analysis \
|
|
--window 5.8:2.2 --window 15.8:2.5
|
|
|
|
Outputs:
|
|
overview-001.jpg ... 2-fps timestamped contact sheets
|
|
scenes/scene-001.jpg ... frames selected by scene-change score
|
|
scene-timestamps.txt scene-change timestamps from FFmpeg showinfo
|
|
window-START.jpg 10-fps contact sheet for each requested window
|
|
|
|
Environment overrides:
|
|
OVERVIEW_FPS=2 SCENE_THRESHOLD=0.18 WINDOW_FPS=10
|
|
EOF
|
|
}
|
|
|
|
if [ "$#" -lt 1 ] || [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
|
usage
|
|
exit $([ "$#" -lt 1 ] && echo 1 || echo 0)
|
|
fi
|
|
|
|
video=$1
|
|
shift
|
|
if [ ! -f "$video" ]; then
|
|
echo "video not found: $video" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$#" -gt 0 ] && [[ $1 != --* ]]; then
|
|
output_dir=$1
|
|
shift
|
|
else
|
|
base=$(basename "$video")
|
|
output_dir="/tmp/${base%.*}-frames"
|
|
fi
|
|
|
|
overview_fps=${OVERVIEW_FPS:-2}
|
|
scene_threshold=${SCENE_THRESHOLD:-0.18}
|
|
window_fps=${WINDOW_FPS:-10}
|
|
windows=()
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--window)
|
|
[ "$#" -ge 2 ] || { echo "--window needs START:DURATION" >&2; exit 2; }
|
|
windows+=("$2")
|
|
shift 2
|
|
;;
|
|
*)
|
|
echo "unknown argument: $1" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
mkdir -p "$output_dir/scenes"
|
|
|
|
# Six-by-six sheets keep individual frames readable while allowing videos of arbitrary length:
|
|
# tile emits additional overview-NNN images after every 36 sampled frames.
|
|
ffmpeg -hide_banner -loglevel error -y -i "$video" \
|
|
-vf "fps=${overview_fps},scale=320:-1,drawtext=text='%{pts\\:hms}':x=8:y=8:fontsize=20:fontcolor=yellow:borderw=2,tile=6x6:padding=4:margin=4" \
|
|
-fps_mode vfr "$output_dir/overview-%03d.jpg"
|
|
|
|
# Keep stderr because showinfo reports the original timestamps there.
|
|
ffmpeg -hide_banner -y -i "$video" \
|
|
-vf "select='gt(scene,${scene_threshold})',showinfo" -fps_mode vfr \
|
|
"$output_dir/scenes/scene-%03d.jpg" 2>"$output_dir/scene-showinfo.log" || true
|
|
sed -n 's/.*pts_time:\([^ ]*\).*/\1/p' "$output_dir/scene-showinfo.log" \
|
|
>"$output_dir/scene-timestamps.txt"
|
|
|
|
for window in "${windows[@]}"; do
|
|
start=${window%%:*}
|
|
duration=${window#*:}
|
|
if [ "$start" = "$window" ] || [ -z "$start" ] || [ -z "$duration" ]; then
|
|
echo "invalid window '$window'; expected START:DURATION" >&2
|
|
exit 2
|
|
fi
|
|
safe_start=${start//./_}
|
|
# A 5x5 sheet covers 2.5 seconds at the default 10 fps. Longer windows naturally emit more sheets.
|
|
ffmpeg -hide_banner -loglevel error -y -ss "$start" -t "$duration" -i "$video" \
|
|
-vf "fps=${window_fps},scale=320:-1,drawtext=text='%{pts\\:hms}':x=6:y=6:fontsize=18:fontcolor=yellow:borderw=2,tile=5x5:padding=3:margin=3" \
|
|
-fps_mode vfr "$output_dir/window-${safe_start}-%03d.jpg"
|
|
done
|
|
|
|
echo "video analysis frames: $output_dir"
|