feat: add complete web UI for configuration and video generation

- Add dashboard page with quick actions and progress overview
- Add settings page for Qwen TTS, Reddit scraper, and video configuration
- Add backgrounds page for uploading custom video/audio backgrounds
- Add videos page for viewing and downloading generated content
- Add TTS connection test endpoint
- Update docker-compose for standalone operation
- Create unified CSS and JavaScript for consistent UI experience

https://claude.ai/code/session_01HLLH3WjpmRzvaoY6eYSFAD
pull/2456/head
Claude 3 days ago
parent cd9f9f5b40
commit 2c4a8a8a64
No known key found for this signature in database

@ -1,263 +1,210 @@
{% extends "layout.html" %}
{% block main %}
<!-- Delete Background Modal -->
<div class="modal fade" id="deleteBtnModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete background</h5>
</div>
<div class="modal-body">
Are you sure you want to delete this background?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<form action="background/delete" method="post">
<input type="hidden" id="background-key" name="background-key" value="">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backgrounds - Reddit Video Maker Bot</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<nav class="sidebar">
<div class="logo">
<h2>Reddit Video Bot</h2>
</div>
</div>
</div>
<!-- Add Background Modal -->
<div class="modal fade" id="backgroundAddModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add background video</h5>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/backgrounds" class="active">Backgrounds</a></li>
<li><a href="/videos">Videos</a></li>
<li><a href="/progress">Progress</a></li>
</ul>
</nav>
<main class="content">
<header>
<h1>Backgrounds</h1>
</header>
<!-- Video Backgrounds -->
<div class="card">
<div class="card-header">
<h3>Background Videos</h3>
<label for="video-upload" class="btn btn-primary">
Upload Video
<input type="file" id="video-upload" accept=".mp4,.webm,.mov,.avi,.mkv" hidden>
</label>
</div>
<div class="modal-body">
<p class="card-description">Upload 16:9 landscape videos. They will be cropped/scaled to fit 9:16 portrait videos.</p>
<!-- Add video form -->
<form id="addBgForm" action="background/add" method="post" novalidate>
<div class="form-group row">
<label class="col-4 col-form-label" for="youtube_uri">YouTube URI</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-youtube"></i>
</div>
<input name="youtube_uri" placeholder="https://www.youtube.com/watch?v=..." type="text"
class="form-control">
</div>
<span id="feedbackYT" class="form-text feedback-invalid"></span>
</div>
</div>
<div class="form-group row">
<label for="filename" class="col-4 col-form-label">Filename</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-file-earmark"></i>
</div>
<input name="filename" placeholder="Example: cool-background" type="text"
class="form-control">
</div>
<span id="feedbackFilename" class="form-text feedback-invalid"></span>
</div>
</div>
<div class="form-group row">
<label for="citation" class="col-4 col-form-label">Credits (owner of the video)</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-person-circle"></i>
</div>
<input name="citation" placeholder="YouTube Channel" type="text" class="form-control">
</div>
<span class="form-text text-muted">Include the channel name of the
owner of the background video you are adding.</span>
</div>
</div>
<div class="form-group row">
<label for="position" class="col-4 col-form-label">Position of screenshots</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-arrows-fullscreen"></i>
</div>
<input name="position" placeholder="Example: center" type="text" class="form-control">
</div>
<span class="form-text text-muted">Advanced option (you can leave it
empty). Valid options are "center" and decimal numbers</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button name="submit" type="submit" class="btn btn-success">Add background</button>
</form>
<div id="video-list" class="media-grid">
<div class="loading">Loading videos...</div>
</div>
</div>
</div>
</div>
<main>
<div class="album py-2 bg-light">
<div class="container">
<div class="row justify-content-between mt-2">
<div class="col-12 col-md-3 mb-3">
<input type="text" class="form-control searchFilter" placeholder="Search backgrounds"
onkeyup="searchFilter()">
</div>
<div class="col-12 col-md-2 mb-3">
<button type="button" class="btn btn-primary form-control" data-toggle="modal"
data-target="#backgroundAddModal">
Add background video
</button>
</div>
<!-- Audio Backgrounds -->
<div class="card">
<div class="card-header">
<h3>Background Audio</h3>
<label for="audio-upload" class="btn btn-primary">
Upload Audio
<input type="file" id="audio-upload" accept=".mp3,.wav,.ogg,.m4a" hidden>
</label>
</div>
<p class="card-description">Upload background music tracks. Audio will loop to match video length.</p>
<div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="backgrounds">
<div id="audio-list" class="media-grid">
<div class="loading">Loading audio...</div>
</div>
</div>
<!-- Upload Progress -->
<div id="upload-progress" class="upload-progress hidden">
<div class="upload-progress-bar">
<div class="upload-progress-fill"></div>
</div>
<span class="upload-progress-text">Uploading...</span>
</div>
</div>
</main>
</main>
<script>
var keys = [];
var youtube_urls = [];
<script src="/static/js/app.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
loadVideos();
loadAudios();
// Show background videos
$(document).ready(function () {
$.getJSON("backgrounds.json",
function (data) {
delete data["__comment"];
var background = '';
$.each(data, function (key, value) {
// Add YT urls and keys (for validation)
keys.push(key);
youtube_urls.push(value[0]);
document.getElementById('video-upload').addEventListener('change', (e) => uploadFile(e, 'video'));
document.getElementById('audio-upload').addEventListener('change', (e) => uploadFile(e, 'audio'));
});
background += '<div class="col">';
background += '<div class="card shadow-sm">';
background += '<iframe class="bd-placeholder-img card-img-top" width="100%" height="225" src="https://www.youtube-nocookie.com/embed/' + value[0].split("?v=")[1] + '" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
background += '<div class="card-body">';
background += '<p class="card-text">' + value[2] + ' • ' + key + '</p>';
background += '<div class="d-flex justify-content-between align-items-center">';
background += '<div class="btn-group">';
background += '<button type="button" class="btn btn-outline-danger" data-toggle="modal" data-target="#deleteBtnModal" data-background-key="' + key + '">Delete</button>';
background += '</div>';
background += '</div>';
background += '</div>';
background += '</div>';
background += '</div>';
function loadVideos() {
fetch('/api/backgrounds/video')
.then(r => r.json())
.then(data => {
const container = document.getElementById('video-list');
if (data.videos && data.videos.length > 0) {
container.innerHTML = data.videos.map(v => `
<div class="media-item">
<div class="media-preview video-preview">
<video src="/backgrounds/video/${v.filename}" muted loop onmouseenter="this.play()" onmouseleave="this.pause(); this.currentTime=0;"></video>
</div>
<div class="media-info">
<span class="media-name">${v.name}</span>
<span class="media-size">${formatFileSize(v.size)}</span>
</div>
<button class="btn btn-danger btn-small" onclick="deleteVideo('${v.filename}')">Delete</button>
</div>
`).join('');
} else {
container.innerHTML = `
<div class="empty-state">
<p>No custom background videos</p>
<p class="hint">Upload a 16:9 video to use as background</p>
</div>
`;
}
})
.catch(err => {
document.getElementById('video-list').innerHTML = '<div class="error">Error loading videos</div>';
});
$('#backgrounds').append(background);
});
});
// Add background key when deleting
$('#deleteBtnModal').on('show.bs.modal', function (event) {
var button = $(event.relatedTarget);
var key = button.data('background-key');
$('#background-key').prop('value', key);
});
var searchFilter = () => {
const input = document.querySelector(".searchFilter");
const cards = document.getElementsByClassName("col");
console.log(cards[1])
let filter = input.value
for (let i = 0; i < cards.length; i++) {
let title = cards[i].querySelector(".card-text");
if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
cards[i].classList.remove("d-none")
} else {
cards[i].classList.add("d-none")
}
}
}
// Validate form
$("#addBgForm").submit(function (event) {
$("#addBgForm input").each(function () {
if (!(validate($(this)))) {
event.preventDefault();
event.stopPropagation();
}
});
});
$('#addBgForm input[type="text"]').on("keyup", function () {
validate($(this));
});
function validate(object) {
let bool = check(object.prop("name"), object.prop("value"));
// Change class
if (bool) {
object.removeClass("is-invalid");
object.addClass("is-valid");
}
else {
object.removeClass("is-valid");
object.addClass("is-invalid");
function loadAudios() {
fetch('/api/backgrounds/audio')
.then(r => r.json())
.then(data => {
const container = document.getElementById('audio-list');
if (data.audios && data.audios.length > 0) {
container.innerHTML = data.audios.map(a => `
<div class="media-item">
<div class="media-preview audio-preview">
<audio src="/backgrounds/audio/${a.filename}" controls></audio>
</div>
<div class="media-info">
<span class="media-name">${a.name}</span>
<span class="media-size">${formatFileSize(a.size)}</span>
</div>
<button class="btn btn-danger btn-small" onclick="deleteAudio('${a.filename}')">Delete</button>
</div>
`).join('');
} else {
container.innerHTML = `
<div class="empty-state">
<p>No custom background audio</p>
<p class="hint">Upload audio files to use as background music</p>
</div>
`;
}
})
.catch(err => {
document.getElementById('audio-list').innerHTML = '<div class="error">Error loading audio</div>';
});
}
return bool;
// Check values (return true/false)
function check(name, value) {
if (name == "youtube_uri") {
// URI validation
let regex = /(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/;
if (!(regex.test(value))) {
$("#feedbackYT").html("Invalid URI");
$("#feedbackYT").show();
return false;
}
// Check if this background already exists
if (youtube_urls.includes(value)) {
$("#feedbackYT").html("This background is already added");
$("#feedbackYT").show();
return false;
}
$("#feedbackYT").hide();
return true;
}
if (name == "filename") {
// Check if key is already taken
if (keys.includes(value)) {
$("#feedbackFilename").html("This filename is already taken");
$("#feedbackFilename").show();
return false;
function uploadFile(event, type) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const progressEl = document.getElementById('upload-progress');
progressEl.classList.remove('hidden');
fetch(`/api/backgrounds/${type}`, {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
progressEl.classList.add('hidden');
if (data.success) {
showNotification(data.message, 'success');
if (type === 'video') loadVideos();
else loadAudios();
} else {
showNotification(data.message, 'error');
}
})
.catch(err => {
progressEl.classList.add('hidden');
showNotification('Upload failed', 'error');
});
let regex = /^([a-zA-Z0-9\s_-]{1,100})$/;
if (!(regex.test(value))) {
return false;
}
// Reset file input
event.target.value = '';
}
return true;
}
function deleteVideo(filename) {
if (!confirm(`Delete video "${filename}"?`)) return;
if (name == "citation") {
if (value.trim()) {
return true;
}
}
fetch(`/api/backgrounds/video/${filename}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
showNotification(data.message, data.success ? 'success' : 'error');
if (data.success) loadVideos();
})
.catch(err => showNotification('Delete failed', 'error'));
}
if (name == "position") {
if (!(value == "center" || value.length == 0 || value % 1 == 0)) {
return false;
}
function deleteAudio(filename) {
if (!confirm(`Delete audio "${filename}"?`)) return;
return true;
}
fetch(`/api/backgrounds/audio/${filename}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
showNotification(data.message, data.success ? 'success' : 'error');
if (data.success) loadAudios();
})
.catch(err => showNotification('Delete failed', 'error'));
}
}
</script>
{% endblock %}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
</script>
</body>
</html>

@ -0,0 +1,233 @@
<!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 Bot</title>
<link rel="stylesheet" href="/static/css/app.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.6.0/socket.io.min.js"></script>
</head>
<body>
<nav class="sidebar">
<div class="logo">
<h2>Reddit Video Bot</h2>
</div>
<ul class="nav-links">
<li><a href="/" class="active">Dashboard</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/backgrounds">Backgrounds</a></li>
<li><a href="/videos">Videos</a></li>
<li><a href="/progress">Progress</a></li>
</ul>
</nav>
<main class="content">
<header>
<h1>Dashboard</h1>
<div class="status-indicator" id="status-indicator">
<span class="dot"></span>
<span id="status-text">Ready</span>
</div>
</header>
<div class="dashboard-grid">
<!-- Quick Actions -->
<div class="card">
<h3>Quick Actions</h3>
<div class="action-buttons">
<button id="btn-generate" class="btn btn-primary btn-large">
Generate Video
</button>
<button id="btn-stop" class="btn btn-danger btn-large" disabled>
Stop Generation
</button>
</div>
</div>
<!-- Current Config -->
<div class="card">
<h3>Current Configuration</h3>
<div class="config-summary" id="config-summary">
<p>Loading...</p>
</div>
<a href="/settings" class="btn btn-secondary">Edit Settings</a>
</div>
<!-- Progress -->
<div class="card card-wide">
<h3>Current Progress</h3>
<div id="progress-container">
<div class="no-progress">
<p>No video generation in progress</p>
<p class="hint">Click "Generate Video" to start</p>
</div>
</div>
</div>
<!-- Recent Videos -->
<div class="card card-wide">
<h3>Recent Videos</h3>
<div id="recent-videos">
<p>Loading...</p>
</div>
<a href="/videos" class="btn btn-secondary">View All Videos</a>
</div>
</div>
</main>
<script src="/static/js/app.js"></script>
<script>
// Initialize dashboard
document.addEventListener('DOMContentLoaded', () => {
loadConfig();
loadRecentVideos();
initProgressSocket();
document.getElementById('btn-generate').addEventListener('click', startGeneration);
document.getElementById('btn-stop').addEventListener('click', stopGeneration);
});
function loadConfig() {
fetch('/api/config')
.then(r => r.json())
.then(config => {
const summary = document.getElementById('config-summary');
if (config && config.reddit) {
summary.innerHTML = `
<div class="config-item">
<strong>Subreddit:</strong> r/${config.reddit?.thread?.subreddit || 'Not set'}
</div>
<div class="config-item">
<strong>TTS:</strong> ${config.settings?.tts?.voice_choice || 'Not set'}
</div>
<div class="config-item">
<strong>Theme:</strong> ${config.settings?.theme || 'dark'}
</div>
<div class="config-item">
<strong>Resolution:</strong> ${config.settings?.resolution_w || 1080}x${config.settings?.resolution_h || 1920}
</div>
`;
} else {
summary.innerHTML = '<p class="warning">Not configured. <a href="/settings">Click here to configure.</a></p>';
}
});
}
function loadRecentVideos() {
fetch('/api/videos')
.then(r => r.json())
.then(data => {
const container = document.getElementById('recent-videos');
if (data.videos && data.videos.length > 0) {
const recent = data.videos.slice(0, 3);
container.innerHTML = recent.map(v => `
<div class="video-item">
<span class="video-name">${v.name}</span>
<span class="video-subreddit">r/${v.subreddit}</span>
<a href="${v.path}" target="_blank" class="btn btn-small">View</a>
</div>
`).join('');
} else {
container.innerHTML = '<p class="hint">No videos generated yet</p>';
}
});
}
function initProgressSocket() {
const socket = io('/progress');
socket.on('progress_update', (data) => {
updateProgress(data);
updateGenerationStatus(data);
});
}
function updateProgress(data) {
const container = document.getElementById('progress-container');
if (data.current_job) {
const job = data.current_job;
container.innerHTML = `
<div class="progress-info">
<div class="progress-title">${job.title}</div>
<div class="progress-subreddit">r/${job.subreddit}</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${job.overall_progress}%"></div>
</div>
<div class="progress-percent">${Math.round(job.overall_progress)}%</div>
<div class="progress-steps">
${job.steps.map(s => `
<div class="step ${s.status}">
<span class="step-icon">${getStepIcon(s.status)}</span>
<span class="step-name">${s.name}</span>
</div>
`).join('')}
</div>
</div>
`;
} else {
container.innerHTML = `
<div class="no-progress">
<p>No video generation in progress</p>
<p class="hint">Click "Generate Video" to start</p>
</div>
`;
}
}
function updateGenerationStatus(data) {
const btnGenerate = document.getElementById('btn-generate');
const btnStop = document.getElementById('btn-stop');
const statusIndicator = document.getElementById('status-indicator');
const statusText = document.getElementById('status-text');
if (data.current_job && data.current_job.status === 'in_progress') {
btnGenerate.disabled = true;
btnStop.disabled = false;
statusIndicator.classList.add('running');
statusText.textContent = 'Generating...';
} else {
btnGenerate.disabled = false;
btnStop.disabled = true;
statusIndicator.classList.remove('running');
statusText.textContent = 'Ready';
}
}
function getStepIcon(status) {
switch(status) {
case 'completed': return '✓';
case 'in_progress': return '◐';
case 'failed': return '✗';
default: return '○';
}
}
function startGeneration() {
fetch('/api/generate', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
showNotification('Video generation started!', 'success');
} else {
showNotification(data.message, 'error');
}
});
}
function stopGeneration() {
fetch('/api/generate/stop', { method: 'POST' })
.then(r => r.json())
.then(data => {
showNotification(data.message, data.success ? 'success' : 'error');
});
}
function showNotification(message, type) {
const notif = document.createElement('div');
notif.className = `notification ${type}`;
notif.textContent = message;
document.body.appendChild(notif);
setTimeout(() => notif.remove(), 3000);
}
</script>
</body>
</html>

@ -1,621 +1,390 @@
{% extends "layout.html" %}
{% block main %}
<main>
<br>
<div class="container">
<form id="settingsForm" action="/settings" method="post" novalidate>
<!-- Reddit Credentials -->
<p class="h4">Reddit Credentials</p>
<div class="row mb-2">
<label for="client_id" class="col-4">Client ID</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-person"></i>
</div>
<input name="client_id" value="{{ data.client_id }}" placeholder="Your Reddit app's client ID"
type="text" class="form-control" data-toggle="tooltip"
data-original-title='Text under "personal use script" on www.reddit.com/prefs/apps'>
</div>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Reddit Video Maker Bot</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<nav class="sidebar">
<div class="logo">
<h2>Reddit Video Bot</h2>
</div>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/settings" class="active">Settings</a></li>
<li><a href="/backgrounds">Backgrounds</a></li>
<li><a href="/videos">Videos</a></li>
<li><a href="/progress">Progress</a></li>
</ul>
</nav>
<main class="content">
<header>
<h1>Settings</h1>
<button id="btn-save" class="btn btn-primary">Save All Settings</button>
</header>
<form id="settings-form">
<!-- Qwen TTS Settings -->
<div class="card">
<h3>Qwen TTS Settings</h3>
<p class="card-description">Configure your Qwen TTS server connection for text-to-speech generation.</p>
<div class="form-group">
<label for="qwen_api_url">API URL</label>
<input type="url" id="qwen_api_url" name="qwen_api_url" placeholder="http://localhost:8080">
<small>The base URL of your Qwen TTS server</small>
</div>
</div>
<div class="row mb-2">
<label for="client_secret" class="col-4">Client Secret</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-key-fill"></i>
</div>
<input name="client_secret" value="{{ data.client_secret }}"
placeholder="Your Reddit app's client secret" type="text" class="form-control"
data-toggle="tooltip" data-original-title='"Secret" on www.reddit.com/prefs/apps'>
<div class="form-row">
<div class="form-group">
<label for="qwen_email">Email</label>
<input type="email" id="qwen_email" name="qwen_email" placeholder="your@email.com">
</div>
</div>
</div>
<div class="row mb-2">
<label for="username" class="col-4">Reddit Username</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-person-fill"></i>
</div>
<input name="username" value="{{ data.username }}" placeholder="Your Reddit account's username"
type="text" class="form-control">
<div class="form-group">
<label for="qwen_password">Password</label>
<input type="password" id="qwen_password" name="qwen_password" placeholder="Enter password">
</div>
</div>
</div>
<div class="row mb-2">
<label for="password" class="col-4">Reddit Password</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-lock-fill"></i>
</div>
<input name="password" value="{{ data.password }}" placeholder="Your Reddit account's password"
type="password" class="form-control">
<div class="form-row">
<div class="form-group">
<label for="qwen_speaker">Speaker Voice</label>
<select id="qwen_speaker" name="qwen_speaker">
<option value="Chelsie">Chelsie</option>
<option value="Ethan">Ethan</option>
<option value="Vivian" selected>Vivian</option>
<option value="Asher">Asher</option>
<option value="Aria">Aria</option>
<option value="Oliver">Oliver</option>
<option value="Emma">Emma</option>
<option value="Noah">Noah</option>
<option value="Sophia">Sophia</option>
</select>
</div>
</div>
</div>
<div class="row mb-2">
<label class="col-4">Do you have Reddit 2FA enabled?</label>
<div class="col-8">
<div class="form-check form-switch">
<input name="2fa" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip"
data-original-title='Check it if you have enabled 2FA on your Reddit account'>
<div class="form-group">
<label for="qwen_language">Language</label>
<select id="qwen_language" name="qwen_language">
<option value="English" selected>English</option>
<option value="Chinese">Chinese</option>
<option value="Spanish">Spanish</option>
<option value="French">French</option>
<option value="German">German</option>
<option value="Japanese">Japanese</option>
<option value="Korean">Korean</option>
</select>
</div>
<span class="form-text text-muted"><a
href="https://reddit-video-maker-bot.netlify.app/docs/configuring#setting-up-the-api"
target="_blank">Need help? Click here to open step-by-step guide.</a></span>
</div>
</div>
<!-- Reddit Thread -->
<p class="h4">Reddit Thread</p>
<div class="row mb-2">
<label class="col-4">Random Thread</label>
<div class="col-8">
<div class="form-check form-switch">
<input name="random" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip"
data-original-title='If disabled, it will ask you for a thread link, instead of picking random one'>
</div>
</div>
</div>
<div class="row mb-2">
<label for="subreddit" class="col-4">Subreddit</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-reddit"></i>
</div>
<input value="{{ data.subreddit }}" name="subreddit" type="text" class="form-control"
placeholder="Subreddit to pull posts from (e.g. AskReddit)" data-toggle="tooltip"
data-original-title='You can have multiple subreddits,
add "+" between them (e.g. AskReddit+Redditdev)'>
</div>
<div class="form-group">
<label for="qwen_instruct">Voice Style Instructions</label>
<input type="text" id="qwen_instruct" name="qwen_instruct" placeholder="Warm, friendly, conversational.">
<small>Describe the speaking style you want</small>
</div>
<button type="button" id="btn-test-tts" class="btn btn-secondary">Test TTS Connection</button>
<span id="tts-status" class="status-text"></span>
</div>
<div class="row mb-2">
<label for="post_id" class="col-4">Post ID</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-file-text"></i>
</div>
<input value="{{ data.post_id }}" name="post_id" type="text" class="form-control"
placeholder="Used if you want to use a specific post (e.g. urdtfx)">
<!-- Reddit Settings -->
<div class="card">
<h3>Reddit Settings</h3>
<p class="card-description">Configure which subreddits to scrape for content. No API keys required!</p>
<div class="form-group">
<label for="subreddit">Subreddit</label>
<div class="input-with-prefix">
<span class="prefix">r/</span>
<input type="text" id="subreddit" name="subreddit" placeholder="AskReddit">
</div>
<small>Enter subreddit name without r/ prefix. Use + for multiple (e.g., AskReddit+nosleep)</small>
</div>
</div>
<div class="row mb-2">
<label for="max_comment_length" class="col-4">Max Comment Length</label>
<div class="col-8">
<div class="input-group">
<input name="max_comment_length" type="range" class="form-range" min="10" max="10000" step="1"
value="{{ data.max_comment_length }}" data-toggle="tooltip"
data-original-title="{{ data.max_comment_length }}">
<div class="form-row">
<div class="form-group">
<label for="min_comments">Minimum Comments</label>
<input type="number" id="min_comments" name="min_comments" value="20" min="1">
</div>
<span class="form-text text-muted">Max number of characters a comment can have.</span>
</div>
</div>
<div class="row mb-2">
<label for="post_lang" class="col-4">Post Language</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-translate"></i>
</div>
<input value="{{ data.post_lang }}" name="post_lang" type="text" class="form-control"
placeholder="The language you would like to translate to">
<div class="form-group">
<label for="max_comment_length">Max Comment Length</label>
<input type="number" id="max_comment_length" name="max_comment_length" value="500" min="50">
</div>
</div>
</div>
<div class="row mb-2">
<label for="min_comments" class="col-4">Minimum Comments</label>
<div class="col-8">
<div class="input-group">
<input name="min_comments" type="range" class="form-range" min="15" max="1000" step="1"
value="{{ data.min_comments }}" data-toggle="tooltip"
data-original-title="{{ data.min_comments }}">
</div>
<span class="form-text text-muted">The minimum number of comments a post should have to be
included.</span>
<div class="form-group">
<label for="post_id">Specific Post ID (Optional)</label>
<input type="text" id="post_id" name="post_id" placeholder="Leave empty for random">
<small>Enter a specific Reddit post ID, or leave empty to fetch random posts</small>
</div>
</div>
<!-- General Settings -->
<p class="h4">General Settings</p>
<div class="row mb-2">
<label class="col-4">Allow NSFW</label>
<div class="col-8">
<div class="form-check form-switch">
<input name="allow_nsfw" class="form-check-input" type="checkbox" value="True"
data-toggle="tooltip" data-original-title='If checked NSFW posts will be allowed'>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="random" name="random" checked>
Pick random posts from subreddit
</label>
</div>
</div>
<div class="row mb-2">
<label for="theme" class="col-4">Reddit Theme</label>
<div class="col-8">
<select name="theme" class="form-select" data-toggle="tooltip"
data-original-title='Sets the theme of Reddit screenshots'>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="allow_nsfw" name="allow_nsfw">
Allow NSFW content
</label>
</div>
</div>
<div class="row mb-2">
<label for="times_to_run" class="col-4">Times To Run</label>
<div class="col-8">
<div class="input-group">
<input name="times_to_run" type="range" class="form-range" min="1" max="1000" step="1"
value="{{ data.times_to_run }}" data-toggle="tooltip"
data-original-title="{{ data.times_to_run }}">
<!-- Video Settings -->
<div class="card">
<h3>Video Settings</h3>
<p class="card-description">Configure video output and visual settings.</p>
<div class="form-row">
<div class="form-group">
<label for="resolution_w">Width</label>
<input type="number" id="resolution_w" name="resolution_w" value="1080">
</div>
<span class="form-text text-muted">Used if you want to create multiple videos.</span>
</div>
</div>
<div class="row mb-2">
<label for="opacity" class="col-4">Opacity Of Comments</label>
<div class="col-8">
<div class="input-group">
<input name="opacity" type="range" class="form-range" min="0" max="1" step="0.05"
value="{{ data.opacity }}" data-toggle="tooltip" data-original-title="{{ data.opacity }}">
<div class="form-group">
<label for="resolution_h">Height</label>
<input type="number" id="resolution_h" name="resolution_h" value="1920">
</div>
<span class="form-text text-muted">Sets the opacity of the comments when overlayed over the
background.</span>
</div>
</div>
<div class="row mb-2">
<label for="transition" class="col-4">Transition</label>
<div class="col-8">
<div class="input-group">
<input name="transition" type="range" class="form-range" min="0" max="2" step="0.05"
value="{{ data.transition }}" data-toggle="tooltip"
data-original-title="{{ data.transition }}">
<div class="form-row">
<div class="form-group">
<label for="theme">Screenshot Theme</label>
<select id="theme" name="theme">
<option value="dark" selected>Dark</option>
<option value="light">Light</option>
<option value="transparent">Transparent</option>
</select>
</div>
<span class="form-text text-muted">Sets the transition time (in seconds) between the
comments. Set to 0 if you want to disable it.</span>
</div>
</div>
<div class="row mb-2">
<label for="background_choice" class="col-4">Background Choice</label>
<div class="col-8">
<select name="background_choice" class="form-select" data-toggle="tooltip"
data-original-title='Sets the background of the video'>
<option value=" ">Random Video</option>
{% for background in checks["background_video"]["options"][1:] %}
<option value="{{background}}">{{background}}</option>
{% endfor %}
</select>
<span class="form-text text-muted"><a href="/backgrounds" target="_blank">See all available
backgrounds</a></span>
</div>
</div>
<div class="row mb-2">
<label for="background_thumbnail" class="col-4">Background Thumbnail</label>
<div class="col-8">
<div class="form-check form-switch">
<input name="background_thumbnail" class="form-check-input" type="checkbox" value="True"
data-toggle="tooltip"
data-original-title='If checked a thumbnail will be added to the video (put a thumbnail.png file in the assets/backgrounds directory for it to be used.)'>
<div class="form-group">
<label for="opacity">Opacity</label>
<input type="number" id="opacity" name="opacity" value="0.9" min="0" max="1" step="0.1">
</div>
</div>
</div>
<div class="row mb-2">
<label for="background_thumbnail_font_family" class="col-4">Background Thumbnail Font Family (.ttf)</label>
<div class="col-8">
<input name="background_thumbnail_font_family" type="text" class="form-control"
placeholder="arial" value="{{ data.background_thumbnail_font_family }}">
</div>
</div>
<div class="row mb-2">
<label for="background_thumbnail_font_size" class="col-4">Background Thumbnail Font Size (px)</label>
<div class="col-8">
<input name="background_thumbnail_font_size" type="number" class="form-control"
placeholder="96" value="{{ data.background_thumbnail_font_size }}">
</div>
</div>
<!-- need to create a color picker -->
<div class="row mb-2">
<label for="background_thumbnail_font_color" class="col-4">Background Thumbnail Font Color (rgb)</label>
<div class="col-8">
<input name="background_thumbnail_font_color" type="text" class="form-control"
placeholder="255,255,255" value="{{ data.background_thumbnail_font_color }}">
</div>
</div>
<!-- TTS Settings -->
<p class="h4">TTS Settings</p>
<div class="row mb-2">
<label for="voice_choice" class="col-4">TTS Voice Choice</label>
<div class="col-8">
<select name="voice_choice" class="form-select" data-toggle="tooltip"
data-original-title='The voice platform used for TTS generation'>
<option value="streamlabspolly">Streamlabspolly</option>
<option value="tiktok">TikTok</option>
<option value="googletranslate">Google Translate</option>
<option value="awspolly">AWS Polly</option>
<option value="pyttsx">Python TTS (pyttsx)</option>
</select>
<div class="form-group">
<label for="times_to_run">Videos per Run</label>
<input type="number" id="times_to_run" name="times_to_run" value="1" min="1" max="10">
<small>How many videos to generate when you click "Generate"</small>
</div>
</div>
<div class="row mb-2">
<label for="aws_polly_voice" class="col-4">AWS Polly Voice</label>
<div class="col-8">
<div class="input-group voices">
<select name="aws_polly_voice" class="form-select" data-toggle="tooltip"
data-original-title='The voice used for AWS Polly'>
<option value="Brian">Brian</option>
<option value="Emma">Emma</option>
<option value="Russell">Russell</option>
<option value="Joey">Joey</option>
<option value="Matthew">Matthew</option>
<option value="Joanna">Joanna</option>
<option value="Kimberly">Kimberly</option>
<option value="Amy">Amy</option>
<option value="Geraint">Geraint</option>
<option value="Nicole">Nicole</option>
<option value="Justin">Justin</option>
<option value="Ivy">Ivy</option>
<option value="Kendra">Kendra</option>
<option value="Salli">Salli</option>
<option value="Raveena">Raveena</option>
</select>
<button type="button" class="btn btn-primary"><i id="awspolly_icon"
class="bi-volume-up-fill"></i></button>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="storymode" name="storymode">
Story Mode (for narrative subreddits like r/nosleep)
</label>
</div>
</div>
<div class="row mb-2">
<label for="streamlabs_polly_voice" class="col-4">Streamlabs Polly Voice</label>
<div class="col-8">
<div class="input-group voices">
<select id="streamlabs_polly_voice" name="streamlabs_polly_voice" class="form-select"
data-toggle="tooltip" data-original-title='The voice used for Streamlabs Polly'>
<option value="Brian">Brian</option>
<option value="Emma">Emma</option>
<option value="Russell">Russell</option>
<option value="Joey">Joey</option>
<option value="Matthew">Matthew</option>
<option value="Joanna">Joanna</option>
<option value="Kimberly">Kimberly</option>
<option value="Amy">Amy</option>
<option value="Geraint">Geraint</option>
<option value="Nicole">Nicole</option>
<option value="Justin">Justin</option>
<option value="Ivy">Ivy</option>
<option value="Kendra">Kendra</option>
<option value="Salli">Salli</option>
<option value="Raveena">Raveena</option>
</select>
<button type="button" class="btn btn-primary"><i id="streamlabs_icon"
class="bi bi-volume-up-fill"></i></button>
</div>
</div>
</div>
<div class="row mb-2">
<label for="tiktok_voice" class="col-4">TikTok Voice</label>
<div class="col-8">
<div class="input-group voices">
<select name="tiktok_voice" class="form-select" data-toggle="tooltip"
data-original-title='The voice used for TikTok TTS'>
<option disabled value="">-----Disney Voices-----</option>
<option value="en_us_ghostface">Ghost Face</option>
<option value="en_us_chewbacca">Chewbacca</option>
<option value="en_us_c3po">C3PO</option>
<option value="en_us_stitch">Stitch</option>
<option value="en_us_stormtrooper">Stormtrooper</option>
<option value="en_us_rocket">Rocket</option>
<option disabled value="">-----English Voices-----</option>
<option value="en_au_001">English AU - Female</option>
<option value="en_au_002">English AU - Male</option>
<option value="en_uk_001">English UK - Male 1</option>
<option value="en_uk_003">English UK - Male 2</option>
<option value="en_us_001">English US - Female (Int. 1)</option>
<option value="en_us_002">English US - Female (Int. 2)</option>
<option value="en_us_006">English US - Male 1</option>
<option value="en_us_007">English US - Male 2</option>
<option value="en_us_009">English US - Male 3</option>
<option value="en_us_010">English US - Male 4</option>
<option disabled value="">-----European Voices-----</option>
<option value="fr_001">French - Male 1</option>
<option value="fr_002">French - Male 2</option>
<option value="de_001">German - Female</option>
<option value="de_002">German - Male</option>
<option value="es_002">Spanish - Male</option>
<option disabled value="">-----American Voices-----</option>
<option value="es_mx_002">Spanish MX - Male</option>
<option value="br_001">Portuguese BR - Female 1</option>
<option value="br_003">Portuguese BR - Female 2</option>
<option value="br_004">Portuguese BR - Female 3</option>
<option value="br_005">Portuguese BR - Male</option>
<option disabled value="">-----Asian Voices-----</option>
<option value="id_001">Indonesian - Female</option>
<option value="jp_001">Japanese - Female 1</option>
<option value="jp_003">Japanese - Female 2</option>
<option value="jp_005">Japanese - Female 3</option>
<option value="jp_006">Japanese - Male</option>
<option value="kr_002">Korean - Male 1</option>
<option value="kr_003">Korean - Female</option>
<option value="kr_004">Korean - Male 2</option>
<!-- Background Settings -->
<div class="card">
<h3>Background Settings</h3>
<p class="card-description">Select background video and audio. Upload custom backgrounds in the Backgrounds page.</p>
<div class="form-row">
<div class="form-group">
<label for="background_video">Background Video</label>
<select id="background_video" name="background_video">
<option value="minecraft">Minecraft Parkour</option>
<option value="gta">GTA V</option>
<option value="rocket-league">Rocket League</option>
<option value="subway-surfers">Subway Surfers</option>
</select>
<button type="button" class="btn btn-primary"><i id="tiktok_icon"
class="bi-volume-up-fill"></i></button>
</div>
</div>
</div>
<div class="row mb-2">
<label for="tiktok_sessionid" class="col-4">TikTok SessionId</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-mic-fill"></i>
</div>
<input value="{{ data.tiktok_sessionid }}" name="tiktok_sessionid" type="text" class="form-control"
data-toggle="tooltip"
data-original-title="TikTok sessionid needed for the TTS API request. Check documentation if you don't know how to obtain it.">
</div>
</div>
</div>
<div class="row mb-2">
<label for="python_voice" class="col-4">Python Voice</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-mic-fill"></i>
</div>
<input value="{{ data.python_voice }}" name="python_voice" type="text" class="form-control"
data-toggle="tooltip"
data-original-title='The index of the system TTS voices (can be downloaded externally, run ptt.py to find value, start from zero)'>
</div>
</div>
</div>
<div class="row mb-2">
<label for="py_voice_num" class="col-4">Py Voice Number</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-headset"></i>
</div>
<input value="{{ data.py_voice_num }}" name="py_voice_num" type="text" class="form-control"
data-toggle="tooltip"
data-original-title='The number of system voices (2 are pre-installed in Windows)'>
<div class="form-group">
<label for="background_audio">Background Audio</label>
<select id="background_audio" name="background_audio">
<option value="lofi">Lo-Fi Beats</option>
<option value="lofi-2">Lo-Fi Beats 2</option>
<option value="chill-summer">Chill Summer</option>
<option value="none">No Background Audio</option>
</select>
</div>
</div>
</div>
<div class="row mb-2">
<label for="silence_duration" class="col-4">Silence Duration</label>
<div class="col-8">
<div class="input-group">
<input name="silence_duration" type="range" class="form-range" min="0" max="5" step="0.05"
value="{{ data.silence_duration }}" data-toggle="tooltip"
data-original-title="{{ data.silence_duration }}">
</div>
<span class="form-text text-muted">Time in seconds between TTS comments.</span>
<div class="form-group">
<label for="background_audio_volume">Background Audio Volume</label>
<input type="range" id="background_audio_volume" name="background_audio_volume" min="0" max="1" step="0.05" value="0.15">
<span id="volume-display">15%</span>
</div>
</div>
<div class="col text-center">
<br>
<button id="defaultSettingsBtn" type="button" class="btn btn-secondary">Default
Settings</button>
<button id="submitButton" type="submit" class="btn btn-success">Save
Changes</button>
</div>
</form>
</div>
<audio src=""></audio>
</main>
<script>
// Test voices buttons
var playing = false;
$(".voices button").click(function () {
var icon = $(this).find("i");
var audio = $("audio");
if (playing) {
playing.toggleClass("bi-volume-up-fill bi-stop-fill");
// Clicked the same button - stop audio
if (playing.prop("id") == icon.prop("id")) {
audio[0].pause();
playing = false;
return;
}
}
icon.toggleClass("bi-volume-up-fill bi-stop-fill");
let path = "voices/" + $(this).closest(".voices").find("select").prop("value").toLowerCase() + ".mp3";
audio.prop("src", path);
audio[0].play();
playing = icon;
audio[0].onended = function () {
icon.toggleClass("bi-volume-up-fill bi-stop-fill");
playing = false;
}
});
// Wait for DOM to load
$(document).ready(function () {
// Add tooltips
$('[data-toggle="tooltip"]').tooltip();
$('[data-toggle="tooltip"]').on('click', function () {
$(this).tooltip('hide');
});
// Update slider tooltip
$(".form-range").on("input", function () {
$(this).attr("value", $(this).val());
$(this).attr("data-original-title", $(this).val());
$(this).tooltip("show");
});
// Get current config
var data = JSON.parse('{{data | tojson}}');
// Set current checkboxes
$('.form-check-input').each(function () {
$(this).prop("checked", data[$(this).prop("name")]);
});
// Set current select options
$('.form-select').each(function () {
$(this).prop("value", data[$(this).prop("name")]);
});
// Submit "False" when checkbox isn't ticked
$('#settingsForm').submit(function () {
$('.form-check-input').each(function () {
if (!($(this).is(':checked'))) {
$(this).prop("value", "False");
$(this).prop("checked", true);
}
</main>
<script src="/static/js/app.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
loadCustomBackgrounds();
document.getElementById('btn-save').addEventListener('click', saveSettings);
document.getElementById('btn-test-tts').addEventListener('click', testTTSConnection);
// Volume slider display
const volumeSlider = document.getElementById('background_audio_volume');
const volumeDisplay = document.getElementById('volume-display');
volumeSlider.addEventListener('input', () => {
volumeDisplay.textContent = Math.round(volumeSlider.value * 100) + '%';
});
});
function loadSettings() {
fetch('/api/config')
.then(r => r.json())
.then(config => {
if (!config || !config.settings) return;
// TTS settings
const tts = config.settings.tts || {};
setFieldValue('qwen_api_url', tts.qwen_api_url);
setFieldValue('qwen_email', tts.qwen_email);
setFieldValue('qwen_speaker', tts.qwen_speaker);
setFieldValue('qwen_language', tts.qwen_language);
setFieldValue('qwen_instruct', tts.qwen_instruct);
// Reddit settings
const thread = config.reddit?.thread || {};
setFieldValue('subreddit', thread.subreddit);
setFieldValue('min_comments', thread.min_comments);
setFieldValue('max_comment_length', thread.max_comment_length);
setFieldValue('post_id', thread.post_id);
setCheckbox('random', thread.random);
setCheckbox('allow_nsfw', config.settings.allow_nsfw);
// Video settings
setFieldValue('resolution_w', config.settings.resolution_w);
setFieldValue('resolution_h', config.settings.resolution_h);
setFieldValue('theme', config.settings.theme);
setFieldValue('opacity', config.settings.opacity);
setFieldValue('times_to_run', config.settings.times_to_run);
setCheckbox('storymode', config.settings.storymode);
// Background settings
const bg = config.settings.background || {};
setFieldValue('background_video', bg.background_video);
setFieldValue('background_audio', bg.background_audio);
setFieldValue('background_audio_volume', bg.background_audio_volume);
// Update volume display
const vol = bg.background_audio_volume || 0.15;
document.getElementById('volume-display').textContent = Math.round(vol * 100) + '%';
})
.catch(err => console.error('Error loading settings:', err));
}
// Get validation values
let validateChecks = JSON.parse('{{checks | tojson}}');
// Set default values
$("#defaultSettingsBtn").click(function (event) {
$("#settingsForm input, #settingsForm select").each(function () {
let check = validateChecks[$(this).prop("name")];
if (check["default"]) {
$(this).prop("value", check["default"]);
// Update tooltip value for input[type="range"]
if ($(this).prop("type") == "range") {
$(this).attr("data-original-title", check["default"]);
function loadCustomBackgrounds() {
fetch('/api/backgrounds/video')
.then(r => r.json())
.then(data => {
const videoSelect = document.getElementById('background_video');
// Add custom videos
if (data.videos && data.videos.length > 0) {
const customGroup = document.createElement('optgroup');
customGroup.label = 'Custom Videos';
data.videos.forEach(v => {
const opt = document.createElement('option');
opt.value = 'custom:' + v.filename;
opt.textContent = v.name;
customGroup.appendChild(opt);
});
videoSelect.appendChild(customGroup);
}
}
});
});
// Validate form
$('#settingsForm').submit(function (event) {
$("#settingsForm input, #settingsForm select").each(function () {
if (!(validate($(this)))) {
event.preventDefault();
event.stopPropagation();
$("html, body").animate({
scrollTop: $(this).offset().top
});
}
});
});
$("#settingsForm input").on("keyup", function () {
validate($(this));
});
$("#settingsForm select").on("change", function () {
validate($(this));
});
function validate(object) {
let bool = check(object.prop("name"), object.prop("value"));
})
.catch(err => console.error('Error loading backgrounds:', err));
}
// Change class
if (bool) {
object.removeClass("is-invalid");
object.addClass("is-valid");
}
else {
object.removeClass("is-valid");
object.addClass("is-invalid");
function setFieldValue(id, value) {
const field = document.getElementById(id);
if (field && value !== undefined && value !== null) {
field.value = value;
}
}
return bool;
// Check values (return true/false)
function check(name, value) {
let check = validateChecks[name];
// If value is empty - check if it's optional
if (value.length == 0) {
if (check["optional"] == false) {
return false;
}
else {
object.prop("value", check["default"]);
return true;
}
}
// Check if value is too short
if (check["nmin"]) {
if (check["type"] == "int" || check["type"] == "float") {
if (value < check["nmin"]) {
return false;
}
}
else {
if (value.length < check["nmin"]) {
return false;
}
}
}
// Check if value is too long
if (check["nmax"]) {
if (check["type"] == "int" || check["type"] == "float") {
if (value > check["nmax"]) {
return false;
}
}
else {
if (value.length > check["nmax"]) {
return false;
}
function setCheckbox(id, value) {
const field = document.getElementById(id);
if (field) {
field.checked = !!value;
}
}
}
function saveSettings() {
const form = document.getElementById('settings-form');
const config = {
subreddit: form.subreddit.value,
post_id: form.post_id.value,
min_comments: parseInt(form.min_comments.value),
max_comment_length: parseInt(form.max_comment_length.value),
random: form.random.checked,
allow_nsfw: form.allow_nsfw.checked,
theme: form.theme.value,
opacity: parseFloat(form.opacity.value),
times_to_run: parseInt(form.times_to_run.value),
storymode: form.storymode.checked,
resolution_w: parseInt(form.resolution_w.value),
resolution_h: parseInt(form.resolution_h.value),
voice_choice: 'qwentts',
qwen_api_url: form.qwen_api_url.value,
qwen_email: form.qwen_email.value,
qwen_password: form.qwen_password.value,
qwen_speaker: form.qwen_speaker.value,
qwen_language: form.qwen_language.value,
qwen_instruct: form.qwen_instruct.value,
background_video: form.background_video.value,
background_audio: form.background_audio.value,
background_audio_volume: parseFloat(form.background_audio_volume.value)
};
fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
})
.then(r => r.json())
.then(data => {
if (data.success) {
showNotification('Settings saved successfully!', 'success');
} else {
showNotification('Failed to save: ' + data.message, 'error');
}
})
.catch(err => showNotification('Error saving settings', 'error'));
}
// Check if value matches regex
if (check["regex"]) {
let regex = new RegExp(check["regex"]);
if (!(regex.test(value))) {
return false;
}
function testTTSConnection() {
const statusEl = document.getElementById('tts-status');
statusEl.textContent = 'Testing...';
statusEl.className = 'status-text';
const config = {
qwen_api_url: document.getElementById('qwen_api_url').value,
qwen_email: document.getElementById('qwen_email').value,
qwen_password: document.getElementById('qwen_password').value
};
fetch('/api/tts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
})
.then(r => r.json())
.then(data => {
if (data.success) {
statusEl.textContent = 'Connection successful!';
statusEl.className = 'status-text success';
} else {
statusEl.textContent = 'Failed: ' + data.message;
statusEl.className = 'status-text error';
}
return true;
}
})
.catch(err => {
statusEl.textContent = 'Connection error';
statusEl.className = 'status-text error';
});
}
});
</script>
{% endblock %}
</script>
</body>
</html>

@ -0,0 +1,774 @@
/* Reddit Video Maker Bot - Main Application 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);
--sidebar-width: 240px;
}
* {
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;
}
/* Sidebar Navigation */
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
padding: 20px 0;
overflow-y: auto;
z-index: 100;
}
.sidebar .logo {
padding: 20px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
}
.sidebar .logo h2 {
font-size: 1.2rem;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-links {
list-style: none;
}
.nav-links li a {
display: block;
padding: 12px 24px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.nav-links li a:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.nav-links li a.active {
background: rgba(255, 69, 0, 0.1);
color: var(--accent-primary);
border-left-color: var(--accent-primary);
}
/* Main Content */
.content {
margin-left: var(--sidebar-width);
padding: 30px;
min-height: 100vh;
}
.content header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.content header h1 {
font-size: 2rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
/* 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 h3 {
font-size: 1.3rem;
margin-bottom: 8px;
color: var(--text-primary);
}
.card-description {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-wide {
grid-column: span 2;
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.card-wide {
grid-column: span 1;
}
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
gap: 8px;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover {
background: var(--accent-secondary);
}
.btn-primary:disabled {
background: var(--bg-tertiary);
color: var(--text-muted);
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-secondary);
border-color: var(--text-muted);
}
.btn-danger {
background: var(--error);
color: white;
}
.btn-danger:hover {
background: #d32f2f;
}
.btn-danger:disabled {
background: var(--bg-tertiary);
color: var(--text-muted);
cursor: not-allowed;
}
.btn-small {
padding: 6px 12px;
font-size: 0.8rem;
}
.btn-large {
padding: 14px 28px;
font-size: 1rem;
width: 100%;
margin-bottom: 12px;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Forms */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.95rem;
transition: border-color 0.2s ease;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.form-group small {
display: block;
margin-top: 6px;
color: var(--text-muted);
font-size: 0.8rem;
}
.form-group input[type="range"] {
padding: 0;
height: 8px;
background: var(--bg-tertiary);
border: none;
cursor: pointer;
}
.form-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
background: var(--accent-primary);
border-radius: 50%;
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
.input-with-prefix {
display: flex;
align-items: stretch;
}
.input-with-prefix .prefix {
display: flex;
align-items: center;
padding: 0 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-right: none;
border-radius: 8px 0 0 8px;
color: var(--text-secondary);
font-weight: 500;
}
.input-with-prefix input {
border-radius: 0 8px 8px 0;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-weight: 400;
}
.checkbox-group input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Status Indicator */
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-tertiary);
border-radius: 20px;
}
.status-indicator .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--success);
}
.status-indicator.running .dot {
background: var(--info);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Config Summary */
.config-summary .config-item {
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
}
.config-summary .config-item:last-child {
border-bottom: none;
}
.config-summary .warning {
color: var(--warning);
}
.config-summary a {
color: var(--accent-primary);
}
/* Progress */
.progress-info {
padding: 16px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.progress-title {
font-weight: 600;
margin-bottom: 4px;
}
.progress-subreddit {
color: var(--accent-primary);
font-size: 0.9rem;
margin-bottom: 16px;
}
.progress-bar-container {
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-percent {
text-align: right;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16px;
}
.progress-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-steps .step {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 6px;
}
.progress-steps .step.completed {
color: var(--success);
}
.progress-steps .step.in_progress {
color: var(--info);
}
.progress-steps .step.failed {
color: var(--error);
}
.step-icon {
width: 20px;
text-align: center;
}
.no-progress {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.no-progress .hint {
margin-top: 10px;
font-size: 0.9rem;
}
/* Video Items */
.video-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.video-item:last-child {
border-bottom: none;
}
.video-name {
font-weight: 500;
}
.video-subreddit {
color: var(--accent-primary);
font-size: 0.85rem;
}
/* Videos Grid */
.videos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
.video-card .video-preview {
margin: -24px -24px 16px -24px;
border-radius: 12px 12px 0 0;
overflow: hidden;
background: #000;
}
.video-card .video-preview video {
width: 100%;
max-height: 300px;
display: block;
}
.video-card .video-info {
margin-bottom: 16px;
}
.video-card .video-name {
font-size: 1rem;
margin-top: 4px;
word-break: break-word;
}
.video-card .video-meta {
display: flex;
gap: 16px;
margin-top: 8px;
color: var(--text-muted);
font-size: 0.85rem;
}
.video-card .video-actions {
display: flex;
gap: 8px;
}
/* Media Grid */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-top: 16px;
}
.media-item {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.media-preview {
border-radius: 6px;
overflow: hidden;
background: #000;
}
.media-preview video {
width: 100%;
height: 160px;
object-fit: cover;
}
.media-preview audio {
width: 100%;
}
.media-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.media-name {
font-weight: 500;
}
.media-size {
color: var(--text-muted);
font-size: 0.85rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state h3 {
margin-bottom: 10px;
}
.empty-state p {
margin-bottom: 20px;
}
.empty-state .hint {
color: var(--text-muted);
font-size: 0.9rem;
}
/* Loading */
.loading {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Error */
.error {
text-align: center;
padding: 40px;
color: var(--error);
}
/* Upload Progress */
.upload-progress {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px 24px;
box-shadow: var(--card-shadow);
z-index: 1000;
}
.upload-progress.hidden {
display: none;
}
.upload-progress-bar {
width: 200px;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
.upload-progress-fill {
height: 100%;
background: var(--accent-primary);
animation: indeterminate 1.5s infinite linear;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
/* Status Text */
.status-text {
margin-left: 12px;
font-size: 0.9rem;
}
.status-text.success {
color: var(--success);
}
.status-text.error {
color: var(--error);
}
/* Filter Select */
.filter-select {
padding: 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
}
/* Notifications */
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 8px;
font-weight: 500;
z-index: 1000;
animation: slideIn 0.3s ease;
}
.notification.success {
background: var(--success);
color: white;
}
.notification.error {
background: var(--error);
color: white;
}
.notification.info {
background: var(--info);
color: white;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 100%;
height: auto;
position: relative;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.sidebar .logo {
display: none;
}
.nav-links {
display: flex;
overflow-x: auto;
padding: 0 10px;
}
.nav-links li a {
padding: 12px 16px;
white-space: nowrap;
border-left: none;
border-bottom: 3px solid transparent;
}
.nav-links li a.active {
border-left-color: transparent;
border-bottom-color: var(--accent-primary);
}
.content {
margin-left: 0;
padding: 20px;
}
.content header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.content header h1 {
font-size: 1.5rem;
}
.form-row {
grid-template-columns: 1fr;
}
.videos-grid,
.media-grid {
grid-template-columns: 1fr;
}
}

@ -0,0 +1,174 @@
/**
* Reddit Video Maker Bot - Shared JavaScript Utilities
*/
// Show notification toast
function showNotification(message, type = 'info') {
const notif = document.createElement('div');
notif.className = `notification ${type}`;
notif.textContent = message;
document.body.appendChild(notif);
setTimeout(() => {
notif.style.opacity = '0';
notif.style.transform = 'translateY(20px)';
setTimeout(() => notif.remove(), 300);
}, 3000);
}
// Format file size
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Format duration in seconds to mm:ss
function formatDuration(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Format ISO date string to readable format
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Debounce function for search inputs
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Escape HTML to prevent XSS
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Copy text to clipboard
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
showNotification('Copied to clipboard!', 'success');
} catch (err) {
showNotification('Failed to copy', 'error');
}
}
// API helper functions
const api = {
get: async (url) => {
const response = await fetch(url);
return response.json();
},
post: async (url, data) => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
},
delete: async (url) => {
const response = await fetch(url, { method: 'DELETE' });
return response.json();
},
upload: async (url, file, onProgress) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(xhr.statusText));
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.open('POST', url);
xhr.send(formData);
});
}
};
// WebSocket connection manager for progress updates
class ProgressSocket {
constructor(namespace = '/progress') {
this.socket = null;
this.namespace = namespace;
this.callbacks = [];
}
connect() {
if (typeof io !== 'undefined') {
this.socket = io(this.namespace);
this.socket.on('connect', () => {
console.log('Connected to progress socket');
});
this.socket.on('disconnect', () => {
console.log('Disconnected from progress socket');
});
this.socket.on('progress_update', (data) => {
this.callbacks.forEach(cb => cb(data));
});
}
}
onUpdate(callback) {
this.callbacks.push(callback);
}
requestStatus() {
if (this.socket) {
this.socket.emit('request_status');
}
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
}
}
}
// Export for use in other scripts
window.showNotification = showNotification;
window.formatBytes = formatBytes;
window.formatDuration = formatDuration;
window.formatDate = formatDate;
window.debounce = debounce;
window.escapeHtml = escapeHtml;
window.copyToClipboard = copyToClipboard;
window.api = api;
window.ProgressSocket = ProgressSocket;

@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Videos - Reddit Video Maker Bot</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<nav class="sidebar">
<div class="logo">
<h2>Reddit Video Bot</h2>
</div>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/backgrounds">Backgrounds</a></li>
<li><a href="/videos" class="active">Videos</a></li>
<li><a href="/progress">Progress</a></li>
</ul>
</nav>
<main class="content">
<header>
<h1>Generated Videos</h1>
<div class="header-actions">
<select id="filter-subreddit" class="filter-select">
<option value="">All Subreddits</option>
</select>
</div>
</header>
<div id="videos-container">
<div class="loading">Loading videos...</div>
</div>
</main>
<script src="/static/js/app.js"></script>
<script>
let allVideos = [];
document.addEventListener('DOMContentLoaded', () => {
loadVideos();
document.getElementById('filter-subreddit').addEventListener('change', filterVideos);
});
function loadVideos() {
fetch('/api/videos')
.then(r => r.json())
.then(data => {
allVideos = data.videos || [];
populateFilters();
renderVideos(allVideos);
})
.catch(err => {
document.getElementById('videos-container').innerHTML = '<div class="error">Error loading videos</div>';
});
}
function populateFilters() {
const subreddits = [...new Set(allVideos.map(v => v.subreddit))];
const select = document.getElementById('filter-subreddit');
subreddits.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = 'r/' + s;
select.appendChild(opt);
});
}
function filterVideos() {
const subreddit = document.getElementById('filter-subreddit').value;
const filtered = subreddit
? allVideos.filter(v => v.subreddit === subreddit)
: allVideos;
renderVideos(filtered);
}
function renderVideos(videos) {
const container = document.getElementById('videos-container');
if (videos.length === 0) {
container.innerHTML = `
<div class="empty-state card">
<h3>No Videos Yet</h3>
<p>Generated videos will appear here.</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
`;
return;
}
container.innerHTML = `
<div class="videos-grid">
${videos.map(v => `
<div class="video-card card">
<div class="video-preview">
<video src="${v.path}" controls preload="metadata"></video>
</div>
<div class="video-info">
<span class="video-subreddit">r/${v.subreddit}</span>
<h4 class="video-name">${v.name}</h4>
<div class="video-meta">
<span class="video-size">${formatFileSize(v.size)}</span>
<span class="video-date">${formatDate(v.created)}</span>
</div>
</div>
<div class="video-actions">
<a href="${v.path}" download class="btn btn-primary btn-small">Download</a>
<button class="btn btn-secondary btn-small" onclick="copyLink('${v.path}')">Copy Link</button>
</div>
</div>
`).join('')}
</div>
`;
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
function copyLink(path) {
const url = window.location.origin + path;
navigator.clipboard.writeText(url).then(() => {
showNotification('Link copied to clipboard!', 'success');
}).catch(() => {
showNotification('Failed to copy link', 'error');
});
}
</script>
</body>
</html>

@ -10,7 +10,8 @@ services:
ports:
- "5000:5000"
volumes:
- ./config.toml:/app/config.toml:ro
# Config is read-write so UI can save settings
- ./config.toml:/app/config.toml
- ./results:/app/results
- ./assets:/app/assets
environment:
@ -19,18 +20,20 @@ services:
- REDDIT_SUBREDDIT=${REDDIT_SUBREDDIT:-AskReddit}
- REDDIT_REQUEST_DELAY=${REDDIT_REQUEST_DELAY:-2.0}
- REDDIT_RANDOM=${REDDIT_RANDOM:-true}
# TTS Settings (Qwen TTS)
# TTS Settings
- TTS_VOICE_CHOICE=${TTS_VOICE_CHOICE:-qwentts}
- QWEN_API_URL=${QWEN_API_URL:-http://qwen-tts:8080}
- 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}
networks:
- reddit-bot-network
depends_on:
- qwen-tts
# Run the web UI by default
command: python progress_gui.py
# Optional: Qwen TTS Server (if running locally)
# Uncomment or use: docker-compose --profile with-tts up -d
qwen-tts:
image: qwen-tts-server:latest
container_name: qwen-tts
@ -41,6 +44,8 @@ services:
- TTS_MODEL=qwen3-tts
networks:
- reddit-bot-network
profiles:
- with-tts
# Uncomment if using GPU
# deploy:
# resources:
@ -50,27 +55,6 @@ services:
# 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

@ -1,30 +1,129 @@
#!/usr/bin/env python
"""
Progress GUI for Reddit Video Maker Bot.
Real-time progress tracking with steps and previews.
Reddit Video Maker Bot - Web UI
Complete web interface for configuration, video generation, and progress tracking.
"""
import os
import json
import subprocess
import threading
import webbrowser
import shutil
from pathlib import Path
from datetime import datetime
from flask import Flask, render_template, send_from_directory, jsonify, request
import toml
import requests
from flask import Flask, render_template, send_from_directory, jsonify, request, redirect, url_for
from flask_socketio import SocketIO, emit
from werkzeug.utils import secure_filename
from utils.progress import progress_tracker
# Configuration
HOST = "0.0.0.0"
PORT = 5000
CONFIG_PATH = Path("config.toml")
BACKGROUNDS_VIDEO_PATH = Path("assets/backgrounds/video")
BACKGROUNDS_AUDIO_PATH = Path("assets/backgrounds/audio")
RESULTS_PATH = Path("results")
ALLOWED_VIDEO_EXTENSIONS = {'mp4', 'webm', 'mov', 'avi', 'mkv'}
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
# Ensure directories exist
BACKGROUNDS_VIDEO_PATH.mkdir(parents=True, exist_ok=True)
BACKGROUNDS_AUDIO_PATH.mkdir(parents=True, exist_ok=True)
RESULTS_PATH.mkdir(parents=True, exist_ok=True)
# Configure Flask app
app = Flask(__name__, template_folder="GUI", static_folder="GUI/static")
app.secret_key = os.urandom(24)
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB max upload
# Configure SocketIO for real-time updates
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent")
# Track running generation process
generation_process = None
generation_thread = None
def allowed_video_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_VIDEO_EXTENSIONS
def allowed_audio_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
def load_config():
"""Load current configuration."""
if CONFIG_PATH.exists():
try:
return toml.load(CONFIG_PATH)
except Exception as e:
print(f"Error loading config: {e}")
return {}
def save_config(config):
"""Save configuration to file."""
try:
with open(CONFIG_PATH, 'w') as f:
toml.dump(config, f)
return True
except Exception as e:
print(f"Error saving config: {e}")
return False
def get_background_videos():
"""Get list of available background videos."""
videos = []
if BACKGROUNDS_VIDEO_PATH.exists():
for f in BACKGROUNDS_VIDEO_PATH.iterdir():
if f.is_file() and allowed_video_file(f.name):
videos.append({
'name': f.stem,
'filename': f.name,
'size': f.stat().st_size,
'path': str(f)
})
return videos
def get_background_audios():
"""Get list of available background audio tracks."""
audios = []
if BACKGROUNDS_AUDIO_PATH.exists():
for f in BACKGROUNDS_AUDIO_PATH.iterdir():
if f.is_file() and allowed_audio_file(f.name):
audios.append({
'name': f.stem,
'filename': f.name,
'size': f.stat().st_size,
'path': str(f)
})
return audios
def get_generated_videos():
"""Get list of generated videos."""
videos = []
if RESULTS_PATH.exists():
for subreddit_dir in RESULTS_PATH.iterdir():
if subreddit_dir.is_dir():
for f in subreddit_dir.iterdir():
if f.is_file() and f.suffix.lower() == '.mp4':
videos.append({
'name': f.stem,
'filename': f.name,
'subreddit': subreddit_dir.name,
'size': f.stat().st_size,
'created': datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
'path': f"/results/{subreddit_dir.name}/{f.name}"
})
return sorted(videos, key=lambda x: x['created'], reverse=True)
# Progress update callback
def broadcast_progress(data):
@ -45,55 +144,379 @@ def after_request(response):
return response
# ==================== Pages ====================
@app.route("/")
def index():
"""Main progress dashboard."""
"""Main dashboard - redirect to settings if not configured."""
config = load_config()
if not config:
return redirect(url_for('settings_page'))
return render_template("dashboard.html")
@app.route("/settings")
def settings_page():
"""Settings/configuration page."""
return render_template("settings.html")
@app.route("/backgrounds")
def backgrounds_page():
"""Background videos management page."""
return render_template("backgrounds.html")
@app.route("/videos")
def videos_page():
"""Generated videos page."""
return render_template("videos.html")
@app.route("/progress")
def progress_page():
"""Progress tracking page."""
return render_template("progress.html")
# ==================== API Endpoints ====================
@app.route("/api/config", methods=["GET"])
def api_get_config():
"""Get current configuration."""
config = load_config()
# Mask sensitive fields
if config.get('settings', {}).get('tts', {}).get('qwen_password'):
config['settings']['tts']['qwen_password'] = '********'
if config.get('settings', {}).get('tts', {}).get('openai_api_key'):
config['settings']['tts']['openai_api_key'] = '********'
return jsonify(config)
@app.route("/api/config", methods=["POST"])
def api_save_config():
"""Save configuration."""
try:
data = request.json
# Load existing config to preserve masked fields
existing_config = load_config()
# Build new config
config = {
'reddit': {
'scraper': {
'user_agent': data.get('user_agent', 'python:reddit_video_bot:1.0'),
'request_delay': float(data.get('request_delay', 2.0))
},
'thread': {
'subreddit': data.get('subreddit', 'AskReddit'),
'post_id': data.get('post_id', ''),
'random': data.get('random', True),
'max_comment_length': int(data.get('max_comment_length', 500)),
'min_comment_length': int(data.get('min_comment_length', 1)),
'min_comments': int(data.get('min_comments', 20)),
'post_lang': data.get('post_lang', '')
}
},
'ai': {
'ai_similarity_enabled': data.get('ai_similarity_enabled', False),
'ai_similarity_keywords': data.get('ai_similarity_keywords', '')
},
'settings': {
'allow_nsfw': data.get('allow_nsfw', False),
'theme': data.get('theme', 'dark'),
'times_to_run': int(data.get('times_to_run', 1)),
'opacity': float(data.get('opacity', 0.9)),
'storymode': data.get('storymode', False),
'storymodemethod': int(data.get('storymodemethod', 1)),
'storymode_max_length': int(data.get('storymode_max_length', 1000)),
'resolution_w': int(data.get('resolution_w', 1080)),
'resolution_h': int(data.get('resolution_h', 1920)),
'zoom': float(data.get('zoom', 1)),
'channel_name': data.get('channel_name', 'Reddit Tales'),
'background': {
'background_video': data.get('background_video', 'minecraft'),
'background_audio': data.get('background_audio', 'lofi'),
'background_audio_volume': float(data.get('background_audio_volume', 0.15)),
'enable_extra_audio': data.get('enable_extra_audio', False),
'background_thumbnail': data.get('background_thumbnail', False)
},
'tts': {
'voice_choice': data.get('voice_choice', 'qwentts'),
'random_voice': data.get('random_voice', True),
'silence_duration': float(data.get('silence_duration', 0.3)),
'no_emojis': data.get('no_emojis', False),
'qwen_api_url': data.get('qwen_api_url', 'http://localhost:8080'),
'qwen_email': data.get('qwen_email', ''),
'qwen_speaker': data.get('qwen_speaker', 'Vivian'),
'qwen_language': data.get('qwen_language', 'English'),
'qwen_instruct': data.get('qwen_instruct', 'Warm, friendly, conversational.'),
'elevenlabs_voice_name': data.get('elevenlabs_voice_name', 'Bella'),
'elevenlabs_api_key': '',
'tiktok_voice': data.get('tiktok_voice', 'en_us_001'),
'tiktok_sessionid': data.get('tiktok_sessionid', ''),
'openai_api_url': data.get('openai_api_url', 'https://api.openai.com/v1/'),
'openai_voice_name': data.get('openai_voice_name', 'alloy'),
'openai_model': data.get('openai_model', 'tts-1'),
'aws_polly_voice': data.get('aws_polly_voice', 'Matthew'),
'streamlabs_polly_voice': data.get('streamlabs_polly_voice', 'Matthew')
}
}
}
# Preserve password if not changed
if data.get('qwen_password') and data['qwen_password'] != '********':
config['settings']['tts']['qwen_password'] = data['qwen_password']
elif existing_config.get('settings', {}).get('tts', {}).get('qwen_password'):
config['settings']['tts']['qwen_password'] = existing_config['settings']['tts']['qwen_password']
else:
config['settings']['tts']['qwen_password'] = ''
# Preserve API keys if not changed
if data.get('openai_api_key') and data['openai_api_key'] != '********':
config['settings']['tts']['openai_api_key'] = data['openai_api_key']
elif existing_config.get('settings', {}).get('tts', {}).get('openai_api_key'):
config['settings']['tts']['openai_api_key'] = existing_config['settings']['tts']['openai_api_key']
else:
config['settings']['tts']['openai_api_key'] = ''
if save_config(config):
return jsonify({'success': True, 'message': 'Configuration saved successfully'})
else:
return jsonify({'success': False, 'message': 'Failed to save configuration'}), 500
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@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."""
@app.route("/api/backgrounds/video", methods=["GET"])
def api_get_background_videos():
"""Get list of background videos."""
return jsonify({'videos': get_background_videos()})
@app.route("/api/backgrounds/audio", methods=["GET"])
def api_get_background_audios():
"""Get list of background audio tracks."""
return jsonify({'audios': get_background_audios()})
@app.route("/api/backgrounds/video", methods=["POST"])
def api_upload_background_video():
"""Upload a background video."""
if 'file' not in request.files:
return jsonify({'success': False, 'message': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': 'No file selected'}), 400
if file and allowed_video_file(file.filename):
filename = secure_filename(file.filename)
filepath = BACKGROUNDS_VIDEO_PATH / filename
file.save(filepath)
return jsonify({'success': True, 'message': f'Video "{filename}" uploaded successfully'})
return jsonify({'success': False, 'message': 'Invalid file type'}), 400
@app.route("/api/backgrounds/audio", methods=["POST"])
def api_upload_background_audio():
"""Upload a background audio track."""
if 'file' not in request.files:
return jsonify({'success': False, 'message': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': 'No file selected'}), 400
if file and allowed_audio_file(file.filename):
filename = secure_filename(file.filename)
filepath = BACKGROUNDS_AUDIO_PATH / filename
file.save(filepath)
return jsonify({'success': True, 'message': f'Audio "{filename}" uploaded successfully'})
return jsonify({'success': False, 'message': 'Invalid file type'}), 400
@app.route("/api/backgrounds/video/<filename>", methods=["DELETE"])
def api_delete_background_video(filename):
"""Delete a background video."""
filepath = BACKGROUNDS_VIDEO_PATH / secure_filename(filename)
if filepath.exists():
filepath.unlink()
return jsonify({'success': True, 'message': 'Video deleted'})
return jsonify({'success': False, 'message': 'File not found'}), 404
@app.route("/api/backgrounds/audio/<filename>", methods=["DELETE"])
def api_delete_background_audio(filename):
"""Delete a background audio track."""
filepath = BACKGROUNDS_AUDIO_PATH / secure_filename(filename)
if filepath.exists():
filepath.unlink()
return jsonify({'success': True, 'message': 'Audio deleted'})
return jsonify({'success': False, 'message': 'File not found'}), 404
@app.route("/api/videos", methods=["GET"])
def api_get_videos():
"""Get list of generated videos."""
return jsonify({'videos': get_generated_videos()})
@app.route("/api/generate", methods=["POST"])
def api_generate_video():
"""Start video generation."""
global generation_process, generation_thread
if generation_process and generation_process.poll() is None:
return jsonify({'success': False, 'message': 'Generation already in progress'}), 400
config = load_config()
if not config:
return jsonify({'success': False, 'message': 'Please configure settings first'}), 400
def run_generation():
global generation_process
try:
generation_process = subprocess.Popen(
['python', 'main.py'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=str(Path(__file__).parent)
)
generation_process.wait()
except Exception as e:
print(f"Generation error: {e}")
generation_thread = threading.Thread(target=run_generation)
generation_thread.start()
return jsonify({'success': True, 'message': 'Video generation started'})
@app.route("/api/generate/stop", methods=["POST"])
def api_stop_generation():
"""Stop video generation."""
global generation_process
if generation_process and generation_process.poll() is None:
generation_process.terminate()
return jsonify({'success': True, 'message': 'Generation stopped'})
return jsonify({'success': False, 'message': 'No generation in progress'})
@app.route("/api/generate/status", methods=["GET"])
def api_generation_status():
"""Get generation status."""
global generation_process
running = generation_process and generation_process.poll() is None
return jsonify({
"jobs": [job.to_dict() for job in progress_tracker.job_history]
'running': running,
'progress': progress_tracker.get_status()
})
# Serve static files
@app.route("/api/tts/test", methods=["POST"])
def api_test_tts():
"""Test Qwen TTS connection."""
try:
data = request.json
api_url = data.get('qwen_api_url', '').rstrip('/')
email = data.get('qwen_email', '')
password = data.get('qwen_password', '')
if not api_url or not email or not password:
return jsonify({'success': False, 'message': 'Missing required fields'})
# Test authentication
login_url = f"{api_url}/api/agent/api/auth/login"
auth_response = requests.post(
login_url,
json={'email': email, 'password': password},
headers={'Content-Type': 'application/json'},
timeout=10
)
if auth_response.status_code == 200:
result = auth_response.json()
if 'access_token' in result:
return jsonify({'success': True, 'message': 'Authentication successful'})
else:
return jsonify({'success': False, 'message': 'Invalid response from server'})
elif auth_response.status_code == 401:
return jsonify({'success': False, 'message': 'Invalid credentials'})
else:
return jsonify({'success': False, 'message': f'Server error: {auth_response.status_code}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'message': 'Cannot connect to TTS server'})
except requests.exceptions.Timeout:
return jsonify({'success': False, 'message': 'Connection timeout'})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@app.route("/api/backgrounds", methods=["GET"])
def api_get_all_backgrounds():
"""Get all backgrounds (videos and audio)."""
return jsonify({
'videos': get_background_videos(),
'audios': get_background_audios()
})
# ==================== 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
@app.route("/backgrounds/video/<path:name>")
def serve_background_video(name):
"""Serve background videos."""
return send_from_directory(BACKGROUNDS_VIDEO_PATH, name)
@app.route("/backgrounds/audio/<path:name>")
def serve_background_audio(name):
"""Serve background audio."""
return send_from_directory(BACKGROUNDS_AUDIO_PATH, name)
# ==================== SocketIO Events ====================
@socketio.on("connect", namespace="/progress")
def handle_connect():
"""Handle client connection."""
@ -112,12 +535,15 @@ def handle_request_status():
emit("progress_update", progress_tracker.get_status())
# ==================== Run Server ====================
def run_gui(open_browser=True):
"""Run the progress GUI server."""
"""Run the GUI server."""
import webbrowser
if open_browser:
webbrowser.open(f"http://localhost:{PORT}", new=2)
print(f"Progress GUI running at http://localhost:{PORT}")
print(f"Reddit Video Maker Bot UI running at http://localhost:{PORT}")
socketio.run(app, host=HOST, port=PORT, debug=False)

Loading…
Cancel
Save