From 94d8e45cf718a263d422e7324d48600c5be48ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 18:17:26 +0000 Subject: [PATCH] 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 --- Dockerfile | 69 ++++- GUI/progress.html | 87 ++++++ GUI/static/css/progress.css | 539 ++++++++++++++++++++++++++++++++++++ GUI/static/js/progress.js | 307 ++++++++++++++++++++ README.md | 289 +++++++++++-------- TTS/pyttsx.py | 42 --- TTS/qwen_tts.py | 165 +++++++++++ config.example.toml | 80 ++++++ docker-compose.yml | 85 ++++++ docker-entrypoint.sh | 86 ++++++ main.py | 150 ++++++++-- progress_gui.py | 133 +++++++++ ptt.py | 10 - requirements.txt | 4 +- utils/.config.template.toml | 10 +- utils/progress.py | 317 +++++++++++++++++++++ video_creation/voices.py | 4 +- 17 files changed, 2178 insertions(+), 199 deletions(-) create mode 100644 GUI/progress.html create mode 100644 GUI/static/css/progress.css create mode 100644 GUI/static/js/progress.js delete mode 100644 TTS/pyttsx.py create mode 100644 TTS/qwen_tts.py create mode 100644 config.example.toml create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 progress_gui.py delete mode 100644 ptt.py create mode 100644 utils/progress.py diff --git a/Dockerfile b/Dockerfile index 3f53ada..7247f3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/GUI/progress.html b/GUI/progress.html new file mode 100644 index 0000000..51d899a --- /dev/null +++ b/GUI/progress.html @@ -0,0 +1,87 @@ + + + + + + Reddit Video Maker - Progress + + + + +
+
+

Reddit Video Maker Bot

+

Real-time Progress Tracker

+
+ +
+ +
+

Current Job

+
+
+ + + + +
+

Waiting for video generation to start...

+

Start the bot with: python main.py

+
+ +
+ + +
+

Recent Jobs

+
+

No completed jobs yet

+
+
+
+ +
+

Reddit Video Maker Bot - Progress GUI

+
+ + Connecting... +
+
+
+ + + + diff --git a/GUI/static/css/progress.css b/GUI/static/css/progress.css new file mode 100644 index 0000000..fea8423 --- /dev/null +++ b/GUI/static/css/progress.css @@ -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; + } +} diff --git a/GUI/static/js/progress.js b/GUI/static/js/progress.js new file mode 100644 index 0000000..5e30210 --- /dev/null +++ b/GUI/static/js/progress.js @@ -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 = ` +
+ ${isActive ? '
' : icon} +
+
+
${step.name}
+
${step.description}
+ ${step.message ? `
${step.message}
` : ''} +
+ ${step.status === 'in_progress' ? ` +
+
+
+
+
${Math.round(step.progress)}%
+
+ ` : ''} + `; + + 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 = ` +
+

Preview will appear here during processing

+
+ `; + 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 = ` + + `; + } else { + container.innerHTML = ` + Preview + `; + } + } + + renderHistory() { + const { historyList } = this.elements; + + if (!this.jobHistory || this.jobHistory.length === 0) { + historyList.innerHTML = '

No completed jobs yet

'; + 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 = ` +
+ ${job.status === 'completed' ? '✓' : '✗'} +
+
+
r/${job.subreddit}
+
${job.title}
+
+ ${this.formatDate(job.created_at)} • ${duration} + ${job.error ? `• ${job.error}` : ''} +
+
+
+ ${job.output_path ? ` + + View Video + + ` : ''} +
+ `; + + 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(); +}); diff --git a/README.md b/README.md index 8042755..b83a68f 100644 --- a/README.md +++ b/README.md @@ -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 - - - - - - +- **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 - +## 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) diff --git a/TTS/pyttsx.py b/TTS/pyttsx.py deleted file mode 100644 index bf47601..0000000 --- a/TTS/pyttsx.py +++ /dev/null @@ -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) diff --git a/TTS/qwen_tts.py b/TTS/qwen_tts.py new file mode 100644 index 0000000..01c47a0 --- /dev/null +++ b/TTS/qwen_tts.py @@ -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)}") diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..a720548 --- /dev/null +++ b/config.example.toml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b87da9e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..e2af214 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 "$@" diff --git a/main.py b/main.py index 742fedf..0da1508 100755 --- a/main.py +++ b/main.py @@ -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"]}' diff --git a/progress_gui.py b/progress_gui.py new file mode 100644 index 0000000..98485cd --- /dev/null +++ b/progress_gui.py @@ -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/") +def static_files(filename): + """Serve static files.""" + return send_from_directory("GUI/static", filename) + + +# Serve result videos +@app.route("/results/") +def results(name): + """Serve result videos.""" + return send_from_directory("results", name) + + +# Serve preview images +@app.route("/preview/") +def previews(name): + """Serve preview images.""" + return send_from_directory("assets/temp", name) + + +# Serve temp assets (screenshots, audio visualizations) +@app.route("/assets/") +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() diff --git a/ptt.py b/ptt.py deleted file mode 100644 index 6b49ef6..0000000 --- a/ptt.py +++ /dev/null @@ -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() diff --git a/requirements.txt b/requirements.txt index 7aa38ee..bc80d05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/utils/.config.template.toml b/utils/.config.template.toml index 9185a29..99e36bf 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -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" } diff --git a/utils/progress.py b/utils/progress.py new file mode 100644 index 0000000..5aea470 --- /dev/null +++ b/utils/progress.py @@ -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() diff --git a/video_creation/voices.py b/video_creation/voices.py index 3d48e9e..55d88b1 100644 --- a/video_creation/voices.py +++ b/video_creation/voices.py @@ -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, }