Coverage for src/slide_stream/media.py: 15%

72 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-24 15:45 +0800

1"""Media handling functionality for Slide Stream.""" 

2 

3import os 

4import textwrap 

5 

6import requests 

7from gtts import gTTS 

8from moviepy import AudioFileClip, ImageClip 

9from PIL import Image, ImageDraw, ImageFont 

10from rich.console import Console 

11 

12from .config import ( 

13 BG_COLOR, 

14 DEFAULT_CONTENT_FONT_SIZE, 

15 DEFAULT_SLIDE_DURATION, 

16 DEFAULT_TITLE_FONT_SIZE, 

17 FONT_COLOR, 

18 IMAGE_DOWNLOAD_TIMEOUT, 

19 MAX_LINE_WIDTH, 

20 SLIDE_DURATION_PADDING, 

21 VIDEO_CODEC, 

22 VIDEO_FPS, 

23 VIDEO_RESOLUTION, 

24) 

25 

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

27 

28 

29def search_and_download_image(query: str, filename: str) -> str: 

30 """Download image from Unsplash based on search query.""" 

31 try: 

32 url = f"https://source.unsplash.com/random/{VIDEO_RESOLUTION[0]}x{VIDEO_RESOLUTION[1]}/?{query.replace(' ', ',')}" 

33 response = requests.get( 

34 url, timeout=IMAGE_DOWNLOAD_TIMEOUT, allow_redirects=True 

35 ) 

36 response.raise_for_status() 

37 

38 with open(filename, "wb") as f: 

39 f.write(response.content) 

40 

41 return filename 

42 

43 except requests.exceptions.RequestException as e: 

44 err_console.print(f" - Image download error: {e}. Using a placeholder.") 

45 return create_text_image("Image not found", [f"Query: {query}"], filename) 

46 

47 

48def create_text_image(title: str, content_items: list, filename: str) -> str: 

49 """Create a text-based image for slides.""" 

50 img = Image.new("RGB", VIDEO_RESOLUTION, color=BG_COLOR) 

51 draw = ImageDraw.Draw(img) 

52 

53 # Try to load custom fonts, fall back to default 

54 try: 

55 title_font = ImageFont.truetype("arial.ttf", DEFAULT_TITLE_FONT_SIZE) 

56 content_font = ImageFont.truetype("arial.ttf", DEFAULT_CONTENT_FONT_SIZE) 

57 except OSError: 

58 try: 

59 # Try alternative common font names 

60 title_font = ImageFont.truetype("DejaVuSans.ttf", DEFAULT_TITLE_FONT_SIZE) 

61 content_font = ImageFont.truetype( 

62 "DejaVuSans.ttf", DEFAULT_CONTENT_FONT_SIZE 

63 ) 

64 except OSError: 

65 title_font = ImageFont.load_default() 

66 content_font = ImageFont.load_default() 

67 

68 # Draw title 

69 draw.text( 

70 (VIDEO_RESOLUTION[0] * 0.1, VIDEO_RESOLUTION[1] * 0.1), 

71 title, 

72 font=title_font, 

73 fill=FONT_COLOR, 

74 ) 

75 

76 # Draw content items 

77 y_pos = VIDEO_RESOLUTION[1] * 0.3 

78 for item in content_items: 

79 wrapped_lines = textwrap.wrap(f"{item}", width=MAX_LINE_WIDTH) 

80 for line in wrapped_lines: 

81 draw.text( 

82 (VIDEO_RESOLUTION[0] * 0.1, y_pos), 

83 line, 

84 font=content_font, 

85 fill=FONT_COLOR, 

86 ) 

87 y_pos += 70 

88 y_pos += 30 

89 

90 img.save(filename) 

91 return filename 

92 

93 

94def text_to_speech(text: str, filename: str) -> str | None: 

95 """Convert text to speech using gTTS.""" 

96 try: 

97 tts = gTTS(text=text, lang="en") 

98 tts.save(filename) 

99 return filename 

100 except Exception as e: 

101 err_console.print(f" - Audio generation error: {e}") 

102 return None 

103 

104 

105def create_video_fragment( 

106 image_path: str, audio_path: str | None, output_path: str 

107) -> str | None: 

108 """Create video fragment from image and audio.""" 

109 try: 

110 # Load audio if it exists 

111 audio_clip = None 

112 if audio_path and os.path.exists(audio_path): 

113 audio_clip = AudioFileClip(audio_path) 

114 

115 # Determine duration 

116 duration = ( 

117 audio_clip.duration + SLIDE_DURATION_PADDING 

118 if audio_clip 

119 else DEFAULT_SLIDE_DURATION 

120 ) 

121 

122 # Create image clip 

123 image_clip = ImageClip(image_path, duration=duration).set_position("center") 

124 

125 # Resize image to fit resolution if needed 

126 if image_clip.h > VIDEO_RESOLUTION[1]: 

127 image_clip = image_clip.resize(height=VIDEO_RESOLUTION[1]) 

128 if image_clip.w > VIDEO_RESOLUTION[0]: 

129 image_clip = image_clip.resize(width=VIDEO_RESOLUTION[0]) 

130 

131 # Combine with audio 

132 final_clip = image_clip.set_audio(audio_clip) if audio_clip else image_clip 

133 

134 # Write video file 

135 final_clip.write_videofile( 

136 output_path, fps=VIDEO_FPS, codec=VIDEO_CODEC, logger=None 

137 ) 

138 

139 # Clean up clips 

140 if audio_clip: 

141 audio_clip.close() 

142 image_clip.close() 

143 final_clip.close() 

144 

145 return output_path 

146 

147 except Exception as e: 

148 err_console.print(f" - Video fragment creation error: {e}") 

149 return None