Skip to content

CLI Module

Command-line interface implementation using Typer and Rich.


📖 Module Documentation

cli

Command-line interface for the loups package.

Classes

Functions

get_default_template
get_default_template() -> Path

Get path to the default bundled template image.

Returns:

Type Description
Path

Path to the bundled template_solid.png file.

Raises:

Type Description
FileNotFoundError

If the default template cannot be located.

Source code in loups/cli.py
def get_default_template() -> Path:
    """Get path to the default bundled template image.

    Returns:
        Path to the bundled template_solid.png file.

    Raises:
        FileNotFoundError: If the default template cannot be located.
    """
    try:
        # Use importlib.resources to locate the bundled template
        template_path = files("loups").joinpath("data/template_solid.png")
        # For Python 3.9+, we need to use as_file context manager
        # but for simpler usage, we'll convert to string path
        return Path(str(template_path))
    except Exception:
        # Fallback for development/testing
        fallback = Path(__file__).parent / "data" / "template_solid.png"
        if fallback.exists():
            return fallback
        raise FileNotFoundError(
            "Could not locate default template. "
            "Please specify a template with --template"
        )
get_default_thumbnail_template
get_default_thumbnail_template() -> Path

Get path to the default bundled thumbnail template image.

Returns:

Type Description
Path

Path to the bundled thumbnail_template.png file.

Raises:

Type Description
FileNotFoundError

If the default thumbnail template cannot be located.

Source code in loups/cli.py
def get_default_thumbnail_template() -> Path:
    """Get path to the default bundled thumbnail template image.

    Returns:
        Path to the bundled thumbnail_template.png file.

    Raises:
        FileNotFoundError: If the default thumbnail template cannot be located.
    """
    try:
        # Use importlib.resources to locate the bundled thumbnail template
        template_path = files("loups").joinpath("data/thumbnail_template.png")
        return Path(str(template_path))
    except Exception:
        # Fallback for development/testing
        fallback = Path(__file__).parent / "data" / "thumbnail_template.png"
        if fallback.exists():
            return fallback
        raise FileNotFoundError(
            "Could not locate default thumbnail template. "
            "Please specify a template with --thumbnail-template or "
            "add thumbnail_template.png to loups/data/"
        )
setup_logging
setup_logging(
    log_path: Optional[Path] = None,
    quiet: bool = False,
    debug: bool = False,
) -> None

Configure logging with file rotation and error output.

Parameters:

Name Type Description Default
log_path Optional[Path]

Optional path for log file. If None, file logging is disabled.

None
quiet bool

If True, suppress non-error console output.

False
debug bool

If True, set file log level to DEBUG instead of INFO.

False
Note

File logs rotate at 10MB with 3 backup files. Errors always go to stderr regardless of quiet mode.

Source code in loups/cli.py
def setup_logging(
    log_path: Optional[Path] = None, quiet: bool = False, debug: bool = False
) -> None:
    """Configure logging with file rotation and error output.

    Args:
        log_path: Optional path for log file. If None, file logging is disabled.
        quiet: If True, suppress non-error console output.
        debug: If True, set file log level to DEBUG instead of INFO.

    Note:
        File logs rotate at 10MB with 3 backup files.
        Errors always go to stderr regardless of quiet mode.
    """
    # Configure root logger
    root_logger = logging.getLogger()
    root_logger.setLevel(logging.DEBUG)

    # Create rotating file handler if log_path is provided
    if log_path is not None:
        file_handler = RotatingFileHandler(
            log_path,
            maxBytes=10 * 1024 * 1024,  # 10MB
            backupCount=3,
        )
        # Set file log level based on debug flag
        file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
        file_formatter = logging.Formatter(
            "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
        )
        file_handler.setFormatter(file_formatter)
        root_logger.addHandler(file_handler)

    # Always log errors to stderr (even in quiet mode)
    console_handler = logging.StreamHandler(sys.stderr)
    console_handler.setLevel(logging.ERROR)
    console_formatter = logging.Formatter("%(levelname)s: %(message)s")
    console_handler.setFormatter(console_formatter)
    root_logger.addHandler(console_handler)
format_elapsed_time
format_elapsed_time(seconds: float) -> str

Format elapsed time as HH:MM:SS or MM:SS.

Parameters:

Name Type Description Default
seconds float

Elapsed time in seconds.

required

Returns:

Type Description
str

Formatted time string (MM:SS if < 1 hour, otherwise HH:MM:SS).

Examples:

format_elapsed_time(65)    # "01:05"
format_elapsed_time(3665)  # "01:01:05"
Source code in loups/cli.py
def format_elapsed_time(seconds: float) -> str:
    """Format elapsed time as HH:MM:SS or MM:SS.

    Args:
        seconds: Elapsed time in seconds.

    Returns:
        Formatted time string (MM:SS if < 1 hour, otherwise HH:MM:SS).

    Examples:
        ```python
        format_elapsed_time(65)    # "01:05"
        format_elapsed_time(3665)  # "01:01:05"
        ```
    """
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    secs = int(seconds % 60)

    if hours > 0:
        return f"{hours:02d}:{minutes:02d}:{secs:02d}"
    else:
        return f"{minutes:02d}:{secs:02d}"
create_progress_display
create_progress_display(
    elapsed: float,
    batter_count: int,
    spinner_state: int,
    percent: Optional[float] = None,
    last_batter: Optional[str] = None,
) -> Text

Create animated progress display with softball animation.

Parameters:

Name Type Description Default
elapsed float

Elapsed time in seconds.

required
batter_count int

Number of batters found so far.

required
spinner_state int

Current animation frame number.

required
percent Optional[float]

Optional scan completion percentage.

None
last_batter Optional[str]

Optional name of most recently found batter.

None

Returns:

Type Description
Text

Rich Text object with animated progress display.

Source code in loups/cli.py
def create_progress_display(
    elapsed: float,
    batter_count: int,
    spinner_state: int,
    percent: Optional[float] = None,
    last_batter: Optional[str] = None,
) -> Text:
    """Create animated progress display with softball animation.

    Args:
        elapsed: Elapsed time in seconds.
        batter_count: Number of batters found so far.
        spinner_state: Current animation frame number.
        percent: Optional scan completion percentage.
        last_batter: Optional name of most recently found batter.

    Returns:
        Rich Text object with animated progress display.
    """
    # Softball moving left and right (bouncing animation)
    positions = ["🥎   ", " 🥎  ", "  🥎 ", "   🥎", "  🥎 ", " 🥎  "]
    softball_display = positions[spinner_state % len(positions)]

    # Build the display text
    text = Text()
    text.append(softball_display, style="bold yellow")
    text.append(" Scanning batters... ", style="bold")

    # Show percentage if available
    if percent is not None:
        text.append(f"{percent:.1f}%", style="bold cyan")
        text.append(" | ", style="dim")

    text.append("Found ", style="dim")
    text.append(f"{batter_count}", style="bold green")
    text.append(f" batter{'s' if batter_count != 1 else ''}", style="dim")

    # Show last batter found
    if last_batter:
        text.append("\n")
        text.append("🏟️     Found: ", style="dim")
        text.append(last_batter, style="bold green")

    return text
create_thumbnail_progress_display
create_thumbnail_progress_display(
    spinner_state: int,
) -> Text

Create animated progress display for thumbnail extraction.

Parameters:

Name Type Description Default
spinner_state int

Current animation frame number.

required

Returns:

Type Description
Text

Rich Text object with animated progress display.

Source code in loups/cli.py
def create_thumbnail_progress_display(
    spinner_state: int,
) -> Text:
    """Create animated progress display for thumbnail extraction.

    Args:
        spinner_state: Current animation frame number.

    Returns:
        Rich Text object with animated progress display.
    """
    positions = ["🥎   ", " 🥎  ", "  🥎 ", "   🥎", "  🥎 ", " 🥎  "]
    softball_display = positions[spinner_state % len(positions)]

    text = Text()
    text.append(softball_display, style="bold yellow")
    text.append(" Extracting thumbnail...", style="bold")
    return text
thumbnail
thumbnail(
    ctx: Context,
    thumbnail_template: Optional[Path] = Option(
        None,
        "--thumbnail-template",
        help="Path to thumbnail template image (defaults to bundled template)",
    ),
    thumbnail_output: Optional[Path] = Option(
        None,
        "--thumbnail-output",
        help="Output path for thumbnail (default: <video>-thumbnail.jpg in cwd)",
    ),
    thumbnail_scan_duration: int = Option(
        120,
        "--thumbnail-scan-duration",
        help="Maximum seconds to scan from video start",
    ),
    thumbnail_threshold: float = Option(
        0.35,
        "--thumbnail-threshold",
        min=0.0,
        max=1.0,
        help="Minimum SSIM score to accept (0.0-1.0)",
    ),
    thumbnail_frames_per_second: int = Option(
        3,
        "--thumbnail-frames-per-second",
        min=1,
        help="Frame sampling rate (frames to check per second)",
    ),
    quiet: bool = Option(
        False,
        "--quiet",
        "-q",
        help="Suppress progress display and output",
    ),
    log: Optional[str] = Option(
        None,
        "--log",
        "-l",
        is_flag=False,
        flag_value="loups.log",
        help="Enable logging. Use without argument for default 'loups.log', or provide a path for custom location. Logs rotate at 10MB, keeps 3 backups.",
    ),
    debug: bool = Option(
        False,
        "--debug",
        "-d",
        help="Enable DEBUG level logging to file (default is INFO)",
    ),
) -> None

Extract thumbnail from video using SSIM-based template matching.

Scan video frames from the beginning to find a frame matching the template. Uses Structural Similarity Index (SSIM) for accurate frame matching. Stops at first match above threshold for efficiency.

Examples:

Extract with default template:

loups video.mp4 thumbnail

Custom template and output:

loups video.mp4 thumbnail --thumbnail-template custom.png             --thumbnail-output thumb.jpg

Source code in loups/cli.py
@app.command()
def thumbnail(
    ctx: typer.Context,
    thumbnail_template: Optional[Path] = typer.Option(  # noqa: B008
        None,
        "--thumbnail-template",
        help="Path to thumbnail template image (defaults to bundled template)",
    ),
    thumbnail_output: Optional[Path] = typer.Option(  # noqa: B008
        None,
        "--thumbnail-output",
        help="Output path for thumbnail (default: <video>-thumbnail.jpg in cwd)",
    ),
    thumbnail_scan_duration: int = typer.Option(  # noqa: B008
        120,
        "--thumbnail-scan-duration",
        help="Maximum seconds to scan from video start",
    ),
    thumbnail_threshold: float = typer.Option(  # noqa: B008
        0.35,
        "--thumbnail-threshold",
        min=0.0,
        max=1.0,
        help="Minimum SSIM score to accept (0.0-1.0)",
    ),
    thumbnail_frames_per_second: int = typer.Option(  # noqa: B008
        3,
        "--thumbnail-frames-per-second",
        min=1,
        help="Frame sampling rate (frames to check per second)",
    ),
    quiet: bool = typer.Option(  # noqa: B008
        False,
        "--quiet",
        "-q",
        help="Suppress progress display and output",
    ),
    log: Optional[str] = typer.Option(  # noqa: B008
        None,
        "--log",
        "-l",
        is_flag=False,
        flag_value="loups.log",
        help=(
            "Enable logging. Use without argument for default 'loups.log', "
            "or provide a path for custom location. "
            "Logs rotate at 10MB, keeps 3 backups."
        ),
    ),
    debug: bool = typer.Option(  # noqa: B008
        False,
        "--debug",
        "-d",
        help="Enable DEBUG level logging to file (default is INFO)",
    ),
) -> None:
    """Extract thumbnail from video using SSIM-based template matching.

    Scan video frames from the beginning to find a frame matching the template.
    Uses Structural Similarity Index (SSIM) for accurate frame matching.
    Stops at first match above threshold for efficiency.

    Examples:
        Extract with default template:
        ```bash
        loups video.mp4 thumbnail
        ```

        Custom template and output:
        ```bash
        loups video.mp4 thumbnail --thumbnail-template custom.png \
            --thumbnail-output thumb.jpg
        ```
    """
    # Get video from parent context
    video = ctx.parent.params["video"]
    # Ensure video is a Path object (defensive programming)
    if not isinstance(video, Path):
        video = Path(video)

    # Set up logging
    if log is not None:
        log_path = Path(log)
    else:
        log_path = None
    setup_logging(log_path, quiet, debug)

    # Use shared helper function with fatal error handling
    _run_thumbnail_extraction(
        video=video,
        template=thumbnail_template,
        output=thumbnail_output,
        threshold=thumbnail_threshold,
        scan_duration=thumbnail_scan_duration,
        frames_per_second=thumbnail_frames_per_second,
        quiet=quiet,
        is_fatal_on_error=True,  # Exit on errors (standalone command)
    )
callback
callback(
    ctx: Context,
    video: Path = Argument(
        ..., help="Path to the video file to scan"
    ),
    template: Optional[Path] = Option(
        None,
        "--template",
        "-t",
        help="Path to template image (defaults to bundled template)",
    ),
    log: Optional[str] = Option(
        None,
        "--log",
        "-l",
        is_flag=False,
        flag_value="loups.log",
        help="Enable logging. Use without argument for default 'loups.log', or provide a path for custom location. Logs rotate at 10MB, keeps 3 backups.",
    ),
    output: Optional[Path] = Option(
        None,
        "--output",
        "-o",
        help="Save results to file (YouTube chapter format)",
    ),
    quiet: bool = Option(
        False,
        "--quiet",
        "-q",
        help="Suppress progress display and output (errors still go to stderr)",
    ),
    debug: bool = Option(
        False,
        "--debug",
        "-d",
        help="Enable DEBUG level logging to file (default is INFO)",
    ),
    extract_thumbnail: bool = Option(
        False,
        "--extract-thumbnail",
        help="Extract thumbnail during chapter scan",
    ),
    thumbnail_template: Optional[Path] = Option(
        None,
        "--thumbnail-template",
        help="Path to thumbnail template (defaults to bundled template)",
    ),
    thumbnail_output: Optional[Path] = Option(
        None,
        "--thumbnail-output",
        help="Output path for thumbnail (default: <video>-thumbnail.jpg in cwd)",
    ),
    thumbnail_threshold: float = Option(
        0.35,
        "--thumbnail-threshold",
        min=0.0,
        max=1.0,
        help="Minimum SSIM score for thumbnail (0.0-1.0)",
    ),
    thumbnail_scan_duration: int = Option(
        120,
        "--thumbnail-scan-duration",
        help="Maximum seconds to scan for thumbnail",
    ),
    thumbnail_frames_per_second: int = Option(
        3,
        "--thumbnail-frames-per-second",
        min=1,
        help="Frame sampling rate for thumbnail extraction",
    ),
) -> None

Scan video to extract batter information and generate YouTube chapters.

Main command for processing Lights Out HB fastpitch game videos (or any video with consistent identifying frames). Detects batters using template matching and extracts names via OCR. Outputs YouTube-compatible chapter timestamps.

Examples:

Basic scan with default template:

loups game.mp4

Save chapters to file:

loups -o chapters.txt game.mp4

Extract thumbnail and scan for batters:

loups --extract-thumbnail --thumbnail-output thumb.jpg -o chapters.txt game.mp4

Use 'thumbnail' subcommand for standalone thumbnail extraction:

loups game.mp4 thumbnail

Source code in loups/cli.py
@app.callback()
def callback(
    ctx: typer.Context,
    video: Path = typer.Argument(  # noqa: B008
        ...,
        help="Path to the video file to scan",
    ),
    template: Optional[Path] = typer.Option(  # noqa: B008
        None,
        "--template",
        "-t",
        help="Path to template image (defaults to bundled template)",
    ),
    log: Optional[str] = typer.Option(  # noqa: B008
        None,
        "--log",
        "-l",
        is_flag=False,
        flag_value="loups.log",
        help=(
            "Enable logging. Use without argument for default 'loups.log', "
            "or provide a path for custom location. "
            "Logs rotate at 10MB, keeps 3 backups."
        ),
    ),
    output: Optional[Path] = typer.Option(  # noqa: B008
        None,
        "--output",
        "-o",
        help="Save results to file (YouTube chapter format)",
    ),
    quiet: bool = typer.Option(  # noqa: B008
        False,
        "--quiet",
        "-q",
        help="Suppress progress display and output (errors still go to stderr)",
    ),
    debug: bool = typer.Option(  # noqa: B008
        False,
        "--debug",
        "-d",
        help="Enable DEBUG level logging to file (default is INFO)",
    ),
    extract_thumbnail: bool = typer.Option(  # noqa: B008
        False,
        "--extract-thumbnail",
        help="Extract thumbnail during chapter scan",
    ),
    thumbnail_template: Optional[Path] = typer.Option(  # noqa: B008
        None,
        "--thumbnail-template",
        help="Path to thumbnail template (defaults to bundled template)",
    ),
    thumbnail_output: Optional[Path] = typer.Option(  # noqa: B008
        None,
        "--thumbnail-output",
        help="Output path for thumbnail (default: <video>-thumbnail.jpg in cwd)",
    ),
    thumbnail_threshold: float = typer.Option(  # noqa: B008
        0.35,
        "--thumbnail-threshold",
        min=0.0,
        max=1.0,
        help="Minimum SSIM score for thumbnail (0.0-1.0)",
    ),
    thumbnail_scan_duration: int = typer.Option(  # noqa: B008
        120,
        "--thumbnail-scan-duration",
        help="Maximum seconds to scan for thumbnail",
    ),
    thumbnail_frames_per_second: int = typer.Option(  # noqa: B008
        3,
        "--thumbnail-frames-per-second",
        min=1,
        help="Frame sampling rate for thumbnail extraction",
    ),
) -> None:
    """Scan video to extract batter information and generate YouTube chapters.

    Main command for processing Lights Out HB fastpitch game videos (or any video
    with consistent identifying frames). Detects batters using template matching
    and extracts names via OCR. Outputs YouTube-compatible chapter timestamps.

    Examples:
        Basic scan with default template:
        ```bash
        loups game.mp4
        ```

        Save chapters to file:
        ```bash
        loups -o chapters.txt game.mp4
        ```

        Extract thumbnail and scan for batters:
        ```bash
        loups --extract-thumbnail --thumbnail-output thumb.jpg -o chapters.txt game.mp4
        ```

        Use 'thumbnail' subcommand for standalone thumbnail extraction:
        ```bash
        loups game.mp4 thumbnail
        ```
    """
    # If a subcommand was invoked, let it run
    if ctx.invoked_subcommand is not None:
        return

    # No subcommand - run the default scan behavior
    # Verify video exists
    if not video.exists():
        err_console.print(f"[red]Error:[/red] Video file not found: {video}")
        raise typer.Exit(1)

    # Determine log path
    if log is not None:
        log_path = Path(log)
    else:
        log_path = None

    # Set up logging
    setup_logging(log_path, quiet, debug)

    # Detect if stdout is being piped/redirected
    is_piped = not sys.stdout.isatty()

    # Get template path
    if template is None:
        try:
            template = get_default_template()
        except FileNotFoundError as e:
            err_console.print(f"[red]Error:[/red] {e}")
            raise typer.Exit(1)

    # Verify template exists
    if not template.exists():
        err_console.print(f"[red]Error:[/red] Template file not found: {template}")
        raise typer.Exit(1)

    # Show "Scanning video:" header at the top
    show_progress = not quiet and not is_piped
    if show_progress:
        console.print(f"[bold]Scanning video:[/bold] {video}")
        console.print()

    # Extract thumbnail if requested
    if extract_thumbnail:
        # Use shared helper function with non-fatal error handling
        _run_thumbnail_extraction(
            video=video,
            template=thumbnail_template,
            output=thumbnail_output,
            threshold=thumbnail_threshold,
            scan_duration=thumbnail_scan_duration,
            frames_per_second=thumbnail_frames_per_second,
            quiet=quiet,
            is_fatal_on_error=False,  # Warnings only, continue on errors
            show_header=False,  # Header already shown above
        )

    # Progress tracking
    start_time = time.time()
    batter_count = 0
    spinner_state = 0
    last_batter_name = None
    progress_percent = 0.0

    def on_batter_found(batter_info):
        """Handle callback when a new batter is found."""
        nonlocal batter_count, last_batter_name
        batter_count += 1
        last_batter_name = batter_info.batter_name

    def on_progress(frames_processed, total_frames):
        """Update progress percentage on each frame processed."""
        nonlocal progress_percent
        if total_frames > 0:
            progress_percent = (frames_processed / total_frames) * 100

    # Initialize Loups with callback
    try:
        game = Loups(
            str(video),
            str(template),
            on_batter_found=on_batter_found,
            on_progress=on_progress,
        )
    except Exception as e:
        err_console.print(f"[red]Error:[/red] Failed to initialize scanner: {e}")
        raise typer.Exit(1)

    # Run scan with progress display (disabled when piped or quiet)
    if show_progress:
        # Add separator if we just extracted a thumbnail
        if extract_thumbnail:
            console.print()

        # Variables to track scan completion and errors
        scan_complete = threading.Event()
        scan_error = None

        def run_scan():
            """Run the scan in a background thread."""
            nonlocal scan_error
            try:
                game.scan()
            except Exception as e:
                scan_error = e
            finally:
                scan_complete.set()

        # Start scan in background thread
        scan_thread = threading.Thread(target=run_scan, daemon=True)
        scan_thread.start()

        with Live(
            create_progress_display(0, 0, 0, 0.0),
            refresh_per_second=4,
            console=console,
        ) as live:
            # Continuously update display while scan is running
            while not scan_complete.is_set():
                current_time = time.time()
                elapsed = current_time - start_time
                spinner_state += 1
                live.update(
                    create_progress_display(
                        elapsed,
                        batter_count,
                        spinner_state,
                        progress_percent,
                        last_batter_name,
                    )
                )
                time.sleep(0.25)  # Update every 250ms

            # Final update
            elapsed = time.time() - start_time
            live.update(
                create_progress_display(
                    elapsed,
                    batter_count,
                    spinner_state,
                    progress_percent,
                    last_batter_name,
                )
            )

        # Check for errors
        if scan_error:
            err_console.print(f"\n[red]Error:[/red] Scan failed: {scan_error}")
            raise typer.Exit(1)

        console.print()
        console.print(
            f"🏆 [bold green]Scan complete![/bold green] "
            f"Found {game.batter_count} batters "
            f"in {format_elapsed_time(elapsed)}"
        )
        console.print()
    else:
        # Quiet mode or piped output - just run the scan
        try:
            game.scan()
        except Exception as e:
            err_console.print(f"[red]Error:[/red] Scan failed: {e}")
            raise typer.Exit(1)

    # Get results using the display() method
    results = game.batters.display()

    # Output to stdout (unless quiet)
    if not quiet:
        if is_piped:
            # When piped, output plain text to stdout (no formatting)
            print(results)
        else:
            # Interactive terminal: show formatted output
            console.print("[bold]YouTube Chapters:[/bold]")
            console.print(results)

    # Output to file if specified
    if output:
        try:
            output.write_text(results)
            if show_progress:
                console.print()
                console.print(f"✓ Results saved to: [cyan]{output}[/cyan]")
        except Exception as e:
            err_console.print(f"[red]Error:[/red] Failed to write output file: {e}")
            raise typer.Exit(1)

⚙ CLI Architecture

The Loups CLI is built with:

  • Typer - Modern CLI framework
  • Rich - Beautiful terminal output
  • Subcommands - Main command + thumbnail extraction
graph TD
    A[loups CLI] --> B[Main Command]
    A --> C[Thumbnail Subcommand]

    B --> B1[Parse Arguments]
    B1 --> B2[Initialize Loups]
    B2 --> B3[Scan Video]
    B3 --> B4[Display/Save Results]

    C --> C1[Parse Arguments]
    C1 --> C2[Extract Thumbnail]
    C2 --> C3[Save JPEG]

    style A fill:#00ffff,stroke:#000,color:#000
    style B fill:#00b8d4,stroke:#000,color:#fff
    style C fill:#00b8d4,stroke:#000,color:#fff
    style B4 fill:#00ffff,stroke:#000,color:#000
    style C3 fill:#00ffff,stroke:#000,color:#000

🔧 Customizing the CLI

Extending Commands

You can build on top of the Loups CLI:

from loups.cli import app
import typer

@app.command()
def batch(
    directory: str = typer.Argument(..., help="Directory with videos"),
    template: str = typer.Option(None, "-t", "--template")
):
    """Process all videos in a directory."""
    from pathlib import Path
    from loups import Loups

    video_dir = Path(directory)

    for video in video_dir.glob("*.mp4"):
        print(f"Processing {video.name}...")

        loups = Loups(
            video_path=str(video),
            template_path=template
        )

        chapters = loups.scan()

        # Save output
        output = video.with_suffix(".txt")
        with open(output, "w") as f:
            for ch in chapters:
                f.write(f"{ch.timestamp} {ch.title}\n")

if __name__ == "__main__":
    app()

Custom Progress Display

Replace the default progress bar:

from loups import Loups
from rich.progress import Progress, SpinnerColumn, TextColumn

class CustomLoups(Loups):
    def scan(self):
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            transient=True
        ) as progress:
            task = progress.add_task("Scanning...", total=None)

            # Your custom processing
            results = super().scan()

            progress.update(task, completed=True)
            return results

🎨 Output Formatting

Custom Chapter Format

from loups import Loups

loups = Loups("video.mp4", "template.png")
chapters = loups.scan()

# Custom format
for i, ch in enumerate(chapters, 1):
    print(f"{i}. [{ch.timestamp}] {ch.title}")

# JSON format
import json
output = json.dumps([
    {"time": ch.timestamp, "title": ch.title}
    for ch in chapters
], indent=2)
print(output)

# Markdown format
print("## Chapters\n")
for ch in chapters:
    print(f"- **{ch.timestamp}** - {ch.title}")

🤖 Automation Examples

Shell Script Integration

#!/bin/bash
# process_videos.sh

for video in videos/*.mp4; do
  echo "Processing: $video"

  # Run Loups
  loups -q -o "${video%.mp4}.txt" "$video"

  # Check exit code
  if [ $? -eq 0 ]; then
    echo "✅ Success: $video"
  else
    echo "❌ Failed: $video"
  fi
done

Python Automation

import subprocess
from pathlib import Path

videos = Path("videos").glob("*.mp4")

for video in videos:
    output = video.with_suffix(".txt")

    # Run Loups CLI
    result = subprocess.run([
        "loups",
        "-q",
        "-o", str(output),
        str(video)
    ], capture_output=True, text=True)

    if result.returncode == 0:
        print(f"✅ {video.name}")
    else:
        print(f"❌ {video.name}: {result.stderr}")

🚧 CLI Development

Running from Source

# Install in development mode
pip install -e .

# Or use Python module
python -m loups.cli --help

Testing CLI Commands

from typer.testing import CliRunner
from loups.cli import app

runner = CliRunner()

def test_main_command():
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    assert "video" in result.stdout.lower()

def test_thumbnail_command():
    result = runner.invoke(app, [
        "test.mp4",
        "thumbnail",
        "--thumbnail-output", "thumb.jpg"
    ])
    assert result.exit_code == 0

Command Reference

For complete CLI usage documentation, see: