feat: video creation dashboard with real-time progress tracking

Add /create page with pipeline stage polling, /video/<id> route for
safe file serving, modernized Tailwind/DaisyUI UI, and pytest
regression tests. Consolidate AGENT.md + AGENTS.md into CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/2551/head
Hong Phuc 4 months ago
parent 5e183d8e2c
commit faaaa85be8

@ -1,392 +0,0 @@
# AGENT.md — Guidance for Agents & AI Working on VideoMakerBot
This document guides **agents, bots, and AI assistants** on how to work effectively with the VideoMakerBot codebase.
---
## Quick Start for Agents
### Core Principle
**VideoMakerBot uses a platform-agnostic factory pattern.** Always respect the abstraction:
- Don't import platform-specific modules (reddit/, threads/) directly
- Always use `platforms/__init__.py` factory functions
- Keep platform-specific logic in `platforms/{platform}/`
### The "Do This" Checklist
1. ✅ Read existing CLAUDE.md for architecture context
2. ✅ Use factory: `from platforms import get_content_object, get_screenshot_fn`
3. ✅ Return standard `content_object` dict from all fetchers
4. ✅ Test both Reddit and Threads modes before declaring completion
5. ✅ Use config fallback chains for cross-platform keys
6. ✅ Document platform-specific logic in docstrings
### The "Don't Do This" List
1. ❌ Import `reddit.subreddit` directly in main.py or generic modules
2. ❌ Hardcode subreddit/platform names in core video pipeline
3. ❌ Add platform-specific selectors outside `platforms/{platform}/`
4. ❌ Assume config keys exist without `.get()` and fallbacks
5. ❌ Modify screenshot_downloader.py for non-Reddit platforms
---
## Understanding the Codebase Structure
### Entry Point
**`main.py`** — Single CLI entry point using platform factory
- Calls `get_content_object(POST_ID)` from factory
- Calls `get_screenshot_fn()` from factory
- Everything else is platform-agnostic
### Platform Layer (`platforms/`)
- **`__init__.py`** — Factory dispatch functions (add new platforms here)
- **`threads/fetcher.py`** — Threads Graph API client (returns standard dict)
- **`threads/screenshot.py`** — Threads.net Playwright screenshotter
### Legacy Platform (`reddit/`)
- **`subreddit.py`** — PRAW API client (returns standard dict)
- No changes needed; called via factory
### Video Pipeline (`video_creation/`)
- **`final_video.py`** — FFmpeg composition (platform-aware output folder only)
- **`screenshot_downloader.py`** — Reddit Playwright screenshotter (not called for Threads)
- **`voices.py`** — TTS orchestration (platform-agnostic)
- **`background.py`** — Video/audio download (platform-agnostic)
### TTS Layer (`TTS/`)
- **`engine_wrapper.py`** — Provider abstraction (handles `post_lang` fallback)
- **`*.py`** — Individual provider implementations (elevenlabs, aws_polly, etc.)
### Config & Utils (`utils/`)
- **`settings.py`** — TOML config loading & validation
- **`videos.py`** — Dedup tracking (`check_done()` + `check_done_by_id()`)
- **`.config.template.toml`** — Config schema with `[settings]`, `[reddit.*]`, `[threads.*]`, `[ai]`
---
## How to Approach Common Tasks
### Adding a New Social Platform (e.g., X/Twitter)
**Steps:**
1. Create `platforms/twitter/fetcher.py`:
```python
def get_twitter_content(POST_ID=None) -> dict:
"""Fetch post + replies, return standard content_object."""
# Implement API fetching logic here
return {
"thread_id": ...,
"thread_category": "twitter", # NEW: generic field for output folder
"thread_title": ...,
"thread_url": ...,
"comments": [...]
}
```
2. Create `platforms/twitter/screenshot.py`:
```python
def get_screenshots_of_twitter_posts(content_object: dict, screenshot_num: int):
"""Use Playwright to screenshot X/Twitter posts."""
# Implement Playwright logic here
```
3. Update `platforms/__init__.py`:
```python
elif platform == "twitter":
from platforms.twitter.fetcher import get_twitter_content
return get_twitter_content(POST_ID)
```
4. Add config section to `utils/.config.template.toml`:
```toml
[twitter.creds]
api_key = { ... }
api_secret = { ... }
[twitter.thread]
post_id = { ... }
```
5. Update `main.py` helper:
```python
elif platform == "twitter":
return config.get("twitter", {}).get("thread", {}).get("post_id", "")
```
6. **Zero changes needed to:** TTS, backgrounds, video composition, utils.
**Verification:**
```bash
# Test Reddit (regression check)
sed -i 's/platform = "twitter"/platform = "reddit"/' config.toml
python3 main.py
# Verify results/{subreddit}/ output
# Test Twitter
sed -i 's/platform = "reddit"/platform = "twitter"/' config.toml
python3 main.py --post-id <twitter-id>
# Verify results/twitter/ output
```
---
### Modifying the Video Pipeline
**Scenario:** You need to change FFmpeg composition or add a new processing step.
**Approach:**
1. Check which data the modified code consumes (`content_object` dict)
2. Verify it works with both Reddit and Threads content structures
3. If platform-specific: move logic to `platforms/{platform}/`
4. If generic: keep in `video_creation/`
5. Test both modes before merging
**Example:** Adding video filters
```python
# In final_video.py (generic, works for all platforms)
def apply_filter(video_clip, filter_type):
# No platform-specific logic here
return video_clip.filter(...)
# Test:
# - Reddit mode produces filtered video
# - Threads mode produces filtered video
```
---
### Fixing a Bug in Config Handling
**Scenario:** `post_lang` is not being applied correctly.
**Debug Path:**
1. Check `utils/settings.py` — how is config loaded?
2. Check `TTS/engine_wrapper.py:182` — uses fallback chain:
```python
lang = (settings.config["settings"].get("post_lang") or
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
```
3. Check `video_creation/final_video.py:78` — same fallback logic
4. If still broken: verify `utils/.config.template.toml` has the key defined
5. Test both platforms with `post_lang = "es"` in config
---
### Adding Support for a New TTS Provider
**Scenario:** User wants Whisper TTS support.
**Steps:**
1. Create `TTS/whisper_tts.py`:
```python
class WhisperTTS:
def make_voice(self, text):
# Call Whisper API
return audio_bytes
```
2. Update `TTS/engine_wrapper.py:make_voice()`:
```python
elif voice_choice == "whisper":
from TTS.whisper_tts import WhisperTTS
return WhisperTTS().make_voice(text)
```
3. Add config to `utils/.config.template.toml`:
```toml
[settings.tts]
whisper_api_key = { optional = true, ... }
```
4. Test:
```bash
# In config.toml:
voice_choice = "whisper"
# Run: python3 main.py
```
---
## Common Pitfalls & How to Avoid Them
### Pitfall 1: Platform-Specific Code in Generic Modules
**Problem:**
```python
# BAD: In video_creation/final_video.py
subreddit = settings.config["reddit"]["thread"]["subreddit"]
```
**Will break** when platform = "threads" (no reddit.thread.subreddit).
**Solution:**
```python
# GOOD:
platform = settings.config["settings"].get("platform", "reddit")
if platform == "reddit":
category = settings.config["reddit"]["thread"]["subreddit"]
else:
category = reddit_obj.get("thread_category", platform)
```
### Pitfall 2: Hardcoding Selectors in Platform-Agnostic Code
**Problem:**
```python
# BAD: In video_creation/voices.py
element = page.locator("#t1_{comment_id}") # Reddit-only selector!
```
**Will fail** when running Threads mode (different DOM).
**Solution:**
- Keep all Playwright logic in `platforms/{platform}/screenshot.py`
- Never hardcode selectors in generic modules
### Pitfall 3: Forgetting to Test Both Modes
**Problem:** You change `final_video.py`, test with Reddit, declare done.
Threads mode breaks because you didn't test it.
**Solution:**
```bash
# Test both before committing:
sed -i 's/platform = "threads"/platform = "reddit"/' config.toml
python3 main.py
# Check results/{subreddit}/
sed -i 's/platform = "reddit"/platform = "threads"/' config.toml
python3 main.py --post-id <id>
# Check results/threads/
```
### Pitfall 4: Assuming Config Keys Exist
**Problem:**
```python
# BAD:
lang = settings.config["reddit"]["thread"]["post_lang"]
```
**Will crash** if key doesn't exist.
**Solution:**
```python
# GOOD:
lang = (settings.config["settings"].get("post_lang") or
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
```
---
## Code Review Checklist for Agents
Before marking work complete, verify:
- [ ] **No platform imports in main.py** — Uses factory only
- [ ] **Standard content_object dict** — All fetchers return same shape
- [ ] **Platform-specific logic isolated** — Only in `platforms/{platform}/`
- [ ] **Config fallback chains** — No hardcoded section names in generic code
- [ ] **Both modes tested** — Reddit AND Threads produce correct output
- [ ] **Docstrings updated** — New functions document platform assumptions
- [ ] **Error messages clear** — Include platform name + actionable guidance
- [ ] **Video dedup works** — No duplicate videos created
---
## Understanding Data Flow
### Happy Path: Fetch → TTS → Screenshot → Compose → Output
```
1. main.py:main()
└─→ platforms/__init__.py:get_content_object()
└─→ platforms/threads/fetcher.py:get_threads_content()
└─→ Returns: {thread_id, thread_title, comments, ...}
2. video_creation/voices.py:save_text_to_mp3()
└─→ TTS/engine_wrapper.py:process_text()
└─→ TTS/engine_wrapper.py:make_voice()
└─→ TTS/{provider}.py: {elevenlabs,tiktok,etc}
└─→ Returns: audio_length, comment_count
3. platforms/__init__.py:get_screenshot_fn()
└─→ platforms/threads/screenshot.py:get_screenshots_of_threads_posts()
└─→ Uses Playwright on threads.net
└─→ Saves: assets/temp/{thread_id}/png/{title,comment_0,etc}.png
4. video_creation/background.py
└─→ download_background_video() & download_background_audio()
└─→ Uses yt-dlp to fetch YouTube videos/audio
└─→ Saves to: assets/temp/{thread_id}/{video,audio}
5. video_creation/final_video.py:make_final_video()
└─→ Uses FFmpeg to compose everything
└─→ Reads: audio files, screenshot PNGs, background video
└─→ Writes: results/{thread_category}/{filename}.mp4
6. utils/videos.py:save_data()
└─→ Records video in videos.json for dedup
```
### Config Flow
```
config.toml (user settings)
utils/settings.py:check_toml()
└─→ Validates against .config.template.toml schema
└─→ Returns: settings.config (dict)
Used by:
├─ main.py (platform selection)
├─ platforms/reddit/ (subreddit, etc.)
├─ platforms/threads/ (Graph API token, etc.)
├─ TTS/engine_wrapper.py (post_lang fallback)
├─ video_creation/ (theme, resolution, etc.)
└─ utils/videos.py (dedup behavior)
```
---
## Deployment Notes
### Python Version
- **Minimum:** 3.10
- **Tested:** 3.10, 3.11, 3.12
- **Reason:** F-strings, type hints, modern async patterns
### Critical Dependencies
- **reddit platform:** praw 7.8.1 (requires Reddit OAuth app)
- **threads platform:** requests (for Graph API calls)
- **screenshots:** playwright 1.49.1 (requires browser installation: `playwright install`)
- **video:** moviepy 2.2.1, ffmpeg-python 0.2.0 (requires FFmpeg system binary)
- **tts:** varies per provider (elevenlabs, aws_polly, openai, etc.)
### Versions That Caused Issues
- **yt-dlp==2026.3.17** — Doesn't exist (use 2025.10.14 or latest stable)
- **playwright without browser install** — Will crash on first screenshot
---
## When to Escalate
### Escalate to User if:
- User needs new platform support (only they know requirements)
- Config changes affect backward compatibility
- Performance optimization needed (only user knows acceptable limits)
- Security concern (token handling, credential storage, etc.)
### Safe to Implement as Agent:
- Bug fixes within existing architecture
- Adding new TTS providers
- Extending config options for existing platforms
- Performance optimizations (caching, parallelization)
- New filter/processing features that work platform-agnostically
- Documentation & refactoring
---
## Final Guidance
**Golden Rule:** The factory pattern is your friend. When in doubt, check if your change breaks the abstraction. If it does, rethink it.
**Test Obsessively:** Always run both Reddit and Threads modes. The codebase is designed for multi-platform support, and it's easy to break one platform while fixing another.
**Document Platform Assumptions:** If your code works differently for Reddit vs Threads, say so explicitly in docstrings and comments.
**Ask Yourself:** "Would this work for X/Twitter?" If no, it probably belongs in `platforms/threads/`, not in generic code.
Good luck, and happy contributing! 🎥

@ -1,457 +0,0 @@
# AGENTS.md — VideoMakerBot Development Guide
## Project Overview
**VideoMakerBot** — Automated short-form video creator from social media content.
**Status:** Production-ready, actively maintained (v3.4.0)
**Language:** Python 3.10+
**Platforms:** Reddit (original), Threads (NEW), X/Twitter (planned)
### Core Mission
Transforms social media threads (post + comments/replies) into complete short-form videos with:
- AI-generated speech (7+ TTS providers)
- UI screenshots (Playwright)
- Background video/audio overlays
- FFmpeg composition & output
---
## Architecture at a Glance
```
main.py (CLI)
↓ [platform factory]
├─→ reddit/subreddit.py [PRAW API]
└─→ platforms/threads/fetcher.py [Graph API]
↓ [standard data dict]
├─→ TTS/engine_wrapper.py [7+ providers]
├─→ screenshot_downloader.py (Reddit)
│ or platforms/threads/screenshot.py (Threads)
├─→ video_creation/background.py
└─→ video_creation/final_video.py [FFmpeg]
results/{category}/{video.mp4}
```
### Key Design: Platform Abstraction via Factory Pattern
**Why:** Single codebase supports multiple platforms without tight coupling.
**How:** `platforms/__init__.py` exports:
- `get_content_object(POST_ID=None)` — routes to right fetcher
- `get_screenshot_fn()` — routes to right screenshotter
**Result:** Adding X/Twitter requires only: new module + config section + two `elif` branches.
---
## Data Contract: The "content_object" Dict
All fetchers return this shape (defined in `platforms/__init__.py`):
```python
{
# Unique identifiers
"thread_id": str, # Used for temp folder: assets/temp/{id}/
"thread_category": str, # "reddit", "threads", etc. → output folder
# Content
"thread_title": str, # TTS as title + output filename
"thread_url": str, # Playwright navigates here for screenshot
"is_nsfw": bool, # Content filter flag
# Replies/Comments (mutually exclusive with thread_post)
"comments": [
{
"comment_body": str, # TTS per reply
"comment_url": str, # Playwright navigates here
"comment_id": str, # CSS selector ID or unique identifier
}
],
# OR Story mode:
"thread_post": str | list, # Long-form text (no comments)
}
```
**Why:** Loose coupling—TTS, backgrounds, and video composition don't need platform-specific logic.
---
## File Organization
```
VideoMakerBot/
├── platforms/ # Multi-platform abstraction
│ ├── __init__.py # Factory: get_content_object(), get_screenshot_fn()
│ └── threads/ # Threads (Meta) implementation
│ ├── fetcher.py # Graph API → content_object
│ └── screenshot.py # Playwright Threads screenshotter
├── reddit/ # Reddit implementation (kept as-is)
│ └── subreddit.py # PRAW API → content_object + thread_category
├── video_creation/
│ ├── final_video.py # FFmpeg composition (platform-aware folder naming)
│ ├── screenshot_downloader.py # Playwright Reddit UI capturer
│ ├── voices.py # TTS orchestrator (platform-agnostic)
│ ├── background.py # Video/audio downloader (platform-agnostic)
│ └── data/
│ ├── videos.json # Dedup tracker
│ ├── cookie-dark-mode.json # Reddit theme cookie
│ └── cookie-threads.json # Threads session cookie (auto-created)
├── TTS/ # Text-to-Speech
│ ├── engine_wrapper.py # Provider abstraction + post_lang fallback
│ ├── elevenlabs.py, aws_polly.py, etc. # 7+ provider implementations
├── utils/
│ ├── settings.py # Config loading + validation
│ ├── videos.py # check_done() + check_done_by_id()
│ ├── console.py # Rich terminal output
│ ├── .config.template.toml # Config schema (platform sections)
│ └── ... (id, voice, cleanup, etc.)
├── main.py # CLI entry (platform-routed via factory)
├── GUI.py # Flask web UI (localhost:4000 in host mode, 0.0.0.0 in Docker)
├── requirements.txt # Dependencies
└── AGENTS.md / AGENT.md # This file + agent guidelines
```
---
## Configuration
**File:** `utils/.config.template.toml` (schema) → `config.toml` (user config)
### Platform Selection
```toml
[settings]
platform = "reddit" # or "threads"
post_lang = "es-cr" # Optional: translation language (all platforms)
```
### Reddit Config
```toml
[reddit.creds]
client_id = "..." # OAuth app
client_secret = "..."
username = "..."
password = "..."
2fa = true/false
[reddit.thread]
subreddit = "AskReddit"
post_id = "" # Leave blank for auto-pick
max_comment_length = 500
min_comment_length = 1
min_comments = 20
blocked_words = "..."
```
### Threads Config (NEW)
```toml
[threads.creds]
access_token = "EAABsbCS..." # Meta Graph API token (60-day expiry)
user_id = "12345678901234567"
username = "your_insta" # For Playwright login
password = "your_password"
[threads.thread]
post_id = "" # Leave blank for auto-pick
max_reply_length = 500
min_reply_length = 1
min_replies = 5
blocked_words = "..."
```
### Generic Settings
```toml
[settings]
theme = "dark"
resolution_w = 1080
resolution_h = 1920
storymode = false
times_to_run = 1
[settings.tts]
voice_choice = "tiktok" # or "elevenlabs", "awspolly", "googletranslate", etc.
random_voice = true
silence_duration = 0.3
[settings.background]
background_video = "minecraft"
background_audio = "lofi"
background_audio_volume = 0.15
```
---
## Development Guidelines
### ✅ DO:
1. **Use platform factory in main.py**
```python
from platforms import get_content_object, get_screenshot_fn
reddit_object = get_content_object(POST_ID)
screenshot_fn = get_screenshot_fn()
screenshot_fn(reddit_object, number_of_comments)
```
2. **Return standard content dict** from all fetchers
```python
return {
"thread_id": ...,
"thread_category": ..., # NEW: replaces hardcoded subreddit
"comments": [...]
}
```
3. **Use config fallback chains** for cross-platform keys
```python
lang = (settings.config["settings"].get("post_lang") or
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
```
4. **Read thread_category from dict** instead of config
```python
# WRONG:
subreddit = settings.config["reddit"]["thread"]["subreddit"]
# RIGHT:
platform = settings.config["settings"].get("platform", "reddit")
if platform == "reddit":
subreddit = settings.config["reddit"]["thread"]["subreddit"]
else:
subreddit = reddit_obj.get("thread_category", platform)
```
5. **Test both platforms** after core pipeline changes
```bash
# Test Reddit (must not regress)
sed -i 's/platform = "threads"/platform = "reddit"/' config.toml
python3 main.py
# Test Threads
sed -i 's/platform = "reddit"/platform = "threads"/' config.toml
python3 main.py --post-id <threads-id>
```
### ❌ DON'T:
1. **Don't import platform modules directly** in main.py/utils
```python
# WRONG: from reddit.subreddit import get_subreddit_threads
# RIGHT: from platforms import get_content_object
```
2. **Don't hardcode platform names** in generic modules
```python
# WRONG in final_video.py:
subreddit = settings.config["reddit"]["thread"]["subreddit"]
# RIGHT:
subreddit = reddit_obj.get("thread_category", "unknown")
```
3. **Don't add platform-specific UI selectors** outside `platforms/{platform}/screenshot.py`
- Reddit selectors stay in `video_creation/screenshot_downloader.py`
- Threads selectors stay in `platforms/threads/screenshot.py`
4. **Don't assume config keys exist** without fallback
```python
# WRONG: lang = settings.config["reddit"]["thread"]["post_lang"]
# RIGHT: lang = settings.config.get("settings", {}).get("post_lang", "")
```
---
## Platform-Specific Knowledge
### Reddit
- **API:** PRAW (Python Reddit API Wrapper)
- **Auth:** OAuth app (client_id, secret) + username/password
- **Screenshot:** Playwright on reddit.com/new.reddit.com
- Login form: `input[name="username"]`, `input[name="password"]`
- Post selector: `[data-test-id="post-content"]`
- Comment selector: `#t1_{comment_id}`
- **NSFW:** `submission.over_18`
- **Output folder:** `results/{subreddit}/`
### Threads
- **API:** Meta Graph API (v18.0+)
- **Auth:** User access token (60-day lifetime) via https://developers.facebook.com/
- **Screenshot:** Playwright on threads.net
- Login form: `input[autocomplete="username"]`, `input[autocomplete="current-password"]`
- Post selector: `article` (universal, more stable than Reddit)
- Cookies saved to: `video_creation/data/cookie-threads.json`
- **NSFW:** API doesn't provide; always False
- **Output folder:** `results/threads/`
### Future: X/Twitter
Create: `platforms/twitter/fetcher.py` + `platforms/twitter/screenshot.py` + config section
Update: `platforms/__init__.py` with `elif platform == "twitter"` branches
---
## Extending the Project
### Adding a New TTS Provider
1. Create `TTS/my_provider.py` with a class implementing the TTS interface
2. Add config keys to `[settings.tts]` in `.config.template.toml`
3. Update `TTS/engine_wrapper.py` to call your provider
4. Test with `settings.config["settings"]["tts"]["voice_choice"] = "my_provider"`
### Adding a New Platform (e.g., X/Twitter)
1. **Create fetcher:** `platforms/twitter/fetcher.py`
- Implement `get_twitter_content(POST_ID=None)` returning standard dict
2. **Create screenshotter:** `platforms/twitter/screenshot.py`
- Implement `get_screenshots_of_twitter_posts(content_object, screenshot_num)`
3. **Update config:** Add `[twitter.creds]` and `[twitter.thread]` sections
4. **Update factory:** Add `elif platform == "twitter"` in `platforms/__init__.py`
5. **Update CLI helper:** Add case to `_get_platform_post_id()` in `main.py`
6. **Test:** Verify Reddit mode still works, test Twitter mode end-to-end
**Zero changes needed to:** TTS, backgrounds, video composition, or utils.
---
## Debugging Tips
### "No matching distribution found for yt-dlp==2026.3.17"
→ yt-dlp uses date versioning (YYYY.M.DD, no leading zeros). Use `2025.10.14` (latest stable).
### "Threads API: Invalid or expired access_token"
→ Meta tokens expire every 60 days. Refresh at https://developers.facebook.com/tools/explorer/
### Playwright timeout on Threads screenshot
→ Login cookies corrupted or expired. Delete `video_creation/data/cookie-threads.json` to force fresh login next run.
### "No eligible Threads posts found"
→ Configure `[threads.thread].min_replies = 5` (or lower). Ensure your Threads account has public posts with replies.
### Video dedup not working
→ Check `video_creation/data/videos.json` is writable. Ensure `check_done_by_id()` is called before fetching content.
---
## Testing Checklist
- [ ] Reddit mode: `platform = "reddit"` produces video to `results/{subreddit}/`
- [ ] Threads mode: `platform = "threads"` produces video to `results/threads/`
- [ ] Video dedup: Running same post_id twice skips second run
- [ ] Translation: `post_lang = "es"` translates filenames
- [ ] TTS providers: Test with different voice_choice values
- [ ] Background selection: Custom background video/audio works
- [ ] Story mode: storymode=true only uses thread_post, not comments
- [ ] Error handling: Invalid credentials show clear messages
---
## Key Files to Know
| File | Purpose |
|------|---------|
| `main.py` | CLI entry; orchestrates pipeline via factory |
| `platforms/__init__.py` | Factory dispatch for multi-platform support |
| `platforms/threads/fetcher.py` | Threads Graph API client |
| `platforms/threads/screenshot.py` | Threads.net Playwright screenshotter |
| `video_creation/final_video.py` | FFmpeg composition; platform-aware output naming |
| `TTS/engine_wrapper.py` | TTS provider abstraction; post_lang fallback |
| `utils/settings.py` | Config loading & validation |
| `utils/videos.py` | Video dedup tracking |
| `utils/.config.template.toml` | Config schema |
| `requirements.txt` | Dependencies |
---
## Useful Commands
```bash
# Install dependencies
pip install -r requirements.txt
# Run CLI
python3 main.py
# Run with specific post
python3 main.py <post_id>
# Run Flask GUI
python3 GUI.py
# Check syntax
python3 -m py_compile main.py platforms/threads/fetcher.py
# Format code
black main.py platforms/ utils/
# Lint
pylint main.py
```
## Docker Workflow
- Use `docker compose build` to build the shared image for both CLI and GUI.
- Use `docker compose up gui` to run the Flask app on port `4000`.
- Use `docker compose run --rm cli` to run the video generator in a container.
- The repo root is bind-mounted in Compose, so `config.toml`, `results/`, `assets/temp/`, `video_creation/data/videos.json`, and `utils/backgrounds.json` should persist across runs.
- The GUI must bind to `0.0.0.0` in Docker; do not switch it back to `localhost` for container use.
---
## When You Get Stuck
1. **"What does this module do?"** → Check imports in `main.py` or docstrings
2. **"How do I add support for platform X?"** → See "Adding a New Platform" section above
3. **"Why is my config not being read?"** → Check `utils/settings.py:check_toml()` and `.config.template.toml` schema
4. **"Why isn't my TTS provider being called?"** → Check `TTS/engine_wrapper.py:make_voice()` and config `voice_choice`
5. **"How do I debug the Playwright screenshot?"** → Uncomment `page.pause()` in screenshot downloader, run headful browser
Good luck! 🚀
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **VideoMakerBot** (802 symbols, 1287 relationships, 32 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/VideoMakerBot/context` | Codebase overview, check index freshness |
| `gitnexus://repo/VideoMakerBot/clusters` | All functional areas |
| `gitnexus://repo/VideoMakerBot/processes` | All execution flows |
| `gitnexus://repo/VideoMakerBot/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

@ -5,16 +5,18 @@
**VideoMakerBot** — Automated short-form video creator from social media content. **VideoMakerBot** — Automated short-form video creator from social media content.
**Status:** Production-ready, actively maintained (v3.4.0) **Status:** Production-ready, actively maintained (v3.4.0)
**Language:** Python 3.10+ **Language:** Python 3.10 (locked by `Dockerfile`; host venv may use 3.14 for tooling only)
**Runtime:** **Docker only** — all CLI, GUI, and test invocations go through `docker compose`. Do not invoke `python` on the host.
**Platforms:** Reddit (PRAW API), Threads (Graph API + Web Scraping) **Platforms:** Reddit (PRAW API), Threads (Graph API + Web Scraping)
### Core Mission ### Core Mission
Transforms social media threads (post + comments/replies) into complete short-form videos with: Transforms social media threads (post + comments/replies) into complete short-form videos with:
- AI-generated speech (7+ TTS providers) - AI-generated speech (7+ TTS providers)
- UI screenshots (Playwright) - UI screenshots (Playwright, headless Chromium pre-installed in image)
- Background video/audio overlays - Background video/audio overlays
- FFmpeg composition & output - FFmpeg composition & output (Linux ffmpeg with full filter set, including `drawtext`)
- Optional YouTube upload - Optional YouTube upload
- Modern web UI (Tailwind CSS + DaisyUI + Lucide + vanilla ES6) on `localhost:4000`
--- ---
@ -101,8 +103,21 @@ VideoMakerBot/
│ ├── background_audios.json # Background audio manifest │ ├── background_audios.json # Background audio manifest
│ └── ... │ └── ...
├── GUI/ # Flask templates (Tailwind + DaisyUI + Lucide)
│ ├── layout.html # Base layout (no jQuery, no Bootstrap)
│ ├── index.html # Video Library (3 buttons: source / download / copy link)
│ ├── backgrounds.html # Background Manager (videos catalog)
│ ├── settings.html # Config editor (validated against template)
│ └── create.html # Render progress page
├── tests/
│ └── test_gui_utils.py # pytest regression for add/delete background
├── main.py # CLI entry (platform-routed via factory) ├── main.py # CLI entry (platform-routed via factory)
├── GUI.py # Flask web UI (localhost:4000) ├── GUI.py # Flask web UI; `/video/<id>` serves files with sanitized headers
├── Dockerfile # python:3.10-slim-bookworm + ffmpeg + playwright + pytest
├── docker-compose.yml # Services: gui, cli, test
├── docker-entrypoint.sh # Runs `utils.docker_bootstrap` then exec's the command
├── requirements.txt ├── requirements.txt
└── CLAUDE.md └── CLAUDE.md
``` ```
@ -229,33 +244,40 @@ Last 1-4: engagement metrics (likes, replies, reposts, quotes)
### ✅ DO: ### ✅ DO:
1. **Use platform factory** — never import platform modules directly 1. **Run everything through Docker**`docker compose up gui`, `docker compose run --rm cli`, `docker compose run --rm test`
2. **Return standard content_object** from all fetchers 2. **Use platform factory** — never import platform modules directly
3. **Use clean body text** for TTS — parse out username/timestamp metadata 3. **Return standard content_object** from all fetchers
4. **Default to `googletranslate` TTS on macOS** — pyttsx3 hangs in headless environments 4. **Use clean body text** for TTS — parse out username/timestamp metadata
5. **Use `libx264` encoder on macOS**`h264_nvenc` is NVIDIA-only 5. **Default to `googletranslate` TTS** for headless containers — no API key, fast, free
6. **Test both Threads discovery methods:** `api` and `scrape` 6. **Use `libx264` encoder**`h264_nvenc` is NVIDIA-only and not available in the slim image
7. **Test both Threads discovery methods:** `api` and `scrape`
8. **Bind-mount preserves state** — edits to `config.toml`, `results/`, `assets/temp/`, `video_creation/data/`, and the `utils/background_*.json` catalogs persist across container runs
9. **GUI must bind to `0.0.0.0`** in Docker (already enforced via `GUI_HOST=0.0.0.0` env)
10. **Use `/video/<id>` to serve renders** — the route looks up the file by id in `videos.json`, sanitizes the `Content-Disposition` filename, and avoids 404s caused by literal newlines in titles
### ❌ DON'T: ### ❌ DON'T:
1. **Don't use `<article>` selectors** on Threads.net — the DOM is div-based 1. **Don't run `python GUI.py` or `python main.py` on the host** — Docker is the only supported path
2. **Don't hardcode `h264_nvenc`** — use `libx264` for cross-platform compatibility 2. **Don't use `<article>` selectors** on Threads.net — the DOM is div-based
3. **Don't rely on `drawtext` FFmpeg filter** — not available in Homebrew builds 3. **Don't hardcode `h264_nvenc`** — use `libx264` for cross-platform compatibility
4. **Don't import platform modules directly** in main.py/utils 4. **Don't import platform modules directly** in main.py/utils
5. **Don't assume config keys exist** without `.get()` fallback 5. **Don't assume config keys exist** without `.get()` fallback
6. **Don't reintroduce jQuery, Bootstrap, or ClipboardJS** — the UI is vanilla ES6 + Tailwind + DaisyUI + Lucide
7. **Don't write to `utils/backgrounds.json`** — it is a legacy empty file. Use `utils/background_videos.json` and `utils/background_audios.json`
--- ---
## macOS-Specific Notes ## Web UI (Flask, served by `gui` service)
- **TTS:** `googletranslate` (gTTS) is the most reliable — free, fast, no API key - **Stack:** Tailwind CSS, DaisyUI, Lucide Icons, vanilla ES6 (no jQuery, no Bootstrap, no ClipboardJS)
- `tiktok` auto-falls back to `pyttsx3` if sessionid missing, but pyttsx3 is very slow - **Routes:**
- `pyttsx3` works but takes ~60s to initialize NSSpeechSynthesizer - `/` — Video Library; cards show source-post link, download, and copy-link buttons
- **FFmpeg encoder:** MUST use `libx264``h264_nvenc` is NVIDIA GPU only - `/video/<id>` — serves the rendered mp4 by id (lookup via `videos.json`); guards path-traversal and sanitizes the filename for `Content-Disposition`
- **FFmpeg filters:** `drawtext` missing from Homebrew bottle — credit text is disabled - `/backgrounds` — Background Manager UI
- **yt-dlp:** Keep updated (`pip install --upgrade yt-dlp`) — YouTube changes APIs frequently - `/backgrounds.json` — serves `utils/background_videos.json` (the videos catalog)
- Format selector: `best[height<=1080]` not `bestvideo` (many videos lack video-only streams) - `/background/add`, `/background/delete` — POST endpoints; mutate **both** `utils/background_videos.json` and the `settings.background.background_video.options` array in `utils/.config.template.toml`
- Upgrade path: `pip install --upgrade yt-dlp` - `/settings` — config editor; loads from `config.toml`, validates against `utils/.config.template.toml`, persists via `utils/gui_utils.modify_settings` (preserves comments/formatting via `tomlkit`)
- **HTML escaping:** the `h()` helper in `index.html` escapes `& " < >` for any user-controlled string embedded in attributes — use it for any new dynamic data on the Library page
--- ---
@ -277,64 +299,79 @@ Last 1-4: engagement metrics (likes, replies, reposts, quotes)
| `reddit/subreddit.py` | PRAW Reddit fetcher with auto-2FA | | `reddit/subreddit.py` | PRAW Reddit fetcher with auto-2FA |
| `utils/settings.py` | Config loading + interactive validation | | `utils/settings.py` | Config loading + interactive validation |
| `utils/videos.py` | Video dedup tracking | | `utils/videos.py` | Video dedup tracking |
| `utils/.config.template.toml` | Config schema | | `utils/.config.template.toml` | Config schema (also drives Settings page validation) |
| `utils/background_videos.json` | Background video manifest | | `utils/background_videos.json` | Background video manifest (served at `/backgrounds.json`) |
| `utils/background_audios.json` | Background audio manifest | | `utils/background_audios.json` | Background audio manifest |
| `utils/gui_utils.py` | `add_background`, `delete_background`, `modify_settings`, `get_checks` |
| `GUI.py` | Flask app: `/`, `/video/<id>`, `/backgrounds`, `/settings`, `/create` |
| `Dockerfile` | python:3.10-slim-bookworm + ffmpeg + Playwright Chromium + pytest |
| `docker-compose.yml` | Three services: `gui` (port 4000), `cli`, `test` |
| `tests/test_gui_utils.py` | Pytest regression for Background Manager round-trip |
--- ---
## Debugging Tips ## Debugging Tips
### FFmpeg "Unknown encoder 'h264_nvenc'" ### FFmpeg "Unknown encoder 'h264_nvenc'"
→ On macOS, change to `libx264`. Find-and-replace `h264_nvenc``libx264` in `video_creation/final_video.py`. → Use `libx264`. Find-and-replace `h264_nvenc``libx264` in `video_creation/final_video.py`. The slim image does not ship with NVIDIA encoders.
### FFmpeg "No such filter: 'drawtext'"
→ Homebrew FFmpeg lacks drawtext. The credit text overlay is automatically skipped.
### yt-dlp "Requested format is not available" ### yt-dlp "Requested format is not available"
→ Update yt-dlp: `pip install --upgrade yt-dlp`. Also change format selector from `bestvideo` to `best` in `video_creation/background.py`. → Bump the pinned version in `requirements.txt` and rebuild (`docker compose build`). Also prefer `best[height<=1080]` over `bestvideo` in `video_creation/background.py` — many videos lack video-only streams.
### pyttsx3 hang on macOS
→ NSSpeechSynthesizer needs GUI session. Switch to `voice_choice = "googletranslate"` for headless use.
### Threads screenshots fail ("Main post article not found") ### Threads screenshots fail ("Main post article not found")
→ Threads.net uses div cards, not `<article>`. Ensure screenshot code uses `a[href*="/post/"]` → ancestor div approach. → Threads.net uses div cards, not `<article>`. Ensure screenshot code uses `a[href*="/post/"]` → ancestor div approach.
### Config validator EOFError in non-interactive mode ### Config validator EOFError in non-interactive mode
`check_toml()` prompts for ALL platform sections regardless of `platform` setting. Fill ALL required fields or load config directly with `toml.load()` + `settings.config = ...`. `check_toml()` prompts for ALL platform sections regardless of `platform` setting. Either fill all required fields, edit through `/settings`, or pre-populate `config.toml` before `docker compose run cli`.
### Playwright timeout on Threads login ### Playwright timeout on Threads login
→ Cookies corrupted. Delete `video_creation/data/cookie-threads.json` for fresh login. Also check button selector: must use `exact=True` due to multiple "Log in" buttons. → Cookies corrupted. Delete `video_creation/data/cookie-threads.json` for fresh login (the file is bind-mounted, so deleting on host clears the container too). Also confirm selectors: button uses `exact=True` due to multiple "Log in" buttons.
### No viral posts found ### No viral posts found
→ Lower `min_engagement` in config. Most Threads feed posts have <100 likes 10000 filters almost everything. → Lower `min_engagement` in config. Most Threads feed posts have <100 likes 10000 filters almost everything.
### Background Manager grid is empty
`/backgrounds.json` must serve `utils/background_videos.json` (split catalog), **not** the legacy `utils/backgrounds.json` (empty `{}`). Verify in `GUI.py:backgrounds_json`.
### `/video/<id>` returns 404
→ The route looks up the entry in `video_creation/data/videos.json` by `id` and resolves the file under `results/<thread_category>/<filename>.mp4`. Confirm both the JSON entry and the file exist; the file may have been pruned.
### JS "Unexpected end of input" on Library page
→ Any user-controlled string interpolated into an HTML attribute must go through the `h()` helper in `index.html`. Avoid inline `onclick=` with `${JSON.stringify(...)}`.
### Stale image after editing `requirements.txt` or `Dockerfile`
`docker compose build` to rebuild. Code changes alone do NOT need a rebuild because the repo root is bind-mounted to `/app`.
--- ---
## Useful Commands ## Useful Commands (Docker-only)
```bash ```bash
# Install dependencies # Build (or rebuild after Dockerfile / requirements.txt changes)
pip install -r requirements.txt docker compose build
# Run CLI # Run the GUI (foreground)
python3 main.py docker compose up gui
# → http://localhost:4000
# Run bypassing config validator (non-interactive) # Run the GUI in the background
python3 -c " docker compose up -d gui
import sys, toml docker compose logs -f gui
sys.path.insert(0, '.') docker compose down
from utils import settings
settings.config = toml.load('config.toml')
from main import main; main()
"
# Update yt-dlp (YouTube downloads fix) # Run the CLI pipeline (one-off, removed on exit)
pip install --upgrade yt-dlp docker compose run --rm cli
docker compose run --rm cli python main.py <post_id>
# Check syntax # Run the test suite
python3 -m py_compile main.py platforms/threads/scraper.py docker compose run --rm test
# Run Flask GUI # Open a shell in a fresh container for ad-hoc commands
python3 GUI.py docker compose run --rm --entrypoint /bin/bash gui
# inside: python -m py_compile main.py platforms/threads/scraper.py
# Tail a running GUI container
docker compose exec gui ls /app/results/threads/
``` ```
> Anything that needs `pip install`, `playwright install`, or `apt-get` belongs in `Dockerfile` followed by `docker compose build` — never run those on the host.

@ -16,7 +16,7 @@ RUN apt-get update \
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --upgrade pip \ RUN pip install --upgrade pip \
&& pip install -r requirements.txt \ && pip install -r requirements.txt \
&& python -m spacy download en_core_web_sm && pip install pytest
RUN python -m playwright install --with-deps chromium RUN python -m playwright install --with-deps chromium

128
GUI.py

@ -1,4 +1,8 @@
import io
import json
import os import os
import sys
import threading
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
@ -6,9 +10,12 @@ from pathlib import Path
import tomlkit import tomlkit
from flask import ( from flask import (
Flask, Flask,
abort,
jsonify,
redirect, redirect,
render_template, render_template,
request, request,
send_file,
send_from_directory, send_from_directory,
url_for, url_for,
) )
@ -99,13 +106,57 @@ def videos_json():
# Make backgrounds.json accessible # Make backgrounds.json accessible
@app.route("/backgrounds.json") @app.route("/backgrounds.json")
def backgrounds_json(): def backgrounds_json():
return send_from_directory("utils", "backgrounds.json") return send_from_directory("utils", "background_videos.json")
# Make videos in results folder accessible # Make videos in results folder accessible
@app.route("/results/<path:name>") @app.route("/results/<path:name>")
def results(name): def results(name):
return send_from_directory("results", name, as_attachment=True) as_attachment = request.args.get("download", "0").lower() in {"1", "true", "yes"}
return send_from_directory("results", name, as_attachment=as_attachment)
# Serve a video by its videos.json id (handles filenames with unsafe chars like newlines)
@app.route("/video/<video_id>")
def video_by_id(video_id):
try:
with open("video_creation/data/videos.json", "r", encoding="utf-8") as f:
videos = json.load(f)
except (OSError, json.JSONDecodeError):
abort(404)
entry = next((v for v in videos if v.get("id") == video_id), None)
if not entry:
abort(404)
subreddit = entry.get("subreddit", "")
filename = entry.get("filename", "")
file_path = (Path("results") / subreddit / filename).resolve()
results_root = Path("results").resolve()
# Prevent path traversal: ensure resolved file is inside results/
try:
file_path.relative_to(results_root)
except ValueError:
abort(404)
if not file_path.is_file():
abort(404)
as_attachment = request.args.get("download", "0").lower() in {"1", "true", "yes"}
safe_name = filename.replace("\n", " ").replace("\r", " ").strip() or f"{video_id}.mp4"
return send_file(file_path, as_attachment=as_attachment, download_name=safe_name)
# Delete one or more videos by ID
@app.route("/videos/delete", methods=["POST"])
def video_delete():
data = request.get_json(silent=True) or {}
ids = data.get("ids", [])
if not ids or not isinstance(ids, list):
return jsonify({"error": "No IDs provided"}), 400
deleted = gui.delete_videos(ids)
return jsonify({"deleted": deleted})
# Make voices samples in voices folder accessible # Make voices samples in voices folder accessible
@ -114,6 +165,79 @@ def voices(name):
return send_from_directory("GUI/voices", name, as_attachment=True) return send_from_directory("GUI/voices", name, as_attachment=True)
# --- Pipeline state (shared across thread + HTTP) ---
pipeline_lock = threading.Lock()
pipeline_state: dict = {
"running": False,
"stage": "",
"error": None,
"result": None, # {"title": ..., "file": ..., "url": ...}
"log": [], # Last N status messages
}
def _run_pipeline():
"""Run the video creation pipeline in a background thread."""
import toml
from utils import console as uconsole
from utils import settings
with pipeline_lock:
pipeline_state["running"] = True
pipeline_state["stage"] = "configuring"
pipeline_state["error"] = None
pipeline_state["result"] = None
pipeline_state["log"] = []
try:
# Load config
settings.config = toml.load("config.toml")
# Set up progress callback
def on_progress(stage=""):
with pipeline_lock:
pipeline_state["stage"] = stage
pipeline_state["log"].append(stage)
if len(pipeline_state["log"]) > 20:
pipeline_state["log"] = pipeline_state["log"][-20:]
uconsole.set_progress_callback(on_progress)
from main import main as run_pipeline
run_pipeline()
with pipeline_lock:
pipeline_state["stage"] = "done"
pipeline_state["result"] = {"message": "Video created successfully! Check the home page."}
except Exception as e:
with pipeline_lock:
pipeline_state["stage"] = "error"
pipeline_state["error"] = str(e)[:500].encode("ascii", errors="replace").decode("ascii")
finally:
with pipeline_lock:
pipeline_state["running"] = False
uconsole.set_progress_callback(None)
@app.route("/create", methods=["GET", "POST"])
def create():
if request.method == "POST":
if pipeline_state["running"]:
return jsonify({"status": "already_running"})
thread = threading.Thread(target=_run_pipeline, daemon=True)
thread.start()
return jsonify({"status": "started"})
return render_template("create.html", state=pipeline_state)
@app.route("/create/status")
def create_status():
with pipeline_lock:
state_copy = dict(pipeline_state)
return jsonify(state_copy)
# Run browser and start the app # Run browser and start the app
if __name__ == "__main__": if __name__ == "__main__":
if OPEN_BROWSER: if OPEN_BROWSER:

@ -1,263 +1,235 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block main %} {% block main %}
<!-- Delete Background Modal --> <div class="bg-slate-900 min-h-screen py-8">
<div class="modal fade" id="deleteBtnModal" tabindex="-1" role="dialog" aria-hidden="true"> <div class="container mx-auto px-4">
<div class="modal-dialog modal-dialog-centered" role="document"> <!-- Header & Actions -->
<div class="modal-content"> <div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-8">
<div class="modal-header"> <h1 class="text-2xl font-bold text-white">Background Manager</h1>
<h5 class="modal-title">Delete background</h5> <div class="flex w-full md:w-auto gap-2">
</div> <div class="relative flex-grow md:w-64">
<div class="modal-body"> <i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
Are you sure you want to delete this background? <input type="text"
</div> class="searchFilter input input-bordered w-full pl-10 bg-slate-800 border-slate-700 text-slate-200 focus:border-indigo-500"
<div class="modal-footer"> placeholder="Search..."
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> onkeyup="searchFilter()">
<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>
</div> </div>
<button onclick="add_modal.showModal()" class="btn btn-indigo bg-indigo-600 hover:bg-indigo-500 border-none text-white">
<i data-lucide="plus" class="w-4 h-4"></i>
<span class="hidden sm:inline">Add Video</span>
</button>
</div> </div>
</div> </div>
<!-- Add Background Modal --> <!-- Background Grid -->
<div class="modal fade" id="backgroundAddModal" tabindex="-1" role="dialog" aria-hidden="true"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" id="backgrounds">
<div class="modal-dialog modal-dialog-centered" role="document"> <!-- Backgrounds will be injected here -->
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add background video</h5>
</div> </div>
<div class="modal-body">
<!-- Add video form --> <!-- Empty State -->
<form id="addBgForm" action="background/add" method="post" novalidate> <div id="empty-state" class="hidden flex flex-col items-center justify-center py-20 text-slate-500">
<div class="form-group row"> <i data-lucide="film" class="w-16 h-16 mb-4 opacity-20"></i>
<label class="col-4 col-form-label" for="youtube_uri">YouTube URI</label> <p class="text-lg">No backgrounds found</p>
<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>
<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> </div>
<span class="form-text text-muted">Include the channel name of the
owner of the background video you are adding.</span> <!-- Delete Background Modal -->
</div> <dialog id="delete_modal" class="modal modal-bottom sm:modal-middle">
</div> <div class="modal-box bg-slate-800 border border-white/10">
<div class="form-group row"> <h3 class="font-bold text-lg text-white">Delete Background</h3>
<label for="position" class="col-4 col-form-label">Position of screenshots</label> <p class="py-4 text-slate-400">Are you sure you want to delete this background video? This action cannot be undone.</p>
<div class="col-8"> <div class="modal-action">
<div class="input-group"> <form action="background/delete" method="post" class="flex gap-2">
<div class="input-group-text"> <input type="hidden" id="background-key" name="background-key" value="">
<i class="bi bi-arrows-fullscreen"></i> <button type="button" onclick="delete_modal.close()" class="btn btn-ghost text-slate-400">Cancel</button>
</div> <button type="submit" class="btn btn-error">Delete</button>
<input name="position" placeholder="Example: center" type="text" class="form-control"> </form>
</div> </div>
<span class="form-text text-muted">Advanced option (you can leave it
empty). Valid options are "center" and decimal numbers</span>
</div> </div>
</dialog>
<!-- Add Background Modal -->
<dialog id="add_modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box bg-slate-800 border border-white/10 max-w-lg">
<h3 class="font-bold text-lg text-white mb-6">Add Background Video</h3>
<form id="addBgForm" action="background/add" method="post" novalidate class="space-y-4">
<div class="form-control w-full">
<label class="label"><span class="label-text text-slate-300">YouTube URI</span></label>
<div class="join w-full">
<div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
<i data-lucide="youtube" class="w-4 h-4 text-red-500"></i>
</div> </div>
<input name="youtube_uri" type="text" placeholder="https://www.youtube.com/watch?v=..."
class="input input-bordered join-item w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500">
</div> </div>
<div class="modal-footer"> <label class="label h-6"><span id="feedbackYT" class="label-text-alt text-error hidden"></span></label>
<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 class="form-control w-full">
<label class="label"><span class="label-text text-slate-300">Filename</span></label>
<div class="join w-full">
<div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
<i data-lucide="file-video" class="w-4 h-4 text-indigo-500"></i>
</div> </div>
<input name="filename" type="text" placeholder="e.g. minecraft-parkour"
class="input input-bordered join-item w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500">
</div> </div>
<label class="label h-6"><span id="feedbackFilename" class="label-text-alt text-error hidden"></span></label>
</div> </div>
<main> <div class="form-control w-full">
<div class="album py-2 bg-light"> <label class="label"><span class="label-text text-slate-300">Credits</span></label>
<div class="container"> <div class="join w-full">
<div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
<div class="row justify-content-between mt-2"> <i data-lucide="user" class="w-4 h-4 text-slate-400"></i>
<div class="col-12 col-md-3 mb-3">
<input type="text" class="form-control searchFilter" placeholder="Search backgrounds"
onkeyup="searchFilter()">
</div> </div>
<div class="col-12 col-md-2 mb-3"> <input name="citation" type="text" placeholder="YouTube Channel Name"
<button type="button" class="btn btn-primary form-control" data-toggle="modal" class="input input-bordered join-item w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500">
data-target="#backgroundAddModal">
Add background video
</button>
</div> </div>
<label class="label"><span class="label-text-alt text-slate-500 italic">Name of the video owner.</span></label>
</div> </div>
<div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="backgrounds"> <div class="form-control w-full">
<label class="label"><span class="label-text text-slate-300 text-xs">Advanced: Position</span></label>
<input name="position" type="text" placeholder="center (optional)"
class="input input-bordered w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500 text-sm h-10">
</div> </div>
<div class="modal-action">
<button type="button" onclick="add_modal.close()" class="btn btn-ghost text-slate-400">Cancel</button>
<button type="submit" class="btn btn-indigo bg-indigo-600 hover:bg-indigo-500 border-none text-white">Add Background</button>
</div> </div>
</form>
</div> </div>
</main> </dialog>
<script> <script>
var keys = []; let keys = [];
var youtube_urls = []; let youtube_urls = [];
// Show background videos async function loadBackgrounds() {
$(document).ready(function () { try {
$.getJSON("backgrounds.json", const response = await fetch("backgrounds.json");
function (data) { const data = await response.json();
delete data["__comment"]; delete data["__comment"];
var background = '';
$.each(data, function (key, value) { const container = document.getElementById('backgrounds');
// Add YT urls and keys (for validation) let html = '';
Object.entries(data).forEach(([key, value]) => {
keys.push(key); keys.push(key);
youtube_urls.push(value[0]); youtube_urls.push(value[0]);
background += '<div class="col">'; const videoId = value[0].includes('?v=') ? value[0].split('?v=')[1] : value[0].split('/').pop();
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>'; html += `
background += '<div class="card-body">'; <div class="bg-card group bg-slate-800 rounded-xl overflow-hidden border border-white/5 hover:border-indigo-500/50 transition-all duration-300 shadow-lg">
background += '<p class="card-text">' + value[2] + ' • ' + key + '</p>'; <div class="aspect-video w-full bg-black relative">
background += '<div class="d-flex justify-content-between align-items-center">'; <iframe class="w-full h-full"
background += '<div class="btn-group">'; src="https://www.youtube-nocookie.com/embed/${videoId}"
background += '<button type="button" class="btn btn-outline-danger" data-toggle="modal" data-target="#deleteBtnModal" data-background-key="' + key + '">Delete</button>'; title="YouTube video player"
background += '</div>'; frameborder="0"
background += '</div>'; allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
background += '</div>'; allowfullscreen></iframe>
background += '</div>'; </div>
background += '</div>'; <div class="p-4">
<h3 class="text-slate-200 font-medium truncate mb-1" title="${key}">${key}</h3>
<p class="text-slate-500 text-xs truncate mb-4">${value[2]}</p>
<div class="flex justify-end">
<button onclick="confirmDelete('${key}')" class="btn btn-square btn-sm btn-ghost hover:bg-red-500/20 hover:text-red-400">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
`;
}); });
$('#backgrounds').append(background); container.innerHTML = html;
}); lucide.createIcons();
}); } catch (error) {
console.error("Error loading backgrounds:", error);
}
}
// Add background key when deleting function confirmDelete(key) {
$('#deleteBtnModal').on('show.bs.modal', function (event) { document.getElementById('background-key').value = key;
var button = $(event.relatedTarget); delete_modal.showModal();
var key = button.data('background-key'); }
function searchFilter() {
const query = document.querySelector(".searchFilter").value.toLowerCase();
const cards = document.querySelectorAll(".bg-card");
let visibleCount = 0;
$('#background-key').prop('value', key); cards.forEach(card => {
const text = card.textContent.toLowerCase();
const matches = text.includes(query);
card.classList.toggle('hidden', !matches);
if (matches) visibleCount++;
}); });
var searchFilter = () => { document.getElementById('empty-state').classList.toggle('hidden', visibleCount > 0);
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 const form = document.getElementById('addBgForm');
$("#addBgForm").submit(function (event) { form.addEventListener('submit', (e) => {
$("#addBgForm input").each(function () { let isValid = true;
if (!(validate($(this)))) { form.querySelectorAll('input').forEach(input => {
event.preventDefault(); if (!validate(input)) isValid = false;
event.stopPropagation();
}
}); });
if (!isValid) e.preventDefault();
}); });
$('#addBgForm input[type="text"]').on("keyup", function () { form.querySelectorAll('input').forEach(input => {
validate($(this)); input.addEventListener('keyup', () => validate(input));
}); });
function validate(object) { function validate(input) {
let bool = check(object.prop("name"), object.prop("value")); const name = input.name;
const value = input.value;
// Change class let valid = true;
if (bool) { let message = "";
object.removeClass("is-invalid");
object.addClass("is-valid");
}
else {
object.removeClass("is-valid");
object.addClass("is-invalid");
}
return bool;
// Check values (return true/false) if (name === "youtube_uri") {
function check(name, value) { const regex = /(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/;
if (name == "youtube_uri") { if (!regex.test(value)) {
// URI validation message = "Invalid YouTube URI";
let regex = /(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/; valid = false;
if (!(regex.test(value))) { } else if (youtube_urls.includes(value)) {
$("#feedbackYT").html("Invalid URI"); message = "Background already added";
$("#feedbackYT").show(); valid = false;
return false;
} }
const feedback = document.getElementById('feedbackYT');
// Check if this background already exists feedback.textContent = message;
if (youtube_urls.includes(value)) { feedback.classList.toggle('hidden', valid);
$("#feedbackYT").html("This background is already added");
$("#feedbackYT").show();
return false;
} }
$("#feedbackYT").hide(); if (name === "filename") {
return true;
}
if (name == "filename") {
// Check if key is already taken
if (keys.includes(value)) { if (keys.includes(value)) {
$("#feedbackFilename").html("This filename is already taken"); message = "Filename already taken";
$("#feedbackFilename").show(); valid = false;
return false; } else if (!/^([a-zA-Z0-9\s_-]{1,100})$/.test(value)) {
} valid = false;
let regex = /^([a-zA-Z0-9\s_-]{1,100})$/;
if (!(regex.test(value))) {
return false;
} }
const feedback = document.getElementById('feedbackFilename');
return true; feedback.textContent = message;
feedback.classList.toggle('hidden', valid);
} }
if (name == "citation") { if (name === "position") {
if (value.trim()) { if (value && value !== "center" && isNaN(parseFloat(value))) {
return true; valid = false;
} }
} }
if (name == "position") { input.classList.toggle('input-error', !valid);
if (!(value == "center" || value.length == 0 || value % 1 == 0)) { return valid;
return false;
} }
return true; document.addEventListener('DOMContentLoaded', loadBackgrounds);
}
}
}
</script> </script>
{% endblock %} {% endblock %}

@ -0,0 +1,245 @@
{% extends "layout.html" %}
{% block main %}
<div class="bg-slate-900 min-h-screen py-12">
<div class="container mx-auto px-4 max-w-2xl">
<div class="card bg-slate-800 border border-white/5 shadow-2xl">
<div class="card-body p-8">
<div class="flex items-center gap-4 mb-8">
<div class="bg-indigo-600/20 p-3 rounded-xl">
<i data-lucide="plus-square" class="w-8 h-8 text-indigo-500"></i>
</div>
<div>
<h2 class="card-title text-2xl text-white">Create New Short</h2>
<p class="text-slate-400 text-sm">Start the automated video creation pipeline.</p>
</div>
</div>
<div class="space-y-8">
<!-- Action Button -->
<button id="create-btn" class="btn btn-indigo btn-lg w-full bg-indigo-600 hover:bg-indigo-500 border-none text-white h-16 text-lg"
onclick="startPipeline()" disabled>
<span id="btn-text">Start Generation</span>
<span id="btn-spinner" class="loading loading-spinner loading-md hidden"></span>
</button>
<!-- Progress Visualization -->
<div id="progress-area" class="hidden space-y-4 animate-in fade-in duration-500">
<div class="flex justify-between items-end">
<div class="space-y-1">
<span class="text-xs uppercase tracking-widest text-slate-500 font-bold">Current Stage</span>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-indigo-500 animate-pulse"></div>
<h3 id="stage-text" class="text-indigo-400 font-semibold text-lg capitalize">Preparing...</h3>
</div>
</div>
<span id="pct-text" class="text-2xl font-black text-slate-700 font-mono">0%</span>
</div>
<progress id="progress-bar" class="progress progress-indigo w-full h-3 bg-slate-900" value="0" max="100"></progress>
<div class="grid grid-cols-2 gap-4 pt-4">
<div class="bg-slate-900/50 p-3 rounded-lg border border-white/5 flex items-center gap-3">
<i data-lucide="clock" class="w-4 h-4 text-slate-500"></i>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-500 font-bold">Elapsed</span>
<span id="elapsed-time" class="text-sm font-mono text-slate-300">00:00</span>
</div>
</div>
<div class="bg-slate-900/50 p-3 rounded-lg border border-white/5 flex items-center gap-3">
<i data-lucide="layers" class="w-4 h-4 text-slate-500"></i>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-500 font-bold">Status</span>
<span class="text-sm text-indigo-400">Processing</span>
</div>
</div>
</div>
</div>
<!-- Success Message -->
<div id="done-area" class="hidden animate-in zoom-in duration-300">
<div class="alert bg-emerald-500/10 border-emerald-500/20 text-emerald-400 flex flex-col items-start gap-4 p-6">
<div class="flex items-center gap-3">
<div class="bg-emerald-500 text-slate-900 p-1 rounded-full">
<i data-lucide="check" class="w-4 h-4"></i>
</div>
<span class="font-bold text-lg">Generation Complete!</span>
</div>
<p id="done-msg" class="text-slate-300 text-sm">Your video has been rendered and saved to the library.</p>
<a href="/" class="btn btn-emerald btn-sm bg-emerald-600 hover:bg-emerald-500 border-none text-white px-6">View Video</a>
</div>
</div>
<!-- Error Message -->
<div id="error-area" class="hidden">
<div class="alert bg-red-500/10 border-red-500/20 text-red-400 p-6">
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
<div>
<h3 class="font-bold">Pipeline Failed</h3>
<div id="error-text" class="text-xs mt-2 font-mono bg-black/20 p-3 rounded overflow-x-auto whitespace-pre-wrap"></div>
</div>
</div>
</div>
<!-- Log Output -->
<div id="log-area" class="hidden space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-xs uppercase tracking-widest text-slate-500 font-bold">Execution Logs</h4>
<span class="badge badge-outline border-slate-700 text-slate-500 text-[10px]">Real-time</span>
</div>
<div id="log-list" class="bg-slate-900 rounded-xl p-4 font-mono text-[11px] leading-relaxed text-slate-400 h-48 overflow-y-auto border border-white/5 shadow-inner">
<!-- Logs will appear here -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let pollTimer = null;
let startTime = null;
let elapsedTimer = null;
const stageWeights = {
'configuring': 5,
'discovering': 15,
'scraping': 20,
'fetching': 25,
'saving': 35,
'tts': 45,
'screenshots': 60,
'background': 70,
'chopping': 75,
'creating': 80,
'rendering': 90,
'done': 100,
'error': 0
};
function updateElapsedTime() {
if (!startTime) return;
const now = new Date();
const diff = Math.floor((now - startTime) / 1000);
const mins = Math.floor(diff / 60).toString().padStart(2, '0');
const secs = (diff % 60).toString().padStart(2, '0');
document.getElementById('elapsed-time').textContent = `${mins}:${secs}`;
}
function stageProgress(stage) {
let pct = 0;
const s = stage.toLowerCase();
for (let [key, val] of Object.entries(stageWeights)) {
if (s.includes(key)) { pct = val; }
}
return pct;
}
async function startPipeline() {
const btn = document.getElementById('create-btn');
const btnText = document.getElementById('btn-text');
const spinner = document.getElementById('btn-spinner');
btn.disabled = true;
spinner.classList.remove('hidden');
btnText.textContent = 'Initializing...';
document.getElementById('progress-area').classList.remove('hidden');
document.getElementById('log-area').classList.remove('hidden');
document.getElementById('done-area').classList.add('hidden');
document.getElementById('error-area').classList.add('hidden');
try {
const r = await fetch('/create', { method: 'POST' });
const data = await r.json();
if (data.status === 'started' || data.status === 'already_running') {
btnText.textContent = 'Processing...';
startTime = new Date();
elapsedTimer = setInterval(updateElapsedTime, 1000);
pollTimer = setInterval(pollStatus, 2000);
}
} catch (err) {
console.error("Failed to start pipeline:", err);
btn.disabled = false;
spinner.classList.add('hidden');
btnText.textContent = 'Retry';
}
}
async function pollStatus() {
try {
const r = await fetch('/create/status');
const state = await r.json();
const stageText = document.getElementById('stage-text');
const progressBar = document.getElementById('progress-bar');
const pctText = document.getElementById('pct-text');
const logList = document.getElementById('log-list');
stageText.textContent = state.stage || 'Running...';
const pct = stageProgress(state.stage || '');
progressBar.value = pct;
pctText.textContent = `${pct}%`;
if (state.log && state.log.length > 0) {
logList.innerHTML = state.log.map(l =>
`<div class="py-0.5 border-b border-white/5 last:border-0">${l}</div>`
).join('');
logList.scrollTop = logList.scrollHeight;
}
if (!state.running) {
clearInterval(pollTimer);
clearInterval(elapsedTimer);
pollTimer = null;
document.getElementById('btn-spinner').classList.add('hidden');
if (state.stage === 'done' || state.result) {
progressBar.value = 100;
progressBar.classList.add('progress-success');
document.getElementById('btn-text').textContent = 'Create New';
document.getElementById('done-area').classList.remove('hidden');
if (state.result) {
document.getElementById('done-msg').textContent = state.result.message;
}
} else if (state.error) {
progressBar.classList.add('progress-error');
document.getElementById('btn-text').textContent = 'Retry';
document.getElementById('error-area').classList.remove('hidden');
document.getElementById('error-text').textContent = state.error;
}
document.getElementById('create-btn').disabled = false;
}
} catch (err) {
console.error("Status poll failed:", err);
}
}
window.addEventListener('load', async function() {
lucide.createIcons();
try {
const r = await fetch('/create/status');
const state = await r.json();
const btn = document.getElementById('create-btn');
if (state.running) {
document.getElementById('progress-area').classList.remove('hidden');
document.getElementById('log-area').classList.remove('hidden');
btn.disabled = true;
document.getElementById('btn-spinner').classList.remove('hidden');
document.getElementById('btn-text').textContent = 'Running...';
startTime = new Date(); // Approximate
elapsedTimer = setInterval(updateElapsedTime, 1000);
pollTimer = setInterval(pollStatus, 2000);
} else {
btn.disabled = false;
}
} catch (err) {
console.error("Initial status check failed:", err);
}
});
</script>
{% endblock %}

@ -1,23 +1,96 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block main %} {% block main %}
<main> <div class="bg-slate-900 min-h-screen py-8">
<div class="album py-2 bg-light"> <div class="container mx-auto px-4">
<div class="container"> <!-- Header & Search -->
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-8">
<h1 class="text-2xl font-bold text-white">Video Library</h1>
<div class="row mt-2"> <!-- Bulk-action bar (visible in select mode only) -->
<div class="col-12 col-md-3 mb-3"> <div id="bulk-bar" class="hidden items-center gap-2">
<input type="text" class="form-control searchFilter" placeholder="Search videos" <button type="button" onclick="selectAll()"
aria-label="Search videos" onkeyup="searchFilter()"> class="btn btn-ghost text-slate-400 border border-slate-700">
<i data-lucide="check-square" class="w-4 h-4 mr-1"></i>
<span id="select-all-label">Select All</span>
</button>
<button type="button" onclick="cancelSelectMode()"
class="btn btn-ghost text-slate-400">
Cancel
</button>
<button id="bulk-delete-btn" type="button" onclick="confirmBulkDelete()"
class="btn btn-error" disabled>
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
Delete (<span id="selection-count">0</span>)
</button>
</div>
<!-- Normal toolbar (hidden in select mode) -->
<div id="normal-toolbar" class="flex items-center gap-2 w-full md:w-auto">
<button type="button" onclick="toggleSelectMode()"
class="btn btn-ghost text-slate-400 border border-slate-700 shrink-0"
style="height: 3rem; min-height: 3rem;">
<i data-lucide="check-square" class="w-4 h-4 mr-1"></i>
Select
</button>
<div class="relative w-full md:w-72">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
<input type="text"
class="searchFilter input input-bordered w-full pl-10 bg-slate-800 border-slate-700 text-slate-200 focus:border-indigo-500"
style="height: 3rem;"
placeholder="Search videos..."
onkeyup="searchFilter()">
</div>
</div>
</div>
<!-- Video Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" id="videos">
<!-- Videos will be injected here -->
</div>
<!-- Empty State -->
<div id="empty-state" class="hidden flex flex-col items-center justify-center py-20 text-slate-500">
<i data-lucide="video-off" class="w-16 h-16 mb-4 opacity-20"></i>
<p class="text-lg">No videos found</p>
</div>
</div> </div>
</div> </div>
<div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="videos"> <!-- Video Player Modal -->
<dialog id="player_modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box bg-slate-900 border border-white/10 max-w-2xl p-0 overflow-hidden">
<div class="flex justify-between items-center px-4 py-3 border-b border-white/5">
<h3 id="player_title" class="font-medium text-slate-200 truncate pr-4"></h3>
<button type="button" onclick="closePlayer()" class="btn btn-square btn-sm btn-ghost text-slate-400">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<video id="player_video" class="w-full bg-black" controls playsinline></video>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Delete Confirmation Modal -->
<dialog id="delete_modal" class="modal">
<div class="modal-box bg-slate-900 border border-white/10">
<div class="flex items-center gap-3 mb-3">
<i data-lucide="triangle-alert" class="w-5 h-5 text-red-400 shrink-0"></i>
<h3 class="font-bold text-lg text-white">Delete Video?</h3>
</div> </div>
<p id="delete-modal-msg" class="text-slate-400 mb-6 text-sm"></p>
<div class="modal-action mt-0">
<form method="dialog">
<button class="btn btn-ghost text-slate-400">Cancel</button>
</form>
<button type="button" class="btn btn-error" onclick="executeDelete()">
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
Delete
</button>
</div> </div>
</div> </div>
</main> <form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<script> <script>
const intervals = [ const intervals = [
@ -31,152 +104,287 @@
function timeSince(date) { function timeSince(date) {
const seconds = Math.floor((Date.now() / 1000 - date)); const seconds = Math.floor((Date.now() / 1000 - date));
const interval = intervals.find(i => i.seconds < seconds); const interval = intervals.find(i => i.seconds <= seconds) || intervals[intervals.length - 1];
const count = Math.floor(seconds / interval.seconds); const count = Math.floor(seconds / interval.seconds);
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`; return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
} }
$(document).ready(function () { function categoryLabel(subreddit) {
$.getJSON("videos.json", if (!subreddit) return "";
function (data) { if (subreddit === "threads") return "Threads";
data.sort((b, a) => a['time'] - b['time']) return `r/${subreddit}`;
var video = ''; }
$.each(data, function (key, value) {
video += '<div class="col">';
video += '<div class="card shadow-sm">';
//keeping original themed image card for future thumbnail usage video += '<svg class="bd-placeholder-img card-img-top" width="100%" height="225" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Placeholder: Thumbnail" preserveAspectRatio="xMidYMid slice" focusable="false"><title>Placeholder</title><rect width="100%" height="100%" fill="#55595c"/><text x="50%" y="50%" fill="#eceeef" dy=".3em">r/'+value.subreddit+'</text></svg>';
video += '<div class="card-body">';
video += '<p class="card-text">r/' + value.subreddit + ' • ' + checkTitle(value.reddit_title, value.filename) + '</p>';
video += '<div class="d-flex justify-content-between align-items-center">';
video += '<div class="btn-group">';
video += '<a href="https://www.reddit.com/r/' + value.subreddit + '/comments/' + value.id + '/" class="btn btn-sm btn-outline-secondary" target="_blank">View</a>';
video += '<a href="http://localhost:4000/results/' + value.subreddit + '/' + value.filename + '" class="btn btn-sm btn-outline-secondary" download>Download</a>';
video += '</div>';
video += '<div class="btn-group">';
video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + getCopyData(value.subreddit, value.reddit_title, value.filename, value.background_credit) + '"><i class="bi bi-card-text"></i></button>';
video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + checkTitle(value.reddit_title, value.filename) + ' #Shorts #reddit"><i class="bi bi-youtube"></i></button>';
video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + checkTitle(value.reddit_title, value.filename) + ' #reddit"><i class="bi bi-instagram"></i></button>';
video += '</div>';
video += '<small class="text-muted">' + timeSince(value.time) + '</small>';
video += '</div>';
video += '</div>';
video += '</div>';
video += '</div>';
}); function sourceUrl(subreddit, id) {
if (subreddit === "threads") {
return `https://www.threads.net/post/${id}`;
}
return `https://www.reddit.com/r/${subreddit}/comments/${id}/`;
}
function checkTitle(reddit_title, filename) {
const file = filename.slice(0, -4);
return reddit_title === file ? reddit_title : file;
}
// Escape arbitrary strings for safe embedding inside HTML attributes
function h(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
async function loadVideos() {
try {
const response = await fetch("videos.json");
const data = await response.json();
data.sort((b, a) => a['time'] - b['time']);
const container = document.getElementById('videos');
// Use data-* attributes for arbitrary strings — never embed them in onclick/href
container.innerHTML = data.map(v => {
const title = checkTitle(v.reddit_title, v.filename);
return `
<div class="video-card group bg-slate-800 rounded-xl overflow-hidden border border-white/5 hover:border-indigo-500/50 transition-all duration-300 hover:shadow-2xl hover:shadow-indigo-500/10 relative"
data-video-id="${h(v.id)}">
<!-- Checkbox overlay (shown in select mode) -->
<div class="select-overlay hidden absolute top-3 right-3 z-10 pointer-events-none">
<input type="checkbox" class="card-checkbox checkbox checkbox-primary w-6 h-6 pointer-events-auto" />
</div>
<button type="button"
class="play-btn aspect-video w-full bg-slate-900 flex items-center justify-center relative overflow-hidden cursor-pointer"
data-video-id="${h(v.id)}"
data-video-title="${h(title)}">
<i data-lucide="play-circle" class="w-12 h-12 text-slate-700 group-hover:text-indigo-500 transition-colors"></i>
<div class="absolute top-3 left-3">
<span class="badge badge-sm bg-slate-900/80 border-none text-indigo-400 font-medium backdrop-blur-md">
${h(categoryLabel(v.subreddit))}
</span>
</div>
</button>
<div class="p-4">
<h3 class="text-slate-200 font-medium line-clamp-2 mb-4 h-12" title="${h(title)}">
${h(title)}
</h3>
<div class="flex items-center justify-between gap-2">
<div class="flex gap-1">
<a href="${h(sourceUrl(v.subreddit, v.id))}" target="_blank"
class="btn btn-square btn-sm btn-ghost hover:bg-indigo-500/20 hover:text-indigo-400"
title="View Source">
<i data-lucide="external-link" class="w-4 h-4"></i>
</a>
<a href="/video/${encodeURIComponent(v.id)}?download=1" download
class="btn btn-square btn-sm btn-ghost hover:bg-indigo-500/20 hover:text-indigo-400"
title="Download">
<i data-lucide="download" class="w-4 h-4"></i>
</a>
</div>
<div class="flex gap-1">
<button class="btn btn-square btn-sm btn-ghost hover:bg-slate-700 copy-btn"
data-copy="${h(sourceUrl(v.subreddit, v.id))}"
title="Copy Link">
<i data-lucide="link" class="w-4 h-4"></i>
</button>
<button class="btn btn-square btn-sm btn-ghost hover:bg-red-500/20 hover:text-red-400 delete-btn"
data-video-id="${h(v.id)}"
title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="mt-4 pt-4 border-t border-white/5 flex justify-between items-center">
<span class="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">
${timeSince(v.time)}
</span>
</div>
</div>
</div>`;
}).join('');
$('#videos').append(video); // Wire play buttons — in select mode, toggle checkbox instead of playing
container.querySelectorAll('.play-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (selectMode) {
const card = btn.closest('.video-card');
const cb = card.querySelector('.card-checkbox');
cb.checked = !cb.checked;
updateSelectionCount();
} else {
openPlayer(`/video/${encodeURIComponent(btn.dataset.videoId)}`, btn.dataset.videoTitle);
}
}); });
}); });
$(document).ready(function () { // Wire copy buttons
$('[data-toggle="tooltip"]').tooltip(); container.querySelectorAll('.copy-btn').forEach(btn => {
$('[data-toggle="tooltip"]').on('click', function () { btn.addEventListener('click', () => {
$(this).tooltip('hide'); navigator.clipboard.writeText(btn.dataset.copy).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i data-lucide="check" class="w-4 h-4 text-green-500"></i>';
lucide.createIcons();
setTimeout(() => { btn.innerHTML = orig; lucide.createIcons(); }, 2000);
});
}); });
}); });
$('#copy').tooltip({ // Wire single-delete buttons
trigger: 'click', container.querySelectorAll('.delete-btn').forEach(btn => {
placement: 'bottom' btn.addEventListener('click', () => confirmSingleDelete(btn.dataset.videoId));
});
// Wire checkboxes to update the bulk-delete counter
container.querySelectorAll('.card-checkbox').forEach(cb => {
cb.addEventListener('change', updateSelectionCount);
}); });
function setTooltip(btn, message) { // Re-init icons
$(btn).tooltip('hide') lucide.createIcons();
.attr('data-original-title', message) } catch (error) {
.tooltip('show'); console.error("Error loading videos:", error);
}
}
// ── Select mode ────────────────────────────────────────────────────────────
let selectMode = false;
let pendingDeleteIds = [];
function toggleSelectMode() {
selectMode = true;
document.getElementById('bulk-bar').classList.remove('hidden');
document.getElementById('bulk-bar').classList.add('flex');
document.getElementById('normal-toolbar').classList.add('hidden');
document.querySelectorAll('.select-overlay').forEach(el => el.classList.remove('hidden'));
document.querySelectorAll('.card-checkbox').forEach(cb => cb.checked = false);
updateSelectionCount();
lucide.createIcons();
} }
function hoverTooltip(btn, message) { function cancelSelectMode() {
$(btn).tooltip('hide') selectMode = false;
.attr('data-original-title', message) document.getElementById('bulk-bar').classList.add('hidden');
.tooltip('show'); document.getElementById('bulk-bar').classList.remove('flex');
document.getElementById('normal-toolbar').classList.remove('hidden');
document.querySelectorAll('.select-overlay').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.card-checkbox').forEach(cb => cb.checked = false);
updateSelectionCount();
} }
function hideTooltip(btn) { function selectAll() {
setTimeout(function () { const checkboxes = document.querySelectorAll('.card-checkbox');
$(btn).tooltip('hide'); const allChecked = [...checkboxes].every(cb => cb.checked);
}, 1000); checkboxes.forEach(cb => cb.checked = !allChecked);
document.getElementById('select-all-label').textContent = allChecked ? 'Select All' : 'Deselect All';
updateSelectionCount();
} }
function disposeTooltip(btn) { function updateSelectionCount() {
setTimeout(function () { const count = document.querySelectorAll('.card-checkbox:checked').length;
$(btn).tooltip('dispose'); document.getElementById('selection-count').textContent = count;
}, 1500); document.getElementById('bulk-delete-btn').disabled = count === 0;
} }
var clipboard = new ClipboardJS('#copy'); function getSelectedIds() {
return [...document.querySelectorAll('.card-checkbox:checked')]
.map(cb => cb.closest('.video-card').dataset.videoId);
}
// ── Delete confirmation ─────────────────────────────────────────────────
function confirmBulkDelete() {
pendingDeleteIds = getSelectedIds();
if (!pendingDeleteIds.length) return;
const n = pendingDeleteIds.length;
document.getElementById('delete-modal-msg').textContent =
`Are you sure you want to delete ${n} video${n !== 1 ? 's' : ''}? This cannot be undone.`;
document.getElementById('delete_modal').showModal();
}
function confirmSingleDelete(videoId) {
pendingDeleteIds = [videoId];
document.getElementById('delete-modal-msg').textContent =
'Are you sure you want to delete this video? This cannot be undone.';
document.getElementById('delete_modal').showModal();
}
clipboard.on('success', function (e) { async function executeDelete() {
e.clearSelection(); document.getElementById('delete_modal').close();
console.info('Action:', e.action); if (!pendingDeleteIds.length) return;
console.info('Text:', e.text);
console.info('Trigger:', e.trigger); const ids = [...pendingDeleteIds];
setTooltip(e.trigger, 'Copied!'); pendingDeleteIds = [];
hideTooltip(e.trigger);
disposeTooltip(e.trigger); try {
await fetch('/videos/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
}); });
} catch (err) {
console.error('Delete request failed:', err);
}
clipboard.on('error', function (e) { // Remove cards from DOM regardless (optimistic UI)
console.error('Action:', e.action); ids.forEach(id => {
console.error('Trigger:', e.trigger); const card = document.querySelector(`.video-card[data-video-id="${CSS.escape(id)}"]`);
setTooltip(e.trigger, fallbackMessage(e.action)); if (card) card.remove();
hideTooltip(e.trigger);
}); });
function getCopyData(subreddit, reddit_title, filename, background_credit) { // Show empty state if nothing remains
const remaining = document.querySelectorAll('.video-card:not(.hidden)').length;
document.getElementById('empty-state').classList.toggle('hidden', remaining > 0);
if (subreddit == undefined) { if (selectMode) cancelSelectMode();
subredditCopy = "";
} else {
subredditCopy = "r/" + subreddit + "\n\n";
} }
const file = filename.slice(0, -4); function searchFilter() {
if (reddit_title == file) { const query = document.querySelector(".searchFilter").value.toLowerCase();
titleCopy = reddit_title; const cards = document.querySelectorAll(".video-card");
} else { let visibleCount = 0;
titleCopy = file;
}
var copyData = ""; cards.forEach(card => {
copyData += subredditCopy; const text = card.textContent.toLowerCase();
copyData += titleCopy; const matches = text.includes(query);
copyData += "\n\nBackground credit: " + background_credit; card.classList.toggle('hidden', !matches);
return copyData; if (matches) visibleCount++;
} });
function getLink(subreddit, id, reddit_title) { document.getElementById('empty-state').classList.toggle('hidden', visibleCount > 0);
if (subreddit == undefined) {
return reddit_title;
} else {
return "<a target='_blank' href='https://www.reddit.com/r/" + subreddit + "/comments/" + id + "/'>" + reddit_title + "</a>";
}
} }
function checkTitle(reddit_title, filename) { function openPlayer(src, title) {
const file = filename.slice(0, -4); const modal = document.getElementById('player_modal');
if (reddit_title == file) { const video = document.getElementById('player_video');
return reddit_title; const titleEl = document.getElementById('player_title');
} else { titleEl.textContent = title || '';
return file; video.src = src;
} modal.showModal();
video.play().catch(() => {});
} }
var searchFilter = () => { function closePlayer() {
const input = document.querySelector(".searchFilter"); const modal = document.getElementById('player_modal');
const cards = document.getElementsByClassName("col"); const video = document.getElementById('player_video');
console.log(cards[1]) video.pause();
let filter = input.value video.removeAttribute('src');
for (let i = 0; i < cards.length; i++) { video.load();
let title = cards[i].querySelector(".card-text"); modal.close();
if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
cards[i].classList.remove("d-none")
} else {
cards[i].classList.add("d-none")
}
}
} }
document.getElementById('player_modal').addEventListener('close', () => {
const video = document.getElementById('player_video');
video.pause();
video.removeAttribute('src');
video.load();
});
document.addEventListener('DOMContentLoaded', loadVideos);
</script> </script>
{% endblock %} {% endblock %}

@ -1,155 +1,128 @@
<html lang="en"> <!DOCTYPE html>
<html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="cache-control" content="no-cache" /> <meta http-equiv="cache-control" content="no-cache" />
<title>RedditVideoMakerBot</title> <title>VideoMakerBot</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/css/bootstrap.min.css"
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> <!-- Modern Tech Stack -->
<link href="https://getbootstrap.com/docs/5.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css"> <script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest"></script>
<style> <style>
.bd-placeholder-img { body {
font-size: 1.125rem; font-family: 'Inter', sans-serif;
text-anchor: middle; background-color: #0f172a; /* Slate 900 */
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
} }
.feedback-invalid { .glass-nav {
color: #dc3545; background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
} }
@media (min-width: 768px) { /* Custom scrollbar for a modern look */
.bd-placeholder-img-lg { ::-webkit-scrollbar {
font-size: 3.5rem; width: 8px;
} }
::-webkit-scrollbar-track {
background: #1e293b;
} }
::-webkit-scrollbar-thumb {
.bi { background: #334155;
vertical-align: -.125em; border-radius: 10px;
fill: currentColor;
} }
::-webkit-scrollbar-thumb:hover {
.nav { background: #475569;
display: flex;
flex-wrap: nowrap;
padding-bottom: 1rem;
margin-top: -1px;
overflow-x: auto;
text-align: center;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
#tooltip {
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 13px;
} }
.tooltip-inner { /* Dotted path inputs shouldn't show default browser validation UI */
max-width: 500px !important; input:invalid {
} box-shadow: none;
#hard-reload {
cursor: pointer;
color: darkblue;
}
#hard-reload:hover {
color: blue;
} }
</style> </style>
</head> </head>
<script src="https://code.jquery.com/jquery-3.1.1.js" integrity="sha256-16cdPddA6VdVInumRGo6IbivbERE8p7CQR3HzTBuELA=" <body class="min-h-screen flex flex-col">
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.3/dist/umd/popper.min.js"
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/js/bootstrap.min.js"
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
<script src="https://unpkg.com/isotope-layout@3/dist/isotope.pkgd.js"></script>
<body>
<header>
{% if get_flashed_messages() %}
{% for category, message in get_flashed_messages(with_categories=true) %}
{% if category == "error" %}
<div class="alert alert-danger mb-0 text-center" role="alert">
{{ message }}
</div>
{% else %} <header class="sticky top-0 z-50 glass-nav">
<div class="alert alert-success mb-0 text-center" role="alert"> <div class="container mx-auto px-4">
{{ message }} <div class="navbar px-0">
<div class="flex-1">
<a href="/" class="flex items-center gap-2 group">
<div class="bg-indigo-600 p-2 rounded-lg group-hover:bg-indigo-500 transition-colors">
<i data-lucide="video" class="w-5 h-5 text-white"></i>
</div> </div>
{% endif %} <span class="text-xl font-bold tracking-tight text-white">VideoMakerBot</span>
{% endfor %}
{% endif %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a href="/" class="navbar-brand d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="me-2"
viewBox="0 0 24 24">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<strong>RedditVideoMakerBot</strong>
</a> </a>
</div>
<div class="collapse navbar-collapse"> <div class="flex-none gap-2">
<ul class="navbar-nav mr-auto"> <ul class="menu menu-horizontal px-1 gap-1">
<li class="nav-item"> <li><a href="/" class="rounded-lg hover:bg-white/10 text-slate-300">Library</a></li>
<a class="nav-link" href="backgrounds">Background Manager</a> <li><a href="/backgrounds" class="rounded-lg hover:bg-white/10 text-slate-300">Backgrounds</a></li>
</li> <li><a href="/settings" class="rounded-lg hover:bg-white/10 text-slate-300">Settings</a></li>
<li class="nav-item">
<a class="nav-link" href="settings">Settings</a>
</li>
</ul>
<!-- Future feature
<ul class="navbar-nav">
<li class="nav-item">
<button class="btn btn-outline-success mr-auto mt-2 mt-lg-0">Create new short</button>
</li>
</ul> </ul>
--> <a href="/create" class="btn btn-indigo btn-sm ml-2 rounded-lg capitalize border-none bg-indigo-600 hover:bg-indigo-500 text-white">
<i data-lucide="plus" class="w-4 h-4"></i>
Create
</a>
</div>
</div> </div>
</div> </div>
</nav>
</header> </header>
{% block main %}{% endblock %} {% if get_flashed_messages() %}
<div class="container mx-auto px-4 mt-4">
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="alert {{ 'alert-error' if category == 'error' else 'alert-success' }} shadow-lg mb-2">
<i data-lucide="{{ 'alert-circle' if category == 'error' else 'check-circle' }}" class="w-5 h-5"></i>
<span>{{ message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<footer class="text-muted py-5"> <main class="flex-grow">
<div class="container"> {% block main %}{% endblock %}
<p class="float-end mb-1"> </main>
<a href="#">Back to top</a>
</p> <footer class="bg-slate-900 border-t border-white/5 py-12 mt-12">
<p class="mb-1"><a href="https://getbootstrap.com/docs/5.2/examples/album/" target="_blank">Album</a> <div class="container mx-auto px-4">
Example <div class="flex flex-col md:flex-row justify-between items-center gap-6">
Theme by &copy; Bootstrap. <a <div class="flex flex-col gap-2">
href="https://github.com/elebumm/RedditVideoMakerBot/blob/master/README.md#developers-and-maintainers" <div class="flex items-center gap-2">
target="_blank">Developers and Maintainers</a></p> <i data-lucide="video" class="w-5 h-5 text-indigo-500"></i>
<p class="mb-0">If your data is not refreshing, try to hard reload(Ctrl + F5) or click <a id="hard-reload">this</a> and visit your local <span class="text-lg font-semibold text-white">VideoMakerBot</span>
</div>
<p class="text-slate-400 text-sm">Automated short-form video creator.</p>
</div>
<strong>{{ file }}</strong> file. <div class="flex gap-6 text-sm text-slate-400">
</p> <a href="https://github.com/elebumm/RedditVideoMakerBot" target="_blank" class="hover:text-white transition-colors">GitHub</a>
<a href="#" class="hover:text-white transition-colors" id="hard-reload">Hard Reload</a>
</div>
</div>
<div class="mt-8 pt-8 border-t border-white/5 text-center text-xs text-slate-500">
&copy; 2026 VideoMakerBot. Built for speed and creativity.
</div>
</div> </div>
</footer> </footer>
<script> <script>
document.getElementById("hard-reload").addEventListener("click", function () { // Initialize Lucide icons
lucide.createIcons();
document.getElementById("hard-reload").addEventListener("click", function (e) {
e.preventDefault();
window.location.reload(true); window.location.reload(true);
}); });
</script> </script>
</body> </body>
</html> </html>

@ -1,621 +1,461 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block main %} {% block main %}
<main> <div class="bg-slate-900 min-h-screen py-8">
<br> <div class="container mx-auto px-4 max-w-5xl">
<div class="container"> <div class="flex flex-col md:flex-row justify-between items-end gap-4 mb-8">
<form id="settingsForm" action="/settings" method="post" novalidate> <div>
<h1 class="text-3xl font-bold text-white mb-2">Settings</h1>
<!-- Reddit Credentials --> <p class="text-slate-400">Configure your platform credentials and video generation preferences.</p>
<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>
</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> </div>
<div class="flex gap-2">
<button id="defaultSettingsBtn" type="button" class="btn btn-outline btn-sm text-slate-400 border-slate-700 hover:bg-slate-800">
Reset Defaults
</button>
<button form="settingsForm" type="submit" class="btn btn-indigo btn-sm bg-indigo-600 hover:bg-indigo-500 border-none text-white">
Save Changes
</button>
</div> </div>
</div> </div>
<div class="row mb-2">
<label for="username" class="col-4">Reddit Username</label> <form id="settingsForm" action="/settings" method="post" novalidate>
<div class="col-8"> <div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div class="input-group">
<div class="input-group-text"> <!-- Navigation Tabs -->
<i class="bi bi-person-fill"></i> <div class="lg:col-span-1">
</div> <ul class="menu bg-slate-800/50 rounded-xl p-2 border border-white/5 sticky top-24" id="settingsTabs">
<input name="username" value="{{ data.username }}" placeholder="Your Reddit account's username" <li><a class="active flex gap-3 py-3" data-tab="platform"><i data-lucide="layout-template" class="w-4 h-4"></i> Platform</a></li>
type="text" class="form-control"> <li><a class="flex gap-3 py-3" data-tab="content"><i data-lucide="file-text" class="w-4 h-4"></i> Content</a></li>
</div> <li><a class="flex gap-3 py-3" data-tab="visuals"><i data-lucide="palette" class="w-4 h-4"></i> Visuals</a></li>
</div> <li><a class="flex gap-3 py-3" data-tab="audio"><i data-lucide="volume-2" class="w-4 h-4"></i> Audio</a></li>
<li><a class="flex gap-3 py-3" data-tab="integration"><i data-lucide="share-2" class="w-4 h-4"></i> Integration</a></li>
</ul>
</div>
<!-- Tab Content -->
<div class="lg:col-span-3 space-y-6">
<!-- Platform Tab -->
<div id="tab-platform" class="tab-pane">
<div class="card bg-slate-800 border border-white/5 shadow-xl">
<div class="card-body">
<h2 class="card-title text-white mb-6">Source Configuration</h2>
<div class="form-control w-full mb-8">
<label class="label"><span class="label-text text-slate-300 font-medium">Content Platform</span></label>
<select name="settings.platform" id="platformSelect" class="select select-bordered bg-slate-900 border-slate-700 focus:border-indigo-500">
<option value="reddit">Reddit</option>
<option value="threads">Threads (Meta)</option>
</select>
<label class="label"><span class="label-text-alt text-slate-500">Which social media platform to pull content from</span></label>
</div> </div>
<div class="row mb-2">
<label for="password" class="col-4">Reddit Password</label> <!-- Reddit Section -->
<div class="col-8"> <div class="platform-section space-y-6" data-platform="reddit">
<div class="input-group"> <div class="divider text-slate-500 text-xs uppercase tracking-widest">Reddit Credentials</div>
<div class="input-group-text"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<i class="bi bi-lock-fill"></i> <div class="form-control w-full">
<label class="label"><span class="label-text text-slate-400">Client ID</span></label>
<input name="reddit.creds.client_id" value="{{ data['reddit.creds.client_id'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700" placeholder="Your Client ID">
</div> </div>
<input name="password" value="{{ data.password }}" placeholder="Your Reddit account's password" <div class="form-control w-full">
type="password" class="form-control"> <label class="label"><span class="label-text text-slate-400">Client Secret</span></label>
<input name="reddit.creds.client_secret" value="{{ data['reddit.creds.client_secret'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700" placeholder="Your Client Secret">
</div> </div>
<div class="form-control w-full">
<label class="label"><span class="label-text text-slate-400">Username</span></label>
<input name="reddit.creds.username" value="{{ data['reddit.creds.username'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700" placeholder="Reddit Username">
</div> </div>
<div class="form-control w-full">
<label class="label"><span class="label-text text-slate-400">Password</span></label>
<input name="reddit.creds.password" value="{{ data['reddit.creds.password'] }}" type="password" class="input input-bordered bg-slate-900 border-slate-700" placeholder="Reddit Password">
</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> </div>
<span class="form-text text-muted"><a <div class="flex items-center gap-4 bg-slate-900/50 p-4 rounded-lg border border-white/5">
href="https://reddit-video-maker-bot.netlify.app/docs/configuring#setting-up-the-api" <input name="reddit.creds.2fa" type="checkbox" class="toggle toggle-indigo" value="True">
target="_blank">Need help? Click here to open step-by-step guide.</a></span> <span class="text-sm text-slate-300">Enable 2FA Support</span>
</div> </div>
</div> </div>
<!-- Reddit Thread --> <!-- Threads Section -->
<p class="h4">Reddit Thread</p> <div class="platform-section space-y-6 hidden" data-platform="threads">
<div class="row mb-2"> <div class="divider text-slate-500 text-xs uppercase tracking-widest">Threads Configuration</div>
<label class="col-4">Random Thread</label> <div class="form-control w-full">
<div class="col-8"> <label class="label"><span class="label-text text-slate-400">Discovery Method</span></label>
<div class="form-check form-switch"> <select name="threads.discovery_method" class="select select-bordered bg-slate-900 border-slate-700">
<input name="random" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip" <option value="api">API (Your own posts)</option>
data-original-title='If disabled, it will ask you for a thread link, instead of picking random one'> <option value="scrape">Scrape (For You feed)</option>
</select>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text text-slate-400">Threads Username</span></label>
<input name="threads.creds.username" value="{{ data['threads.creds.username'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700" placeholder="Username">
</div> </div>
<div class="form-control w-full">
<label class="label"><span class="label-text text-slate-400">Threads Password</span></label>
<input name="threads.creds.password" value="{{ data['threads.creds.password'] }}" type="password" class="input input-bordered bg-slate-900 border-slate-700" placeholder="Password">
</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> </div>
<input value="{{ data.subreddit }}" name="subreddit" type="text" class="form-control" <div class="form-control w-full">
placeholder="Subreddit to pull posts from (e.g. AskReddit)" data-toggle="tooltip" <label class="label"><span class="label-text text-slate-400">Access Token</span></label>
data-original-title='You can have multiple subreddits, <input name="threads.creds.access_token" value="{{ data['threads.creds.access_token'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700" placeholder="Long-lived Graph API Token">
add "+" between them (e.g. AskReddit+Redditdev)'>
</div> </div>
</div> </div>
</div> </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> </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>
<!-- Content Tab -->
<div id="tab-content" class="tab-pane hidden">
<div class="card bg-slate-800 border border-white/5 shadow-xl">
<div class="card-body space-y-6">
<h2 class="card-title text-white mb-2">Content Filtering</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Reddit Options -->
<div class="platform-section space-y-4" data-platform="reddit">
<div class="form-control w-full">
<label class="label"><span class="label-text text-slate-400">Target Subreddit</span></label>
<input name="reddit.thread.subreddit" value="{{ data['reddit.thread.subreddit'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700">
</div> </div>
<div class="form-control">
<label class="label"><span class="label-text text-slate-400">Max Comment Length: <span class="val-display font-mono text-indigo-400"></span></span></label>
<input name="reddit.thread.max_comment_length" type="range" min="100" max="1000" step="50" class="range range-xs range-indigo" value="{{ data['reddit.thread.max_comment_length'] }}">
</div> </div>
<div class="row mb-2"> <div class="form-control">
<label for="max_comment_length" class="col-4">Max Comment Length</label> <label class="label"><span class="label-text text-slate-400">Min Comments: <span class="val-display font-mono text-indigo-400"></span></span></label>
<div class="col-8"> <input name="reddit.thread.min_comments" type="range" min="1" max="100" step="1" class="range range-xs range-indigo" value="{{ data['reddit.thread.min_comments'] }}">
<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>
</div> </div>
<!-- Threads Options -->
<div class="platform-section hidden space-y-4" data-platform="threads">
<div class="form-control w-full">
<label class="label"><span class="label-text text-slate-400">Search Queries</span></label>
<input name="threads.thread.search_queries" value="{{ data['threads.thread.search_queries'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700" placeholder="news,viral,stories">
</div> </div>
<div class="row mb-2"> <div class="form-control w-full">
<label for="post_lang" class="col-4">Post Language</label> <label class="label"><span class="label-text text-slate-400">Max Reply Length: <span class="val-display font-mono text-indigo-400"></span></span></label>
<div class="col-8"> <input name="threads.thread.max_reply_length" type="range" min="100" max="1000" step="50" class="range range-xs range-indigo" value="{{ data['threads.thread.max_reply_length'] }}">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-translate"></i>
</div> </div>
<input value="{{ data.post_lang }}" name="post_lang" type="text" class="form-control" <div class="form-control w-full">
placeholder="The language you would like to translate to"> <label class="label"><span class="label-text text-slate-400">Min Replies: <span class="val-display font-mono text-indigo-400"></span></span></label>
<input name="threads.thread.min_replies" type="range" min="1" max="50" step="1" class="range range-xs range-indigo" value="{{ data['threads.thread.min_replies'] }}">
</div> </div>
</div> </div>
</div> </div>
<div class="row mb-2">
<label for="min_comments" class="col-4">Minimum Comments</label> <div class="divider border-white/5">General</div>
<div class="col-8">
<div class="input-group"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input name="min_comments" type="range" class="form-range" min="15" max="1000" step="1" <div class="flex items-center gap-4 bg-slate-900/50 p-4 rounded-lg border border-white/5">
value="{{ data.min_comments }}" data-toggle="tooltip" <input name="settings.allow_nsfw" type="checkbox" class="toggle toggle-error" value="True">
data-original-title="{{ data.min_comments }}"> <span class="text-sm text-slate-300">Allow NSFW Content</span>
</div> </div>
<span class="form-text text-muted">The minimum number of comments a post should have to be <div class="form-control w-full">
included.</span> <label class="label"><span class="label-text text-slate-400 font-medium">Videos to generate: <span class="val-display font-mono text-indigo-400"></span></span></label>
<input name="settings.times_to_run" type="range" min="1" max="50" step="1" class="range range-xs range-indigo" value="{{ data['settings.times_to_run'] }}">
</div> </div>
</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>
</div> </div>
</div> </div>
<div class="row mb-2">
<label for="theme" class="col-4">Reddit Theme</label> <!-- Visuals Tab -->
<div class="col-8"> <div id="tab-visuals" class="tab-pane hidden">
<select name="theme" class="form-select" data-toggle="tooltip" <div class="card bg-slate-800 border border-white/5 shadow-xl">
data-original-title='Sets the theme of Reddit screenshots'> <div class="card-body space-y-6">
<h2 class="card-title text-white">Visual Styling</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="form-control">
<label class="label"><span class="label-text text-slate-400">Screenshot Theme</span></label>
<select name="settings.theme" class="select select-bordered bg-slate-900 border-slate-700">
<option value="dark">Dark</option> <option value="dark">Dark</option>
<option value="light">Light</option> <option value="light">Light</option>
<option value="transparent">Transparent</option>
</select> </select>
</div> </div>
<div class="form-control">
<label class="label"><span class="label-text text-slate-400">Comment Opacity: <span class="val-display font-mono text-indigo-400"></span></span></label>
<input name="settings.opacity" type="range" min="0" max="1" step="0.05" class="range range-xs range-indigo" value="{{ data['settings.opacity'] }}">
</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 }}">
</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>
<div class="row mb-2"> <div class="divider border-white/5">Backgrounds</div>
<label for="transition" class="col-4">Transition</label>
<div class="col-8"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="input-group"> <div class="form-control w-full">
<input name="transition" type="range" class="form-range" min="0" max="2" step="0.05" <label class="label"><span class="label-text text-slate-400 font-medium">Video Background</span></label>
value="{{ data.transition }}" data-toggle="tooltip" <select name="settings.background.background_video" class="select select-bordered bg-slate-900 border-slate-700">
data-original-title="{{ data.transition }}"> {% for background in checks["settings.background.background_video"]["options"] %}
</div> <option value="{{background}}">{{ background or 'Random' }}</option>
<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 %} {% endfor %}
</select> </select>
<span class="form-text text-muted"><a href="/backgrounds" target="_blank">See all available <label class="label"><a href="/backgrounds" target="_blank" class="label-text-alt text-indigo-400 hover:text-indigo-300">Manage video files →</a></label>
backgrounds</a></span>
</div>
</div> </div>
<div class="row mb-2"> <div class="form-control w-full">
<label for="background_thumbnail" class="col-4">Background Thumbnail</label> <label class="label"><span class="label-text text-slate-400 font-medium">Audio Track</span></label>
<div class="col-8"> <select name="settings.background.background_audio" class="select select-bordered bg-slate-900 border-slate-700">
<div class="form-check form-switch"> {% for audio in checks["settings.background.background_audio"]["options"] %}
<input name="background_thumbnail" class="form-check-input" type="checkbox" value="True" <option value="{{audio}}">{{ audio or 'None' }}</option>
data-toggle="tooltip" {% endfor %}
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.)'> </select>
</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> </div>
<div class="row mb-2">
<label for="background_thumbnail_font_size" class="col-4">Background Thumbnail Font Size (px)</label> <div class="flex items-center gap-4 bg-slate-900/50 p-4 rounded-lg border border-white/5">
<div class="col-8"> <input name="settings.background.background_thumbnail" type="checkbox" class="toggle toggle-indigo" value="True">
<input name="background_thumbnail_font_size" type="number" class="form-control" <span class="text-sm text-slate-300">Generate Thumbnail overlay</span>
placeholder="96" value="{{ data.background_thumbnail_font_size }}">
</div> </div>
</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 --> <!-- Audio Tab -->
<p class="h4">TTS Settings</p> <div id="tab-audio" class="tab-pane hidden">
<div class="row mb-2"> <div class="card bg-slate-800 border border-white/5 shadow-xl">
<label for="voice_choice" class="col-4">TTS Voice Choice</label> <div class="card-body space-y-6">
<div class="col-8"> <h2 class="card-title text-white">Voice & Speech</h2>
<select name="voice_choice" class="form-select" data-toggle="tooltip"
data-original-title='The voice platform used for TTS generation'> <div class="form-control w-full">
<option value="streamlabspolly">Streamlabspolly</option> <label class="label"><span class="label-text text-slate-400 font-medium">TTS Provider</span></label>
<select name="settings.tts.voice_choice" id="voiceChoiceSelect" class="select select-bordered bg-slate-900 border-slate-700">
<option value="streamlabspolly">Streamlabs Polly (Free)</option>
<option value="tiktok">TikTok</option> <option value="tiktok">TikTok</option>
<option value="googletranslate">Google Translate</option> <option value="googletranslate">Google Translate</option>
<option value="awspolly">AWS Polly</option> <option value="awspolly">AWS Polly</option>
<option value="pyttsx">Python TTS (pyttsx)</option> <option value="elevenlabs">ElevenLabs</option>
<option value="OpenAI">OpenAI</option>
<option value="pyttsx">System Voice (pyttsx)</option>
</select> </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"
class="bi-volume-up-fill"></i></button>
</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" <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
class="bi bi-volume-up-fill"></i></button> <div class="form-control">
</div> <label class="label"><span class="label-text text-slate-400">Silence between comments: <span class="val-display font-mono text-indigo-400"></span>s</span></label>
</div> <input name="settings.tts.silence_duration" type="range" min="0" max="5" step="0.1" class="range range-xs range-indigo" value="{{ data['settings.tts.silence_duration'] }}">
</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"
class="bi-volume-up-fill"></i></button>
</div> </div>
<div class="flex flex-col gap-3 justify-center">
<div class="flex items-center gap-4">
<input name="settings.tts.random_voice" type="checkbox" class="toggle toggle-indigo" value="True">
<span class="text-sm text-slate-300">Randomize voices</span>
</div> </div>
<div class="flex items-center gap-4">
<input name="settings.tts.no_emojis" type="checkbox" class="toggle toggle-indigo" value="True">
<span class="text-sm text-slate-300">Strip emojis</span>
</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> </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 class="divider border-white/5">API Credentials</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text text-slate-500">ElevenLabs Key</span></label>
<input name="settings.tts.elevenlabs_api_key" value="{{ data['settings.tts.elevenlabs_api_key'] }}" type="password" class="input input-bordered input-sm bg-slate-900 border-slate-700">
</div> </div>
<div class="form-control">
<label class="label"><span class="label-text text-slate-500">OpenAI Key</span></label>
<input name="settings.tts.openai_api_key" value="{{ data['settings.tts.openai_api_key'] }}" type="password" class="input input-bordered input-sm bg-slate-900 border-slate-700">
</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> </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>
</div> </div>
<div class="row mb-2">
<label for="py_voice_num" class="col-4">Py Voice Number</label> <!-- Integration Tab -->
<div class="col-8"> <div id="tab-integration" class="tab-pane hidden">
<div class="input-group"> <div class="card bg-slate-800 border border-white/5 shadow-xl">
<div class="input-group-text"> <div class="card-body space-y-6">
<i class="bi bi-headset"></i> <h2 class="card-title text-white">YouTube Integration</h2>
<div class="flex items-center gap-4 bg-slate-900/50 p-4 rounded-lg border border-white/5">
<input name="youtube.enabled" type="checkbox" class="toggle toggle-success" value="True">
<span class="text-sm text-slate-300 font-medium">Auto-upload after rendering</span>
</div> </div>
<input value="{{ data.py_voice_num }}" name="py_voice_num" type="text" class="form-control"
data-toggle="tooltip" <div class="form-control w-full">
data-original-title='The number of system voices (2 are pre-installed in Windows)'> <label class="label"><span class="label-text text-slate-400">Client Secret Path</span></label>
<input name="youtube.client_secret_path" value="{{ data['youtube.client_secret_path'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700" placeholder="path/to/secret.json">
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label"><span class="label-text text-slate-400">Privacy</span></label>
<select name="youtube.privacy" class="select select-bordered bg-slate-900 border-slate-700">
<option value="public">Public</option>
<option value="private">Private</option>
<option value="unlisted">Unlisted</option>
</select>
</div> </div>
<div class="form-control col-span-2">
<label class="label"><span class="label-text text-slate-400">Tags (comma-separated)</span></label>
<input name="youtube.tags" value="{{ data['youtube.tags'] }}" type="text" class="input input-bordered bg-slate-900 border-slate-700" placeholder="shorts, reddit, viral">
</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> </div>
<span class="form-text text-muted">Time in seconds between TTS comments.</span>
</div> </div>
</div> </div>
<div class="col text-center"> </div>
<br>
<button id="defaultSettingsBtn" type="button" class="btn btn-secondary">Default </div>
Settings</button>
<button id="submitButton" type="submit" class="btn btn-success">Save
Changes</button>
</div> </div>
</form> </form>
</div> </div>
<audio src=""></audio> </div>
</main>
<script> <script>
// Test voices buttons document.addEventListener('DOMContentLoaded', function () {
var playing = false; const data = {{ data | tojson | safe }};
const validateChecks = {{ checks | tojson | safe }};
$(".voices button").click(function () { const form = document.getElementById('settingsForm');
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"); // ---- Tab Switching -------------------------------------------------
let path = "voices/" + $(this).closest(".voices").find("select").prop("value").toLowerCase() + ".mp3"; const tabs = document.querySelectorAll('#settingsTabs a');
const panes = document.querySelectorAll('.tab-pane');
audio.prop("src", path); tabs.forEach(tab => {
audio[0].play(); tab.addEventListener('click', (e) => {
playing = icon; e.preventDefault();
const target = tab.dataset.tab;
audio[0].onended = function () { tabs.forEach(t => t.classList.remove('active'));
icon.toggleClass("bi-volume-up-fill bi-stop-fill"); tab.classList.add('active');
playing = false;
}
});
// Wait for DOM to load panes.forEach(p => p.classList.toggle('hidden', p.id !== `tab-${target}`));
$(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 // ---- Form Initialization -------------------------------------------
var data = JSON.parse('{{data | tojson}}'); // Set values for all inputs based on the flattened data object
form.querySelectorAll('input, select, textarea').forEach(input => {
// Set current checkboxes const name = input.name;
$('.form-check-input').each(function () { if (data[name] !== undefined) {
$(this).prop("checked", data[$(this).prop("name")]); if (input.type === 'checkbox') {
}); input.checked = (data[name] === "True" || data[name] === true);
} else {
// Set current select options input.value = data[name];
$('.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);
} }
});
});
// 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"]);
} }
}
});
});
// Validate form // Trigger input events for range displays
$('#settingsForm').submit(function (event) { if (input.type === 'range') {
$("#settingsForm input, #settingsForm select").each(function () { updateRangeDisplay(input);
if (!(validate($(this)))) { input.addEventListener('input', () => updateRangeDisplay(input));
event.preventDefault();
event.stopPropagation();
$("html, body").animate({
scrollTop: $(this).offset().top
});
} }
});
}); });
$("#settingsForm input").on("keyup", function () { function updateRangeDisplay(input) {
validate($(this)); const display = input.closest('.form-control')?.querySelector('.val-display');
}); if (display) display.textContent = input.value;
}
$("#settingsForm select").on("change", function () { // ---- Platform Visibility -------------------------------------------
validate($(this)); const platformSelect = document.getElementById('platformSelect');
function applyPlatformVisibility() {
const current = platformSelect.value || "reddit";
document.querySelectorAll('.platform-section').forEach(section => {
const matches = section.dataset.platform === current;
section.classList.toggle('hidden', !matches);
section.querySelectorAll('input, select, textarea').forEach(el => el.disabled = !matches);
}); });
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 { platformSelect.addEventListener('change', applyPlatformVisibility);
object.removeClass("is-valid"); applyPlatformVisibility();
object.addClass("is-invalid");
// ---- Validation ----------------------------------------------------
function validateInput(input) {
const check = validateChecks[input.name];
if (!check) return true;
let value = input.value;
let isValid = true;
// Optional/Empty check
if (value.length === 0) {
isValid = !!check.optional;
} else {
// Type specific checks
if (check.type === 'int' || check.type === 'float') {
const num = parseFloat(value);
if (check.nmin !== undefined && num < check.nmin) isValid = false;
if (check.nmax !== undefined && num > check.nmax) isValid = false;
} else {
if (check.nmin !== undefined && value.length < check.nmin) isValid = false;
if (check.nmax !== undefined && value.length > check.nmax) isValid = false;
} }
return bool; // Regex check
if (isValid && check.regex) {
// Check values (return true/false) const re = new RegExp(check.regex);
function check(name, value) { if (!re.test(value)) isValid = false;
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 input.classList.toggle('input-error', !isValid);
if (check["nmin"]) { input.classList.toggle('select-error', !isValid && input.tagName === 'SELECT');
if (check["type"] == "int" || check["type"] == "float") { return isValid;
if (value < check["nmin"]) {
return false;
}
}
else {
if (value.length < check["nmin"]) {
return false;
} }
} form.querySelectorAll('input, select').forEach(input => {
} input.addEventListener('change', () => validateInput(input));
if (input.tagName === 'INPUT') input.addEventListener('keyup', () => validateInput(input));
});
// Check if value is too long // ---- Submit Logic --------------------------------------------------
if (check["nmax"]) { form.addEventListener('submit', (e) => {
if (check["type"] == "int" || check["type"] == "float") { let formIsValid = true;
if (value > check["nmax"]) { const enabledInputs = form.querySelectorAll('input:not(:disabled), select:not(:disabled)');
return false;
} enabledInputs.forEach(input => {
if (!validateInput(input)) {
formIsValid = false;
// Switch to the tab containing the error
const pane = input.closest('.tab-pane');
if (pane) {
const tabBtn = document.querySelector(`[data-tab="${pane.id.replace('tab-', '')}"]`);
if (tabBtn) tabBtn.click();
} }
else {
if (value.length > check["nmax"]) {
return false;
} }
});
} if (!formIsValid) {
e.preventDefault();
return;
} }
// Check if value matches regex // Handle un-checked checkboxes (ensure they submit "False")
if (check["regex"]) { form.querySelectorAll('input[type="checkbox"]:not(:disabled)').forEach(cb => {
let regex = new RegExp(check["regex"]); if (!cb.checked) {
if (!(regex.test(value))) { const hidden = document.createElement('input');
return false; hidden.type = 'hidden';
} hidden.name = cb.name;
hidden.value = 'False';
form.appendChild(hidden);
} }
});
});
return true; // ---- Defaults ------------------------------------------------------
document.getElementById('defaultSettingsBtn').addEventListener('click', () => {
if (!confirm('Are you sure you want to reset visible settings to defaults?')) return;
form.querySelectorAll('input:not(:disabled), select:not(:disabled)').forEach(input => {
const check = validateChecks[input.name];
if (check && check.default !== undefined) {
if (input.type === 'checkbox') {
input.checked = (check.default === "True" || check.default === true);
} else {
input.value = check.default;
} }
if (input.type === 'range') updateRangeDisplay(input);
validateInput(input);
} }
}); });
});
lucide.createIcons();
});
</script> </script>
{% endblock %} {% endblock %}

@ -32,9 +32,10 @@ The only original thing being done is the editing and gathering of all materials
## Requirements ## Requirements
- Python 3.10 - Python 3.10+
- Playwright (this should install automatically in installation) - Playwright (this should install automatically in installation)
- Docker and Docker Compose for the container workflow - Docker and Docker Compose for the container workflow
- FFmpeg (for video composition)
## Installation 👩‍💻 ## Installation 👩‍💻
@ -136,21 +137,38 @@ For a more detailed guide about the bot, please refer to the [documentation](htt
https://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4 https://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4
## Web User Interface 🖥️
VideoMakerBot features a modernized Flask-based web UI for easier management and generation.
- **Technology Stack**: Tailwind CSS, DaisyUI, Lucide Icons, Vanilla ES6 JavaScript.
- **Video Library**: View, download, and copy source links for generated videos.
- **Background Manager**: Add and remove background videos (YouTube-linked) and manage audio tracks.
- **Settings**: Complete configuration of platform credentials (Reddit, Threads), TTS providers, and visual preferences.
To start the UI locally without Docker:
```sh
python GUI.py
```
Visit `http://localhost:4000` to access the dashboard.
## Contributing & Ways to improve 📈 ## Contributing & Ways to improve 📈
In its current state, this bot does exactly what it needs to do. However, improvements can always be made! In its current state, this bot does exactly what it needs to do. However, improvements can always be made!
I have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute! I have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute!
- [ ] Creating better documentation and adding a command line interface. - [x] Creating better documentation and adding a command line interface.
- [x] Allowing the user to choose background music for their videos. - [x] Allowing the user to choose background music for their videos.
- [x] Allowing users to choose a reddit thread instead of being randomized. - [x] Allowing users to choose a reddit/threads thread instead of being randomized.
- [x] Allowing users to choose a background that is picked instead of the Minecraft one. - [x] Allowing users to choose a background that is picked instead of the Minecraft one.
- [x] Allowing users to choose between any subreddit. - [x] Allowing users to choose between any subreddit.
- [x] Allowing users to change voice. - [x] Allowing users to change voice.
- [x] Checks if a video has already been created - [x] Checks if a video has already been created.
- [x] Light and Dark modes - [x] Light and Dark modes.
- [x] NSFW post filter - [x] NSFW post filter.
- [x] Threads platform support.
- [x] Modern Web UI (Tailwind + DaisyUI).
Please read our [contributing guidelines](CONTRIBUTING.md) for more detailed information. Please read our [contributing guidelines](CONTRIBUTING.md) for more detailed information.

@ -25,3 +25,15 @@ services:
volumes: volumes:
- ./:/app - ./:/app
shm_size: "1gb" shm_size: "1gb"
test:
build:
context: .
image: videomakerbot:latest
command: ["pytest", "tests/", "-v"]
environment:
PYTHONUNBUFFERED: "1"
PYTHONPATH: "/app"
volumes:
- ./:/app
shm_size: "1gb"

@ -0,0 +1,72 @@
import json
import os
import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
from utils import gui_utils
@pytest.fixture
def mock_background_json(tmp_path):
bg_file = tmp_path / "background_videos.json"
initial_data = {
"__comment": "test",
"minecraft": ["https://www.youtube.com/watch?v=n_Dv4JMiwK8", "parkour.mp4", "bbswitzer", "center"]
}
bg_file.write_text(json.dumps(initial_data))
return bg_file
@pytest.fixture
def mock_template_toml(tmp_path):
template_file = tmp_path / ".config.template.toml"
template_content = """
[settings.background]
background_video = { optional = true, default = "minecraft", options = ["minecraft"] }
"""
template_file.write_text(template_content)
return template_file
@patch("utils.gui_utils.flash")
def test_delete_background(mock_flash, mock_background_json, mock_template_toml):
# We need to patch the paths used in gui_utils
with patch("utils.gui_utils.open", MagicMock(side_effect=lambda path, *args, **kwargs: open(mock_background_json if "background_videos.json" in str(path) else path, *args, **kwargs))), \
patch("utils.gui_utils.Path", MagicMock(side_effect=lambda path: Path(mock_template_toml) if ".config.template.toml" in str(path) else Path(path))):
gui_utils.delete_background("minecraft")
# Verify background_videos.json
with open(mock_background_json, "r") as f:
data = json.load(f)
assert "minecraft" not in data
# Verify .config.template.toml
import tomlkit
template_data = tomlkit.loads(mock_template_toml.read_text())
assert "minecraft" not in template_data["settings"]["background"]["background_video"]["options"]
mock_flash.assert_called_with('Successfully removed "minecraft" background!')
@patch("utils.gui_utils.flash")
def test_add_background(mock_flash, mock_background_json, mock_template_toml):
with patch("utils.gui_utils.open", MagicMock(side_effect=lambda path, *args, **kwargs: open(mock_background_json if "background_videos.json" in str(path) else path, *args, **kwargs))), \
patch("utils.gui_utils.Path", MagicMock(side_effect=lambda path: Path(mock_template_toml) if ".config.template.toml" in str(path) else Path(path))):
# Test adding a new background
gui_utils.add_background(
youtube_uri="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
filename="test_new",
citation="Rick",
position="center"
)
# Verify background_videos.json
with open(mock_background_json, "r") as f:
data = json.load(f)
assert "test_new" in data
assert data["test_new"][0] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
# Verify .config.template.toml
import tomlkit
template_data = tomlkit.loads(mock_template_toml.read_text())
assert "test_new" in template_data["settings"]["background"]["background_video"]["options"]
mock_flash.assert_called_with('Added "Rick-test_new.mp4" as a new background video!')

@ -9,6 +9,15 @@ from rich.text import Text
console = Console() console = Console()
# Progress callback for GUI integration.
# Set by GUI.py to receive stage-change notifications during pipeline runs.
_progress_callback = None
def set_progress_callback(cb):
global _progress_callback
_progress_callback = cb
def print_markdown(text) -> None: def print_markdown(text) -> None:
"""Prints a rich info message. Support Markdown syntax.""" """Prints a rich info message. Support Markdown syntax."""
@ -22,6 +31,8 @@ def print_step(text) -> None:
panel = Panel(Text(text, justify="left")) panel = Panel(Text(text, justify="left"))
console.print(panel) console.print(panel)
if _progress_callback:
_progress_callback(stage=text)
def print_table(items) -> None: def print_table(items) -> None:

@ -7,32 +7,37 @@ import tomlkit
from flask import flash from flask import flash
# Get validation checks from template # Get validation checks from template, keyed by dotted path
# (e.g. "reddit.creds.username", "threads.creds.username") so that
# leaf-key collisions across platform sections don't clobber each other.
def get_checks(): def get_checks():
template = toml.load("utils/.config.template.toml") template = toml.load("utils/.config.template.toml")
checks = {} checks = {}
def unpack_checks(obj: dict): def unpack_checks(obj: dict, path):
for key in obj.keys(): for key in obj.keys():
if "optional" in obj[key].keys(): full = f"{path}.{key}" if path else key
checks[key] = obj[key] if isinstance(obj[key], dict) and "optional" in obj[key].keys():
else: checks[full] = obj[key]
unpack_checks(obj[key]) elif isinstance(obj[key], dict):
unpack_checks(obj[key], full)
unpack_checks(template) unpack_checks(template, "")
return checks return checks
# Get current config (from config.toml) as dict # Get current config (from config.toml) as a dict keyed by dotted path.
def get_config(obj: dict, done=None): # Mirrors the path layout of get_checks() so the GUI can match values to checks.
def get_config(obj: dict, done=None, path=""):
if done is None: if done is None:
done = {} done = {}
for key in obj.keys(): for key in obj.keys():
full = f"{path}.{key}" if path else key
if not isinstance(obj[key], dict): if not isinstance(obj[key], dict):
done[key] = obj[key] done[full] = obj[key]
else: else:
get_config(obj[key], done) get_config(obj[key], done, full)
return done return done
@ -92,29 +97,30 @@ def check(value, checks):
# Modify settings (after the form is submitted) # Modify settings (after the form is submitted)
def modify_settings(data: dict, config_load, checks: dict): def modify_settings(data: dict, config_load, checks: dict):
# Modify config settings # Walk the dotted path and set the value at the precise location.
def modify_config(obj: dict, config_name: str, value: any): # Example: "reddit.creds.username" -> config_load["reddit"]["creds"]["username"]
for key in obj.keys(): def set_by_path(obj: dict, dotted_path: str, value):
if config_name == key: parts = dotted_path.split(".")
obj[key] = value cursor = obj
elif not isinstance(obj[key], dict): for part in parts[:-1]:
continue if part not in cursor or not isinstance(cursor[part], dict):
else: cursor[part] = {}
modify_config(obj[key], config_name, value) cursor = cursor[part]
cursor[parts[-1]] = value
# Remove empty/incorrect key-value pairs
data = {key: value for key, value in data.items() if value and key in checks.keys()} # Filter data to only include keys present in checks
data = {key: value for key, value in data.items() if key in checks.keys()}
# Validate values
for name in data.keys(): # Validate and apply values
value = check(data[name], checks[name]) for name, raw_value in data.items():
value = check(raw_value, checks[name])
# Value is invalid # Value is invalid
if value == "Error": if value == "Error":
flash("Some values were incorrect and didn't save!", "error") flash("Some values were incorrect and didn't save!", "error")
else: else:
# Value is valid # Value is valid
modify_config(config_load, name, value) set_by_path(config_load, name, value)
# Save changes in config.toml # Save changes in config.toml
with Path("config.toml").open("w") as toml_file: with Path("config.toml").open("w") as toml_file:
@ -127,21 +133,22 @@ def modify_settings(data: dict, config_load, checks: dict):
# Delete background video # Delete background video
def delete_background(key): def delete_background(key):
# Read backgrounds.json # Read background catalog
with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds: with open("utils/background_videos.json", "r", encoding="utf-8") as backgrounds:
data = json.load(backgrounds) data = json.load(backgrounds)
# Remove background from backgrounds.json if data.pop(key, None) is None:
with open("utils/backgrounds.json", "w", encoding="utf-8") as backgrounds:
if data.pop(key, None):
json.dump(data, backgrounds, ensure_ascii=False, indent=4)
else:
flash("Couldn't find this background. Try refreshing the page.", "error") flash("Couldn't find this background. Try refreshing the page.", "error")
return return
with open("utils/background_videos.json", "w", encoding="utf-8") as backgrounds:
json.dump(data, backgrounds, ensure_ascii=False, indent=4)
# Remove background video from ".config.template.toml" # Remove background video from ".config.template.toml"
config = tomlkit.loads(Path("utils/.config.template.toml").read_text()) config = tomlkit.loads(Path("utils/.config.template.toml").read_text())
config["settings"]["background"]["background_choice"]["options"].remove(key) options = config["settings"]["background"]["background_video"]["options"]
if key in options:
options.remove(key)
with Path("utils/.config.template.toml").open("w") as toml_file: with Path("utils/.config.template.toml").open("w") as toml_file:
toml_file.write(tomlkit.dumps(config)) toml_file.write(tomlkit.dumps(config))
@ -181,7 +188,7 @@ def add_background(youtube_uri, filename, citation, position):
filename = filename.replace(" ", "_") filename = filename.replace(" ", "_")
# Check if the background doesn't already exist # Check if the background doesn't already exist
with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds: with open("utils/background_videos.json", "r", encoding="utf-8") as backgrounds:
data = json.load(backgrounds) data = json.load(backgrounds)
# Check if key isn't already taken # Check if key isn't already taken
@ -190,21 +197,24 @@ def add_background(youtube_uri, filename, citation, position):
return return
# Check if the YouTube URI isn't already used under different name # Check if the YouTube URI isn't already used under different name
if youtube_uri in [data[i][0] for i in list(data.keys())]: if youtube_uri in [data[i][0] for i in list(data.keys()) if i != "__comment"]:
flash("Background video with this YouTube URI is already added!", "error") flash("Background video with this YouTube URI is already added!", "error")
return return
# Add background video to json file # Add background video to json file
with open("utils/backgrounds.json", "r+", encoding="utf-8") as backgrounds: with open("utils/background_videos.json", "r+", encoding="utf-8") as backgrounds:
data = json.load(backgrounds) data = json.load(backgrounds)
data[filename] = [youtube_uri, filename + ".mp4", citation, position] data[filename] = [youtube_uri, filename + ".mp4", citation, position]
backgrounds.seek(0) backgrounds.seek(0)
backgrounds.truncate()
json.dump(data, backgrounds, ensure_ascii=False, indent=4) json.dump(data, backgrounds, ensure_ascii=False, indent=4)
# Add background video to ".config.template.toml" # Add background video to ".config.template.toml"
config = tomlkit.loads(Path("utils/.config.template.toml").read_text()) config = tomlkit.loads(Path("utils/.config.template.toml").read_text())
config["settings"]["background"]["background_choice"]["options"].append(filename) options = config["settings"]["background"]["background_video"]["options"]
if filename not in options:
options.append(filename)
with Path("utils/.config.template.toml").open("w") as toml_file: with Path("utils/.config.template.toml").open("w") as toml_file:
toml_file.write(tomlkit.dumps(config)) toml_file.write(tomlkit.dumps(config))
@ -212,3 +222,36 @@ def add_background(youtube_uri, filename, citation, position):
flash(f'Added "{citation}-{filename}.mp4" as a new background video!') flash(f'Added "{citation}-{filename}.mp4" as a new background video!')
return return
# Delete videos by ID list — removes entries from videos.json and mp4 files from disk.
# Returns the number of files actually removed from disk.
def delete_videos(ids):
ids = set(ids)
videos_path = Path("video_creation/data/videos.json")
results_root = Path("results").resolve()
with videos_path.open("r", encoding="utf-8") as f:
videos = json.load(f)
to_delete = {v["id"]: v for v in videos if v.get("id") in ids}
remaining = [v for v in videos if v.get("id") not in ids]
deleted = 0
for entry in to_delete.values():
subreddit = entry.get("subreddit", "")
filename = entry.get("filename", "")
if subreddit and filename:
try:
file_path = (results_root / subreddit / filename).resolve()
file_path.relative_to(results_root) # path-traversal guard
if file_path.exists():
file_path.unlink()
deleted += 1
except (ValueError, OSError):
pass
with videos_path.open("w", encoding="utf-8") as f:
json.dump(remaining, f, ensure_ascii=False, indent=4)
return deleted

Loading…
Cancel
Save