slide-stream 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slide_stream/__init__.py +5 -0
- slide_stream/cli.py +242 -0
- slide_stream/config.py +25 -0
- slide_stream/llm.py +118 -0
- slide_stream/media.py +149 -0
- slide_stream/parser.py +38 -0
- slide_stream-1.0.0.dist-info/METADATA +204 -0
- slide_stream-1.0.0.dist-info/RECORD +11 -0
- slide_stream-1.0.0.dist-info/WHEEL +4 -0
- slide_stream-1.0.0.dist-info/entry_points.txt +2 -0
- slide_stream-1.0.0.dist-info/licenses/LICENSE +21 -0
slide_stream/__init__.py
ADDED
slide_stream/cli.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Command line interface for Slide Stream."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from moviepy import ImageClip, concatenate_videoclips
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .config import TEMP_DIR
|
|
15
|
+
from .llm import get_llm_client, query_llm
|
|
16
|
+
from .media import (
|
|
17
|
+
create_text_image,
|
|
18
|
+
create_video_fragment,
|
|
19
|
+
search_and_download_image,
|
|
20
|
+
text_to_speech,
|
|
21
|
+
)
|
|
22
|
+
from .parser import parse_markdown
|
|
23
|
+
|
|
24
|
+
# Rich Console Initialization
|
|
25
|
+
console = Console()
|
|
26
|
+
err_console = Console(stderr=True, style="bold red")
|
|
27
|
+
|
|
28
|
+
# Typer Application Initialization
|
|
29
|
+
app = 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
|
+
|
|
39
|
+
def 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()
|
|
49
|
+
def 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
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
app()
|
slide_stream/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Configuration constants for Slide Stream."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Video settings
|
|
5
|
+
VIDEO_RESOLUTION = (1920, 1080)
|
|
6
|
+
SLIDE_DURATION_PADDING = 1.0
|
|
7
|
+
DEFAULT_SLIDE_DURATION = 5.0
|
|
8
|
+
VIDEO_FPS = 24
|
|
9
|
+
VIDEO_CODEC = "libx264"
|
|
10
|
+
AUDIO_CODEC = "aac"
|
|
11
|
+
|
|
12
|
+
# Image settings
|
|
13
|
+
IMAGE_DOWNLOAD_TIMEOUT = 15
|
|
14
|
+
BG_COLOR = "black"
|
|
15
|
+
|
|
16
|
+
# Directories
|
|
17
|
+
TEMP_DIR = "temp_files"
|
|
18
|
+
|
|
19
|
+
# Font settings
|
|
20
|
+
DEFAULT_TITLE_FONT_SIZE = 100
|
|
21
|
+
DEFAULT_CONTENT_FONT_SIZE = 60
|
|
22
|
+
FONT_COLOR = "white"
|
|
23
|
+
|
|
24
|
+
# Text wrapping
|
|
25
|
+
MAX_LINE_WIDTH = 50
|
slide_stream/llm.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""LLM integration for Slide Stream."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
err_console = Console(stderr=True, style="bold red")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_llm_client(provider: str) -> Any:
|
|
12
|
+
"""Get LLM client based on provider."""
|
|
13
|
+
if provider == "gemini":
|
|
14
|
+
try:
|
|
15
|
+
import google.generativeai as genai
|
|
16
|
+
|
|
17
|
+
api_key = os.getenv("GEMINI_API_KEY")
|
|
18
|
+
if not api_key:
|
|
19
|
+
raise ValueError("GEMINI_API_KEY environment variable not set.")
|
|
20
|
+
genai.configure(api_key=api_key)
|
|
21
|
+
return genai.GenerativeModel("gemini-1.5-flash")
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"Gemini library not found. Please install with: pip install slide-stream[gemini]"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
elif provider == "openai":
|
|
28
|
+
try:
|
|
29
|
+
from openai import OpenAI
|
|
30
|
+
|
|
31
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise ValueError("OPENAI_API_KEY environment variable not set.")
|
|
34
|
+
return OpenAI(api_key=api_key)
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"OpenAI library not found. Please install with: pip install slide-stream[openai]"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
elif provider == "claude":
|
|
41
|
+
try:
|
|
42
|
+
import anthropic
|
|
43
|
+
|
|
44
|
+
api_key = os.getenv("ANTHROPIC_API_KEY")
|
|
45
|
+
if not api_key:
|
|
46
|
+
raise ValueError("ANTHROPIC_API_KEY environment variable not set.")
|
|
47
|
+
return anthropic.Anthropic(api_key=api_key)
|
|
48
|
+
except ImportError:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"Anthropic library not found. Please install with: pip install slide-stream[claude]"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
elif provider == "groq":
|
|
54
|
+
try:
|
|
55
|
+
from groq import Groq
|
|
56
|
+
|
|
57
|
+
api_key = os.getenv("GROQ_API_KEY")
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise ValueError("GROQ_API_KEY environment variable not set.")
|
|
60
|
+
return Groq(api_key=api_key)
|
|
61
|
+
except ImportError:
|
|
62
|
+
raise ImportError(
|
|
63
|
+
"Groq library not found. Please install with: pip install slide-stream[groq]"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
elif provider == "ollama":
|
|
67
|
+
try:
|
|
68
|
+
from openai import OpenAI
|
|
69
|
+
|
|
70
|
+
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
|
71
|
+
return OpenAI(base_url=f"{base_url}/v1", api_key="ollama")
|
|
72
|
+
except ImportError:
|
|
73
|
+
raise ImportError(
|
|
74
|
+
"OpenAI library not found. Please install with: pip install slide-stream[openai]"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError(f"Unknown LLM provider: {provider}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def query_llm(
|
|
82
|
+
client: Any, provider: str, prompt_text: str, rich_console: Console
|
|
83
|
+
) -> str | None:
|
|
84
|
+
"""Query LLM with given prompt."""
|
|
85
|
+
rich_console.print(" - Querying LLM...")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
if provider == "gemini":
|
|
89
|
+
response = client.generate_content(prompt_text)
|
|
90
|
+
return response.text
|
|
91
|
+
|
|
92
|
+
elif provider in ["openai", "ollama"]:
|
|
93
|
+
model = "gpt-4o" if provider == "openai" else "llama3.1"
|
|
94
|
+
response = client.chat.completions.create(
|
|
95
|
+
model=model, messages=[{"role": "user", "content": prompt_text}]
|
|
96
|
+
)
|
|
97
|
+
return response.choices[0].message.content
|
|
98
|
+
|
|
99
|
+
elif provider == "claude":
|
|
100
|
+
response = client.messages.create(
|
|
101
|
+
model="claude-3-sonnet-20240229",
|
|
102
|
+
max_tokens=1024,
|
|
103
|
+
messages=[{"role": "user", "content": prompt_text}],
|
|
104
|
+
)
|
|
105
|
+
return response.content[0].text
|
|
106
|
+
|
|
107
|
+
elif provider == "groq":
|
|
108
|
+
response = client.chat.completions.create(
|
|
109
|
+
model="llama3-8b-8192",
|
|
110
|
+
messages=[{"role": "user", "content": prompt_text}],
|
|
111
|
+
)
|
|
112
|
+
return response.choices[0].message.content
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
except Exception as e:
|
|
117
|
+
err_console.print(f" - LLM Error: {e}")
|
|
118
|
+
return None
|
slide_stream/media.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Media handling functionality for Slide Stream."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import textwrap
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from gtts import gTTS
|
|
8
|
+
from moviepy import AudioFileClip, ImageClip
|
|
9
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from .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
|
+
|
|
26
|
+
err_console = Console(stderr=True, style="bold red")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def 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
|
+
|
|
48
|
+
def 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
|
+
|
|
94
|
+
def 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
|
+
|
|
105
|
+
def 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
|
slide_stream/parser.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Markdown parsing functionality for Slide Stream."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import markdown
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_markdown(markdown_text: str) -> list[dict[str, Any]]:
|
|
10
|
+
"""Parse markdown text into slide data."""
|
|
11
|
+
html = markdown.markdown(markdown_text)
|
|
12
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
13
|
+
slides = []
|
|
14
|
+
|
|
15
|
+
for header in soup.find_all("h1"):
|
|
16
|
+
slide_title = header.get_text()
|
|
17
|
+
|
|
18
|
+
# Find the next sibling that is a list (ul or ol)
|
|
19
|
+
next_sibling = header.find_next_sibling()
|
|
20
|
+
content_items = []
|
|
21
|
+
|
|
22
|
+
while next_sibling:
|
|
23
|
+
if next_sibling.name in ["ul", "ol"]:
|
|
24
|
+
content_items = [
|
|
25
|
+
item.get_text() for item in next_sibling.find_all("li")
|
|
26
|
+
]
|
|
27
|
+
break
|
|
28
|
+
elif next_sibling.name == "p":
|
|
29
|
+
# If it's a paragraph, add it as content
|
|
30
|
+
content_items.append(next_sibling.get_text())
|
|
31
|
+
elif next_sibling.name in ["h1", "h2", "h3"]:
|
|
32
|
+
# Stop if we hit another header
|
|
33
|
+
break
|
|
34
|
+
next_sibling = next_sibling.find_next_sibling()
|
|
35
|
+
|
|
36
|
+
slides.append({"title": slide_title, "content": content_items})
|
|
37
|
+
|
|
38
|
+
return slides
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slide-stream
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: An AI-powered tool to automatically create video presentations from text and Markdown
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourusername/slide-stream
|
|
6
|
+
Project-URL: Repository, https://github.com/yourusername/slide-stream
|
|
7
|
+
Project-URL: Issues, https://github.com/yourusername/slide-stream/issues
|
|
8
|
+
Author-email: Your Name <your.email@example.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,cli,markdown,presentation,video
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Multimedia :: Video
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: anthropic>=0.7.0; extra == 'claude'
|
|
26
|
+
Requires-Dist: beautifulsoup4>=4.12.0
|
|
27
|
+
Requires-Dist: google-generativeai>=0.3.0; extra == 'gemini'
|
|
28
|
+
Requires-Dist: groq>=0.4.0; extra == 'groq'
|
|
29
|
+
Requires-Dist: gtts>=2.4.0
|
|
30
|
+
Requires-Dist: markdown>=3.5.0
|
|
31
|
+
Requires-Dist: moviepy>=1.0.3
|
|
32
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
33
|
+
Requires-Dist: pillow>=10.0.0
|
|
34
|
+
Requires-Dist: requests>=2.31.0
|
|
35
|
+
Requires-Dist: rich>=13.0.0
|
|
36
|
+
Requires-Dist: typer>=0.9.0
|
|
37
|
+
Requires-Dist: typing-extensions>=4.8.0
|
|
38
|
+
Provides-Extra: all-ai
|
|
39
|
+
Requires-Dist: anthropic>=0.7.0; extra == 'all-ai'
|
|
40
|
+
Requires-Dist: google-generativeai>=0.3.0; extra == 'all-ai'
|
|
41
|
+
Requires-Dist: groq>=0.4.0; extra == 'all-ai'
|
|
42
|
+
Requires-Dist: openai>=1.0.0; extra == 'all-ai'
|
|
43
|
+
Provides-Extra: claude
|
|
44
|
+
Requires-Dist: anthropic>=0.7.0; extra == 'claude'
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: basedpyright>=1.8.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: pre-commit>=3.5.0; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-mock>=3.12.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
52
|
+
Provides-Extra: gemini
|
|
53
|
+
Requires-Dist: google-generativeai>=0.3.0; extra == 'gemini'
|
|
54
|
+
Provides-Extra: groq
|
|
55
|
+
Requires-Dist: groq>=0.4.0; extra == 'groq'
|
|
56
|
+
Provides-Extra: openai
|
|
57
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
58
|
+
Description-Content-Type: text/markdown
|
|
59
|
+
|
|
60
|
+
# Slide Stream
|
|
61
|
+
|
|
62
|
+
🎬 An AI-powered tool to automatically create video presentations from Markdown files.
|
|
63
|
+
|
|
64
|
+
[](https://badge.fury.io/py/slide-stream)
|
|
65
|
+
[](https://www.python.org/downloads/)
|
|
66
|
+
[](https://opensource.org/licenses/MIT)
|
|
67
|
+
|
|
68
|
+
Transform your Markdown documents into professional video presentations with AI-powered content enhancement, automatic image sourcing, and natural text-to-speech narration.
|
|
69
|
+
|
|
70
|
+
## ✨ Features
|
|
71
|
+
|
|
72
|
+
- 📝 **Markdown to Video**: Convert Markdown files into professional video presentations
|
|
73
|
+
- 🤖 **AI Enhancement**: Improve content using OpenAI, Gemini, Claude, Groq, or Ollama
|
|
74
|
+
- 🖼️ **Smart Images**: Automatic image sourcing from Unsplash or generate text-based slides
|
|
75
|
+
- 🎙️ **Text-to-Speech**: Natural narration using Google Text-to-Speech
|
|
76
|
+
- 🎨 **Customizable**: Professional video output with configurable settings
|
|
77
|
+
- ⚡ **Modern CLI**: Built with Typer and Rich for excellent user experience
|
|
78
|
+
|
|
79
|
+
## 🚀 Quick Start
|
|
80
|
+
|
|
81
|
+
### Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install slide-stream
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Basic Usage
|
|
88
|
+
|
|
89
|
+
Create a simple Markdown file:
|
|
90
|
+
|
|
91
|
+
```markdown
|
|
92
|
+
# Welcome to My Presentation
|
|
93
|
+
|
|
94
|
+
- This is the first point
|
|
95
|
+
- Here's another important point
|
|
96
|
+
- And a final thought
|
|
97
|
+
|
|
98
|
+
# Second Slide
|
|
99
|
+
|
|
100
|
+
- More content here
|
|
101
|
+
- Additional information
|
|
102
|
+
- Conclusion
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Generate your video:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
slide-stream create -i presentation.md -o my-video.mp4
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## 🔧 Installation Options
|
|
112
|
+
|
|
113
|
+
### Core Installation
|
|
114
|
+
```bash
|
|
115
|
+
pip install slide-stream
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### With AI Providers
|
|
119
|
+
```bash
|
|
120
|
+
# For OpenAI GPT models
|
|
121
|
+
pip install slide-stream[openai]
|
|
122
|
+
|
|
123
|
+
# For Google Gemini
|
|
124
|
+
pip install slide-stream[gemini]
|
|
125
|
+
|
|
126
|
+
# For Anthropic Claude
|
|
127
|
+
pip install slide-stream[claude]
|
|
128
|
+
|
|
129
|
+
# For Groq (fast inference)
|
|
130
|
+
pip install slide-stream[groq]
|
|
131
|
+
|
|
132
|
+
# For all AI providers
|
|
133
|
+
pip install slide-stream[all-ai]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 🎯 Usage Examples
|
|
137
|
+
|
|
138
|
+
### Basic video creation
|
|
139
|
+
```bash
|
|
140
|
+
slide-stream create -i slides.md -o presentation.mp4
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### With AI enhancement
|
|
144
|
+
```bash
|
|
145
|
+
# Set your API key
|
|
146
|
+
export OPENAI_API_KEY="your-api-key-here"
|
|
147
|
+
|
|
148
|
+
# Create enhanced video
|
|
149
|
+
slide-stream create \
|
|
150
|
+
-i slides.md \
|
|
151
|
+
-o presentation.mp4 \
|
|
152
|
+
--llm-provider openai \
|
|
153
|
+
--image-source unsplash
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Text-only slides (no internet required)
|
|
157
|
+
```bash
|
|
158
|
+
slide-stream create \
|
|
159
|
+
-i slides.md \
|
|
160
|
+
-o presentation.mp4 \
|
|
161
|
+
--image-source text
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## 🔑 Environment Variables
|
|
165
|
+
|
|
166
|
+
Set these environment variables for AI providers:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# OpenAI
|
|
170
|
+
export OPENAI_API_KEY="your-openai-key"
|
|
171
|
+
|
|
172
|
+
# Google Gemini
|
|
173
|
+
export GEMINI_API_KEY="your-gemini-key"
|
|
174
|
+
|
|
175
|
+
# Anthropic Claude
|
|
176
|
+
export ANTHROPIC_API_KEY="your-claude-key"
|
|
177
|
+
|
|
178
|
+
# Groq
|
|
179
|
+
export GROQ_API_KEY="your-groq-key"
|
|
180
|
+
|
|
181
|
+
# Ollama (local)
|
|
182
|
+
export OLLAMA_BASE_URL="http://localhost:11434"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## 📋 Requirements
|
|
186
|
+
|
|
187
|
+
- Python 3.10+
|
|
188
|
+
- FFmpeg (for video processing)
|
|
189
|
+
- Internet connection (for Unsplash images and AI providers)
|
|
190
|
+
|
|
191
|
+
## 🤝 Contributing
|
|
192
|
+
|
|
193
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
194
|
+
|
|
195
|
+
## 📄 License
|
|
196
|
+
|
|
197
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
198
|
+
|
|
199
|
+
## 🙏 Acknowledgments
|
|
200
|
+
|
|
201
|
+
- Built with [Typer](https://typer.tiangolo.com/) for the CLI
|
|
202
|
+
- [Rich](https://rich.readthedocs.io/) for beautiful terminal output
|
|
203
|
+
- [MoviePy](https://moviepy.readthedocs.io/) for video processing
|
|
204
|
+
- [Pillow](https://pillow.readthedocs.io/) for image handling
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
slide_stream/__init__.py,sha256=YPuLXtn8zbpE3HN3zAjG63REnJzF7z4blOP5r_Tu7c4,170
|
|
2
|
+
slide_stream/cli.py,sha256=9ImnPa6y7xPY6mqZOJ0u_B1FlmfDE6Ea67dsPOyVFUY,8089
|
|
3
|
+
slide_stream/config.py,sha256=AEenMpsNuhMawBvnkH0gN26qT7pYaf5puiU9UWi59Ow,456
|
|
4
|
+
slide_stream/llm.py,sha256=c3_zRM9Nu6T6ixyTLPK31BGSsSfkn4TOyHjTxwugWco,3949
|
|
5
|
+
slide_stream/media.py,sha256=amNkYilDaARgCU260fEELAy3joJJYJvBZat8cN9Cmuw,4576
|
|
6
|
+
slide_stream/parser.py,sha256=_9Mq0Xngg7_u6X-SmVE4xZjYAKUwI9ALWHoPM1cBrKQ,1232
|
|
7
|
+
slide_stream-1.0.0.dist-info/METADATA,sha256=H62mOMRozxGEJX2ZqNSJNexUPbiePDvyHX-iK_2Mqv4,5873
|
|
8
|
+
slide_stream-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
slide_stream-1.0.0.dist-info/entry_points.txt,sha256=g7GgszRmRunGIcmwanS_RX2_I44odc0WGQrLdikuSbc,54
|
|
10
|
+
slide_stream-1.0.0.dist-info/licenses/LICENSE,sha256=g1qHuBwyB3NcBzHvVFp-N1vV6q7Y8iDu1YDY01cp3p8,1081
|
|
11
|
+
slide_stream-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Slide Stream Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|