Files
CAD-Documenter/src/cad_documenter/cli.py
2026-01-28 02:18:32 +00:00

390 lines
13 KiB
Python

"""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()