Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 180 additions & 8 deletions lib/capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,42 @@ recording_start() {

RECORDING_DIR=$(mktemp -d)
RECORDING=true
RECORDING_PAUSED=false
RECORDING_FRAME=0
RECORDING_COLS=$(tmux_cmd display-message -t "$SESSION" -p '#{pane_width}')
echo "Recording started"
}

recording_pause() {
if [[ "$RECORDING" != true ]]; then
echo "Warning: Not currently recording"
return
fi
if [[ "$RECORDING_PAUSED" == true ]]; then
echo "Warning: Recording already paused"
return
fi
RECORDING_PAUSED=true
echo "Recording paused"
}

recording_resume() {
if [[ "$RECORDING" != true ]]; then
echo "Warning: Not currently recording"
return
fi
if [[ "$RECORDING_PAUSED" != true ]]; then
echo "Warning: Recording not paused"
return
fi
RECORDING_PAUSED=false
recording_capture_frame # Capture resume state
echo "Recording resumed"
}

# Capture a single frame (called after each key when recording)
recording_capture_frame() {
if [[ "$RECORDING" != true ]]; then
if [[ "$RECORDING" != true ]] || [[ "$RECORDING_PAUSED" == true ]]; then
return
fi

Expand Down Expand Up @@ -131,25 +159,169 @@ recording_stop() {
# Ensure output directory exists
mkdir -p "$OUTPUT_DIR"

# Check if any decorations are enabled
local has_decorations=false
[[ -n "$GIF_WINDOW_BAR" && "$GIF_WINDOW_BAR" != "none" ]] && has_decorations=true
[[ -n "$GIF_BORDER_RADIUS" && "$GIF_BORDER_RADIUS" -gt 0 ]] 2>/dev/null && has_decorations=true
[[ -n "$GIF_MARGIN" && "$GIF_MARGIN" -gt 0 ]] 2>/dev/null && has_decorations=true
[[ -n "$GIF_PADDING" && "$GIF_PADDING" -gt 0 ]] 2>/dev/null && has_decorations=true

if [[ "$has_decorations" == true ]]; then
# Use Python pipeline for decorations
recording_stop_with_decorations "$output_file"
else
# Use simple FFmpeg for plain GIF
recording_stop_simple "$output_file"
fi

# Cleanup
rm -rf "$RECORDING_DIR"
RECORDING=false
RECORDING_FRAME=0
}

recording_stop_simple() {
local output_file="$1"

# Convert GIF frame delay to centiseconds (100cs = 1 second)
local delay_cs=$(echo "scale=0; $GIF_FRAME_DELAY_MS / 10" | bc)
[[ "$delay_cs" -lt 2 ]] && delay_cs=2 # Minimum 20ms per frame

# Generate GIF with ffmpeg using frame delay
# Calculate effective delay with playback speed
# Speed > 1 = faster playback (shorter delay), Speed < 1 = slower
local effective_delay=$(echo "scale=4; $delay_cs / $GIF_PLAYBACK_SPEED" | bc)
local effective_rate=$(echo "scale=4; 100 / $effective_delay" | bc)

# Generate GIF with ffmpeg using frame delay and playback speed
# reserve_transparent=0 prevents transparency which breaks macOS Preview
ffmpeg -y -framerate 1 -i "$RECORDING_DIR/frame_%05d.png" \
-vf "settb=1,setpts=N*$delay_cs/100/TB,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff:reserve_transparent=0[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5" \
-r 100/$delay_cs \
-vf "settb=1,setpts=N*$effective_delay/100/TB,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff:reserve_transparent=0[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5" \
-r $effective_rate \
"$OUTPUT_DIR/$output_file" 2>/dev/null

if [[ -f "$OUTPUT_DIR/$output_file" ]]; then
echo "Saved: $OUTPUT_DIR/$output_file"
else
echo "Error: Failed to create GIF"
fi
}

# Cleanup
rm -rf "$RECORDING_DIR"
RECORDING=false
RECORDING_FRAME=0
recording_stop_with_decorations() {
local output_file="$1"

# Get script directory to find Python modules
local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local python_dir="$script_dir/python"

# Build options JSON for Python pipeline
local opts_json='{'
opts_json+='"gif_delay":'${GIF_FRAME_DELAY_MS:-200}','
opts_json+='"speed":'${GIF_PLAYBACK_SPEED:-1.0}

if [[ -n "$GIF_WINDOW_BAR" && "$GIF_WINDOW_BAR" != "none" ]]; then
opts_json+=',"window_bar":"'$GIF_WINDOW_BAR'"'
fi
if [[ -n "$GIF_BAR_COLOR" ]]; then
opts_json+=',"bar_color":"'$GIF_BAR_COLOR'"'
fi
if [[ -n "$GIF_BORDER_RADIUS" && "$GIF_BORDER_RADIUS" -gt 0 ]] 2>/dev/null; then
opts_json+=',"border_radius":'$GIF_BORDER_RADIUS
fi
if [[ -n "$GIF_MARGIN" && "$GIF_MARGIN" -gt 0 ]] 2>/dev/null; then
opts_json+=',"margin":'$GIF_MARGIN
fi
if [[ -n "$GIF_MARGIN_COLOR" ]]; then
opts_json+=',"margin_color":"'$GIF_MARGIN_COLOR'"'
fi
if [[ -n "$GIF_PADDING" && "$GIF_PADDING" -gt 0 ]] 2>/dev/null; then
opts_json+=',"padding":'$GIF_PADDING
fi
if [[ -n "$GIF_PADDING_COLOR" ]]; then
opts_json+=',"padding_color":"'$GIF_PADDING_COLOR'"'
fi

opts_json+='}'

# Run Python pipeline to build and execute FFmpeg command
PYTHONPATH="$python_dir:$PYTHONPATH" python3 -c "
import sys
import os
import subprocess
import json

# Add lib/python to path
sys.path.insert(0, '$python_dir')

from ffmpeg_pipeline import DecorationPipeline, DecorationOptions

opts_dict = json.loads('$opts_json')
options = DecorationOptions(
window_bar_style=opts_dict.get('window_bar'),
bar_color=opts_dict.get('bar_color', '#1e1e1e'),
border_radius=opts_dict.get('border_radius', 0),
margin=opts_dict.get('margin', 0),
margin_color=opts_dict.get('margin_color', '#000000'),
padding=opts_dict.get('padding', 0),
padding_color=opts_dict.get('padding_color', '#1e1e1e'),
speed=opts_dict.get('speed', 1.0),
frame_delay_ms=opts_dict.get('gif_delay', 200),
)

# Get frame dimensions from first frame
first_frame = '$RECORDING_DIR/frame_00000.png'
result = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height', '-of', 'csv=p=0', first_frame],
capture_output=True, text=True
)
if result.returncode != 0:
print(f'Error getting frame dimensions: {result.stderr}', file=sys.stderr)
sys.exit(1)

dims = result.stdout.strip().split(',')
frame_width, frame_height = int(dims[0]), int(dims[1])

# Build pipeline
pipeline = DecorationPipeline(
frame_width=frame_width,
frame_height=frame_height,
options=options,
recording_dir='$RECORDING_DIR',
)

# Calculate effective frame delay with speed
delay_cs = max(2, options.frame_delay_ms // 10)
effective_delay = delay_cs / options.speed
effective_rate = 100 / effective_delay

# Add decorations in correct order
pipeline.add_padding()
pipeline.add_window_bar()
pipeline.add_border_radius()
pipeline.add_margin()

# Build filter complex
input_args, filter_complex, output_stream = pipeline.build()

# Prepend frame setup to filter complex
frames_filter = f'[0:v]settb=1,setpts=N*{effective_delay}/100/TB[frames]'
full_filter = f'{frames_filter};{filter_complex}'

# Build complete command
cmd = ['ffmpeg', '-y', '-framerate', '1', '-i', '$RECORDING_DIR/frame_%05d.png']
cmd.extend(input_args)
cmd.extend(['-filter_complex', full_filter, '-map', f'[{output_stream}]', '-r', str(effective_rate), '$OUTPUT_DIR/$output_file'])

# Execute
result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0:
print(f'FFmpeg error: {result.stderr.decode()}', file=sys.stderr)
sys.exit(1)
"

if [[ -f "$OUTPUT_DIR/$output_file" ]]; then
echo "Saved: $OUTPUT_DIR/$output_file"
else
echo "Error: Failed to create GIF"
fi
}
1 change: 1 addition & 0 deletions lib/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ RECORDING_DIR=""
RECORDING_FRAME=0
RECORDING_COLS=""
GIF_FRAME_DELAY_MS=200 # Time each frame displays in GIF (default: 200ms)
GIF_PLAYBACK_SPEED=1.0 # Playback speed multiplier (0.25-4.0, default: 1.0)

# tmux socket name (isolates betamax sessions)
TMUX_SOCKET="betamax"
Expand Down
43 changes: 43 additions & 0 deletions lib/keys.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,49 @@ process_directives() {
@set:gif_delay:*)
GIF_FRAME_DELAY_MS="${key#@set:gif_delay:}"
;;
@set:speed:*)
local speed_val="${key#@set:speed:}"
# Validate speed is a number in range 0.25-4.0
if [[ "$speed_val" =~ ^[0-9]*\.?[0-9]+$ ]]; then
local valid=$(echo "$speed_val >= 0.25 && $speed_val <= 4.0" | bc -l)
if [[ "$valid" == "1" ]]; then
GIF_PLAYBACK_SPEED="$speed_val"
else
echo "Error: @set:speed must be between 0.25 and 4.0 (got: $speed_val)" >&2
exit 1
fi
else
echo "Error: @set:speed requires a numeric value (got: $speed_val)" >&2
exit 1
fi
;;
@set:window_bar:*)
GIF_WINDOW_BAR="${key#@set:window_bar:}"
;;
@set:bar_color:*)
local color="${key#@set:bar_color:}"
[[ "$color" != \#* ]] && color="#$color"
GIF_BAR_COLOR="$color"
;;
@set:border_radius:*)
GIF_BORDER_RADIUS="${key#@set:border_radius:}"
;;
@set:margin:*)
GIF_MARGIN="${key#@set:margin:}"
;;
@set:margin_color:*)
local color="${key#@set:margin_color:}"
[[ "$color" != \#* ]] && color="#$color"
GIF_MARGIN_COLOR="$color"
;;
@set:padding:*)
GIF_PADDING="${key#@set:padding:}"
;;
@set:padding_color:*)
local color="${key#@set:padding_color:}"
[[ "$color" != \#* ]] && color="#$color"
GIF_PADDING_COLOR="$color"
;;
@require:*)
REQUIRED_CMDS+=("${key#@require:}")
;;
Expand Down
Loading