feat: integrate Qwen3 TTS and add progress GUI

Major changes:
- Add Qwen3 TTS provider with authentication support
- Remove local pyttsx3 TTS (replaced with cloud TTS)
- Add real-time progress GUI with WebSocket updates
- Comprehensive Docker setup with docker-compose
- Updated README with new documentation

New features:
- Qwen TTS: Supports multiple speakers and languages
- Progress GUI: Live step-by-step tracking at http://localhost:5000
- Docker: Full containerization with environment variables
- Config: Example config file for easy setup

Files added:
- TTS/qwen_tts.py - Qwen3 TTS provider
- progress_gui.py - Flask/SocketIO progress server
- utils/progress.py - Progress tracking module
- GUI/progress.html - Progress dashboard template
- GUI/static/css/progress.css - Progress GUI styles
- GUI/static/js/progress.js - WebSocket client
- docker-compose.yml - Docker orchestration
- docker-entrypoint.sh - Container startup script
- config.example.toml - Example configuration

https://claude.ai/code/session_01HLLH3WjpmRzvaoY6eYSFAD
pull/2456/head
Claude 4 days ago
parent 902ff00cb0
commit 94d8e45cf7
No known key found for this signature in database

@ -1,12 +1,67 @@
FROM python:3.10.14-slim
RUN apt update
RUN apt-get install -y ffmpeg
RUN apt install python3-pip -y
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV REDDIT_BOT_GUI=true
RUN mkdir /app
ADD . /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
wget \
gnupg \
libglib2.0-0 \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxcb1 \
libxkbcommon0 \
libx11-6 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
libatspi2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# Create app directory
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python3", "main.py"]
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
RUN playwright install chromium
RUN playwright install-deps chromium
# Download spaCy language model
RUN python -m spacy download en_core_web_sm
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p assets/temp assets/backgrounds/video assets/backgrounds/audio results
# Expose ports
EXPOSE 5000
# Set entrypoint
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["python", "main.py"]

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reddit Video Maker - Progress</title>
<link rel="stylesheet" href="/static/css/progress.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.6.0/socket.io.min.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>Reddit Video Maker Bot</h1>
<p class="subtitle">Real-time Progress Tracker</p>
</header>
<main>
<!-- Current Job Section -->
<section id="current-job" class="card">
<h2>Current Job</h2>
<div id="no-job" class="no-job">
<div class="waiting-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12,6 12,12 16,14"></polyline>
</svg>
</div>
<p>Waiting for video generation to start...</p>
<p class="hint">Start the bot with: <code>python main.py</code></p>
</div>
<div id="job-info" class="job-info hidden">
<div class="job-header">
<div class="job-title">
<span class="subreddit" id="job-subreddit"></span>
<h3 id="job-title"></h3>
</div>
<div class="job-status">
<span id="job-status-badge" class="status-badge"></span>
</div>
</div>
<!-- Overall Progress -->
<div class="overall-progress">
<div class="progress-bar">
<div class="progress-fill" id="overall-progress-fill"></div>
</div>
<span class="progress-text" id="overall-progress-text">0%</span>
</div>
<!-- Steps -->
<div class="steps-container" id="steps-container">
<!-- Steps will be dynamically inserted here -->
</div>
<!-- Preview Section -->
<div class="preview-section" id="preview-section">
<h4>Preview</h4>
<div class="preview-container" id="preview-container">
<div class="preview-placeholder">
<p>Preview will appear here</p>
</div>
</div>
</div>
</div>
</section>
<!-- Job History Section -->
<section id="history" class="card">
<h2>Recent Jobs</h2>
<div id="history-list" class="history-list">
<p class="no-history">No completed jobs yet</p>
</div>
</section>
</main>
<footer>
<p>Reddit Video Maker Bot - Progress GUI</p>
<div class="connection-status">
<span class="status-dot" id="connection-dot"></span>
<span id="connection-text">Connecting...</span>
</div>
</footer>
</div>
<script src="/static/js/progress.js"></script>
</body>
</html>

@ -0,0 +1,539 @@
/* Reddit Video Maker Bot - Progress GUI Styles */
:root {
--bg-primary: #0f0f0f;
--bg-secondary: #1a1a1a;
--bg-tertiary: #252525;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #666666;
--accent-primary: #ff4500;
--accent-secondary: #ff6b35;
--success: #4caf50;
--warning: #ff9800;
--error: #f44336;
--info: #2196f3;
--border-color: #333333;
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* Header */
header {
text-align: center;
padding: 40px 20px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 30px;
}
header h1 {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 10px;
}
header .subtitle {
color: var(--text-secondary);
font-size: 1.1rem;
}
/* Cards */
.card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--card-shadow);
border: 1px solid var(--border-color);
}
.card h2 {
font-size: 1.5rem;
margin-bottom: 20px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
/* No Job State */
.no-job {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.no-job .waiting-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
color: var(--text-muted);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
.no-job .hint {
margin-top: 20px;
font-size: 0.9rem;
color: var(--text-muted);
}
.no-job code {
background: var(--bg-tertiary);
padding: 4px 12px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
}
/* Job Info */
.job-info.hidden {
display: none;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
gap: 20px;
}
.job-title {
flex: 1;
}
.job-title .subreddit {
color: var(--accent-primary);
font-size: 0.9rem;
font-weight: 600;
}
.job-title h3 {
font-size: 1.3rem;
margin-top: 4px;
word-break: break-word;
}
/* Status Badges */
.status-badge {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.pending {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.status-badge.in_progress {
background: rgba(33, 150, 243, 0.2);
color: var(--info);
animation: glow 1.5s infinite;
}
@keyframes glow {
0%, 100% { box-shadow: 0 0 5px rgba(33, 150, 243, 0.3); }
50% { box-shadow: 0 0 15px rgba(33, 150, 243, 0.5); }
}
.status-badge.completed {
background: rgba(76, 175, 80, 0.2);
color: var(--success);
}
.status-badge.failed {
background: rgba(244, 67, 54, 0.2);
color: var(--error);
}
.status-badge.skipped {
background: rgba(255, 152, 0, 0.2);
color: var(--warning);
}
/* Overall Progress */
.overall-progress {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 30px;
}
.progress-bar {
flex: 1;
height: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 6px;
transition: width 0.3s ease;
width: 0%;
}
.progress-text {
font-weight: 600;
color: var(--text-primary);
min-width: 50px;
text-align: right;
}
/* Steps */
.steps-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.step {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--bg-tertiary);
border-radius: 8px;
transition: all 0.3s ease;
}
.step.active {
background: rgba(33, 150, 243, 0.1);
border-left: 3px solid var(--info);
}
.step.completed {
background: rgba(76, 175, 80, 0.1);
border-left: 3px solid var(--success);
}
.step.failed {
background: rgba(244, 67, 54, 0.1);
border-left: 3px solid var(--error);
}
.step-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.step.pending .step-icon {
background: var(--bg-secondary);
color: var(--text-muted);
}
.step.active .step-icon {
background: var(--info);
color: white;
}
.step.completed .step-icon {
background: var(--success);
color: white;
}
.step.failed .step-icon {
background: var(--error);
color: white;
}
.step.skipped .step-icon {
background: var(--warning);
color: white;
}
.step-content {
flex: 1;
min-width: 0;
}
.step-name {
font-weight: 600;
margin-bottom: 4px;
}
.step-description {
font-size: 0.85rem;
color: var(--text-secondary);
}
.step-message {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 4px;
font-style: italic;
}
.step-progress {
width: 80px;
flex-shrink: 0;
}
.step-progress-bar {
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.step-progress-fill {
height: 100%;
background: var(--info);
transition: width 0.3s ease;
}
.step-progress-text {
font-size: 0.75rem;
color: var(--text-muted);
text-align: right;
margin-top: 4px;
}
/* Spinner */
.spinner {
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Preview Section */
.preview-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
.preview-section h4 {
margin-bottom: 15px;
color: var(--text-secondary);
}
.preview-container {
background: var(--bg-tertiary);
border-radius: 8px;
overflow: hidden;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-placeholder {
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.preview-image {
max-width: 100%;
max-height: 400px;
object-fit: contain;
}
.preview-video {
max-width: 100%;
max-height: 400px;
}
/* History */
.history-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.history-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--bg-tertiary);
border-radius: 8px;
transition: transform 0.2s ease;
}
.history-item:hover {
transform: translateX(5px);
}
.history-item .subreddit {
color: var(--accent-primary);
font-size: 0.85rem;
font-weight: 600;
}
.history-item .title {
font-weight: 500;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-item .meta {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 4px;
}
.history-item .actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover {
background: var(--accent-secondary);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-tertiary);
}
.no-history {
color: var(--text-muted);
text-align: center;
padding: 40px;
}
/* Footer */
footer {
margin-top: 40px;
padding: 20px;
text-align: center;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 0.9rem;
}
.connection-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 10px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
transition: background 0.3s ease;
}
.status-dot.connected {
background: var(--success);
}
.status-dot.disconnected {
background: var(--error);
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 10px;
}
header h1 {
font-size: 1.8rem;
}
.job-header {
flex-direction: column;
}
.step {
flex-wrap: wrap;
}
.step-progress {
width: 100%;
margin-top: 10px;
}
.history-item {
flex-direction: column;
align-items: flex-start;
}
.history-item .actions {
margin-left: 0;
margin-top: 12px;
width: 100%;
}
.history-item .actions .btn {
flex: 1;
text-align: center;
}
}

@ -0,0 +1,307 @@
/**
* Reddit Video Maker Bot - Progress GUI JavaScript
* Real-time progress tracking via WebSocket
*/
class ProgressTracker {
constructor() {
this.socket = null;
this.connected = false;
this.currentJob = null;
this.jobHistory = [];
// DOM elements
this.elements = {
noJob: document.getElementById('no-job'),
jobInfo: document.getElementById('job-info'),
jobSubreddit: document.getElementById('job-subreddit'),
jobTitle: document.getElementById('job-title'),
jobStatusBadge: document.getElementById('job-status-badge'),
overallProgressFill: document.getElementById('overall-progress-fill'),
overallProgressText: document.getElementById('overall-progress-text'),
stepsContainer: document.getElementById('steps-container'),
previewContainer: document.getElementById('preview-container'),
historyList: document.getElementById('history-list'),
connectionDot: document.getElementById('connection-dot'),
connectionText: document.getElementById('connection-text'),
};
this.stepIcons = {
pending: '○',
in_progress: '◐',
completed: '✓',
failed: '✗',
skipped: '⊘',
};
this.init();
}
init() {
this.connectWebSocket();
this.fetchInitialStatus();
}
connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
this.socket = io(wsUrl + '/progress', {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: Infinity,
});
this.socket.on('connect', () => {
this.connected = true;
this.updateConnectionStatus(true);
console.log('Connected to progress server');
});
this.socket.on('disconnect', () => {
this.connected = false;
this.updateConnectionStatus(false);
console.log('Disconnected from progress server');
});
this.socket.on('progress_update', (data) => {
this.handleProgressUpdate(data);
});
this.socket.on('connect_error', (error) => {
console.error('Connection error:', error);
this.updateConnectionStatus(false);
});
}
fetchInitialStatus() {
fetch('/api/status')
.then(response => response.json())
.then(data => this.handleProgressUpdate(data))
.catch(error => console.error('Error fetching status:', error));
}
updateConnectionStatus(connected) {
const { connectionDot, connectionText } = this.elements;
if (connected) {
connectionDot.classList.add('connected');
connectionDot.classList.remove('disconnected');
connectionText.textContent = 'Connected';
} else {
connectionDot.classList.remove('connected');
connectionDot.classList.add('disconnected');
connectionText.textContent = 'Disconnected - Reconnecting...';
}
}
handleProgressUpdate(data) {
this.currentJob = data.current_job;
this.jobHistory = data.job_history || [];
this.renderCurrentJob();
this.renderHistory();
}
renderCurrentJob() {
const { noJob, jobInfo, jobSubreddit, jobTitle, jobStatusBadge,
overallProgressFill, overallProgressText, stepsContainer, previewContainer } = this.elements;
if (!this.currentJob) {
noJob.classList.remove('hidden');
jobInfo.classList.add('hidden');
return;
}
noJob.classList.add('hidden');
jobInfo.classList.remove('hidden');
// Update job info
jobSubreddit.textContent = `r/${this.currentJob.subreddit}`;
jobTitle.textContent = this.currentJob.title;
// Update status badge
jobStatusBadge.textContent = this.formatStatus(this.currentJob.status);
jobStatusBadge.className = `status-badge ${this.currentJob.status}`;
// Update overall progress
const progress = this.currentJob.overall_progress || 0;
overallProgressFill.style.width = `${progress}%`;
overallProgressText.textContent = `${Math.round(progress)}%`;
// Render steps
this.renderSteps(stepsContainer, this.currentJob.steps);
// Update preview
this.updatePreview(previewContainer, this.currentJob.steps);
}
renderSteps(container, steps) {
container.innerHTML = '';
steps.forEach((step, index) => {
const stepEl = document.createElement('div');
stepEl.className = `step ${this.getStepClass(step.status)}`;
const icon = this.getStepIcon(step.status);
const isActive = step.status === 'in_progress';
stepEl.innerHTML = `
<div class="step-icon">
${isActive ? '<div class="spinner"></div>' : icon}
</div>
<div class="step-content">
<div class="step-name">${step.name}</div>
<div class="step-description">${step.description}</div>
${step.message ? `<div class="step-message">${step.message}</div>` : ''}
</div>
${step.status === 'in_progress' ? `
<div class="step-progress">
<div class="step-progress-bar">
<div class="step-progress-fill" style="width: ${step.progress}%"></div>
</div>
<div class="step-progress-text">${Math.round(step.progress)}%</div>
</div>
` : ''}
`;
container.appendChild(stepEl);
});
}
getStepClass(status) {
const classMap = {
pending: 'pending',
in_progress: 'active',
completed: 'completed',
failed: 'failed',
skipped: 'skipped',
};
return classMap[status] || 'pending';
}
getStepIcon(status) {
return this.stepIcons[status] || this.stepIcons.pending;
}
formatStatus(status) {
const statusMap = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
failed: 'Failed',
skipped: 'Skipped',
};
return statusMap[status] || status;
}
updatePreview(container, steps) {
// Find the latest step with a preview
let previewPath = null;
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].preview_path) {
previewPath = steps[i].preview_path;
break;
}
}
if (!previewPath) {
container.innerHTML = `
<div class="preview-placeholder">
<p>Preview will appear here during processing</p>
</div>
`;
return;
}
// Determine if it's an image or video
const extension = previewPath.split('.').pop().toLowerCase();
const isVideo = ['mp4', 'webm', 'mov'].includes(extension);
if (isVideo) {
container.innerHTML = `
<video class="preview-video" controls autoplay muted loop>
<source src="${previewPath}" type="video/${extension}">
Your browser does not support video playback.
</video>
`;
} else {
container.innerHTML = `
<img class="preview-image" src="${previewPath}" alt="Preview">
`;
}
}
renderHistory() {
const { historyList } = this.elements;
if (!this.jobHistory || this.jobHistory.length === 0) {
historyList.innerHTML = '<p class="no-history">No completed jobs yet</p>';
return;
}
historyList.innerHTML = '';
// Show most recent first
const sortedHistory = [...this.jobHistory].reverse();
sortedHistory.forEach(job => {
const item = document.createElement('div');
item.className = 'history-item';
const duration = job.completed_at && job.created_at
? this.formatDuration(job.completed_at - job.created_at)
: 'N/A';
const statusClass = job.status === 'completed' ? 'completed' : 'failed';
item.innerHTML = `
<div class="step-icon" style="background: var(--${job.status === 'completed' ? 'success' : 'error'}); color: white;">
${job.status === 'completed' ? '✓' : '✗'}
</div>
<div style="flex: 1; min-width: 0;">
<div class="subreddit">r/${job.subreddit}</div>
<div class="title">${job.title}</div>
<div class="meta">
${this.formatDate(job.created_at)} ${duration}
${job.error ? `• <span style="color: var(--error);">${job.error}</span>` : ''}
</div>
</div>
<div class="actions">
${job.output_path ? `
<a href="${job.output_path}" class="btn btn-primary" target="_blank">
View Video
</a>
` : ''}
</div>
`;
historyList.appendChild(item);
});
}
formatDuration(seconds) {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
} else if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${mins}m ${secs}s`;
} else {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
}
}
formatDate(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
}
// Initialize the progress tracker when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.progressTracker = new ProgressTracker();
});

@ -1,142 +1,213 @@
# Reddit Video Maker Bot 🎥
# Reddit Video Maker Bot
All done WITHOUT video editing or asset compiling. Just pure ✨programming magic✨.
Automatically generate short-form videos from Reddit posts. Supports multiple TTS engines including Qwen3 TTS.
Created by Lewis Menelaws & [TMRRW](https://tmrrwinc.ca)
## Features
<a target="_blank" href="https://tmrrwinc.ca">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/6053155/170528535-e274dc0b-7972-4b27-af22-637f8c370133.png">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png">
<img src="https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png" width="350">
</picture>
- **Multiple TTS Engines**: Qwen3 TTS (default), OpenAI TTS, ElevenLabs, TikTok, Google Translate, AWS Polly
- **Real-time Progress GUI**: Web-based dashboard showing video generation progress with live updates
- **Docker Support**: Fully containerized with docker-compose for easy deployment
- **Background Customization**: Multiple background videos and audio tracks included
- **Story Mode**: Special mode for narrative subreddits (r/nosleep, r/tifu, etc.)
- **AI Content Sorting**: Optional semantic similarity sorting for relevant posts
</a>
## Quick Start with Docker
## Video Explainer
```bash
# Clone the repository
git clone https://github.com/elebumm/RedditVideoMakerBot.git
cd RedditVideoMakerBot
[![lewisthumbnail](https://user-images.githubusercontent.com/6053155/173631669-1d1b14ad-c478-4010-b57d-d79592a789f2.png)
](https://www.youtube.com/watch?v=3gjcY_00U1w)
# Create your config file
cp config.example.toml config.toml
# Edit config.toml with your credentials
## Motivation 🤔
# Start with docker-compose
docker-compose up -d
These videos on TikTok, YouTube and Instagram get MILLIONS of views across all platforms and require very little effort.
The only original thing being done is the editing and gathering of all materials...
# View progress at http://localhost:5000
```
... but what if we can automate that process? 🤔
## Manual Installation
## Disclaimers 🚨
### Requirements
- **At the moment**, this repository won't attempt to upload this content through this bot. It will give you a file that
you will then have to upload manually. This is for the sake of avoiding any sort of community guideline issues.
- Python 3.10, 3.11, or 3.12
- FFmpeg
- Playwright browsers
## Requirements
### Setup
- Python 3.10
- Playwright (this should install automatically in installation)
```bash
# Clone repository
git clone https://github.com/elebumm/RedditVideoMakerBot.git
cd RedditVideoMakerBot
## Installation 👩‍💻
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: .\venv\Scripts\activate
1. Clone this repository:
```sh
git clone https://github.com/elebumm/RedditVideoMakerBot.git
cd RedditVideoMakerBot
```
# Install dependencies
pip install -r requirements.txt
2. Create and activate a virtual environment:
- On **Windows**:
```sh
python -m venv ./venv
.\venv\Scripts\activate
```
- On **macOS and Linux**:
```sh
python3 -m venv ./venv
source ./venv/bin/activate
```
# Install Playwright browsers
playwright install
playwright install-deps
3. Install the required dependencies:
```sh
pip install -r requirements.txt
```
# Download spaCy model (for story mode)
python -m spacy download en_core_web_sm
4. Install Playwright and its dependencies:
```sh
python -m playwright install
python -m playwright install-deps
```
# Run the bot
python main.py
```
---
## Configuration
**EXPERIMENTAL!!!!**
Create a `config.toml` file in the project root. The bot will prompt you for settings on first run.
- On macOS and Linux (Debian, Arch, Fedora, CentOS, and based on those), you can run an installation script that will automatically install steps 1 to 3. (requires bash)
- `bash <(curl -sL https://raw.githubusercontent.com/elebumm/RedditVideoMakerBot/master/install.sh)`
- This can also be used to update the installation
### Reddit API Setup
---
1. Go to [Reddit Apps](https://www.reddit.com/prefs/apps)
2. Create a new app with type "script"
3. Note your `client_id` and `client_secret`
### Qwen TTS Setup (Default)
Qwen TTS requires a running Qwen TTS server:
```toml
[settings.tts]
voice_choice = "qwentts"
qwen_api_url = "http://localhost:8080"
qwen_email = "your_email@example.com"
qwen_password = "your_password"
qwen_speaker = "Vivian" # Options: Chelsie, Ethan, Vivian, Asher, Aria, Oliver, Emma, Noah, Sophia
qwen_language = "English"
qwen_instruct = "Warm, friendly, conversational."
```
5. Run the bot:
```sh
python main.py
```
### TTS Options
6. Visit [the Reddit Apps page](https://www.reddit.com/prefs/apps), and set up an app that is a "script". Paste any URL in the redirect URL field, for example: `https://jasoncameron.dev`.
| Provider | Key | Requirements |
|----------|-----|--------------|
| Qwen TTS | `qwentts` | Qwen TTS server |
| OpenAI | `openai` | API key |
| ElevenLabs | `elevenlabs` | API key |
| TikTok | `tiktok` | Session ID |
| Google Translate | `googletranslate` | None (free) |
| AWS Polly | `awspolly` | AWS credentials |
| Streamlabs Polly | `streamlabspolly` | None (rate limited) |
7. The bot will prompt you to fill in your details to connect to the Reddit API and configure the bot to your liking.
## Progress GUI
8. Enjoy 😎
The bot includes a real-time progress tracking GUI.
```bash
# Enable GUI mode
export REDDIT_BOT_GUI=true
python main.py
# Or run GUI standalone
python progress_gui.py
```
9. If you need to reconfigure the bot, simply open the `config.toml` file and delete the lines that need to be changed. On the next run of the bot, it will help you reconfigure those options.
Access at: http://localhost:5000
(Note: If you encounter any errors installing or running the bot, try using `python3` or `pip3` instead of `python` or `pip`.)
### Features
- Real-time progress updates via WebSocket
- Step-by-step visualization
- Preview images during generation
- Job history tracking
For a more detailed guide about the bot, please refer to the [documentation](https://reddit-video-maker-bot.netlify.app/).
## Docker Deployment
## Video
### Using Docker Compose
https://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4
```yaml
# docker-compose.yml
services:
reddit-video-bot:
build: .
ports:
- "5000:5000"
volumes:
- ./config.toml:/app/config.toml:ro
- ./results:/app/results
environment:
- REDDIT_BOT_GUI=true
```
### Environment Variables
All config options can be set via environment variables:
| Variable | Description |
|----------|-------------|
| `REDDIT_CLIENT_ID` | Reddit API client ID |
| `REDDIT_CLIENT_SECRET` | Reddit API client secret |
| `REDDIT_USERNAME` | Reddit username |
| `REDDIT_PASSWORD` | Reddit password |
| `REDDIT_SUBREDDIT` | Target subreddit |
| `TTS_VOICE_CHOICE` | TTS provider |
| `QWEN_API_URL` | Qwen TTS server URL |
| `QWEN_EMAIL` | Qwen TTS email |
| `QWEN_PASSWORD` | Qwen TTS password |
## Project Structure
```
RedditVideoMakerBot/
├── main.py # Entry point
├── progress_gui.py # Progress GUI server
├── config.toml # Configuration file
├── TTS/ # TTS engine modules
│ ├── qwen_tts.py # Qwen TTS provider
│ ├── openai_tts.py # OpenAI TTS provider
│ └── ...
├── video_creation/ # Video generation
├── reddit/ # Reddit API
├── utils/ # Utilities
│ ├── progress.py # Progress tracking
│ └── settings.py # Configuration
├── GUI/ # Web GUI templates
│ ├── progress.html
│ └── static/
├── Dockerfile
└── docker-compose.yml
```
## Output
Generated videos are saved to `results/{subreddit}/`.
## Troubleshooting
### Common Issues
**FFmpeg not found**
```bash
# Ubuntu/Debian
sudo apt install ffmpeg
# macOS
brew install ffmpeg
# Windows
# Download from https://ffmpeg.org/download.html
```
**Playwright browsers missing**
```bash
playwright install
playwright install-deps
```
**TTS authentication failed**
- Verify your Qwen TTS server is running
- Check credentials in config.toml
- Ensure the API URL is correct
## License
## Contributing & Ways to improve 📈
In its current state, this bot does exactly what it needs to do. However, improvements can always be made!
I have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute!
- [ ] Creating better documentation and adding a command line interface.
- [x] Allowing the user to choose background music for their videos.
- [x] Allowing users to choose a reddit thread instead of being randomized.
- [x] Allowing users to choose a background that is picked instead of the Minecraft one.
- [x] Allowing users to choose between any subreddit.
- [x] Allowing users to change voice.
- [x] Checks if a video has already been created
- [x] Light and Dark modes
- [x] NSFW post filter
Please read our [contributing guidelines](CONTRIBUTING.md) for more detailed information.
### For any questions or support join the [Discord](https://discord.gg/qfQSx45xCV) server
## Developers and maintainers.
Elebumm (Lewis#6305) - https://github.com/elebumm (Founder)
Jason Cameron - https://github.com/JasonLovesDoggo (Maintainer)
Simon (OpenSourceSimon) - https://github.com/OpenSourceSimon
CallumIO (c.#6837) - https://github.com/CallumIO
Verq (Verq#2338) - https://github.com/CordlessCoder
LukaHietala (Pix.#0001) - https://github.com/LukaHietala
Freebiell (Freebie#3263) - https://github.com/FreebieII
Aman Raza (electro199#8130) - https://github.com/electro199
Cyteon (cyteon) - https://github.com/cyteon
## LICENSE
[Roboto Fonts](https://fonts.google.com/specimen/Roboto/about) are licensed under [Apache License V2](https://www.apache.org/licenses/LICENSE-2.0)

@ -1,42 +0,0 @@
import random
import pyttsx3
from utils import settings
class pyttsx:
def __init__(self):
self.max_chars = 5000
self.voices = []
def run(
self,
text: str,
filepath: str,
random_voice=False,
):
voice_id = settings.config["settings"]["tts"]["python_voice"]
voice_num = settings.config["settings"]["tts"]["py_voice_num"]
if voice_id == "" or voice_num == "":
voice_id = 2
voice_num = 3
raise ValueError("set pyttsx values to a valid value, switching to defaults")
else:
voice_id = int(voice_id)
voice_num = int(voice_num)
for i in range(voice_num):
self.voices.append(i)
i = +1
if random_voice:
voice_id = self.randomvoice()
engine = pyttsx3.init()
voices = engine.getProperty("voices")
engine.setProperty(
"voice", voices[voice_id].id
) # changing index changes voices but ony 0 and 1 are working here
engine.save_to_file(text, f"{filepath}")
engine.runAndWait()
def randomvoice(self):
return random.choice(self.voices)

@ -0,0 +1,165 @@
import random
import requests
from utils import settings
class QwenTTS:
"""
A Text-to-Speech engine that uses the Qwen3 TTS API endpoint to generate audio from text.
This TTS provider connects to a Qwen TTS server and authenticates using email/password
to obtain a bearer token, then sends TTS requests.
Attributes:
max_chars (int): Maximum number of characters allowed per API call.
api_base_url (str): Base URL for the Qwen TTS API server.
email (str): Email for authentication.
password (str): Password for authentication.
token (str): Bearer token obtained after login.
available_voices (list): List of supported Qwen TTS voices.
"""
# Available Qwen TTS speakers
AVAILABLE_SPEAKERS = [
"Chelsie",
"Ethan",
"Vivian",
"Asher",
"Aria",
"Oliver",
"Emma",
"Noah",
"Sophia",
]
# Available languages
AVAILABLE_LANGUAGES = [
"English",
"Chinese",
"Spanish",
"French",
"German",
"Japanese",
"Korean",
"Portuguese",
"Russian",
"Italian",
"Arabic",
"Hindi",
]
def __init__(self):
self.max_chars = 5000
self.token = None
# Get configuration
tts_config = settings.config["settings"]["tts"]
self.api_base_url = tts_config.get("qwen_api_url", "http://localhost:8080")
if self.api_base_url.endswith("/"):
self.api_base_url = self.api_base_url[:-1]
self.email = tts_config.get("qwen_email")
self.password = tts_config.get("qwen_password")
if not self.email or not self.password:
raise ValueError(
"Qwen TTS requires 'qwen_email' and 'qwen_password' in settings! "
"Please configure these in your config.toml file."
)
self.available_voices = self.AVAILABLE_SPEAKERS
self._authenticate()
def _authenticate(self):
"""
Authenticate with the Qwen TTS server and obtain a bearer token.
"""
login_url = f"{self.api_base_url}/api/agent/api/auth/login"
payload = {"email": self.email, "password": self.password}
headers = {"Content-Type": "application/json"}
try:
response = requests.post(login_url, json=payload, headers=headers, timeout=30)
if response.status_code != 200:
raise RuntimeError(
f"Qwen TTS authentication failed: {response.status_code} {response.text}"
)
data = response.json()
self.token = data.get("access_token")
if not self.token:
raise RuntimeError("Qwen TTS authentication failed: No access_token in response")
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Failed to connect to Qwen TTS server: {str(e)}")
def get_available_voices(self):
"""
Return a list of supported voices for Qwen TTS.
"""
return self.AVAILABLE_SPEAKERS
def randomvoice(self):
"""
Select and return a random voice from the available voices.
"""
return random.choice(self.available_voices)
def run(self, text: str, filepath: str, random_voice: bool = False):
"""
Convert the provided text to speech and save the resulting audio to the specified filepath.
Args:
text (str): The input text to convert.
filepath (str): The file path where the generated audio will be saved.
random_voice (bool): If True, select a random voice from the available voices.
"""
tts_config = settings.config["settings"]["tts"]
# Choose voice based on configuration or randomly if requested
if random_voice:
speaker = self.randomvoice()
else:
speaker = tts_config.get("qwen_speaker", "Vivian")
# Get language and instruct settings
language = tts_config.get("qwen_language", "English")
instruct = tts_config.get("qwen_instruct", "Warm, friendly, conversational.")
# Build TTS request
tts_url = f"{self.api_base_url}/api/qwen-tts"
payload = {
"text": text,
"language": language,
"speaker": speaker,
"instruct": instruct,
}
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
try:
response = requests.post(tts_url, json=payload, headers=headers, timeout=120)
# Handle token expiration - re-authenticate and retry
if response.status_code == 401:
self._authenticate()
headers["Authorization"] = f"Bearer {self.token}"
response = requests.post(tts_url, json=payload, headers=headers, timeout=120)
if response.status_code != 200:
raise RuntimeError(
f"Qwen TTS generation failed: {response.status_code} {response.text}"
)
# Write the audio response to file
with open(filepath, "wb") as f:
f.write(response.content)
except requests.exceptions.Timeout:
raise RuntimeError("Qwen TTS request timed out. The server may be overloaded.")
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Failed to generate audio with Qwen TTS: {str(e)}")

@ -0,0 +1,80 @@
# Reddit Video Maker Bot Configuration
# Copy this file to config.toml and fill in your credentials
[reddit.creds]
client_id = "your_reddit_client_id"
client_secret = "your_reddit_client_secret"
username = "your_reddit_username"
password = "your_reddit_password"
2fa = false
[reddit.thread]
random = true
subreddit = "AskReddit"
post_id = ""
max_comment_length = 500
min_comment_length = 1
post_lang = ""
min_comments = 20
[ai]
ai_similarity_enabled = false
ai_similarity_keywords = ""
[settings]
allow_nsfw = false
theme = "dark"
times_to_run = 1
opacity = 0.9
storymode = false
storymodemethod = 1
storymode_max_length = 1000
resolution_w = 1080
resolution_h = 1920
zoom = 1
channel_name = "Reddit Tales"
[settings.background]
background_video = "minecraft"
background_audio = "lofi"
background_audio_volume = 0.15
enable_extra_audio = false
background_thumbnail = false
background_thumbnail_font_family = "arial"
background_thumbnail_font_size = 96
background_thumbnail_font_color = "255,255,255"
[settings.tts]
# TTS Provider: qwentts, elevenlabs, tiktok, googletranslate, awspolly, streamlabspolly, openai
voice_choice = "qwentts"
random_voice = true
silence_duration = 0.3
no_emojis = false
# Qwen TTS Settings (default)
qwen_api_url = "http://localhost:8080"
qwen_email = "your_email@example.com"
qwen_password = "your_password"
qwen_speaker = "Vivian"
qwen_language = "English"
qwen_instruct = "Warm, friendly, conversational."
# OpenAI TTS Settings
openai_api_url = "https://api.openai.com/v1/"
openai_api_key = ""
openai_voice_name = "alloy"
openai_model = "tts-1"
# ElevenLabs Settings
elevenlabs_voice_name = "Bella"
elevenlabs_api_key = ""
# TikTok TTS Settings
tiktok_voice = "en_us_001"
tiktok_sessionid = ""
# AWS Polly Settings
aws_polly_voice = "Matthew"
# Streamlabs Polly Settings
streamlabs_polly_voice = "Matthew"

@ -0,0 +1,85 @@
version: '3.8'
services:
reddit-video-bot:
build:
context: .
dockerfile: Dockerfile
container_name: reddit-video-bot
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- ./config.toml:/app/config.toml:ro
- ./results:/app/results
- ./assets:/app/assets
environment:
- REDDIT_BOT_GUI=true
# Reddit Credentials (can also be set in config.toml)
- REDDIT_CLIENT_ID=${REDDIT_CLIENT_ID:-}
- REDDIT_CLIENT_SECRET=${REDDIT_CLIENT_SECRET:-}
- REDDIT_USERNAME=${REDDIT_USERNAME:-}
- REDDIT_PASSWORD=${REDDIT_PASSWORD:-}
- REDDIT_2FA=${REDDIT_2FA:-false}
# Reddit Thread Settings
- REDDIT_SUBREDDIT=${REDDIT_SUBREDDIT:-AskReddit}
- REDDIT_RANDOM=${REDDIT_RANDOM:-true}
# TTS Settings (Qwen TTS)
- TTS_VOICE_CHOICE=${TTS_VOICE_CHOICE:-qwentts}
- QWEN_API_URL=${QWEN_API_URL:-http://qwen-tts:8080}
- QWEN_EMAIL=${QWEN_EMAIL:-}
- QWEN_PASSWORD=${QWEN_PASSWORD:-}
- QWEN_SPEAKER=${QWEN_SPEAKER:-Vivian}
- QWEN_LANGUAGE=${QWEN_LANGUAGE:-English}
networks:
- reddit-bot-network
depends_on:
- qwen-tts
qwen-tts:
image: qwen-tts-server:latest
container_name: qwen-tts
restart: unless-stopped
ports:
- "8080:8080"
environment:
- TTS_MODEL=qwen3-tts
networks:
- reddit-bot-network
# Uncomment if using GPU
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
# Optional: Progress GUI only mode
progress-gui:
build:
context: .
dockerfile: Dockerfile
container_name: reddit-video-gui
restart: unless-stopped
ports:
- "5001:5000"
volumes:
- ./config.toml:/app/config.toml:ro
- ./results:/app/results
- ./assets:/app/assets
environment:
- REDDIT_BOT_GUI=true
command: python progress_gui.py
networks:
- reddit-bot-network
profiles:
- gui-only
networks:
reddit-bot-network:
driver: bridge
volumes:
results:
assets:

@ -0,0 +1,86 @@
#!/bin/bash
set -e
# Create config from environment if not exists
if [ ! -f /app/config.toml ]; then
echo "Creating config.toml from template..."
# Check if all required environment variables are set
if [ -z "$REDDIT_CLIENT_ID" ] || [ -z "$REDDIT_CLIENT_SECRET" ] || [ -z "$REDDIT_USERNAME" ] || [ -z "$REDDIT_PASSWORD" ]; then
echo "Warning: Reddit credentials not set via environment variables."
echo "Please set REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD"
echo "Or mount your config.toml file to /app/config.toml"
fi
# Create basic config from environment
cat > /app/config.toml << EOF
[reddit.creds]
client_id = "${REDDIT_CLIENT_ID:-}"
client_secret = "${REDDIT_CLIENT_SECRET:-}"
username = "${REDDIT_USERNAME:-}"
password = "${REDDIT_PASSWORD:-}"
2fa = ${REDDIT_2FA:-false}
[reddit.thread]
random = ${REDDIT_RANDOM:-true}
subreddit = "${REDDIT_SUBREDDIT:-AskReddit}"
post_id = "${REDDIT_POST_ID:-}"
max_comment_length = ${MAX_COMMENT_LENGTH:-500}
min_comment_length = ${MIN_COMMENT_LENGTH:-1}
post_lang = "${POST_LANG:-}"
min_comments = ${MIN_COMMENTS:-20}
[ai]
ai_similarity_enabled = ${AI_SIMILARITY_ENABLED:-false}
ai_similarity_keywords = "${AI_SIMILARITY_KEYWORDS:-}"
[settings]
allow_nsfw = ${ALLOW_NSFW:-false}
theme = "${THEME:-dark}"
times_to_run = ${TIMES_TO_RUN:-1}
opacity = ${OPACITY:-0.9}
storymode = ${STORYMODE:-false}
storymodemethod = ${STORYMODEMETHOD:-1}
storymode_max_length = ${STORYMODE_MAX_LENGTH:-1000}
resolution_w = ${RESOLUTION_W:-1080}
resolution_h = ${RESOLUTION_H:-1920}
zoom = ${ZOOM:-1}
channel_name = "${CHANNEL_NAME:-Reddit Tales}"
[settings.background]
background_video = "${BACKGROUND_VIDEO:-minecraft}"
background_audio = "${BACKGROUND_AUDIO:-lofi}"
background_audio_volume = ${BACKGROUND_AUDIO_VOLUME:-0.15}
enable_extra_audio = ${ENABLE_EXTRA_AUDIO:-false}
background_thumbnail = ${BACKGROUND_THUMBNAIL:-false}
background_thumbnail_font_family = "${THUMBNAIL_FONT_FAMILY:-arial}"
background_thumbnail_font_size = ${THUMBNAIL_FONT_SIZE:-96}
background_thumbnail_font_color = "${THUMBNAIL_FONT_COLOR:-255,255,255}"
[settings.tts]
voice_choice = "${TTS_VOICE_CHOICE:-qwentts}"
random_voice = ${TTS_RANDOM_VOICE:-true}
elevenlabs_voice_name = "${ELEVENLABS_VOICE_NAME:-Bella}"
elevenlabs_api_key = "${ELEVENLABS_API_KEY:-}"
aws_polly_voice = "${AWS_POLLY_VOICE:-Matthew}"
streamlabs_polly_voice = "${STREAMLABS_POLLY_VOICE:-Matthew}"
tiktok_voice = "${TIKTOK_VOICE:-en_us_001}"
tiktok_sessionid = "${TIKTOK_SESSIONID:-}"
silence_duration = ${TTS_SILENCE_DURATION:-0.3}
no_emojis = ${TTS_NO_EMOJIS:-false}
openai_api_url = "${OPENAI_API_URL:-https://api.openai.com/v1/}"
openai_api_key = "${OPENAI_API_KEY:-}"
openai_voice_name = "${OPENAI_VOICE_NAME:-alloy}"
openai_model = "${OPENAI_MODEL:-tts-1}"
qwen_api_url = "${QWEN_API_URL:-http://localhost:8080}"
qwen_email = "${QWEN_EMAIL:-}"
qwen_password = "${QWEN_PASSWORD:-}"
qwen_speaker = "${QWEN_SPEAKER:-Vivian}"
qwen_language = "${QWEN_LANGUAGE:-English}"
qwen_instruct = "${QWEN_INSTRUCT:-Warm, friendly, conversational.}"
EOF
echo "Config file created successfully!"
fi
# Execute the command passed to docker run
exec "$@"

@ -1,10 +1,15 @@
#!/usr/bin/env python
"""
Reddit Video Maker Bot
Generates short-form videos from Reddit posts with Qwen TTS.
"""
import math
import os
import sys
from os import name
from pathlib import Path
from subprocess import Popen
from typing import Dict, NoReturn
from typing import Dict, NoReturn, Optional
from prawcore import ResponseException
@ -15,6 +20,7 @@ from utils.console import print_markdown, print_step, print_substep
from utils.ffmpeg_install import ffmpeg_install
from utils.id import extract_id
from utils.version import checkversion
from utils.progress import progress_tracker
from video_creation.background import (
chop_background,
download_background_audio,
@ -25,7 +31,10 @@ from video_creation.final_video import make_final_video
from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts
from video_creation.voices import save_text_to_mp3
__VERSION__ = "3.4.0"
__VERSION__ = "4.0.0"
# Check if GUI mode is enabled
GUI_MODE = os.environ.get("REDDIT_BOT_GUI", "false").lower() == "true"
print(
"""
@ -38,7 +47,7 @@ print(
"""
)
print_markdown(
"### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/"
"### Reddit Video Maker Bot v4.0 - Now with Qwen TTS and Progress GUI!"
)
checkversion(__VERSION__)
@ -46,25 +55,88 @@ reddit_id: str
reddit_object: Dict[str, str | list]
def main(POST_ID=None) -> None:
def main(POST_ID: Optional[str] = None) -> None:
"""Main video generation function with progress tracking."""
global reddit_id, reddit_object
reddit_object = get_subreddit_threads(POST_ID)
reddit_id = extract_id(reddit_object)
print_substep(f"Thread ID is {reddit_id}", style="bold blue")
length, number_of_comments = save_text_to_mp3(reddit_object)
length = math.ceil(length)
get_screenshots_of_reddit_posts(reddit_object, number_of_comments)
bg_config = {
"video": get_background_config("video"),
"audio": get_background_config("audio"),
}
download_background_video(bg_config["video"])
download_background_audio(bg_config["audio"])
chop_background(bg_config, length, reddit_object)
make_final_video(number_of_comments, length, reddit_object, bg_config)
def run_many(times) -> None:
try:
# Step 1: Fetch Reddit Post
progress_tracker.start_step("fetch_reddit", "Connecting to Reddit API...")
reddit_object = get_subreddit_threads(POST_ID)
reddit_id = extract_id(reddit_object)
# Start job tracking
progress_tracker.start_job(
reddit_id=reddit_id,
title=reddit_object.get("thread_title", "Unknown"),
subreddit=reddit_object.get("subreddit", "Unknown"),
)
progress_tracker.update_step_progress("fetch_reddit", 50, f"Found post: {reddit_id}")
print_substep(f"Thread ID is {reddit_id}", style="bold blue")
progress_tracker.complete_step("fetch_reddit", f"Loaded {len(reddit_object.get('comments', []))} comments")
# Step 2: Generate TTS Audio
progress_tracker.start_step("generate_tts", "Initializing TTS engine...")
length, number_of_comments = save_text_to_mp3(reddit_object)
length = math.ceil(length)
progress_tracker.complete_step("generate_tts", f"Generated audio for {number_of_comments} comments ({length}s)")
# Step 3: Capture Screenshots
progress_tracker.start_step("capture_screenshots", "Launching browser...")
get_screenshots_of_reddit_posts(reddit_object, number_of_comments)
# Set preview for screenshots
screenshot_preview = f"assets/temp/{reddit_id}/png/title.png"
if os.path.exists(screenshot_preview):
progress_tracker.set_step_preview("capture_screenshots", f"/assets/temp/{reddit_id}/png/title.png")
progress_tracker.complete_step("capture_screenshots", f"Captured {number_of_comments + 1} screenshots")
# Step 4: Download Background
progress_tracker.start_step("download_background", "Loading background config...")
bg_config = {
"video": get_background_config("video"),
"audio": get_background_config("audio"),
}
progress_tracker.update_step_progress("download_background", 30, "Downloading video background...")
download_background_video(bg_config["video"])
progress_tracker.update_step_progress("download_background", 70, "Downloading audio background...")
download_background_audio(bg_config["audio"])
progress_tracker.complete_step("download_background", "Background assets ready")
# Step 5: Process Background
progress_tracker.start_step("process_background", "Chopping background to fit...")
chop_background(bg_config, length, reddit_object)
progress_tracker.complete_step("process_background", f"Background prepared for {length}s video")
# Step 6: Compose Video
progress_tracker.start_step("compose_video", "Starting video composition...")
make_final_video(number_of_comments, length, reddit_object, bg_config)
progress_tracker.complete_step("compose_video", "Video rendered successfully")
# Step 7: Finalize
progress_tracker.start_step("finalize", "Cleaning up temporary files...")
subreddit = reddit_object.get("subreddit", "Unknown")
output_path = f"/results/{subreddit}/"
progress_tracker.complete_step("finalize", "Video generation complete!")
# Mark job as completed
progress_tracker.complete_job(output_path=output_path)
print_step("Video generation completed successfully!")
except Exception as e:
# Handle errors and update progress
current_step = progress_tracker.get_current_step()
if current_step:
progress_tracker.fail_step(current_step.id, str(e))
progress_tracker.fail_job(str(e))
raise
def run_many(times: int) -> None:
"""Run video generation multiple times."""
for x in range(1, times + 1):
print_step(
f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}'
@ -74,6 +146,7 @@ def run_many(times) -> None:
def shutdown() -> NoReturn:
"""Clean up and exit."""
if "reddit_id" in globals():
print_markdown("## Clearing temp files")
cleanup(reddit_id)
@ -82,12 +155,22 @@ def shutdown() -> NoReturn:
sys.exit()
def start_gui_server():
"""Start the progress GUI server in background."""
from progress_gui import run_gui_background
print_step("Starting Progress GUI server...")
run_gui_background()
print_substep("Progress GUI available at http://localhost:5000", style="bold green")
if __name__ == "__main__":
if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]:
print(
"Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again."
"This program requires Python 3.10, 3.11, or 3.12. "
"Please install a compatible Python version and try again."
)
sys.exit()
ffmpeg_install()
directory = Path().absolute()
config = settings.check_toml(
@ -95,15 +178,31 @@ if __name__ == "__main__":
)
config is False and sys.exit()
# Validate Qwen TTS settings if selected
if config["settings"]["tts"]["voice_choice"].lower() == "qwentts":
if not config["settings"]["tts"].get("qwen_email") or not config["settings"]["tts"].get("qwen_password"):
print_substep(
"Qwen TTS requires 'qwen_email' and 'qwen_password' in config! "
"Please configure these settings.",
"bold red",
)
sys.exit()
# Validate TikTok settings if selected
if (
not settings.config["settings"]["tts"]["tiktok_sessionid"]
or settings.config["settings"]["tts"]["tiktok_sessionid"] == ""
) and config["settings"]["tts"]["voice_choice"] == "tiktok":
) and config["settings"]["tts"]["voice_choice"].lower() == "tiktok":
print_substep(
"TikTok voice requires a sessionid! Check our documentation on how to obtain one.",
"TikTok voice requires a sessionid! Check documentation on how to obtain one.",
"bold red",
)
sys.exit()
# Start GUI server if enabled
if GUI_MODE:
start_gui_server()
try:
if config["reddit"]["thread"]["post_id"]:
for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")):
@ -127,8 +226,9 @@ if __name__ == "__main__":
config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED"
config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED"
config["settings"]["tts"]["openai_api_key"] = "REDACTED"
config["settings"]["tts"]["qwen_password"] = "REDACTED"
print_step(
f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n"
f"Sorry, something went wrong! Try again, and feel free to report this issue on GitHub.\n"
f"Version: {__VERSION__} \n"
f"Error: {err} \n"
f'Config: {config["settings"]}'

@ -0,0 +1,133 @@
#!/usr/bin/env python
"""
Progress GUI for Reddit Video Maker Bot.
Real-time progress tracking with steps and previews.
"""
import os
import json
import threading
import webbrowser
from pathlib import Path
from flask import Flask, render_template, send_from_directory, jsonify, request
from flask_socketio import SocketIO, emit
from utils.progress import progress_tracker
# Configuration
HOST = "0.0.0.0"
PORT = 5000
# Configure Flask app
app = Flask(__name__, template_folder="GUI", static_folder="GUI/static")
app.secret_key = os.urandom(24)
# Configure SocketIO for real-time updates
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent")
# Progress update callback
def broadcast_progress(data):
"""Broadcast progress updates to all connected clients."""
socketio.emit("progress_update", data, namespace="/progress")
# Register the callback
progress_tracker.add_update_callback(broadcast_progress)
@app.after_request
def after_request(response):
"""Ensure responses aren't cached."""
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Expires"] = 0
response.headers["Pragma"] = "no-cache"
return response
@app.route("/")
def index():
"""Main progress dashboard."""
return render_template("progress.html")
@app.route("/api/status")
def get_status():
"""Get current progress status."""
return jsonify(progress_tracker.get_status())
@app.route("/api/history")
def get_history():
"""Get job history."""
return jsonify({
"jobs": [job.to_dict() for job in progress_tracker.job_history]
})
# Serve static files
@app.route("/static/<path:filename>")
def static_files(filename):
"""Serve static files."""
return send_from_directory("GUI/static", filename)
# Serve result videos
@app.route("/results/<path:name>")
def results(name):
"""Serve result videos."""
return send_from_directory("results", name)
# Serve preview images
@app.route("/preview/<path:name>")
def previews(name):
"""Serve preview images."""
return send_from_directory("assets/temp", name)
# Serve temp assets (screenshots, audio visualizations)
@app.route("/assets/<path:name>")
def assets(name):
"""Serve asset files."""
return send_from_directory("assets", name)
# SocketIO Events
@socketio.on("connect", namespace="/progress")
def handle_connect():
"""Handle client connection."""
emit("progress_update", progress_tracker.get_status())
@socketio.on("disconnect", namespace="/progress")
def handle_disconnect():
"""Handle client disconnection."""
pass
@socketio.on("request_status", namespace="/progress")
def handle_request_status():
"""Handle status request from client."""
emit("progress_update", progress_tracker.get_status())
def run_gui(open_browser=True):
"""Run the progress GUI server."""
if open_browser:
webbrowser.open(f"http://localhost:{PORT}", new=2)
print(f"Progress GUI running at http://localhost:{PORT}")
socketio.run(app, host=HOST, port=PORT, debug=False)
def run_gui_background():
"""Run the GUI server in a background thread."""
thread = threading.Thread(target=lambda: socketio.run(app, host=HOST, port=PORT, debug=False, use_reloader=False))
thread.daemon = True
thread.start()
return thread
if __name__ == "__main__":
run_gui()

@ -1,10 +0,0 @@
import pyttsx3
engine = pyttsx3.init()
voices = engine.getProperty("voices")
for voice in voices:
print(voice, voice.id)
engine.setProperty("voice", voice.id)
engine.say("Hello World!")
engine.runAndWait()
engine.stop()

@ -8,9 +8,9 @@ requests==2.32.3
rich==13.9.4
toml==0.10.2
translators==5.9.9
pyttsx3==2.98
tomlkit==0.13.2
Flask==3.1.1
Flask-SocketIO==5.3.6
clean-text==0.6.0
unidecode==1.4.0
spacy==3.8.7
@ -19,3 +19,5 @@ transformers==4.52.4
ffmpeg-python==0.2.0
elevenlabs==1.57.0
yt-dlp==2025.10.22
gevent==24.2.1
gevent-websocket==0.10.1

@ -44,7 +44,7 @@ background_thumbnail_font_size = { optional = true, type = "int", default = 96,
background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Font color in RGB format for the thumbnail text" }
[settings.tts]
voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "tiktok", explanation = "The voice platform used for TTS generation. " }
voice_choice = { optional = false, default = "qwentts", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "qwentts", "OpenAI"], example = "qwentts", explanation = "The voice platform used for TTS generation." }
random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" }
elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] }
elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" }
@ -52,11 +52,15 @@ aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew",
streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" }
tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "The voice used for TikTok TTS" }
tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok sessionid needed if you're using the TikTok TTS. Check documentation if you don't know how to obtain it." }
python_voice = { optional = false, default = "1", example = "1", explanation = "The index of the system tts voices (can be downloaded externally, run ptt.py to find value, start from zero)" }
py_voice_num = { optional = false, default = "2", example = "2", explanation = "The number of system voices (2 are pre-installed in Windows)" }
silence_duration = { optional = true, example = "0.1", explanation = "Time in seconds between TTS comments", default = 0.3, type = "float" }
no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false,], explanation = "Whether to remove emojis from the comments" }
openai_api_url = { optional = true, default = "https://api.openai.com/v1/", example = "https://api.openai.com/v1/", explanation = "The API endpoint URL for OpenAI TTS generation" }
openai_api_key = { optional = true, example = "sk-abc123def456...", explanation = "Your OpenAI API key for TTS generation" }
openai_voice_name = { optional = false, default = "alloy", example = "alloy", explanation = "The voice used for OpenAI TTS generation", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"] }
openai_model = { optional = false, default = "tts-1", example = "tts-1", explanation = "The model variant used for OpenAI TTS generation", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] }
qwen_api_url = { optional = true, default = "http://localhost:8080", example = "http://localhost:8080", explanation = "The base URL for the Qwen TTS API server" }
qwen_email = { optional = true, example = "you@example.com", explanation = "Email for Qwen TTS authentication" }
qwen_password = { optional = true, example = "your_password", explanation = "Password for Qwen TTS authentication" }
qwen_speaker = { optional = false, default = "Vivian", example = "Vivian", explanation = "The speaker voice for Qwen TTS", options = ["Chelsie", "Ethan", "Vivian", "Asher", "Aria", "Oliver", "Emma", "Noah", "Sophia"] }
qwen_language = { optional = false, default = "English", example = "English", explanation = "The language for Qwen TTS output", options = ["English", "Chinese", "Spanish", "French", "German", "Japanese", "Korean", "Portuguese", "Russian", "Italian", "Arabic", "Hindi"] }
qwen_instruct = { optional = true, default = "Warm, friendly, conversational.", example = "Warm, friendly, conversational.", explanation = "Style instructions for Qwen TTS voice generation" }

@ -0,0 +1,317 @@
"""
Progress tracking module for Reddit Video Maker Bot.
Provides real-time progress updates via WebSocket for the GUI.
"""
import os
import json
import time
from dataclasses import dataclass, field, asdict
from typing import Optional, List, Callable
from enum import Enum
from pathlib import Path
class StepStatus(str, Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class Step:
id: str
name: str
description: str
status: StepStatus = StepStatus.PENDING
progress: float = 0.0
message: str = ""
preview_path: Optional[str] = None
started_at: Optional[float] = None
completed_at: Optional[float] = None
error: Optional[str] = None
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"description": self.description,
"status": self.status.value,
"progress": self.progress,
"message": self.message,
"preview_path": self.preview_path,
"started_at": self.started_at,
"completed_at": self.completed_at,
"error": self.error,
"duration": (self.completed_at - self.started_at) if self.completed_at and self.started_at else None,
}
@dataclass
class VideoJob:
id: str
reddit_id: str
title: str
subreddit: str
status: StepStatus = StepStatus.PENDING
steps: List[Step] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
completed_at: Optional[float] = None
output_path: Optional[str] = None
thumbnail_path: Optional[str] = None
error: Optional[str] = None
def to_dict(self):
return {
"id": self.id,
"reddit_id": self.reddit_id,
"title": self.title,
"subreddit": self.subreddit,
"status": self.status.value,
"steps": [step.to_dict() for step in self.steps],
"created_at": self.created_at,
"completed_at": self.completed_at,
"output_path": self.output_path,
"thumbnail_path": self.thumbnail_path,
"error": self.error,
"overall_progress": self.get_overall_progress(),
}
def get_overall_progress(self) -> float:
if not self.steps:
return 0.0
completed = sum(1 for s in self.steps if s.status == StepStatus.COMPLETED)
return (completed / len(self.steps)) * 100
class ProgressTracker:
"""
Singleton progress tracker that manages video generation jobs and steps.
Provides callbacks for real-time GUI updates.
"""
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if ProgressTracker._initialized:
return
ProgressTracker._initialized = True
self.current_job: Optional[VideoJob] = None
self.job_history: List[VideoJob] = []
self._update_callbacks: List[Callable] = []
self._preview_dir = Path("assets/temp/previews")
self._preview_dir.mkdir(parents=True, exist_ok=True)
def add_update_callback(self, callback: Callable):
"""Register a callback function to be called on progress updates."""
self._update_callbacks.append(callback)
def remove_update_callback(self, callback: Callable):
"""Remove a callback function."""
if callback in self._update_callbacks:
self._update_callbacks.remove(callback)
def _notify_update(self):
"""Notify all registered callbacks of a progress update."""
data = self.get_status()
for callback in self._update_callbacks:
try:
callback(data)
except Exception as e:
print(f"Error in progress callback: {e}")
def start_job(self, reddit_id: str, title: str, subreddit: str) -> VideoJob:
"""Start a new video generation job."""
job = VideoJob(
id=f"job_{int(time.time())}_{reddit_id}",
reddit_id=reddit_id,
title=title,
subreddit=subreddit,
status=StepStatus.IN_PROGRESS,
steps=self._create_default_steps(),
)
self.current_job = job
self._notify_update()
return job
def _create_default_steps(self) -> List[Step]:
"""Create the default pipeline steps."""
return [
Step(
id="fetch_reddit",
name="Fetch Reddit Post",
description="Fetching post and comments from Reddit",
),
Step(
id="generate_tts",
name="Generate Audio",
description="Converting text to speech using Qwen TTS",
),
Step(
id="capture_screenshots",
name="Capture Screenshots",
description="Taking screenshots of Reddit comments",
),
Step(
id="download_background",
name="Download Background",
description="Downloading and preparing background video/audio",
),
Step(
id="process_background",
name="Process Background",
description="Chopping background to fit video length",
),
Step(
id="compose_video",
name="Compose Video",
description="Combining all elements into final video",
),
Step(
id="finalize",
name="Finalize",
description="Final processing and cleanup",
),
]
def start_step(self, step_id: str, message: str = ""):
"""Mark a step as in progress."""
if not self.current_job:
return
for step in self.current_job.steps:
if step.id == step_id:
step.status = StepStatus.IN_PROGRESS
step.started_at = time.time()
step.message = message
step.progress = 0
break
self._notify_update()
def update_step_progress(self, step_id: str, progress: float, message: str = ""):
"""Update the progress of a step."""
if not self.current_job:
return
for step in self.current_job.steps:
if step.id == step_id:
step.progress = min(100, max(0, progress))
if message:
step.message = message
break
self._notify_update()
def set_step_preview(self, step_id: str, preview_path: str):
"""Set a preview image/video for a step."""
if not self.current_job:
return
for step in self.current_job.steps:
if step.id == step_id:
step.preview_path = preview_path
break
self._notify_update()
def complete_step(self, step_id: str, message: str = ""):
"""Mark a step as completed."""
if not self.current_job:
return
for step in self.current_job.steps:
if step.id == step_id:
step.status = StepStatus.COMPLETED
step.completed_at = time.time()
step.progress = 100
if message:
step.message = message
break
self._notify_update()
def fail_step(self, step_id: str, error: str):
"""Mark a step as failed."""
if not self.current_job:
return
for step in self.current_job.steps:
if step.id == step_id:
step.status = StepStatus.FAILED
step.completed_at = time.time()
step.error = error
step.message = f"Failed: {error}"
break
self.current_job.status = StepStatus.FAILED
self.current_job.error = error
self._notify_update()
def skip_step(self, step_id: str, reason: str = ""):
"""Mark a step as skipped."""
if not self.current_job:
return
for step in self.current_job.steps:
if step.id == step_id:
step.status = StepStatus.SKIPPED
step.completed_at = time.time()
step.message = reason or "Skipped"
break
self._notify_update()
def complete_job(self, output_path: str, thumbnail_path: Optional[str] = None):
"""Mark the current job as completed."""
if not self.current_job:
return
self.current_job.status = StepStatus.COMPLETED
self.current_job.completed_at = time.time()
self.current_job.output_path = output_path
self.current_job.thumbnail_path = thumbnail_path
self.job_history.append(self.current_job)
self._notify_update()
def fail_job(self, error: str):
"""Mark the current job as failed."""
if not self.current_job:
return
self.current_job.status = StepStatus.FAILED
self.current_job.completed_at = time.time()
self.current_job.error = error
self.job_history.append(self.current_job)
self._notify_update()
def get_status(self) -> dict:
"""Get the current status of all jobs."""
return {
"current_job": self.current_job.to_dict() if self.current_job else None,
"job_history": [job.to_dict() for job in self.job_history[-10:]], # Last 10 jobs
}
def get_current_step(self) -> Optional[Step]:
"""Get the currently active step."""
if not self.current_job:
return None
for step in self.current_job.steps:
if step.status == StepStatus.IN_PROGRESS:
return step
return None
# Global progress tracker instance
progress_tracker = ProgressTracker()

@ -7,7 +7,7 @@ from TTS.elevenlabs import elevenlabs
from TTS.engine_wrapper import TTSEngine
from TTS.GTTS import GTTS
from TTS.openai_tts import OpenAITTS
from TTS.pyttsx import pyttsx
from TTS.qwen_tts import QwenTTS
from TTS.streamlabs_polly import StreamlabsPolly
from TTS.TikTok import TikTok
from utils import settings
@ -20,7 +20,7 @@ TTSProviders = {
"AWSPolly": AWSPolly,
"StreamlabsPolly": StreamlabsPolly,
"TikTok": TikTok,
"pyttsx": pyttsx,
"QwenTTS": QwenTTS,
"ElevenLabs": elevenlabs,
"OpenAI": OpenAITTS,
}

Loading…
Cancel
Save