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

1"""Command line interface for Slide Stream.""" 

2 

3import time 

4from pathlib import Path 

5from typing import Annotated 

6 

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 

12 

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 

23 

24# Rich Console Initialization 

25console = Console() 

26err_console = Console(stderr=True, style="bold red") 

27 

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) 

37 

38 

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

46 

47 

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 ) 

99 

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) 

105 

106 # Setup temporary directory 

107 temp_dir = Path(TEMP_DIR) 

108 temp_dir.mkdir(exist_ok=True) 

109 

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) 

121 

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

129 

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 ) 

142 

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 ) 

149 

150 raw_text = f"Title: {slide['title']}. Content: {' '.join(slide['content'])}" 

151 speech_text = raw_text 

152 search_query = slide["title"] 

153 

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 

162 

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

170 

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" 

175 

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

181 

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 ) 

188 

189 if fragment_file: 

190 video_fragments.append(fragment_file) 

191 

192 progress.update(process_task, advance=1) 

193 time.sleep(0.1) # Small delay for smoother progress bar updates 

194 

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 ) 

207 

208 # Clean up clips 

209 for clip in clips: 

210 clip.close() 

211 final_clip.close() 

212 

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) 

228 

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

239 

240 

241if __name__ == "__main__": 

242 app()