390 lines
13 KiB
Python
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()
|