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 8 months ago
parent cd9f9f5b40
commit 2c4a8a8a64
No known key found for this signature in database

@ -1,263 +1,210 @@
{% extends "layout.html" %} <!DOCTYPE html>
{% block main %} <html lang="en">
<head>
<!-- Delete Background Modal --> <meta charset="UTF-8">
<div class="modal fade" id="deleteBtnModal" tabindex="-1" role="dialog" aria-hidden="true"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<div class="modal-dialog modal-dialog-centered" role="document"> <title>Backgrounds - Reddit Video Maker Bot</title>
<div class="modal-content"> <link rel="stylesheet" href="/static/css/app.css">
<div class="modal-header"> </head>
<h5 class="modal-title">Delete background</h5> <body>
</div> <nav class="sidebar">
<div class="modal-body"> <div class="logo">
Are you sure you want to delete this background? <h2>Reddit Video Bot</h2>
</div> </div>
<div class="modal-footer"> <ul class="nav-links">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> <li><a href="/">Dashboard</a></li>
<form action="background/delete" method="post"> <li><a href="/settings">Settings</a></li>
<input type="hidden" id="background-key" name="background-key" value=""> <li><a href="/backgrounds" class="active">Backgrounds</a></li>
<button type="submit" class="btn btn-danger">Delete</button> <li><a href="/videos">Videos</a></li>
</form> <li><a href="/progress">Progress</a></li>
</div> </ul>
</div> </nav>
</div>
</div> <main class="content">
<header>
<!-- Add Background Modal --> <h1>Backgrounds</h1>
<div class="modal fade" id="backgroundAddModal" tabindex="-1" role="dialog" aria-hidden="true"> </header>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content"> <!-- Video Backgrounds -->
<div class="modal-header"> <div class="card">
<h5 class="modal-title">Add background video</h5> <div class="card-header">
</div> <h3>Background Videos</h3>
<div class="modal-body"> <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>
<p class="card-description">Upload 16:9 landscape videos. They will be cropped/scaled to fit 9:16 portrait videos.</p>
<div id="video-list" class="media-grid">
<div class="loading">Loading videos...</div>
</div>
</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 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>
</main>
<script src="/static/js/app.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
loadVideos();
loadAudios();
document.getElementById('video-upload').addEventListener('change', (e) => uploadFile(e, 'video'));
document.getElementById('audio-upload').addEventListener('change', (e) => uploadFile(e, 'audio'));
});
<!-- Add video form --> function loadVideos() {
<form id="addBgForm" action="background/add" method="post" novalidate> fetch('/api/backgrounds/video')
<div class="form-group row"> .then(r => r.json())
<label class="col-4 col-form-label" for="youtube_uri">YouTube URI</label> .then(data => {
<div class="col-8"> const container = document.getElementById('video-list');
<div class="input-group"> if (data.videos && data.videos.length > 0) {
<div class="input-group-text"> container.innerHTML = data.videos.map(v => `
<i class="bi bi-youtube"></i> <div class="media-item">
</div> <div class="media-preview video-preview">
<input name="youtube_uri" placeholder="https://www.youtube.com/watch?v=..." type="text" <video src="/backgrounds/video/${v.filename}" muted loop onmouseenter="this.play()" onmouseleave="this.pause(); this.currentTime=0;"></video>
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> </div>
<input name="citation" placeholder="YouTube Channel" type="text" class="form-control"> <div class="media-info">
<span class="media-name">${v.name}</span>
<span class="media-size">${formatFileSize(v.size)}</span>
</div> </div>
<span class="form-text text-muted">Include the channel name of the <button class="btn btn-danger btn-small" onclick="deleteVideo('${v.filename}')">Delete</button>
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>
</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> </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> </div>
`;
}
})
.catch(err => {
document.getElementById('video-list').innerHTML = '<div class="error">Error loading videos</div>';
});
}
<div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="backgrounds"> 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>
<div class="media-info">
<span class="media-name">${a.name}</span>
<span class="media-size">${formatFileSize(a.size)}</span>
</div> </div>
<button class="btn btn-danger btn-small" onclick="deleteAudio('${a.filename}')">Delete</button>
</div> </div>
</main> `).join('');
<script>
var keys = [];
var youtube_urls = [];
// 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]);
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>';
});
$('#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 { } else {
cards[i].classList.add("d-none") 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>
// Validate form `;
$("#addBgForm").submit(function (event) {
$("#addBgForm input").each(function () {
if (!(validate($(this)))) {
event.preventDefault();
event.stopPropagation();
} }
})
.catch(err => {
document.getElementById('audio-list').innerHTML = '<div class="error">Error loading audio</div>';
}); });
});
$('#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");
} }
return bool; function uploadFile(event, type) {
const file = event.target.files[0];
if (!file) return;
// Check values (return true/false) const formData = new FormData();
function check(name, value) { formData.append('file', file);
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 const progressEl = document.getElementById('upload-progress');
if (youtube_urls.includes(value)) { progressEl.classList.remove('hidden');
$("#feedbackYT").html("This background is already added");
$("#feedbackYT").show();
return false;
}
$("#feedbackYT").hide(); fetch(`/api/backgrounds/${type}`, {
return true; 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');
});
if (name == "filename") { // Reset file input
// Check if key is already taken event.target.value = '';
if (keys.includes(value)) {
$("#feedbackFilename").html("This filename is already taken");
$("#feedbackFilename").show();
return false;
} }
let regex = /^([a-zA-Z0-9\s_-]{1,100})$/; function deleteVideo(filename) {
if (!(regex.test(value))) { if (!confirm(`Delete video "${filename}"?`)) return;
return false;
}
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 == "citation") { function deleteAudio(filename) {
if (value.trim()) { if (!confirm(`Delete audio "${filename}"?`)) return;
return true;
}
}
if (name == "position") { fetch(`/api/backgrounds/audio/${filename}`, { method: 'DELETE' })
if (!(value == "center" || value.length == 0 || value % 1 == 0)) { .then(r => r.json())
return false; .then(data => {
showNotification(data.message, data.success ? 'success' : 'error');
if (data.success) loadAudios();
})
.catch(err => showNotification('Delete failed', 'error'));
} }
return true; 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>
</script> </body>
</html>
{% endblock %}

@ -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" %} <!DOCTYPE html>
{% block main %} <html lang="en">
<head>
<main> <meta charset="UTF-8">
<br> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<div class="container"> <title>Settings - Reddit Video Maker Bot</title>
<form id="settingsForm" action="/settings" method="post" novalidate> <link rel="stylesheet" href="/static/css/app.css">
</head>
<!-- Reddit Credentials --> <body>
<p class="h4">Reddit Credentials</p> <nav class="sidebar">
<div class="row mb-2"> <div class="logo">
<label for="client_id" class="col-4">Client ID</label> <h2>Reddit Video Bot</h2>
<div class="col-8"> </div>
<div class="input-group"> <ul class="nav-links">
<div class="input-group-text"> <li><a href="/">Dashboard</a></li>
<i class="bi bi-person"></i> <li><a href="/settings" class="active">Settings</a></li>
</div> <li><a href="/backgrounds">Backgrounds</a></li>
<input name="client_id" value="{{ data.client_id }}" placeholder="Your Reddit app's client ID" <li><a href="/videos">Videos</a></li>
type="text" class="form-control" data-toggle="tooltip" <li><a href="/progress">Progress</a></li>
data-original-title='Text under "personal use script" on www.reddit.com/prefs/apps'> </ul>
</div> </nav>
</div>
</div> <main class="content">
<div class="row mb-2"> <header>
<label for="client_secret" class="col-4">Client Secret</label> <h1>Settings</h1>
<div class="col-8"> <button id="btn-save" class="btn btn-primary">Save All Settings</button>
<div class="input-group"> </header>
<div class="input-group-text">
<i class="bi bi-key-fill"></i> <form id="settings-form">
</div> <!-- Qwen TTS Settings -->
<input name="client_secret" value="{{ data.client_secret }}" <div class="card">
placeholder="Your Reddit app's client secret" type="text" class="form-control" <h3>Qwen TTS Settings</h3>
data-toggle="tooltip" data-original-title='"Secret" on www.reddit.com/prefs/apps'> <p class="card-description">Configure your Qwen TTS server connection for text-to-speech generation.</p>
</div>
</div> <div class="form-group">
</div> <label for="qwen_api_url">API URL</label>
<div class="row mb-2"> <input type="url" id="qwen_api_url" name="qwen_api_url" placeholder="http://localhost:8080">
<label for="username" class="col-4">Reddit Username</label> <small>The base URL of your Qwen TTS server</small>
<div class="col-8"> </div>
<div class="input-group">
<div class="input-group-text"> <div class="form-row">
<i class="bi bi-person-fill"></i> <div class="form-group">
</div> <label for="qwen_email">Email</label>
<input name="username" value="{{ data.username }}" placeholder="Your Reddit account's username" <input type="email" id="qwen_email" name="qwen_email" placeholder="your@email.com">
type="text" class="form-control"> </div>
</div> <div class="form-group">
</div> <label for="qwen_password">Password</label>
</div> <input type="password" id="qwen_password" name="qwen_password" placeholder="Enter password">
<div class="row mb-2"> </div>
<label for="password" class="col-4">Reddit Password</label> </div>
<div class="col-8">
<div class="input-group"> <div class="form-row">
<div class="input-group-text"> <div class="form-group">
<i class="bi bi-lock-fill"></i> <label for="qwen_speaker">Speaker Voice</label>
</div> <select id="qwen_speaker" name="qwen_speaker">
<input name="password" value="{{ data.password }}" placeholder="Your Reddit account's password" <option value="Chelsie">Chelsie</option>
type="password" class="form-control"> <option value="Ethan">Ethan</option>
</div> <option value="Vivian" selected>Vivian</option>
</div> <option value="Asher">Asher</option>
</div> <option value="Aria">Aria</option>
<div class="row mb-2"> <option value="Oliver">Oliver</option>
<label class="col-4">Do you have Reddit 2FA enabled?</label> <option value="Emma">Emma</option>
<div class="col-8"> <option value="Noah">Noah</option>
<div class="form-check form-switch"> <option value="Sophia">Sophia</option>
<input name="2fa" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip" </select>
data-original-title='Check it if you have enabled 2FA on your Reddit account'>
</div> </div>
<span class="form-text text-muted"><a <div class="form-group">
href="https://reddit-video-maker-bot.netlify.app/docs/configuring#setting-up-the-api" <label for="qwen_language">Language</label>
target="_blank">Need help? Click here to open step-by-step guide.</a></span> <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> </div>
</div> </div>
<!-- Reddit Thread --> <div class="form-group">
<p class="h4">Reddit Thread</p> <label for="qwen_instruct">Voice Style Instructions</label>
<div class="row mb-2"> <input type="text" id="qwen_instruct" name="qwen_instruct" placeholder="Warm, friendly, conversational.">
<label class="col-4">Random Thread</label> <small>Describe the speaking style you want</small>
<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>
</div> </div>
<div class="row mb-2">
<label for="post_id" class="col-4">Post ID</label> <button type="button" id="btn-test-tts" class="btn btn-secondary">Test TTS Connection</button>
<div class="col-8"> <span id="tts-status" class="status-text"></span>
<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)">
</div>
</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> </div>
<span class="form-text text-muted">Max number of characters a comment can have.</span>
<!-- 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> </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="post_lang" class="col-4">Post Language</label> <div class="form-row">
<div class="col-8"> <div class="form-group">
<div class="input-group"> <label for="min_comments">Minimum Comments</label>
<div class="input-group-text"> <input type="number" id="min_comments" name="min_comments" value="20" min="1">
<i class="bi bi-translate"></i>
</div> </div>
<input value="{{ data.post_lang }}" name="post_lang" type="text" class="form-control" <div class="form-group">
placeholder="The language you would like to translate to"> <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>
<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>
<div class="row mb-2">
<label for="min_comments" class="col-4">Minimum Comments</label> <div class="form-group checkbox-group">
<div class="col-8"> <label>
<div class="input-group"> <input type="checkbox" id="random" name="random" checked>
<input name="min_comments" type="range" class="form-range" min="15" max="1000" step="1" Pick random posts from subreddit
value="{{ data.min_comments }}" data-toggle="tooltip" </label>
data-original-title="{{ data.min_comments }}">
</div> </div>
<span class="form-text text-muted">The minimum number of comments a post should have to be
included.</span> <div class="form-group checkbox-group">
<label>
<input type="checkbox" id="allow_nsfw" name="allow_nsfw">
Allow NSFW content
</label>
</div> </div>
</div> </div>
<!-- General Settings --> <!-- Video Settings -->
<p class="h4">General Settings</p> <div class="card">
<div class="row mb-2"> <h3>Video Settings</h3>
<label class="col-4">Allow NSFW</label> <p class="card-description">Configure video output and visual settings.</p>
<div class="col-8">
<div class="form-check form-switch"> <div class="form-row">
<input name="allow_nsfw" class="form-check-input" type="checkbox" value="True" <div class="form-group">
data-toggle="tooltip" data-original-title='If checked NSFW posts will be allowed'> <label for="resolution_w">Width</label>
<input type="number" id="resolution_w" name="resolution_w" value="1080">
</div> </div>
<div class="form-group">
<label for="resolution_h">Height</label>
<input type="number" id="resolution_h" name="resolution_h" value="1920">
</div> </div>
</div> </div>
<div class="row mb-2">
<label for="theme" class="col-4">Reddit Theme</label> <div class="form-row">
<div class="col-8"> <div class="form-group">
<select name="theme" class="form-select" data-toggle="tooltip" <label for="theme">Screenshot Theme</label>
data-original-title='Sets the theme of Reddit screenshots'> <select id="theme" name="theme">
<option value="dark">Dark</option> <option value="dark" selected>Dark</option>
<option value="light">Light</option> <option value="light">Light</option>
<option value="transparent">Transparent</option>
</select> </select>
</div> </div>
</div> <div class="form-group">
<div class="row mb-2"> <label for="opacity">Opacity</label>
<label for="times_to_run" class="col-4">Times To Run</label> <input type="number" id="opacity" name="opacity" value="0.9" min="0" max="1" step="0.1">
<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 }}">
</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>
<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>
<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>
</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>
</div> </div>
<!-- TTS Settings --> <div class="form-group">
<p class="h4">TTS Settings</p> <label for="times_to_run">Videos per Run</label>
<div class="row mb-2"> <input type="number" id="times_to_run" name="times_to_run" value="1" min="1" max="10">
<label for="voice_choice" class="col-4">TTS Voice Choice</label> <small>How many videos to generate when you click "Generate"</small>
<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> </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" <div class="form-group checkbox-group">
class="bi-volume-up-fill"></i></button> <label>
</div> <input type="checkbox" id="storymode" name="storymode">
Story Mode (for narrative subreddits like r/nosleep)
</label>
</div> </div>
</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" <!-- Background Settings -->
class="bi bi-volume-up-fill"></i></button> <div class="card">
</div> <h3>Background Settings</h3>
</div> <p class="card-description">Select background video and audio. Upload custom backgrounds in the Backgrounds page.</p>
</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>
</select>
<button type="button" class="btn btn-primary"><i id="tiktok_icon" <div class="form-row">
class="bi-volume-up-fill"></i></button> <div class="form-group">
</div> <label for="background_video">Background Video</label>
</div> <select id="background_video" name="background_video">
</div> <option value="minecraft">Minecraft Parkour</option>
<div class="row mb-2"> <option value="gta">GTA V</option>
<label for="tiktok_sessionid" class="col-4">TikTok SessionId</label> <option value="rocket-league">Rocket League</option>
<div class="col-8"> <option value="subway-surfers">Subway Surfers</option>
<div class="input-group"> </select>
<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>
</div>
</div> </div>
<div class="row mb-2"> <div class="form-group">
<label for="silence_duration" class="col-4">Silence Duration</label> <label for="background_audio">Background Audio</label>
<div class="col-8"> <select id="background_audio" name="background_audio">
<div class="input-group"> <option value="lofi">Lo-Fi Beats</option>
<input name="silence_duration" type="range" class="form-range" min="0" max="5" step="0.05" <option value="lofi-2">Lo-Fi Beats 2</option>
value="{{ data.silence_duration }}" data-toggle="tooltip" <option value="chill-summer">Chill Summer</option>
data-original-title="{{ data.silence_duration }}"> <option value="none">No Background Audio</option>
</select>
</div> </div>
<span class="form-text text-muted">Time in seconds between TTS comments.</span>
</div> </div>
<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> </div>
</form> </form>
</div> </main>
<audio src=""></audio>
</main> <script src="/static/js/app.js"></script>
<script>
<script> document.addEventListener('DOMContentLoaded', () => {
// Test voices buttons loadSettings();
var playing = false; loadCustomBackgrounds();
$(".voices button").click(function () { document.getElementById('btn-save').addEventListener('click', saveSettings);
var icon = $(this).find("i"); document.getElementById('btn-test-tts').addEventListener('click', testTTSConnection);
var audio = $("audio");
// Volume slider display
if (playing) { const volumeSlider = document.getElementById('background_audio_volume');
playing.toggleClass("bi-volume-up-fill bi-stop-fill"); const volumeDisplay = document.getElementById('volume-display');
volumeSlider.addEventListener('input', () => {
// Clicked the same button - stop audio volumeDisplay.textContent = Math.round(volumeSlider.value * 100) + '%';
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 function loadSettings() {
$('#settingsForm').submit(function () { fetch('/api/config')
$('.form-check-input').each(function () { .then(r => r.json())
if (!($(this).is(':checked'))) { .then(config => {
$(this).prop("value", "False"); if (!config || !config.settings) return;
$(this).prop("checked", true);
// 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 function loadCustomBackgrounds() {
$("#defaultSettingsBtn").click(function (event) { fetch('/api/backgrounds/video')
$("#settingsForm input, #settingsForm select").each(function () { .then(r => r.json())
let check = validateChecks[$(this).prop("name")]; .then(data => {
const videoSelect = document.getElementById('background_video');
if (check["default"]) {
$(this).prop("value", check["default"]); // Add custom videos
if (data.videos && data.videos.length > 0) {
// Update tooltip value for input[type="range"] const customGroup = document.createElement('optgroup');
if ($(this).prop("type") == "range") { customGroup.label = 'Custom Videos';
$(this).attr("data-original-title", check["default"]); data.videos.forEach(v => {
} const opt = document.createElement('option');
} opt.value = 'custom:' + v.filename;
}); opt.textContent = v.name;
}); customGroup.appendChild(opt);
// 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));
}); });
videoSelect.appendChild(customGroup);
$("#settingsForm select").on("change", 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"); .catch(err => console.error('Error loading backgrounds:', err));
object.addClass("is-invalid");
} }
return bool; function setFieldValue(id, value) {
const field = document.getElementById(id);
// Check values (return true/false) if (field && value !== undefined && value !== null) {
function check(name, value) { field.value = 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 function setCheckbox(id, value) {
if (check["nmin"]) { const field = document.getElementById(id);
if (check["type"] == "int" || check["type"] == "float") { if (field) {
if (value < check["nmin"]) { field.checked = !!value;
return false;
} }
} }
else {
if (value.length < check["nmin"]) {
return false;
}
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 is too long function testTTSConnection() {
if (check["nmax"]) { const statusEl = document.getElementById('tts-status');
if (check["type"] == "int" || check["type"] == "float") { statusEl.textContent = 'Testing...';
if (value > check["nmax"]) { statusEl.className = 'status-text';
return false;
} const config = {
} qwen_api_url: document.getElementById('qwen_api_url').value,
else { qwen_email: document.getElementById('qwen_email').value,
if (value.length > check["nmax"]) { qwen_password: document.getElementById('qwen_password').value
return false; };
}
fetch('/api/tts/test', {
} method: 'POST',
} headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
// Check if value matches regex })
if (check["regex"]) { .then(r => r.json())
let regex = new RegExp(check["regex"]); .then(data => {
if (!(regex.test(value))) { if (data.success) {
return false; statusEl.textContent = 'Connection successful!';
} statusEl.className = 'status-text success';
} } else {
statusEl.textContent = 'Failed: ' + data.message;
return true; statusEl.className = 'status-text error';
}
} }
})
.catch(err => {
statusEl.textContent = 'Connection error';
statusEl.className = 'status-text error';
}); });
</script> }
</script>
{% endblock %} </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: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:
- ./config.toml:/app/config.toml:ro # Config is read-write so UI can save settings
- ./config.toml:/app/config.toml
- ./results:/app/results - ./results:/app/results
- ./assets:/app/assets - ./assets:/app/assets
environment: environment:
@ -19,18 +20,20 @@ services:
- REDDIT_SUBREDDIT=${REDDIT_SUBREDDIT:-AskReddit} - REDDIT_SUBREDDIT=${REDDIT_SUBREDDIT:-AskReddit}
- REDDIT_REQUEST_DELAY=${REDDIT_REQUEST_DELAY:-2.0} - REDDIT_REQUEST_DELAY=${REDDIT_REQUEST_DELAY:-2.0}
- REDDIT_RANDOM=${REDDIT_RANDOM:-true} - REDDIT_RANDOM=${REDDIT_RANDOM:-true}
# TTS Settings (Qwen TTS) # TTS Settings
- TTS_VOICE_CHOICE=${TTS_VOICE_CHOICE:-qwentts} - 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_EMAIL=${QWEN_EMAIL:-}
- QWEN_PASSWORD=${QWEN_PASSWORD:-} - QWEN_PASSWORD=${QWEN_PASSWORD:-}
- QWEN_SPEAKER=${QWEN_SPEAKER:-Vivian} - QWEN_SPEAKER=${QWEN_SPEAKER:-Vivian}
- QWEN_LANGUAGE=${QWEN_LANGUAGE:-English} - QWEN_LANGUAGE=${QWEN_LANGUAGE:-English}
networks: networks:
- reddit-bot-network - reddit-bot-network
depends_on: # Run the web UI by default
- qwen-tts command: python progress_gui.py
# Optional: Qwen TTS Server (if running locally)
# Uncomment or use: docker-compose --profile with-tts up -d
qwen-tts: qwen-tts:
image: qwen-tts-server:latest image: qwen-tts-server:latest
container_name: qwen-tts container_name: qwen-tts
@ -41,6 +44,8 @@ services:
- TTS_MODEL=qwen3-tts - TTS_MODEL=qwen3-tts
networks: networks:
- reddit-bot-network - reddit-bot-network
profiles:
- with-tts
# Uncomment if using GPU # Uncomment if using GPU
# deploy: # deploy:
# resources: # resources:
@ -50,27 +55,6 @@ services:
# count: 1 # count: 1
# capabilities: [gpu] # 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: networks:
reddit-bot-network: reddit-bot-network:
driver: bridge driver: bridge

@ -1,30 +1,129 @@
#!/usr/bin/env python #!/usr/bin/env python
""" """
Progress GUI for Reddit Video Maker Bot. Reddit Video Maker Bot - Web UI
Real-time progress tracking with steps and previews. Complete web interface for configuration, video generation, and progress tracking.
""" """
import os import os
import json import json
import subprocess
import threading import threading
import webbrowser import shutil
from pathlib import Path 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 flask_socketio import SocketIO, emit
from werkzeug.utils import secure_filename
from utils.progress import progress_tracker from utils.progress import progress_tracker
# Configuration # Configuration
HOST = "0.0.0.0" HOST = "0.0.0.0"
PORT = 5000 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 # Configure Flask app
app = Flask(__name__, template_folder="GUI", static_folder="GUI/static") app = Flask(__name__, template_folder="GUI", static_folder="GUI/static")
app.secret_key = os.urandom(24) app.secret_key = os.urandom(24)
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB max upload
# Configure SocketIO for real-time updates # Configure SocketIO for real-time updates
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent") 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 # Progress update callback
def broadcast_progress(data): def broadcast_progress(data):
@ -45,55 +144,379 @@ def after_request(response):
return response return response
# ==================== Pages ====================
@app.route("/") @app.route("/")
def index(): 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") 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") @app.route("/api/status")
def get_status(): def get_status():
"""Get current progress status.""" """Get current progress status."""
return jsonify(progress_tracker.get_status()) return jsonify(progress_tracker.get_status())
@app.route("/api/history") @app.route("/api/backgrounds/video", methods=["GET"])
def get_history(): def api_get_background_videos():
"""Get job history.""" """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({ 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>") @app.route("/static/<path:filename>")
def static_files(filename): def static_files(filename):
"""Serve static files.""" """Serve static files."""
return send_from_directory("GUI/static", filename) return send_from_directory("GUI/static", filename)
# Serve result videos
@app.route("/results/<path:name>") @app.route("/results/<path:name>")
def results(name): def results(name):
"""Serve result videos.""" """Serve result videos."""
return send_from_directory("results", name) return send_from_directory("results", name)
# Serve preview images
@app.route("/preview/<path:name>") @app.route("/preview/<path:name>")
def previews(name): def previews(name):
"""Serve preview images.""" """Serve preview images."""
return send_from_directory("assets/temp", name) return send_from_directory("assets/temp", name)
# Serve temp assets (screenshots, audio visualizations)
@app.route("/assets/<path:name>") @app.route("/assets/<path:name>")
def assets(name): def assets(name):
"""Serve asset files.""" """Serve asset files."""
return send_from_directory("assets", name) 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") @socketio.on("connect", namespace="/progress")
def handle_connect(): def handle_connect():
"""Handle client connection.""" """Handle client connection."""
@ -112,12 +535,15 @@ def handle_request_status():
emit("progress_update", progress_tracker.get_status()) emit("progress_update", progress_tracker.get_status())
# ==================== Run Server ====================
def run_gui(open_browser=True): def run_gui(open_browser=True):
"""Run the progress GUI server.""" """Run the GUI server."""
import webbrowser
if open_browser: if open_browser:
webbrowser.open(f"http://localhost:{PORT}", new=2) 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) socketio.run(app, host=HOST, port=PORT, debug=False)

Loading…
Cancel
Save