"""CAD-Documenter CLI - Main entry point.""" import sys from pathlib import Path import click from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn from rich.panel import Panel from rich.table import Table from .pipeline import DocumentationPipeline, PipelineProgress, PipelineStage, create_pipeline from .config import Config, load_config from .cli_project import project as project_commands console = Console() def print_banner(): """Print the CAD-Documenter banner.""" console.print(Panel.fit( "[bold blue]CAD-Documenter[/bold blue] v0.2.0\n" "[dim]Video walkthrough -> Engineering documentation[/dim]", border_style="blue" )) def progress_handler(progress: PipelineProgress): """Handle progress updates from pipeline.""" stage_icons = { PipelineStage.INIT: "[gear]", PipelineStage.FRAMES: "[video]", PipelineStage.TRANSCRIPTION: "[mic]", PipelineStage.ANALYSIS: "šŸ”", PipelineStage.DOCUMENTATION: "[doc]", PipelineStage.PDF: "[pdf]", PipelineStage.COMPLETE: "āœ…", } icon = stage_icons.get(progress.stage, "[wait]") if progress.error: console.print(f" [red]X[/red] {progress.message}") else: console.print(f" {icon} {progress.message}") @click.group(invoke_without_command=True) @click.pass_context def cli(ctx): """CAD-Documenter: Generate engineering documentation from video walkthroughs.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @cli.command() @click.argument("video", type=click.Path(exists=True, path_type=Path)) @click.option("-o", "--output", type=click.Path(path_type=Path), help="Output directory") @click.option("--frames-only", is_flag=True, help="Only extract frames, skip documentation") @click.option("--skip-transcription", is_flag=True, help="Skip audio transcription") @click.option("--atomizer-hints", is_flag=True, help="Generate Atomizer FEA hints") @click.option("--bom", is_flag=True, help="Generate Bill of Materials") @click.option("--pdf", is_flag=True, help="Generate PDF via Atomaste Report Standard") @click.option("--frame-mode", type=click.Choice(["interval", "scene", "hybrid"]), default="hybrid", help="Frame extraction mode") @click.option("--frame-interval", default=2.0, help="Seconds between frames (interval mode)") @click.option("--whisper-model", default="base", help="Whisper model size (tiny/base/small/medium/large)") @click.option("--vision-provider", type=click.Choice(["anthropic", "openai"]), default="anthropic", help="Vision API provider") @click.option("--vision-model", default=None, help="Vision model name (provider-specific)") @click.option("--config", type=click.Path(path_type=Path), help="Config file path") @click.option("--verbose", "-v", is_flag=True, help="Verbose output") def process( video: Path, output: Path | None, frames_only: bool, skip_transcription: bool, atomizer_hints: bool, bom: bool, pdf: bool, frame_mode: str, frame_interval: float, whisper_model: str, vision_provider: str, vision_model: str | None, config: Path | None, verbose: bool, ): """ Process a video walkthrough and generate documentation. VIDEO: Path to the video file (.mp4, .mov, .avi, etc.) Examples: cad-doc process video.mp4 cad-doc process video.mp4 --atomizer-hints --bom --pdf cad-doc process video.mp4 --frame-mode scene --vision-provider openai """ print_banner() console.print(f"\nšŸ“¹ Processing: [cyan]{video}[/cyan]") # Load or create config cfg = load_config(config) # Override config with CLI options cfg.frame_extraction.mode = frame_mode cfg.frame_extraction.interval_seconds = frame_interval cfg.transcription.model = whisper_model cfg.vision.provider = vision_provider if vision_model: cfg.vision.model = vision_model # Default output directory if output is None: output = video.parent / f"{video.stem}_docs" console.print(f"[folder] Output: [cyan]{output}[/cyan]\n") # Create pipeline pipeline = DocumentationPipeline( video_path=video, output_dir=output, config=cfg, progress_callback=progress_handler if verbose else None, ) # Show video info try: metadata = pipeline.get_video_metadata() console.print(f" Duration: {metadata.duration:.1f}s | " f"Resolution: {metadata.width}x{metadata.height} | " f"Audio: {'OK' if metadata.has_audio else 'X'}") except Exception: pass console.print() # Run pipeline with progress with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), console=console, disable=verbose, # Disable progress bar if verbose (use callback instead) ) as progress: task = progress.add_task("Processing...", total=100) def update_progress(p: PipelineProgress): progress.update(task, completed=int(p.progress * 100), description=p.message) if verbose: progress_handler(p) pipeline.progress_callback = update_progress result = pipeline.run( frames_only=frames_only, skip_transcription=skip_transcription, atomizer_hints=atomizer_hints, bom=bom, pdf=pdf, ) # Print results console.print() if result.success: console.print(Panel.fit( f"[bold green]OK Documentation generated successfully![/bold green]\n\n" f"šŸ“Š Frames extracted: {result.frames_extracted}\n" f"[gear] Components found: {result.components_found}\n" f"[mic] Audio duration: {result.transcript_duration:.1f}s", title="Results", border_style="green" )) # Show output files table = Table(title="Output Files", show_header=True) table.add_column("Type", style="cyan") table.add_column("Path") if result.documentation_path: table.add_row("Documentation", str(result.documentation_path)) if result.atomizer_hints_path: table.add_row("Atomizer Hints", str(result.atomizer_hints_path)) if result.bom_path: table.add_row("BOM", str(result.bom_path)) if result.pdf_path: table.add_row("PDF", str(result.pdf_path)) console.print(table) # Show warnings if result.warnings: console.print("\n[yellow]Warnings:[/yellow]") for warning in result.warnings: console.print(f" āš ļø {warning}") else: console.print(Panel.fit( f"[bold red]X Pipeline failed[/bold red]\n\n" + "\n".join(result.errors), title="Error", border_style="red" )) sys.exit(1) @cli.command() @click.argument("video", type=click.Path(exists=True, path_type=Path)) @click.option("-o", "--output", type=click.Path(path_type=Path), help="Output directory") @click.option("--mode", type=click.Choice(["interval", "scene", "hybrid"]), default="hybrid", help="Extraction mode") @click.option("--interval", default=2.0, help="Seconds between frames") @click.option("--threshold", default=0.3, help="Scene change threshold") def frames(video: Path, output: Path | None, mode: str, interval: float, threshold: float): """ Extract frames from a video without full processing. Useful for previewing what frames will be analyzed. """ from .video_processor import VideoProcessor from .config import FrameExtractionConfig print_banner() console.print(f"\nšŸ“¹ Extracting frames from: [cyan]{video}[/cyan]") if output is None: output = video.parent / f"{video.stem}_frames" config = FrameExtractionConfig( mode=mode, interval_seconds=interval, scene_threshold=threshold, ) processor = VideoProcessor(video, output, config) with console.status("Extracting frames..."): frames_list = processor.extract_frames() console.print(f"\n[green]OK[/green] Extracted {len(frames_list)} frames to {output}") # Show frame timestamps table = Table(title="Extracted Frames") table.add_column("#", style="dim") table.add_column("Timestamp") table.add_column("File") table.add_column("Scene Score") for i, frame in enumerate(frames_list[:20]): # Show first 20 table.add_row( str(i + 1), f"{frame.timestamp:.2f}s", frame.path.name, f"{frame.scene_score:.2f}" if frame.scene_score else "-" ) if len(frames_list) > 20: table.add_row("...", f"({len(frames_list) - 20} more)", "", "") console.print(table) @cli.command() @click.argument("video", type=click.Path(exists=True, path_type=Path)) @click.option("--model", default="base", help="Whisper model size") @click.option("--output", "-o", type=click.Path(path_type=Path), help="Output file") def transcribe(video: Path, model: str, output: Path | None): """ Transcribe audio from a video file. Outputs transcript with timestamps. """ from .audio_analyzer import AudioAnalyzer from .config import TranscriptionConfig print_banner() console.print(f"\n[mic] Transcribing: [cyan]{video}[/cyan]") config = TranscriptionConfig(model=model) analyzer = AudioAnalyzer(video, config) with console.status(f"Transcribing with Whisper ({model})..."): transcript = analyzer.transcribe() console.print(f"\n[green]OK[/green] Transcribed {len(transcript.segments)} segments") console.print(f" Duration: {transcript.duration:.1f}s") console.print(f" Language: {transcript.language}\n") # Save or display transcript if output: lines = [] for seg in transcript.segments: lines.append(f"[{seg.start:.2f} - {seg.end:.2f}] {seg.text}") output.write_text("\n".join(lines)) console.print(f"Saved to: {output}") else: for seg in transcript.segments[:10]: console.print(f"[dim][{seg.start:.1f}s][/dim] {seg.text}") if len(transcript.segments) > 10: console.print(f"[dim]... ({len(transcript.segments) - 10} more segments)[/dim]") @cli.command() @click.option("--output", "-o", type=click.Path(path_type=Path), help="Output config file") def init(output: Path | None): """ Create a default configuration file. """ from .config import Config if output is None: output = Path(".cad-documenter.json") config = Config() config.to_file(output) console.print(f"[green]OK[/green] Created config file: {output}") console.print("\nEdit this file to customize:") console.print(" - Vision model and provider") console.print(" - Whisper transcription settings") console.print(" - Frame extraction parameters") @cli.command() @click.argument("video", type=click.Path(exists=True, path_type=Path)) def info(video: Path): """ Show information about a video file. """ from .video_processor import VideoProcessor processor = VideoProcessor(video, Path("/tmp")) metadata = processor.get_metadata() table = Table(title=f"Video Info: {video.name}") table.add_column("Property", style="cyan") table.add_column("Value") table.add_row("Duration", f"{metadata.duration:.2f}s ({metadata.duration/60:.1f} min)") table.add_row("Resolution", f"{metadata.width}x{metadata.height}") table.add_row("FPS", f"{metadata.fps:.2f}") table.add_row("Codec", metadata.codec) table.add_row("Has Audio", "OK" if metadata.has_audio else "X") table.add_row("File Size", f"{video.stat().st_size / 1024 / 1024:.1f} MB") console.print(table) # Legacy command for backwards compatibility @cli.command(name="main", hidden=True) @click.argument("video", type=click.Path(exists=True, path_type=Path)) @click.option("-o", "--output", type=click.Path(path_type=Path)) @click.option("--frames-only", is_flag=True) @click.option("--atomizer-hints", is_flag=True) @click.option("--bom", is_flag=True) @click.option("--pdf", is_flag=True) @click.option("--frame-interval", default=2.0) @click.option("--whisper-model", default="base") @click.pass_context def main_legacy(ctx, video, output, frames_only, atomizer_hints, bom, pdf, frame_interval, whisper_model): """Legacy entry point - redirects to process command.""" ctx.invoke( process, video=video, output=output, frames_only=frames_only, atomizer_hints=atomizer_hints, bom=bom, pdf=pdf, frame_interval=frame_interval, whisper_model=whisper_model, ) def main(): """Main entry point.""" cli() # Register project subcommands cli.add_command(project_commands) if __name__ == "__main__": main()