Skip to content

Commit 60eea90

Browse files
Merge pull request #10 from Traverse-Research/jasper-bekkers/worker-progress-logging
Add local video processing and tagging scripts
2 parents e0042c9 + 1a85a67 commit 60eea90

File tree

8 files changed

+697
-0
lines changed

8 files changed

+697
-0
lines changed

scripts/detect-cuts.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/bin/bash
2+
# Detect scene changes / cut points in a video
3+
# Usage: ./detect-cuts.sh <video_file> [threshold]
4+
#
5+
# threshold: 0.0-1.0, lower = more sensitive (default: 0.3)
6+
# Output: timestamps where scene changes occur
7+
8+
VIDEO="${1:?Usage: $0 <video_file> [threshold]}"
9+
THRESHOLD="${2:-0.3}"
10+
11+
if [ ! -f "$VIDEO" ]; then
12+
echo "File not found: $VIDEO"
13+
exit 1
14+
fi
15+
16+
echo "Analyzing: $VIDEO"
17+
echo "Threshold: $THRESHOLD (lower = more sensitive)"
18+
echo ""
19+
echo "Detecting scene changes..."
20+
echo ""
21+
22+
# Use FFmpeg's select filter with scene detection
23+
# This outputs timestamps where scene change score exceeds threshold
24+
ffmpeg -i "$VIDEO" -vf "select='gt(scene,$THRESHOLD)',showinfo" -vsync vfr -f null - 2>&1 | \
25+
grep showinfo | \
26+
sed -n 's/.*pts_time:\([0-9.]*\).*/\1/p' | \
27+
while read -r timestamp; do
28+
# Convert to HH:MM:SS format
29+
hours=$(echo "$timestamp / 3600" | bc)
30+
mins=$(echo "($timestamp % 3600) / 60" | bc)
31+
secs=$(echo "$timestamp % 60" | bc)
32+
printf "%02d:%02d:%05.2f\n" "$hours" "$mins" "$secs"
33+
done
34+
35+
echo ""
36+
echo "Done!"

scripts/detect-slide-changes.sh

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/bin/bash
2+
# Detect major slide changes in the processed video's slide overlay area
3+
# The slides appear as a 320px-height overlay in the bottom-right corner with 40px margin
4+
# Usage: ./detect-slide-changes.sh <video_file> [threshold]
5+
#
6+
# threshold: 0.0-1.0, higher = only major changes (default: 0.3)
7+
8+
VIDEO="${1:?Usage: $0 <video_file> [threshold]}"
9+
THRESHOLD="${2:-0.3}"
10+
11+
if [ ! -f "$VIDEO" ]; then
12+
echo "File not found: $VIDEO"
13+
exit 1
14+
fi
15+
16+
# Get video dimensions
17+
dimensions=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "$VIDEO" 2>/dev/null)
18+
width=$(echo "$dimensions" | cut -d',' -f1)
19+
height=$(echo "$dimensions" | cut -d',' -f2)
20+
21+
# The slide overlay is 320px tall, aspect ratio ~16:9, so roughly 569x320
22+
# Positioned at bottom-right with 40px margin
23+
# crop=w:h:x:y
24+
crop_w=569
25+
crop_h=320
26+
crop_x=$((width - crop_w - 40))
27+
crop_y=$((height - crop_h - 40))
28+
29+
echo "========================================"
30+
echo "Analyzing: $(basename "$VIDEO")"
31+
echo "Video size: ${width}x${height}"
32+
echo "Monitoring: Slide overlay area (${crop_w}x${crop_h} at ${crop_x},${crop_y})"
33+
echo "Threshold: $THRESHOLD"
34+
echo "========================================"
35+
echo ""
36+
37+
# Get video duration
38+
duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$VIDEO" 2>/dev/null)
39+
echo "Video duration: $(printf '%02d:%02d:%02d' $((${duration%.*}/3600)) $((${duration%.*}%3600/60)) $((${duration%.*}%60)))"
40+
echo ""
41+
echo "Detecting slide changes (this may take a while)..."
42+
echo ""
43+
44+
# Crop to the slide overlay area, then detect scene changes
45+
ffmpeg -i "$VIDEO" \
46+
-vf "crop=${crop_w}:${crop_h}:${crop_x}:${crop_y},select='gt(scene,$THRESHOLD)',showinfo" \
47+
-vsync vfr -f null - 2>&1 | \
48+
grep showinfo | \
49+
sed -n 's/.*pts_time:\([0-9.]*\).*/\1/p' | \
50+
while read -r timestamp; do
51+
hours=$(echo "$timestamp / 3600" | bc)
52+
mins=$(echo "($timestamp % 3600) / 60" | bc)
53+
secs=$(echo "$timestamp % 60" | bc)
54+
printf "%02d:%02d:%05.2f\n" "$hours" "$mins" "$secs"
55+
done | tee /tmp/slide_changes.txt
56+
57+
count=$(wc -l < /tmp/slide_changes.txt)
58+
echo ""
59+
echo "========================================"
60+
echo "Found $count potential talk boundaries"
61+
echo "========================================"
62+
63+
if [ "$count" -gt 0 ]; then
64+
echo ""
65+
echo "To preview each cut point:"
66+
echo " ffmpeg -ss TIMESTAMP -i \"$VIDEO\" -vframes 1 -q:v 2 preview.jpg && img2sixel preview.jpg"
67+
fi

scripts/download-webdav.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/bin/bash
2+
# Download all files from a WebDAV server using rclone
3+
# Usage: ./download-webdav.sh <webdav_url> <username> <password> [output_dir]
4+
#
5+
# Requires: rclone
6+
# Install with: curl https://rclone.org/install.sh | sudo bash
7+
8+
set -e
9+
10+
WEBDAV_URL="${1:?Usage: $0 <webdav_url> <username> <password> [output_dir]}"
11+
USERNAME="${2:?Username required}"
12+
PASSWORD="${3:?Password required}"
13+
OUTPUT_DIR="${4:-.}"
14+
15+
# Remove trailing slash from URL
16+
WEBDAV_URL="${WEBDAV_URL%/}"
17+
18+
echo "Downloading from: $WEBDAV_URL"
19+
echo "Output directory: $OUTPUT_DIR"
20+
21+
mkdir -p "$OUTPUT_DIR"
22+
23+
# Check if rclone is installed
24+
if ! command -v rclone &> /dev/null; then
25+
echo "rclone not found. Install with: curl https://rclone.org/install.sh | sudo bash"
26+
exit 1
27+
fi
28+
29+
# Use rclone with inline WebDAV config
30+
# --webdav-url: WebDAV server URL
31+
# --webdav-user: username
32+
# --webdav-pass: password (obscured)
33+
OBSCURED_PASS=$(rclone obscure "$PASSWORD")
34+
35+
rclone copy \
36+
--webdav-url="$WEBDAV_URL" \
37+
--webdav-user="$USERNAME" \
38+
--webdav-pass="$OBSCURED_PASS" \
39+
--progress \
40+
--transfers=4 \
41+
":webdav:/" \
42+
"$OUTPUT_DIR"
43+
44+
echo "Download complete!"

scripts/find-talks.sh

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/bin/bash
2+
# Find talk boundaries in a conference video
3+
# Looks for major scene changes combined with silence gaps
4+
# Usage: ./find-talks.sh <video_file> [min_gap_seconds]
5+
#
6+
# min_gap_seconds: minimum silence duration to consider a boundary (default: 2)
7+
8+
VIDEO="${1:?Usage: $0 <video_file> [min_gap_seconds]}"
9+
MIN_GAP="${2:-2}"
10+
11+
if [ ! -f "$VIDEO" ]; then
12+
echo "File not found: $VIDEO"
13+
exit 1
14+
fi
15+
16+
echo "========================================"
17+
echo "Analyzing: $(basename "$VIDEO")"
18+
echo "Min silence gap: ${MIN_GAP}s"
19+
echo "========================================"
20+
echo ""
21+
22+
# Get video duration
23+
duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$VIDEO" 2>/dev/null)
24+
echo "Video duration: $(printf '%02d:%02d:%02d' $((${duration%.*}/3600)) $((${duration%.*}%3600/60)) $((${duration%.*}%60)))"
25+
echo ""
26+
27+
# Detect silence periods (potential talk boundaries)
28+
echo "Detecting silence gaps (this may take a while)..."
29+
echo ""
30+
31+
tmpfile=$(mktemp /tmp/silence_XXXXXX.txt)
32+
trap "rm -f $tmpfile" EXIT
33+
34+
# silencedetect finds periods of silence
35+
# -50dB threshold, minimum duration of MIN_GAP seconds
36+
ffmpeg -i "$VIDEO" -af "silencedetect=noise=-50dB:d=$MIN_GAP" -f null - 2>&1 | \
37+
grep -E "silence_(start|end)" > "$tmpfile"
38+
39+
echo "Potential talk boundaries (silence gaps):"
40+
echo "----------------------------------------"
41+
42+
# Parse silence start/end pairs
43+
grep "silence_start" "$tmpfile" | while read -r line; do
44+
start=$(echo "$line" | sed -n 's/.*silence_start: \([0-9.]*\).*/\1/p')
45+
if [ -n "$start" ]; then
46+
hours=$(echo "$start / 3600" | bc)
47+
mins=$(echo "($start % 3600) / 60" | bc)
48+
secs=$(echo "$start % 60" | bc)
49+
printf " %02d:%02d:%05.2f\n" "$hours" "$mins" "$secs"
50+
fi
51+
done
52+
53+
echo ""
54+
echo "========================================"
55+
echo ""
56+
echo "To preview a cut point, run:"
57+
echo " ./preview.sh \"$VIDEO\" <timestamp>"
58+
echo ""
59+
echo "To extract a segment:"
60+
echo " ffmpeg -ss START -to END -i \"$VIDEO\" -c copy output.mp4"

scripts/preview.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/bash
2+
# Show a full-size preview of frame 0 of a video using img2sixel
3+
# Usage: ./preview.sh <video_file>
4+
5+
VIDEO="${1:?Usage: $0 <video_file>}"
6+
7+
if [ ! -f "$VIDEO" ]; then
8+
echo "File not found: $VIDEO"
9+
exit 1
10+
fi
11+
12+
tmpfile=$(mktemp /tmp/preview_XXXXXX.jpg)
13+
trap "rm -f $tmpfile" EXIT
14+
15+
ffmpeg -y -ss 0 -i "$VIDEO" -vframes 1 -q:v 2 "$tmpfile" 2>/dev/null
16+
17+
img2sixel "$tmpfile"

scripts/process-videos.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Process all tagged videos using FFmpeg with background compositing.
4+
5+
Usage: ./process-videos.py [background_image] [output_dir]
6+
7+
Reads: ~/videos/quadrant-tags.json
8+
Requires: ffmpeg
9+
"""
10+
11+
import json
12+
import os
13+
import subprocess
14+
import sys
15+
from pathlib import Path
16+
17+
18+
def get_crop(quadrant: str) -> str:
19+
"""Get FFmpeg crop parameters for a quadrant."""
20+
crops = {
21+
"top-left": "1912:1072:4:4",
22+
"top-right": "1912:1072:1924:4",
23+
"bottom-left": "1912:1072:4:1084",
24+
"bottom-right": "1912:1072:1924:1084",
25+
}
26+
if quadrant not in crops:
27+
raise ValueError(f"Invalid quadrant: {quadrant}")
28+
return crops[quadrant]
29+
30+
31+
def build_filter(presenter_crop: str, slides_crop: str) -> str:
32+
"""Build FFmpeg filter complex.
33+
34+
Output: composited video with slides large and presenter small in corner.
35+
"""
36+
return (
37+
f"[1:v]scale=2560:1440[bg]; "
38+
f"[0:v]crop={slides_crop}[slides_cropped]; "
39+
f"[slides_cropped]scale=1920:1080[slides]; "
40+
f"[0:v]crop={presenter_crop}[presenter_raw]; "
41+
f"[presenter_raw]scale=-1:320[presenter]; "
42+
f"[slides]scale=1920:1080[slides_s]; "
43+
f"[bg][slides_s]overlay=(W-w)/2:(H-h)/2[base]; "
44+
f"[base][presenter]overlay=x=W-w-40:y=H-h-40[outv]"
45+
)
46+
47+
48+
def process_video(input_path: str, output_path: str, bg_image: str,
49+
presenter: str, slides: str) -> bool:
50+
"""Process a single video with FFmpeg."""
51+
try:
52+
presenter_crop = get_crop(presenter)
53+
slides_crop = get_crop(slides)
54+
except ValueError as e:
55+
print(f" Error: {e}")
56+
return False
57+
58+
filter_complex = build_filter(presenter_crop, slides_crop)
59+
60+
cmd = [
61+
"ffmpeg", "-y",
62+
"-i", input_path,
63+
"-i", bg_image,
64+
"-filter_complex", filter_complex,
65+
"-map", "[outv]",
66+
"-map", "0:a?",
67+
"-c:v", "libx264",
68+
"-crf", "18",
69+
"-preset", "veryfast",
70+
"-threads", "0",
71+
"-c:a", "copy",
72+
output_path
73+
]
74+
75+
try:
76+
result = subprocess.run(
77+
cmd,
78+
capture_output=True,
79+
text=True
80+
)
81+
if result.returncode != 0:
82+
print(f" FFmpeg error: {result.stderr[-500:]}")
83+
return False
84+
return True
85+
except Exception as e:
86+
print(f" Error running FFmpeg: {e}")
87+
return False
88+
89+
90+
def main():
91+
# Parse arguments
92+
bg_image = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/gpc-bg.png")
93+
output_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser("~/videos/processed")
94+
tags_file = os.path.expanduser("~/videos/quadrant-tags.json")
95+
96+
# Check dependencies
97+
if not Path(bg_image).exists():
98+
print(f"Background image not found: {bg_image}")
99+
sys.exit(1)
100+
101+
if not Path(tags_file).exists():
102+
print(f"Tags file not found: {tags_file}")
103+
print("Run tag-videos.sh first to create it")
104+
sys.exit(1)
105+
106+
# Create output directory
107+
Path(output_dir).mkdir(parents=True, exist_ok=True)
108+
109+
# Load tags
110+
with open(tags_file) as f:
111+
tags = json.load(f)
112+
113+
total = len(tags)
114+
print("=" * 40)
115+
print(f"Processing {total} video(s)")
116+
print(f"Background: {bg_image}")
117+
print(f"Output dir: {output_dir}")
118+
print("=" * 40)
119+
print()
120+
121+
# Process each video
122+
for i, (filename, data) in enumerate(tags.items(), 1):
123+
presenter = data["presenter"]
124+
slides = data["slides"]
125+
input_path = data["path"]
126+
127+
# Output filename (change extension to .mp4)
128+
output_name = Path(filename).stem + ".mp4"
129+
output_path = os.path.join(output_dir, output_name)
130+
131+
# Skip if already processed
132+
if Path(output_path).exists():
133+
print(f"[{i}/{total}] Skipping {filename} (already exists)")
134+
continue
135+
136+
print(f"[{i}/{total}] Processing: {filename}")
137+
print(f" Input: {input_path}")
138+
print(f" Presenter: {presenter}")
139+
print(f" Slides: {slides}")
140+
print(f" Output: {output_path}")
141+
print(" Running FFmpeg...")
142+
143+
if process_video(input_path, output_path, bg_image, presenter, slides):
144+
print(" Done!")
145+
else:
146+
print(" FAILED!")
147+
print()
148+
149+
print("=" * 40)
150+
print("Processing complete!")
151+
print(f"Output directory: {output_dir}")
152+
print("=" * 40)
153+
154+
# Show summary
155+
print()
156+
print("Processed files:")
157+
for f in Path(output_dir).glob("*.mp4"):
158+
size_mb = f.stat().st_size / (1024 * 1024)
159+
print(f" {f.name}: {size_mb:.1f} MB")
160+
161+
162+
if __name__ == "__main__":
163+
main()

0 commit comments

Comments
 (0)