diff --git a/lib/capture.sh b/lib/capture.sh index 6c6c79c..f413e4a 100644 --- a/lib/capture.sh +++ b/lib/capture.sh @@ -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 @@ -131,15 +159,44 @@ 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 @@ -147,9 +204,124 @@ recording_stop() { 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 } diff --git a/lib/config.sh b/lib/config.sh index f87378a..f5df34c 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -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" diff --git a/lib/keys.sh b/lib/keys.sh index 56af18d..dfc8f49 100644 --- a/lib/keys.sh +++ b/lib/keys.sh @@ -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:}") ;; diff --git a/lib/python/decorations.py b/lib/python/decorations.py new file mode 100644 index 0000000..16706c8 --- /dev/null +++ b/lib/python/decorations.py @@ -0,0 +1,376 @@ +""" +decorations.py - Generate visual decorations for GIF recordings + +Generates decoration images (window bars, corner masks) for FFmpeg compositing. +Uses Pillow if available, falls back to ImageMagick commands. +""" + +import subprocess +import os +from typing import Optional, Tuple, Dict + +# Color palettes for different bar styles +BAR_STYLES = { + 'colorful': { + 'dots': ['#ff5f56', '#ffbd2e', '#27c93f'], # red, yellow, green + 'default_bg': '#1e1e1e', + }, + 'colorful_right': { + 'dots': ['#ff5f56', '#ffbd2e', '#27c93f'], + 'default_bg': '#1e1e1e', + 'align': 'right', + }, + 'rings': { + 'dots': ['#ff5f56', '#ffbd2e', '#27c93f'], + 'default_bg': '#1e1e1e', + 'hollow': True, + }, +} + +# Default dimensions +DEFAULT_BAR_HEIGHT = 30 +DEFAULT_DOT_RADIUS = 6 +DEFAULT_DOT_SPACING = 20 +DEFAULT_DOT_MARGIN = 20 + + +def _validate_hex_color(color: str) -> str: + """ + Validate and normalize hex color format. + + Args: + color: Hex color string (with or without # prefix) + + Returns: + Normalized color with # prefix + + Raises: + ValueError: If color format is invalid + """ + if not color: + raise ValueError('Color cannot be empty') + + # Normalize: add # prefix if missing + if not color.startswith('#'): + color = '#' + color + + # Validate hex format + hex_part = color[1:] + if len(hex_part) not in (3, 6): + raise ValueError(f'Invalid hex color length: {color} (expected 3 or 6 hex digits)') + + try: + int(hex_part, 16) + except ValueError: + raise ValueError(f'Invalid hex color format: {color} (contains non-hex characters)') + + return color + + +def _validate_dimensions(value: int, name: str, min_val: int = 1, max_val: int = 10000) -> int: + """Validate numeric dimensions.""" + if not isinstance(value, int): + raise TypeError(f'{name} must be int, got {type(value).__name__}') + if value < min_val or value > max_val: + raise ValueError(f'{name} must be between {min_val} and {max_val}, got {value}') + return value + + +def _check_pillow() -> bool: + """Check if Pillow is available.""" + try: + from PIL import Image, ImageDraw + return True + except ImportError: + return False + + +def _check_imagemagick() -> bool: + """Check if ImageMagick is available.""" + try: + result = subprocess.run(['convert', '-version'], capture_output=True, timeout=5) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def generate_window_bar_pillow( + width: int, + output_path: str, + style: str = 'colorful', + bg_color: str = None, + bar_height: int = DEFAULT_BAR_HEIGHT, +) -> bool: + """Generate window bar PNG using Pillow.""" + from PIL import Image, ImageDraw + + # Validate inputs + _validate_dimensions(width, 'width') + _validate_dimensions(bar_height, 'bar_height', max_val=500) + + style_config = BAR_STYLES.get(style, BAR_STYLES['colorful']) + bg = bg_color or style_config['default_bg'] + bg = _validate_hex_color(bg) # Validate color to prevent issues + dots = style_config['dots'] + align_right = style_config.get('align') == 'right' + hollow = style_config.get('hollow', False) + + img = Image.new('RGBA', (width, bar_height), bg) + draw = ImageDraw.Draw(img) + + dot_y = bar_height // 2 + dot_radius = DEFAULT_DOT_RADIUS + + for i, color in enumerate(dots): + if align_right: + x = width - DEFAULT_DOT_MARGIN - (len(dots) - 1 - i) * DEFAULT_DOT_SPACING + else: + x = DEFAULT_DOT_MARGIN + i * DEFAULT_DOT_SPACING + + bbox = [x - dot_radius, dot_y - dot_radius, + x + dot_radius, dot_y + dot_radius] + + if hollow: + draw.ellipse(bbox, outline=color, width=2) + else: + draw.ellipse(bbox, fill=color) + + img.save(output_path, 'PNG') + return True + + +def generate_window_bar_imagemagick( + width: int, + output_path: str, + style: str = 'colorful', + bg_color: str = None, + bar_height: int = DEFAULT_BAR_HEIGHT, +) -> bool: + """Generate window bar PNG using ImageMagick.""" + # Validate inputs - CRITICAL for security (prevents command injection) + _validate_dimensions(width, 'width') + _validate_dimensions(bar_height, 'bar_height', max_val=500) + + style_config = BAR_STYLES.get(style, BAR_STYLES['colorful']) + bg = bg_color or style_config['default_bg'] + bg = _validate_hex_color(bg) # CRITICAL: validate before shell interpolation + dots = style_config['dots'] + align_right = style_config.get('align') == 'right' + hollow = style_config.get('hollow', False) + + dot_y = bar_height // 2 + dot_radius = DEFAULT_DOT_RADIUS + + cmd = ['convert', '-size', f'{width}x{bar_height}', f'xc:{bg}'] + + for i, color in enumerate(dots): + if align_right: + x = width - DEFAULT_DOT_MARGIN - (len(dots) - 1 - i) * DEFAULT_DOT_SPACING + else: + x = DEFAULT_DOT_MARGIN + i * DEFAULT_DOT_SPACING + + if hollow: + cmd.extend([ + '-fill', 'none', + '-stroke', color, + '-strokewidth', '2', + '-draw', f'circle {x},{dot_y} {x + dot_radius},{dot_y}' + ]) + else: + cmd.extend([ + '-fill', color, + '-draw', f'circle {x},{dot_y} {x + dot_radius},{dot_y}' + ]) + + cmd.append(output_path) + + try: + result = subprocess.run(cmd, capture_output=True, timeout=30) + return result.returncode == 0 + except subprocess.TimeoutExpired: + return False + + +def generate_window_bar( + width: int, + output_path: str, + style: str = 'colorful', + bg_color: str = None, + bar_height: int = DEFAULT_BAR_HEIGHT, +) -> bool: + """ + Generate window bar PNG for GIF decoration. + + Args: + width: Width of the bar (matches video width) + output_path: Where to save the PNG + style: Bar style ('colorful', 'colorful_right', 'rings') + bg_color: Background color (hex, e.g. '#1e1e1e') + bar_height: Height of the bar in pixels + + Returns: + True on success, False on failure + """ + if _check_pillow(): + return generate_window_bar_pillow(width, output_path, style, bg_color, bar_height) + elif _check_imagemagick(): + return generate_window_bar_imagemagick(width, output_path, style, bg_color, bar_height) + else: + return False + + +def generate_corner_mask_pillow( + width: int, + height: int, + output_path: str, + radius: int, +) -> bool: + """Generate rounded corner alpha mask using Pillow.""" + from PIL import Image, ImageDraw + + # Create mask with alpha channel + mask = Image.new('L', (width, height), 255) + draw = ImageDraw.Draw(mask) + + # Draw black (transparent) corners + # Top-left + draw.rectangle([0, 0, radius, radius], fill=0) + draw.pieslice([0, 0, radius * 2, radius * 2], 180, 270, fill=255) + + # Top-right + draw.rectangle([width - radius, 0, width, radius], fill=0) + draw.pieslice([width - radius * 2, 0, width, radius * 2], 270, 360, fill=255) + + # Bottom-left + draw.rectangle([0, height - radius, radius, height], fill=0) + draw.pieslice([0, height - radius * 2, radius * 2, height], 90, 180, fill=255) + + # Bottom-right + draw.rectangle([width - radius, height - radius, width, height], fill=0) + draw.pieslice([width - radius * 2, height - radius * 2, width, height], 0, 90, fill=255) + + mask.save(output_path, 'PNG') + return True + + +def generate_corner_mask_imagemagick( + width: int, + height: int, + output_path: str, + radius: int, +) -> bool: + """Generate rounded corner alpha mask using ImageMagick.""" + cmd = [ + 'convert', '-size', f'{width}x{height}', + 'xc:white', + '-fill', 'black', + # Top-left corner + '-draw', f'rectangle 0,0 {radius},{radius}', + '-fill', 'white', + '-draw', f'circle {radius},{radius} {radius},0', + # Top-right corner + '-fill', 'black', + '-draw', f'rectangle {width - radius},0 {width},{radius}', + '-fill', 'white', + '-draw', f'circle {width - radius - 1},{radius} {width - radius - 1},0', + # Bottom-left corner + '-fill', 'black', + '-draw', f'rectangle 0,{height - radius} {radius},{height}', + '-fill', 'white', + '-draw', f'circle {radius},{height - radius - 1} {radius},{height - 1}', + # Bottom-right corner + '-fill', 'black', + '-draw', f'rectangle {width - radius},{height - radius} {width},{height}', + '-fill', 'white', + '-draw', f'circle {width - radius - 1},{height - radius - 1} {width - 1},{height - radius - 1}', + output_path + ] + + try: + result = subprocess.run(cmd, capture_output=True, timeout=30) + return result.returncode == 0 + except subprocess.TimeoutExpired: + return False + + +def generate_corner_mask( + width: int, + height: int, + output_path: str, + radius: int, +) -> bool: + """ + Generate rounded corner alpha mask. + + Args: + width: Width of the mask + height: Height of the mask + output_path: Where to save the PNG + radius: Corner radius in pixels + + Returns: + True on success, False on failure + """ + if _check_pillow(): + return generate_corner_mask_pillow(width, height, output_path, radius) + elif _check_imagemagick(): + return generate_corner_mask_imagemagick(width, height, output_path, radius) + else: + return False + + +def get_available_backend() -> Optional[str]: + """Return the available image generation backend.""" + if _check_pillow(): + return 'pillow' + elif _check_imagemagick(): + return 'imagemagick' + return None + + +if __name__ == '__main__': + import sys + + if len(sys.argv) < 2: + print('Usage: decorations.py [args]') + print('Commands:') + print(' window_bar [style] [bg_color]') + print(' corner_mask ') + print(' check_backend') + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == 'window_bar': + width = int(sys.argv[2]) + output = sys.argv[3] + style = sys.argv[4] if len(sys.argv) > 4 else 'colorful' + bg = sys.argv[5] if len(sys.argv) > 5 else None + if generate_window_bar(width, output, style, bg): + print(f'Generated: {output}') + else: + print('Error: Failed to generate window bar', file=sys.stderr) + sys.exit(1) + + elif cmd == 'corner_mask': + width = int(sys.argv[2]) + height = int(sys.argv[3]) + output = sys.argv[4] + radius = int(sys.argv[5]) + if generate_corner_mask(width, height, output, radius): + print(f'Generated: {output}') + else: + print('Error: Failed to generate corner mask', file=sys.stderr) + sys.exit(1) + + elif cmd == 'check_backend': + backend = get_available_backend() + if backend: + print(backend) + else: + print('none') + sys.exit(1) + + else: + print(f'Unknown command: {cmd}', file=sys.stderr) + sys.exit(1) diff --git a/lib/python/ffmpeg_pipeline.py b/lib/python/ffmpeg_pipeline.py new file mode 100644 index 0000000..9eb5ff3 --- /dev/null +++ b/lib/python/ffmpeg_pipeline.py @@ -0,0 +1,411 @@ +""" +ffmpeg_pipeline.py - Unified decoration compositing pipeline for GIF generation + +Builds FFmpeg filter chains that combine multiple decoration layers: +1. Base frames (from termshot) +2. Window bar (macOS-style) +3. Rounded corners (alpha mask) +4. Margin/padding + +Order matters: bar extends height, then corners apply, then margin wraps. +""" + +import os +from dataclasses import dataclass, field +from typing import List, Optional, Tuple +from .decorations import ( + generate_window_bar, + generate_corner_mask, + DEFAULT_BAR_HEIGHT, +) + + +@dataclass +class DecorationOptions: + """Configuration for GIF decorations.""" + # Window bar + window_bar_style: Optional[str] = None # colorful, colorful_right, rings, none + bar_color: str = '#1e1e1e' + bar_height: int = DEFAULT_BAR_HEIGHT + + # Rounded corners + border_radius: int = 0 + + # Margin/padding + margin: int = 0 + margin_color: str = '#000000' + padding: int = 0 + padding_color: str = '#1e1e1e' + + # Playback + speed: float = 1.0 + frame_delay_ms: int = 200 + + +@dataclass +class PipelineInput: + """Input specification for a stream.""" + path: str + index: int + is_image: bool = False # True for decoration images (need loop) + + +class DecorationPipeline: + """ + Builds FFmpeg commands for compositing decorations onto GIF frames. + + Usage: + pipeline = DecorationPipeline(width=800, height=600, options=opts) + pipeline.add_window_bar('/tmp/bar.png') + pipeline.add_border_radius('/tmp/mask.png') + pipeline.add_margin() + + inputs, filter_complex = pipeline.build() + # Use inputs and filter_complex with ffmpeg + """ + + def __init__( + self, + frame_width: int, + frame_height: int, + options: DecorationOptions, + recording_dir: str, + ): + self.frame_width = frame_width + self.frame_height = frame_height + self.options = options + self.recording_dir = recording_dir + + # Track current dimensions (changes as decorations add height/width) + self.current_width = frame_width + self.current_height = frame_height + + # Build state + self._inputs: List[PipelineInput] = [] + self._filter_stages: List[str] = [] + self._stream_counter = 0 + self._prev_stream = '[frames]' + self._decoration_files: List[str] = [] + + def _next_stream(self, name: str = None) -> str: + """Get next unique stream name.""" + self._stream_counter += 1 + if name: + return f'[{name}]' + return f'[s{self._stream_counter}]' + + def add_input(self, path: str, is_image: bool = False) -> int: + """Add an input file and return its index.""" + idx = len(self._inputs) + self._inputs.append(PipelineInput(path=path, index=idx, is_image=is_image)) + return idx + + def add_window_bar(self) -> bool: + """ + Add window bar decoration. + + Generates bar image and adds filter to composite at top. + Returns True on success. + """ + if not self.options.window_bar_style: + return False + if self.options.window_bar_style == 'none': + return False + + bar_path = os.path.join(self.recording_dir, 'decoration_bar.png') + + if not generate_window_bar( + width=self.current_width, + output_path=bar_path, + style=self.options.window_bar_style, + bg_color=self.options.bar_color, + bar_height=self.options.bar_height, + ): + return False + + self._decoration_files.append(bar_path) + bar_idx = self.add_input(bar_path, is_image=True) + bar_stream = f'[{bar_idx}:v]' + + # Pad the frames to add space at top for bar + bar_height = self.options.bar_height + padded = self._next_stream('padded') + self._filter_stages.append( + f'{self._prev_stream}pad=w={self.current_width}:h={self.current_height + bar_height}:x=0:y={bar_height}:color={self.options.bar_color}{padded}' + ) + self._prev_stream = padded + + # Loop the bar image and overlay at top + bar_loop = self._next_stream('bar') + result = self._next_stream('withbar') + self._filter_stages.append(f'{bar_stream}loop=loop=-1:size=1{bar_loop}') + self._filter_stages.append(f'{self._prev_stream}{bar_loop}overlay=0:0{result}') + self._prev_stream = result + + self.current_height += bar_height + return True + + def add_border_radius(self) -> bool: + """ + Add rounded corners. + + Generates corner mask and applies alpha merge. + Returns True on success. + """ + if self.options.border_radius <= 0: + return False + + mask_path = os.path.join(self.recording_dir, 'decoration_mask.png') + + if not generate_corner_mask( + width=self.current_width, + height=self.current_height, + output_path=mask_path, + radius=self.options.border_radius, + ): + return False + + self._decoration_files.append(mask_path) + mask_idx = self.add_input(mask_path, is_image=True) + mask_stream = f'[{mask_idx}:v]' + + # Convert to RGBA format before alphamerge (required for proper alpha handling) + rgba_stream = self._next_stream('rgba') + self._filter_stages.append(f'{self._prev_stream}format=rgba{rgba_stream}') + + # Loop the mask and apply alphamerge + mask_loop = self._next_stream('mask') + result = self._next_stream('rounded') + self._filter_stages.append(f'{mask_stream}loop=loop=-1:size=1{mask_loop}') + self._filter_stages.append(f'{rgba_stream}{mask_loop}alphamerge{result}') + self._prev_stream = result + return True + + def add_padding(self) -> bool: + """Add inner padding around content.""" + if self.options.padding <= 0: + return False + + p = self.options.padding + result = self._next_stream('padded_inner') + self._filter_stages.append( + f'{self._prev_stream}pad=w={self.current_width + p * 2}:h={self.current_height + p * 2}:x={p}:y={p}:color={self.options.padding_color}{result}' + ) + self._prev_stream = result + self.current_width += p * 2 + self.current_height += p * 2 + return True + + def add_margin(self) -> bool: + """Add outer margin around the composition.""" + if self.options.margin <= 0: + return False + + m = self.options.margin + result = self._next_stream('margined') + self._filter_stages.append( + f'{self._prev_stream}pad=w={self.current_width + m * 2}:h={self.current_height + m * 2}:x={m}:y={m}:color={self.options.margin_color}{result}' + ) + self._prev_stream = result + self.current_width += m * 2 + self.current_height += m * 2 + return True + + def build(self) -> Tuple[List[str], str, str]: + """ + Build the FFmpeg arguments. + + Returns: + Tuple of (input_args, filter_complex, output_stream_name) + + The filter_complex includes palette generation for GIF output. + """ + # Build input arguments + input_args = [] + for inp in self._inputs: + if inp.is_image: + input_args.extend(['-i', inp.path]) + else: + input_args.extend(['-i', inp.path]) + + # Add palette generation at the end + final_stream = self._prev_stream + + # Apply speed adjustment + if self.options.speed != 1.0: + speed_result = self._next_stream('sped') + self._filter_stages.append( + f'{final_stream}setpts=PTS/{self.options.speed}{speed_result}' + ) + final_stream = speed_result + + # Split for palette generation + split_a = self._next_stream('pa') + split_b = self._next_stream('pb') + self._filter_stages.append(f'{final_stream}split{split_a}{split_b}') + + # Generate palette + palette = self._next_stream('pal') + self._filter_stages.append( + f'{split_a}palettegen=max_colors=256:stats_mode=diff:reserve_transparent=0{palette}' + ) + + # Apply palette + output = self._next_stream('out') + self._filter_stages.append( + f'{split_b}{palette}paletteuse=dither=bayer:bayer_scale=5{output}' + ) + + filter_complex = ';'.join(self._filter_stages) + return input_args, filter_complex, output.strip('[]') + + def get_decoration_files(self) -> List[str]: + """Get list of generated decoration files for cleanup.""" + return self._decoration_files + + +def build_gif_command( + frame_pattern: str, + output_path: str, + options: DecorationOptions, + recording_dir: str, + frame_count: int, +) -> Tuple[List[str], List[str]]: + """ + Build complete FFmpeg command for GIF generation with decorations. + + Args: + frame_pattern: Path pattern for frames (e.g., '/tmp/frame_%05d.png') + output_path: Output GIF path + options: Decoration options + recording_dir: Directory for temporary decoration files + frame_count: Number of frames (for dimension detection) + + Returns: + Tuple of (FFmpeg command args, list of temp decoration files to cleanup) + """ + # Get frame dimensions from first frame + first_frame = frame_pattern % 0 + if not os.path.exists(first_frame): + first_frame = frame_pattern.replace('%05d', '00000') + + # Use ffprobe to get dimensions + import subprocess + try: + 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, timeout=10 + ) + except FileNotFoundError: + raise RuntimeError('ffprobe not found. Please install FFmpeg.') + except subprocess.TimeoutExpired: + raise RuntimeError(f'ffprobe timed out reading {first_frame}') + + if result.returncode != 0: + raise RuntimeError(f'Failed to get frame dimensions: {result.stderr}') + + 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, + ) + + # Validate speed to prevent division by zero + if options.speed <= 0: + raise ValueError(f'Speed must be positive, got {options.speed}') + if options.speed > 100: + raise ValueError(f'Speed too high: {options.speed} (max 100)') + + # Calculate effective frame delay with speed + delay_cs = max(2, options.frame_delay_ms // 10) + effective_delay = delay_cs / options.speed + # Ensure reasonable bounds for frame rate + effective_delay = max(1, min(effective_delay, 1000)) + effective_rate = 100 / effective_delay + + # Add frames as first input (with framerate) + cmd = ['ffmpeg', '-y', '-framerate', '1', '-i', frame_pattern] + + # Add padding first (inside of rounded corners) + pipeline.add_padding() + + # Add window bar (before rounded corners so corners apply to bar too) + pipeline.add_window_bar() + + # Add rounded corners + pipeline.add_border_radius() + + # Add outer margin last + pipeline.add_margin() + + # Build filter complex + # Start with the frames stream + frames_filter = f'[0:v]settb=1,setpts=N*{effective_delay}/100/TB[frames]' + + input_args, filter_complex, output_stream = pipeline.build() + + # Combine frame setup with decoration filters + full_filter = f'{frames_filter};{filter_complex}' + + # Build final command + cmd.extend(input_args) + cmd.extend([ + '-filter_complex', full_filter, + '-map', f'[{output_stream}]', + '-r', str(effective_rate), + output_path + ]) + + return cmd, pipeline.get_decoration_files() + + +if __name__ == '__main__': + import sys + import json + + if len(sys.argv) < 2: + print('Usage: ffmpeg_pipeline.py [args]') + print('Commands:') + print(' build ') + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == 'build': + frame_pattern = sys.argv[2] + output = sys.argv[3] + recording_dir = sys.argv[4] + opts_json = sys.argv[5] if len(sys.argv) > 5 else '{}' + + 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), + ) + + try: + ffmpeg_cmd, temp_files = build_gif_command( + frame_pattern, output, options, recording_dir, 1 + ) + print(' '.join(ffmpeg_cmd)) + except Exception as e: + print(f'Error: {e}', file=sys.stderr) + sys.exit(1) + + else: + print(f'Unknown command: {cmd}', file=sys.stderr) + sys.exit(1) diff --git a/lib/runner.sh b/lib/runner.sh index 6e4e7b4..5cbaf31 100644 --- a/lib/runner.sh +++ b/lib/runner.sh @@ -66,6 +66,12 @@ run_keys() { recording_start recording_capture_frame # Capture initial state ;; + @record:pause) + recording_pause + ;; + @record:resume) + recording_resume + ;; @record:stop:*) local gif_name="${key#@record:stop:}" recording_stop "$gif_name" diff --git a/lib/validate.sh b/lib/validate.sh index 7834427..8c3e024 100644 --- a/lib/validate.sh +++ b/lib/validate.sh @@ -153,6 +153,45 @@ validate_set_directive() { return 1 fi ;; + speed) + # Validate speed is a decimal between 0.25 and 4.0 + if ! [[ "$value" =~ ^[0-9]*\.?[0-9]+$ ]]; then + validation_error "Invalid speed value: $value (must be numeric)" "$idx" + return 1 + fi + local valid=$(echo "$value >= 0.25 && $value <= 4.0" | bc -l) + if [[ "$valid" != "1" ]]; then + validation_error "Speed must be between 0.25 and 4.0 (got: $value)" "$idx" + return 1 + fi + ;; + window_bar) + # Valid styles: colorful, colorful_right, rings, none + case "$value" in + colorful|colorful_right|rings|none) + ;; + *) + validation_error "Invalid window_bar style: $value (valid: colorful, colorful_right, rings, none)" "$idx" + return 1 + ;; + esac + ;; + bar_color|margin_color|padding_color) + # Validate hex color format (6 hex digits, with or without # prefix) + # Note: # starts a comment in .keys files, so prefer without # + if ! [[ "$value" =~ ^#?[0-9a-fA-F]{6}$ ]]; then + validation_error "Invalid color format for $key: $value (expected RRGGBB or #RRGGBB)" "$idx" + return 1 + fi + ;; + border_radius|margin|padding) + # Validate positive integer + if ! [[ "$value" =~ ^[0-9]+$ ]]; then + validation_error "Invalid integer for $key: $value" "$idx" + return 1 + fi + # Allow 0 for these (means disabled) + ;; *) validation_error "Unknown setting: $key" "$idx" return 1 @@ -333,14 +372,14 @@ validate_record_directive() { local idx="$2" if [[ "$directive" == "@record" ]]; then - validation_error "Invalid @record format (use @record:start or @record:stop:.gif)" "$idx" + validation_error "Invalid @record format (use @record:start, @record:pause, @record:resume, or @record:stop:.gif)" "$idx" return 1 fi local spec="${directive#@record:}" case "$spec" in - start) + start|pause|resume) # Valid ;; stop:*) @@ -355,7 +394,7 @@ validate_record_directive() { fi ;; *) - validation_error "Unknown @record command: $spec (valid: start, stop:.gif)" "$idx" + validation_error "Unknown @record command: $spec (valid: start, pause, resume, stop:.gif)" "$idx" return 1 ;; esac diff --git a/test/output/gradient_wave.gif b/test/output/gradient_wave.gif index b630c7b..2c31c08 100644 Binary files a/test/output/gradient_wave.gif and b/test/output/gradient_wave.gif differ diff --git a/test/output/sidecar/plugin-conversations.png b/test/output/sidecar/plugin-conversations.png index 4f46906..8167fec 100644 Binary files a/test/output/sidecar/plugin-conversations.png and b/test/output/sidecar/plugin-conversations.png differ diff --git a/test/output/sidecar/plugin-files.png b/test/output/sidecar/plugin-files.png index 518f117..b1b71f1 100644 Binary files a/test/output/sidecar/plugin-files.png and b/test/output/sidecar/plugin-files.png differ diff --git a/test/output/sidecar/plugin-git.png b/test/output/sidecar/plugin-git.png index 7315654..cee7bcb 100644 Binary files a/test/output/sidecar/plugin-git.png and b/test/output/sidecar/plugin-git.png differ diff --git a/test/output/sidecar/plugin-td.png b/test/output/sidecar/plugin-td.png index 7012531..4bb5e9e 100644 Binary files a/test/output/sidecar/plugin-td.png and b/test/output/sidecar/plugin-td.png differ diff --git a/test/output/sidecar/plugin-worktrees.png b/test/output/sidecar/plugin-worktrees.png index a7ca510..4bb5e9e 100644 Binary files a/test/output/sidecar/plugin-worktrees.png and b/test/output/sidecar/plugin-worktrees.png differ diff --git a/test/output/vim_test.gif b/test/output/vim_test.gif index dce8f83..6e58ab6 100644 Binary files a/test/output/vim_test.gif and b/test/output/vim_test.gif differ diff --git a/test/validation_tests.sh b/test/validation_tests.sh index 2322c48..383c091 100755 --- a/test/validation_tests.sh +++ b/test/validation_tests.sh @@ -106,6 +106,26 @@ test_set_directives() { expect_valid "@set:timeout:30" "@set:timeout valid" expect_valid "@set:shell:/bin/zsh" "@set:shell valid path" expect_valid "@set:gif_delay:50" "@set:gif_delay valid" + expect_valid "@set:speed:1.0" "@set:speed default valid" + expect_valid "@set:speed:0.25" "@set:speed minimum valid" + expect_valid "@set:speed:4.0" "@set:speed maximum valid" + expect_valid "@set:speed:2" "@set:speed integer valid" + expect_valid "@set:speed:1.5" "@set:speed decimal valid" + + # Decoration directives - valid cases + expect_valid "@set:window_bar:colorful" "@set:window_bar colorful valid" + expect_valid "@set:window_bar:colorful_right" "@set:window_bar colorful_right valid" + expect_valid "@set:window_bar:rings" "@set:window_bar rings valid" + expect_valid "@set:window_bar:none" "@set:window_bar none valid" + expect_valid "@set:bar_color:1e1e1e" "@set:bar_color valid hex (no #)" + expect_valid "@set:bar_color:FFFFFF" "@set:bar_color uppercase hex valid" + expect_valid "@set:border_radius:8" "@set:border_radius valid" + expect_valid "@set:border_radius:0" "@set:border_radius zero valid" + expect_valid "@set:margin:20" "@set:margin valid" + expect_valid "@set:margin:0" "@set:margin zero valid" + expect_valid "@set:margin_color:000000" "@set:margin_color valid" + expect_valid "@set:padding:10" "@set:padding valid" + expect_valid "@set:padding_color:282a36" "@set:padding_color valid" # Error cases expect_error "@set:cols:" "Missing value" "@set:cols missing value" @@ -114,6 +134,21 @@ test_set_directives() { expect_error "@set:rows:0" "must be positive" "@set:rows zero" expect_error "@set:unknown:foo" "Unknown setting" "@set unknown key" expect_error "@set:output:" "Missing value" "@set:output empty" + expect_error "@set:speed:abc" "Invalid speed value" "@set:speed non-numeric" + expect_error "@set:speed:0.1" "between 0.25 and 4.0" "@set:speed too slow" + expect_error "@set:speed:5" "between 0.25 and 4.0" "@set:speed too fast" + expect_error "@set:speed:-1" "Invalid speed value" "@set:speed negative" + + # Decoration directive error cases + expect_error "@set:window_bar:invalid" "Invalid window_bar style" "@set:window_bar invalid style" + expect_error "@set:bar_color:red" "Invalid color format" "@set:bar_color non-hex" + expect_error "@set:bar_color:fff" "Invalid color format" "@set:bar_color short hex" + expect_error "@set:bar_color:GGGGGG" "Invalid color format" "@set:bar_color invalid hex" + expect_error "@set:border_radius:abc" "Invalid integer" "@set:border_radius non-integer" + expect_error "@set:margin:abc" "Invalid integer" "@set:margin non-integer" + expect_error "@set:padding:abc" "Invalid integer" "@set:padding non-integer" + expect_error "@set:margin_color:blue" "Invalid color format" "@set:margin_color non-hex" + expect_error "@set:padding_color:12345" "Invalid color format" "@set:padding_color invalid hex" } # ============================================================ @@ -204,11 +239,13 @@ test_record_directives() { # Valid cases expect_valid "@record:start" "@record:start valid" + expect_valid "@record:pause" "@record:pause valid" + expect_valid "@record:resume" "@record:resume valid" expect_valid "@record:stop:out.gif" "@record:stop:out.gif valid" # Error cases expect_error "@record" "Invalid @record format" "@record alone" - expect_error "@record:pause" "Unknown @record command" "@record:pause unknown" + expect_error "@record:unknown" "Unknown @record command" "@record:unknown invalid" expect_error "@record:stop:" "Missing GIF filename" "@record:stop: empty" expect_error "@record:stop:out.mp4" "must end with .gif" "@record:stop wrong ext" } diff --git a/website/docs/guides/gif-recording.md b/website/docs/guides/gif-recording.md index 94a4914..de6ce09 100644 --- a/website/docs/guides/gif-recording.md +++ b/website/docs/guides/gif-recording.md @@ -62,6 +62,45 @@ A GIF recording session has three parts: Frames are only captured at explicit `@frame` directives. Everything between frames is not recorded. +## Pause and Resume Recording + +The `@record:pause` and `@record:resume` directives let you temporarily stop and restart frame capture without ending the recording session. This is useful for skipping parts of a session you don't want in the final GIF. + +```bash +@record:start + +# Record initial interaction +j +@frame +j +@frame + +# Skip a slow operation +@record:pause +@wait:/Build complete/ # Wait but don't capture frames +@record:resume + +# Continue recording +k +@frame + +@record:stop:demo.gif +``` + +When paused: +- The terminal session continues running normally +- `@frame` directives are ignored +- Keys and commands still execute + +When resumed: +- A frame is automatically captured to show the current state +- Subsequent `@frame` directives capture as normal + +Use cases: +- **Skip slow operations**: Pause during builds or long-running commands +- **Skip sensitive input**: Pause while entering credentials +- **Multiple segments**: Record intro, pause for uninteresting middle, resume for ending + ## Frame Control with @frame The `@frame` directive captures the current terminal state as a frame in your GIF. Place it after each key or action you want visible in the animation. @@ -138,6 +177,149 @@ Lower values create faster, snappier animations. Higher values give viewers more - UI navigation: 150-200ms - Complex state changes: 300-500ms +## Playback Speed + +The `@set:speed:N` directive controls the overall playback speed multiplier. The default is 1.0 (normal speed). + +```bash +@set:speed:2.0 # 2x faster playback + +@record:start +# ... frames ... +@record:stop:fast.gif +``` + +```bash +@set:speed:0.5 # Half speed (slow motion) + +@record:start +# ... frames ... +@record:stop:slow.gif +``` + +Valid speed values range from 0.25 (4x slower) to 4.0 (4x faster): + +- `@set:speed:0.25` - Quarter speed (slow motion) +- `@set:speed:0.5` - Half speed +- `@set:speed:1.0` - Normal speed (default) +- `@set:speed:2.0` - Double speed +- `@set:speed:4.0` - Maximum speed + +Speed works together with `gif_delay`. A GIF with `@set:gif_delay:200` and `@set:speed:2.0` will play at effectively 100ms per frame. + +## GIF Decorations + +Betamax can add visual decorations to your GIFs, making them look polished and professional. Decorations are applied during GIF generation and don't affect the terminal session. + +### Window Bar + +Add a macOS-style window bar with traffic light buttons: + +```bash +@set:window_bar:colorful # Red/yellow/green dots on left +``` + +Available styles: + +| Style | Description | +|-------|-------------| +| `colorful` | Traffic light dots (red, yellow, green) on the left | +| `colorful_right` | Traffic light dots on the right | +| `rings` | Hollow circle outlines instead of filled dots | +| `none` | No window bar (default) | + +Customize the bar background color: + +```bash +@set:window_bar:colorful +@set:bar_color:282a36 # Dracula theme background +``` + +### Rounded Corners + +Add rounded corners to soften the terminal edges: + +```bash +@set:border_radius:8 # 8 pixel corner radius +``` + +Rounded corners work well with window bars for a native app look. + +### Margin and Padding + +Add spacing around your GIF: + +```bash +@set:padding:10 # 10px inner padding (inside rounded corners) +@set:padding_color:1e1e1e # Padding background color + +@set:margin:20 # 20px outer margin +@set:margin_color:000000 # Margin background color +``` + +**Padding** adds space inside the rounded corners, between the terminal content and the border. + +**Margin** adds space outside the entire composition, useful for embedding GIFs on dark or light backgrounds. + +### Color Values + +Colors are specified as 6 hex digits without the `#` prefix (since `#` starts comments in keys files): + +```bash +@set:bar_color:1e1e1e # Dark gray +@set:margin_color:ffffff # White +@set:padding_color:282a36 # Dracula purple-gray +``` + +### Complete Decoration Example + +Here's a fully decorated GIF configuration: + +```bash +# polished-demo.keys + +@set:cols:80 +@set:rows:24 +@set:delay:80 +@set:gif_delay:150 + +# Decorations +@set:window_bar:colorful +@set:bar_color:282a36 +@set:border_radius:8 +@set:padding:10 +@set:padding_color:282a36 +@set:margin:20 +@set:margin_color:1a1a2e + +@require:termshot +@require:ffmpeg + +@sleep:400 +@record:start + +# ... your recording ... + +@record:stop:polished-demo.gif +``` + +This creates a GIF with: +- macOS-style window bar with Dracula theme colors +- 8px rounded corners +- 10px inner padding matching the bar color +- 20px outer margin in a darker shade + +### Decoration Order + +Decorations are applied in this order: + +1. **Padding** - Added inside, around the terminal content +2. **Window bar** - Added at the top +3. **Rounded corners** - Applied to the composition +4. **Margin** - Added outside, around everything + +This means rounded corners apply to both the terminal content and the window bar, creating a cohesive look. + ## Loop Animations with @repeat The `@repeat:N` and `@end` directives create loops for repetitive frame sequences. This is useful for animations that cycle through states. diff --git a/website/docs/keys-file-format.md b/website/docs/keys-file-format.md index fc283d0..cd50728 100644 --- a/website/docs/keys-file-format.md +++ b/website/docs/keys-file-format.md @@ -48,6 +48,14 @@ Settings at the top of a keys file make it self-describing and reproducible. CLI | `@set:timeout:SEC` | Timeout for wait operations in seconds | `-t, --timeout` | | `@set:shell:PATH` | Shell to use for consistent environment | `--shell` | | `@set:gif_delay:MS` | Frame duration in GIF playback (default: 200ms) | - | +| `@set:speed:N` | GIF playback speed multiplier, 0.25-4.0 (default: 1.0) | - | +| `@set:window_bar:STYLE` | Add macOS-style window bar: `colorful`, `colorful_right`, `rings`, `none` | - | +| `@set:bar_color:RRGGBB` | Window bar background color (6 hex digits) | - | +| `@set:border_radius:N` | Rounded corner radius in pixels | - | +| `@set:margin:N` | Outer margin in pixels | - | +| `@set:margin_color:RRGGBB` | Margin background color (6 hex digits) | - | +| `@set:padding:N` | Inner padding in pixels | - | +| `@set:padding_color:RRGGBB` | Padding background color (6 hex digits) | - | ### Example Settings Block @@ -58,6 +66,14 @@ Settings at the top of a keys file make it self-describing and reproducible. CLI @set:output:./screenshots @set:shell:/bin/bash @set:gif_delay:150 +@set:speed:1.5 + +# Decoration settings for polished GIFs +@set:window_bar:colorful +@set:bar_color:282a36 +@set:border_radius:8 +@set:margin:20 +@set:margin_color:1a1a2e ``` ## Dependency Checking with @require @@ -88,6 +104,8 @@ Actions control the flow of execution, timing, and output capture. | `@capture:NAME.txt` | Save as plain text with ANSI codes | | `@capture:NAME` | Save in all available formats | | `@record:start` | Start GIF recording session | +| `@record:pause` | Pause frame capture (session continues) | +| `@record:resume` | Resume frame capture | | `@frame` | Capture current state as a GIF frame (during recording) | | `@record:stop:NAME.gif` | Stop recording and save animated GIF | | `@repeat:N` | Begin a loop that repeats N times |