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_01HLLH3WjpmRzvaoY6eYSFADpull/2456/head
parent
902ff00cb0
commit
94d8e45cf7
@ -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
|
||||
|
||||
[
|
||||
](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 "$@"
|
||||
@ -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()
|
||||
@ -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()
|
||||
Loading…
Reference in new issue