#!/usr/bin/env python3 """Command-line interface for Fansifter Video Clipper. Two top-level commands: clip — existing workflow: generate N standalone 9:16 marketing clips cut-to-music — new workflow: assemble a beat-synced 15-20s highlight reel Backward compatibility: bare invocations without a subcommand (e.g. ``python cli.py harry.mp4``) are automatically routed to the ``clip`` command. """ import sys from datetime import datetime from pathlib import Path import click from rich.console import Console from src.fansifter_clipper.config import settings console = Console() class FallbackGroup(click.Group): """Click Group that routes bare VIDEO_FILE arguments to the 'clip' command. When the first positional argument is not a registered subcommand name, the word 'clip' is injected at the front of argv so existing invocations like ``python cli.py harry.mp4`` continue to work unchanged. """ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: if args and not args[0].startswith("-") and args[0] not in self.commands: args.insert(0, "clip") return super().parse_args(ctx, args) @click.group(cls=FallbackGroup) def cli() -> None: """Fansifter Video Clipper — music video to 9:16 marketing clips.""" # --------------------------------------------------------------------------- # Shared helper # --------------------------------------------------------------------------- def _resolve_video_path(video_input: str) -> Path: """Resolve VIDEO_INPUT to a local file path. Handles public Google Drive URLs (via gdown) and local file paths. Args: video_input: Path string or Google Drive URL. Returns: Path to a local video file. Raises: SystemExit: On download failure or missing local file. """ from src.fansifter_clipper.downloader import GoogleDriveDownloader gd = GoogleDriveDownloader() if gd.is_google_drive_url(video_input): console.print("\n[yellow]Detected Google Drive URL[/yellow]") console.print("Downloading video...") try: video_path = gd.download(video_input) console.print(f"[green]✓[/green] Downloaded to {video_path}") return video_path except ValueError as e: console.print(f"[red]Error:[/red] {e}") sys.exit(1) except RuntimeError as e: console.print(f"[red]Error:[/red] Download failed: {e}") sys.exit(1) video_path = Path(video_input) if not video_path.exists(): console.print(f"[red]Error:[/red] File not found: {video_path}") sys.exit(1) return video_path # --------------------------------------------------------------------------- # clip command (existing workflow, unchanged) # --------------------------------------------------------------------------- @cli.command("clip") @click.argument("video_input", type=str) @click.option( "--output-dir", "-o", type=click.Path(path_type=Path), default=None, help="Output directory for clips (default: ./output)", ) @click.option("--num-clips", "-n", default=3, type=int, help="Number of clips to generate") @click.option( "--dry-run", is_flag=True, help="Only analyze video and show timestamps, don't generate clips", ) @click.option( "--duration", "-d", default=None, help="Duration range in seconds (e.g., 12-17). Omit to let Gemini decide (max 30s).", ) @click.option( "--mock-segments", "-m", help="Mock segments (bypass Gemini API) in format: start1-end1,start2-end2 (e.g., 10-25,50-65)", ) @click.option( "--debug", is_flag=True, help="Enable debug mode (save visualization frames showing crop positions)", ) @click.option( "--caption", default=None, type=str, help="Static text phrase to overlay on every frame (e.g. 'NEW SINGLE OUT NOW')", ) @click.option( "--caption-style", default="tiktok", type=click.Choice( ["viral_hook", "aesthetic_label", "modern_minimal", "tiktok"], case_sensitive=False ), show_default=True, help="Caption visual style preset", ) @click.option( "--caption-position", default="center", type=click.Choice(["top", "bottom", "center"], case_sensitive=False), show_default=True, help="Vertical placement of the caption", ) def clip_command( video_input: str, output_dir: Path | None, num_clips: int, dry_run: bool, duration: str, mock_segments: str | None, debug: bool, caption: str | None, caption_style: str, caption_position: str, ) -> None: """Convert music videos (any aspect ratio) to 9:16 marketing clips. VIDEO_INPUT: Path to video file or Google Drive URL \b Examples: # Local file python cli.py harry.mp4 --dry-run # Generate clips with custom duration python cli.py harry.mp4 -d 8-12 # Use mock segments (bypass Gemini API) python cli.py harry.mp4 --mock-segments "10-22,35-47" # Equivalent using explicit subcommand python cli.py clip harry.mp4 --dry-run """ from src.fansifter_clipper.models import ProcessingConfig from src.fansifter_clipper.processor import VideoProcessor try: # Parse duration range (optional) min_dur: float | None = None max_dur: float | None = None if duration: try: min_dur, max_dur = map(float, duration.split("-")) if min_dur >= max_dur or min_dur <= 0: raise ValueError except ValueError: console.print("[red]Error:[/red] Invalid duration format. Use format like '10-15'") sys.exit(1) video_path = _resolve_video_path(video_input) # Set output directory with timestamp subfolder if output_dir is None: output_dir = settings.default_output_dir timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") output_dir = output_dir / timestamp # Parse mock segments if provided mock_segment_list = None if mock_segments: from src.fansifter_clipper.models import VideoSegment mock_segment_list = [] for i, seg in enumerate(mock_segments.split(",")): start_str, end_str = seg.strip().split("-") start, end = float(start_str), float(end_str) mock_segment_list.append( VideoSegment( start=start, end=end, score=0.9, description=f"Mock segment {i + 1}", ) ) console.print( f"[yellow]Mock mode: Using {len(mock_segment_list)} predefined segments[/yellow]" ) seg_duration = (min_dur, max_dur) if min_dur is not None and max_dur is not None else None config = ProcessingConfig( input_video=video_path, output_dir=output_dir, num_segments=num_clips, segment_duration=seg_duration, dry_run=dry_run, debug=debug, caption_text=caption, caption_style=caption_style, caption_position=caption_position, ) console.print("\n[bold]Fansifter Video Clipper[/bold]") console.print(f"Input: {video_path}") console.print(f"Output: {output_dir}") console.print(f"Clips: {num_clips}") if min_dur is not None and max_dur is not None: console.print(f"Duration range: {min_dur}-{max_dur}s") else: console.print("Duration range: Gemini decides (max 30s)") console.print("Crop mode: SCENE_EQUILIBRIUM") console.print("Detection: MediaPipe (face mesh + pose)") if caption: console.print( f"Caption: '{caption}' | style={caption_style} | position={caption_position}" ) if dry_run: console.print("[yellow]Mode: DRY RUN[/yellow]") processor = VideoProcessor(config) output_paths = processor.process(mock_segments=mock_segment_list) if not dry_run and output_paths: console.print("\n[bold green]Success![/bold green]") console.print(f"Generated {len(output_paths)} clips in {output_dir}/") for path in output_paths: console.print(f" • {path.name}") except KeyboardInterrupt: console.print("\n[yellow]Interrupted by user[/yellow]") sys.exit(130) except Exception as e: console.print(f"\n[red]Error:[/red] {e}") sys.exit(1) # --------------------------------------------------------------------------- # cut-to-music command (new workflow) # --------------------------------------------------------------------------- @cli.command("cut-to-music") @click.argument("video_input", type=str) @click.option( "--output-dir", "-o", type=click.Path(path_type=Path), default=None, help="Output directory (default: ./output)", ) @click.option( "--duration", "-d", default=None, help="Total output duration range in seconds (e.g. 12-17). Omit to let Gemini decide.", ) @click.option( "--segment-duration", "-s", default=None, help="Per-clip duration range in seconds (e.g. 3-5). Omit to let Gemini decide (max 30s).", ) @click.option( "--dry-run", is_flag=True, help="Analyze video and show chorus + visual peaks, don't generate output", ) @click.option( "--debug", is_flag=True, help="Enable debug mode (keep intermediate files, save overlay videos)", ) @click.option( "--caption", default=None, type=str, help="Static text phrase to overlay on every frame", ) @click.option( "--caption-style", default="tiktok", type=click.Choice( ["tiktok", "viral_hook", "aesthetic_label", "modern_minimal"], case_sensitive=False ), show_default=True, help="Caption visual style preset", ) @click.option( "--caption-position", default="center", type=click.Choice(["top", "center", "bottom"], case_sensitive=False), show_default=True, help="Vertical placement of the caption", ) @click.option( "--extra-video", "-e", "extra_videos", multiple=True, type=str, help=( "Additional video file or Google Drive URL to draw visual segments from. " "Can be specified multiple times. Audio always comes from the main VIDEO_INPUT." ), ) @click.option( "--video-source", default="all", type=click.Choice(["all", "main", "extras"], case_sensitive=False), show_default=True, help=( "Which videos contribute visual segments: " "'all' = main + extras (default), 'main' = main only, 'extras' = extra videos only" ), ) def cut_to_music_command( video_input: str, output_dir: Path | None, duration: str, segment_duration: str, dry_run: bool, debug: bool, caption: str | None, caption_style: str, caption_position: str, extra_videos: tuple[str, ...], video_source: str, ) -> None: """Assemble a beat-synced highlight reel from a music video. Selects short clips and assembles them with hard cuts timed to the chorus so each clip's visual peak (dancer's foot landing, camera whip completing, flash peak) lands exactly on a drum hit. The output is a single MP4 using the original chorus audio track. \b Examples: # Full run (default 12-17s total, 3-5s per clip) python cli.py cut-to-music harry.mp4 # Add extra video sources (audio still from harry.mp4) python cli.py cut-to-music harry.mp4 -e backstage.mp4 -e concert.mp4 # Use only extra videos for visual content python cli.py cut-to-music harry.mp4 -e ugc.mp4 --video-source extras # Dry run: see chorus + visual peaks without generating output python cli.py cut-to-music harry.mp4 -e backstage.mp4 --dry-run # Custom total and per-clip duration python cli.py cut-to-music harry.mp4 -d 14-18 -s 3-4 # With text overlay python cli.py cut-to-music harry.mp4 --caption "OUT NOW" # Debug: keep intermediate files python cli.py cut-to-music harry.mp4 --debug """ from src.fansifter_clipper.cut_to_music_processor import CutToMusicProcessor from src.fansifter_clipper.models import ( CaptionPosition, CaptionStyle, CutToMusicConfig, VideoSource, ) from src.fansifter_clipper.onset_detection import InsufficientOnsetError try: # Parse total duration range (optional) total_min: float | None = None total_max: float | None = None if duration: try: total_min, total_max = map(float, duration.split("-")) if total_min >= total_max or total_min <= 0: raise ValueError except ValueError: console.print("[red]Error:[/red] Invalid --duration format. Use e.g. '12-17'") sys.exit(1) # Parse per-clip segment duration range (optional) seg_min: float | None = None seg_max: float | None = None if segment_duration: try: seg_min, seg_max = map(float, segment_duration.split("-")) if seg_min >= seg_max or seg_min <= 0: raise ValueError except ValueError: console.print("[red]Error:[/red] Invalid --segment-duration format. Use e.g. '3-5'") sys.exit(1) video_path = _resolve_video_path(video_input) # Resolve extra video inputs (file paths, YouTube URLs, Google Drive URLs) extra_video_paths: list[Path] = [] for ev in extra_videos: console.print(f"\n[bold]Resolving extra video:[/bold] {ev}") extra_video_paths.append(_resolve_video_path(ev)) if output_dir is None: output_dir = settings.default_output_dir timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") output_dir = output_dir / timestamp total_dur = (total_min, total_max) if total_min is not None and total_max is not None else None seg_dur = (seg_min, seg_max) if seg_min is not None and seg_max is not None else None config = CutToMusicConfig( input_video=video_path, output_dir=output_dir, total_duration=total_dur, segment_duration=seg_dur, debug=debug, caption_text=caption, caption_style=CaptionStyle(caption_style), caption_position=CaptionPosition(caption_position), extra_videos=extra_video_paths, video_source=VideoSource(video_source), ) console.print("\n[bold]Fansifter Video Clipper — Cut to Music[/bold]") console.print(f"Input: {video_path}") if extra_video_paths: for ev in extra_video_paths: console.print(f"Extra video: {ev}") console.print(f"Video source: {video_source}") console.print(f"Output: {output_dir}") if total_min is not None and total_max is not None: console.print(f"Total duration: {total_min:.0f}–{total_max:.0f}s") else: console.print("Total duration: Gemini decides") if seg_min is not None and seg_max is not None: console.print(f"Segment duration: {seg_min:.0f}–{seg_max:.0f}s") else: console.print("Segment duration: Gemini decides (max 30s)") console.print(f"Clips (derived): {config.num_segments}") if caption: console.print( f"Caption: '{caption}' | style={caption_style}" f" | position={caption_position}" ) if dry_run: console.print("[yellow]Mode: DRY RUN (analysis only)[/yellow]") processor = CutToMusicProcessor(config) if dry_run: chorus, visual_peaks, onsets = processor.analyze_only() console.print( f"\n[bold yellow]DRY RUN — Chorus:[/bold yellow] " f"{chorus.chorus_start:.1f}s – {chorus.chorus_end:.1f}s" ) if chorus.description: console.print(f" {chorus.description}") multi_source = len({p.source_video for p in visual_peaks if p.source_video}) > 1 console.print(f"\n[bold yellow]Visual peaks ({len(visual_peaks)}):[/bold yellow]") for seg in sorted(visual_peaks, key=lambda s: s.score, reverse=True): source_label = ( f" [{seg.source_video.name}]" if multi_source and seg.source_video else "" ) console.print( f" {seg.start:.1f}s–{seg.end:.1f}s " f"peak@{seg.visual_peak_timestamp:.2f}s " f"score={seg.score:.2f}{source_label} {seg.description[:50]}" ) if onsets: console.print( f"\n[bold yellow]Onsets detected ({len(onsets)}):[/bold yellow] " + ", ".join(f"{o.time:.2f}s" for o in onsets) ) else: console.print( "\n[yellow]No onsets detected — " "run without --dry-run for full processing.[/yellow]" ) return output_path = processor.process() console.print(f"\n[bold green]Success![/bold green] {output_path}") except InsufficientOnsetError as e: console.print(f"\n[red]Beat detection failed:[/red] {e}") sys.exit(1) except KeyboardInterrupt: console.print("\n[yellow]Interrupted by user[/yellow]") sys.exit(130) except Exception as e: import subprocess as _sp from rich.markup import escape as _escape console.print(f"\n[red]Error:[/red] {_escape(str(e))}") if isinstance(e, _sp.CalledProcessError) and e.stderr: # Print full stderr without Rich markup interpretation console.print("[dim]FFmpeg stderr:[/dim]") console.print(_escape(e.stderr[:3000])) if debug: raise sys.exit(1) if __name__ == "__main__": cli()