Coverage for src/slide_stream/cli.py: 19%
98 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-24 15:45 +0800
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-24 15:45 +0800
1"""Command line interface for Slide Stream."""
3import time
4from pathlib import Path
5from typing import Annotated
7import typer
8from moviepy import ImageClip, concatenate_videoclips
9from rich.console import Console
10from rich.panel import Panel
11from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
13from . import __version__
14from .config import TEMP_DIR
15from .llm import get_llm_client, query_llm
16from .media import (
17 create_text_image,
18 create_video_fragment,
19 search_and_download_image,
20 text_to_speech,
21)
22from .parser import parse_markdown
24# Rich Console Initialization
25console = Console()
26err_console = Console(stderr=True, style="bold red")
28# Typer Application Initialization
29app = typer.Typer(
30 name="slide-stream",
31 help="""
32 SlideStream: An AI-powered tool to automatically create video presentations from text and Markdown.
33 """,
34 add_completion=False,
35 rich_markup_mode="markdown",
36)
39def version_callback(value: bool) -> None:
40 """Print the version of the application and exit."""
41 if value:
42 console.print(
43 f"[bold cyan]SlideStream[/bold cyan] version: [yellow]{__version__}[/yellow]"
44 )
45 raise typer.Exit()
48@app.command()
49def create(
50 input_file: Annotated[
51 typer.FileText,
52 typer.Option(
53 "--input",
54 "-i",
55 help="Path to the input Markdown file.",
56 rich_help_panel="Input/Output Options",
57 ),
58 ],
59 output_filename: Annotated[
60 str,
61 typer.Option(
62 "--output",
63 "-o",
64 help="Filename for the output video.",
65 rich_help_panel="Input/Output Options",
66 ),
67 ] = "output_video.mp4",
68 llm_provider: Annotated[
69 str,
70 typer.Option(
71 help="Select the LLM provider for text enhancement.",
72 rich_help_panel="AI & Content Options",
73 ),
74 ] = "none",
75 image_source: Annotated[
76 str,
77 typer.Option(
78 help="Choose the source for slide images.",
79 rich_help_panel="AI & Content Options",
80 ),
81 ] = "unsplash",
82 version: Annotated[
83 bool,
84 typer.Option(
85 "--version",
86 help="Show application version and exit.",
87 callback=version_callback,
88 is_eager=True,
89 ),
90 ] = False,
91) -> None:
92 """Create a video from a Markdown file."""
93 console.print(
94 Panel.fit(
95 "[bold cyan]🚀 Starting SlideStream! 🚀[/bold cyan]",
96 border_style="green",
97 )
98 )
100 # Read input file
101 markdown_input = input_file.read()
102 if not markdown_input.strip():
103 err_console.print("Input file is empty. Exiting.")
104 raise typer.Exit(code=1)
106 # Setup temporary directory
107 temp_dir = Path(TEMP_DIR)
108 temp_dir.mkdir(exist_ok=True)
110 # Initialize LLM client
111 llm_client = None
112 if llm_provider != "none":
113 try:
114 llm_client = get_llm_client(llm_provider)
115 console.print(
116 f"✅ LLM Provider Initialized: [bold green]{llm_provider}[/bold green]"
117 )
118 except (ImportError, ValueError) as e:
119 err_console.print(f"Error initializing LLM: {e}")
120 raise typer.Exit(code=1)
122 # Parse the Markdown
123 console.print("\n[bold]1. Parsing Markdown...[/bold]")
124 slides = parse_markdown(markdown_input)
125 if not slides:
126 err_console.print("No slides found in the Markdown file. Exiting.")
127 raise typer.Exit(code=1)
128 console.print(f"📄 Found [bold yellow]{len(slides)}[/bold yellow] slides.")
130 # Process each slide with Rich progress bar
131 video_fragments = []
132 with Progress(
133 SpinnerColumn(),
134 TextColumn("[progress.description]{task.description}"),
135 BarColumn(),
136 TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
137 transient=True,
138 ) as progress:
139 process_task = progress.add_task(
140 "[yellow]Processing Slides...", total=len(slides)
141 )
143 for i, slide in enumerate(slides):
144 slide_num = i + 1
145 progress.update(
146 process_task,
147 description=f"[yellow]Processing Slide {slide_num}/{len(slides)}: '{slide['title']}'[/yellow]",
148 )
150 raw_text = f"Title: {slide['title']}. Content: {' '.join(slide['content'])}"
151 speech_text = raw_text
152 search_query = slide["title"]
154 # LLM Processing
155 if llm_client:
156 speech_prompt = f"Convert the following slide points into a natural, flowing script for a voiceover. Speak conversationally. Directly output the script and nothing else.\n\n{raw_text}"
157 natural_speech = query_llm(
158 llm_client, llm_provider, speech_prompt, console
159 )
160 if natural_speech:
161 speech_text = natural_speech
163 if image_source == "unsplash":
164 search_prompt = f"Generate a concise, descriptive search query for a stock photo website (like Unsplash) to find a high-quality, relevant image for this topic. Output only the query. Topic:\n\n{raw_text}"
165 improved_query = query_llm(
166 llm_client, llm_provider, search_prompt, console
167 )
168 if improved_query:
169 search_query = improved_query.strip().replace('"', "")
171 # File paths
172 img_path = temp_dir / f"slide_{slide_num}.png"
173 audio_path = temp_dir / f"slide_{slide_num}.mp3"
174 fragment_path = temp_dir / f"fragment_{slide_num}.mp4"
176 # Image sourcing, audio, and video creation
177 if image_source == "unsplash":
178 search_and_download_image(search_query, str(img_path))
179 else:
180 create_text_image(slide["title"], slide["content"], str(img_path))
182 audio_file = text_to_speech(speech_text, str(audio_path))
183 fragment_file = create_video_fragment(
184 str(img_path),
185 str(audio_path) if audio_file else None,
186 str(fragment_path),
187 )
189 if fragment_file:
190 video_fragments.append(fragment_file)
192 progress.update(process_task, advance=1)
193 time.sleep(0.1) # Small delay for smoother progress bar updates
195 # Combine video fragments
196 console.print("\n[bold]2. Combining Video Fragments...[/bold]")
197 if video_fragments:
198 try:
199 clips = [
200 ImageClip(f).set_duration(ImageClip(f).duration)
201 for f in video_fragments
202 ]
203 final_clip = concatenate_videoclips(clips)
204 final_clip.write_videofile(
205 output_filename, fps=24, codec="libx264", audio_codec="aac", logger=None
206 )
208 # Clean up clips
209 for clip in clips:
210 clip.close()
211 final_clip.close()
213 console.print(
214 Panel(
215 f"🎉 [bold green]Video creation complete![/bold green] 🎉\n\nOutput file: [yellow]{output_filename}[/yellow]",
216 border_style="green",
217 expand=False,
218 )
219 )
220 except Exception as e:
221 err_console.print(f"Error combining video fragments: {e}")
222 raise typer.Exit(code=1)
223 else:
224 err_console.print(
225 "No video fragments were created, so the final video could not be generated."
226 )
227 raise typer.Exit(code=1)
229 # Cleanup
230 console.print("\n[bold]3. Cleaning up temporary files...[/bold]")
231 try:
232 for file_path in temp_dir.iterdir():
233 if file_path.is_file():
234 file_path.unlink()
235 temp_dir.rmdir()
236 console.print("✅ Cleanup complete.")
237 except Exception as e:
238 err_console.print(f"Warning: Could not clean up temporary files: {e}")
241if __name__ == "__main__":
242 app()