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
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-24 15:45 +0800
1"""Media handling functionality for Slide Stream."""
3import os
4import textwrap
6import requests
7from gtts import gTTS
8from moviepy import AudioFileClip, ImageClip
9from PIL import Image, ImageDraw, ImageFont
10from rich.console import Console
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)
26err_console = Console(stderr=True, style="bold red")
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()
38 with open(filename, "wb") as f:
39 f.write(response.content)
41 return filename
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)
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)
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()
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 )
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
90 img.save(filename)
91 return filename
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
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)
115 # Determine duration
116 duration = (
117 audio_clip.duration + SLIDE_DURATION_PADDING
118 if audio_clip
119 else DEFAULT_SLIDE_DURATION
120 )
122 # Create image clip
123 image_clip = ImageClip(image_path, duration=duration).set_position("center")
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])
131 # Combine with audio
132 final_clip = image_clip.set_audio(audio_clip) if audio_clip else image_clip
134 # Write video file
135 final_clip.write_videofile(
136 output_path, fps=VIDEO_FPS, codec=VIDEO_CODEC, logger=None
137 )
139 # Clean up clips
140 if audio_clip:
141 audio_clip.close()
142 image_clip.close()
143 final_clip.close()
145 return output_path
147 except Exception as e:
148 err_console.print(f" - Video fragment creation error: {e}")
149 return None