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
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 -->
|
|
||||||
@ -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>
|
||||||
|
<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>
|
||||||
</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
|
<!-- Delete Background Modal -->
|
||||||
owner of the background video you are adding.</span>
|
<dialog id="delete_modal" class="modal modal-bottom sm:modal-middle">
|
||||||
</div>
|
<div class="modal-box bg-slate-800 border border-white/10">
|
||||||
</div>
|
<h3 class="font-bold text-lg text-white">Delete Background</h3>
|
||||||
<div class="form-group row">
|
<p class="py-4 text-slate-400">Are you sure you want to delete this background video? This action cannot be undone.</p>
|
||||||
<label for="position" class="col-4 col-form-label">Position of screenshots</label>
|
<div class="modal-action">
|
||||||
<div class="col-8">
|
<form action="background/delete" method="post" class="flex gap-2">
|
||||||
<div class="input-group">
|
<input type="hidden" id="background-key" name="background-key" value="">
|
||||||
<div class="input-group-text">
|
<button type="button" onclick="delete_modal.close()" class="btn btn-ghost text-slate-400">Cancel</button>
|
||||||
<i class="bi bi-arrows-fullscreen"></i>
|
<button type="submit" class="btn btn-error">Delete</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<input name="position" placeholder="Example: center" type="text" class="form-control">
|
|
||||||
</div>
|
</div>
|
||||||
<span class="form-text text-muted">Advanced option (you can leave it
|
</dialog>
|
||||||
empty). Valid options are "center" and decimal numbers</span>
|
|
||||||
|
<!-- 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>
|
||||||
|
<label class="label h-6"><span id="feedbackYT" class="label-text-alt text-error hidden"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
<div class="form-control w-full">
|
||||||
<button name="submit" type="submit" class="btn btn-success">Add background</button>
|
<label class="label"><span class="label-text text-slate-300">Filename</span></label>
|
||||||
</form>
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<div class="album py-2 bg-light">
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<div class="row justify-content-between mt-2">
|
<div class="form-control w-full">
|
||||||
<div class="col-12 col-md-3 mb-3">
|
<label class="label"><span class="label-text text-slate-300">Credits</span></label>
|
||||||
<input type="text" class="form-control searchFilter" placeholder="Search backgrounds"
|
<div class="join w-full">
|
||||||
onkeyup="searchFilter()">
|
<div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
|
||||||
|
<i data-lucide="user" class="w-4 h-4 text-slate-400"></i>
|
||||||
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(key) {
|
||||||
|
document.getElementById('background-key').value = key;
|
||||||
|
delete_modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
// Add background key when deleting
|
function searchFilter() {
|
||||||
$('#deleteBtnModal').on('show.bs.modal', function (event) {
|
const query = document.querySelector(".searchFilter").value.toLowerCase();
|
||||||
var button = $(event.relatedTarget);
|
const cards = document.querySelectorAll(".bg-card");
|
||||||
var key = button.data('background-key');
|
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,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 © 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">
|
||||||
|
© 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"
|
|
||||||
class="bi bi-volume-up-fill"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-2">
|
|
||||||
<label for="tiktok_voice" class="col-4">TikTok Voice</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<div class="input-group voices">
|
|
||||||
<select name="tiktok_voice" class="form-select" data-toggle="tooltip"
|
|
||||||
data-original-title='The voice used for TikTok TTS'>
|
|
||||||
<option disabled value="">-----Disney Voices-----</option>
|
|
||||||
<option value="en_us_ghostface">Ghost Face</option>
|
|
||||||
<option value="en_us_chewbacca">Chewbacca</option>
|
|
||||||
<option value="en_us_c3po">C3PO</option>
|
|
||||||
<option value="en_us_stitch">Stitch</option>
|
|
||||||
<option value="en_us_stormtrooper">Stormtrooper</option>
|
|
||||||
<option value="en_us_rocket">Rocket</option>
|
|
||||||
<option disabled value="">-----English Voices-----</option>
|
|
||||||
<option value="en_au_001">English AU - Female</option>
|
|
||||||
<option value="en_au_002">English AU - Male</option>
|
|
||||||
<option value="en_uk_001">English UK - Male 1</option>
|
|
||||||
<option value="en_uk_003">English UK - Male 2</option>
|
|
||||||
<option value="en_us_001">English US - Female (Int. 1)</option>
|
|
||||||
<option value="en_us_002">English US - Female (Int. 2)</option>
|
|
||||||
<option value="en_us_006">English US - Male 1</option>
|
|
||||||
<option value="en_us_007">English US - Male 2</option>
|
|
||||||
<option value="en_us_009">English US - Male 3</option>
|
|
||||||
<option value="en_us_010">English US - Male 4</option>
|
|
||||||
<option disabled value="">-----European Voices-----</option>
|
|
||||||
<option value="fr_001">French - Male 1</option>
|
|
||||||
<option value="fr_002">French - Male 2</option>
|
|
||||||
<option value="de_001">German - Female</option>
|
|
||||||
<option value="de_002">German - Male</option>
|
|
||||||
<option value="es_002">Spanish - Male</option>
|
|
||||||
<option disabled value="">-----American Voices-----</option>
|
|
||||||
<option value="es_mx_002">Spanish MX - Male</option>
|
|
||||||
<option value="br_001">Portuguese BR - Female 1</option>
|
|
||||||
<option value="br_003">Portuguese BR - Female 2</option>
|
|
||||||
<option value="br_004">Portuguese BR - Female 3</option>
|
|
||||||
<option value="br_005">Portuguese BR - Male</option>
|
|
||||||
<option disabled value="">-----Asian Voices-----</option>
|
|
||||||
<option value="id_001">Indonesian - Female</option>
|
|
||||||
<option value="jp_001">Japanese - Female 1</option>
|
|
||||||
<option value="jp_003">Japanese - Female 2</option>
|
|
||||||
<option value="jp_005">Japanese - Female 3</option>
|
|
||||||
<option value="jp_006">Japanese - Male</option>
|
|
||||||
<option value="kr_002">Korean - Male 1</option>
|
|
||||||
<option value="kr_003">Korean - Female</option>
|
|
||||||
<option value="kr_004">Korean - Male 2</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-primary"><i id="tiktok_icon"
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
class="bi-volume-up-fill"></i></button>
|
<div class="form-control">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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"
|
</div>
|
||||||
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 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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-2">
|
|
||||||
<label for="python_voice" class="col-4">Python Voice</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<div class="input-group">
|
|
||||||
<div class="input-group-text">
|
|
||||||
<i class="bi bi-mic-fill"></i>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<!-- Integration Tab -->
|
||||||
|
<div id="tab-integration" 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">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>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<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="row mb-2">
|
|
||||||
<label for="py_voice_num" class="col-4">Py Voice Number</label>
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="col-8">
|
<div class="form-control">
|
||||||
<div class="input-group">
|
<label class="label"><span class="label-text text-slate-400">Privacy</span></label>
|
||||||
<div class="input-group-text">
|
<select name="youtube.privacy" class="select select-bordered bg-slate-900 border-slate-700">
|
||||||
<i class="bi bi-headset"></i>
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
<option value="unlisted">Unlisted</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<input value="{{ data.py_voice_num }}" name="py_voice_num" type="text" class="form-control"
|
<div class="form-control col-span-2">
|
||||||
data-toggle="tooltip"
|
<label class="label"><span class="label-text text-slate-400">Tags (comma-separated)</span></label>
|
||||||
data-original-title='The number of system voices (2 are pre-installed in Windows)'>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-2">
|
|
||||||
<label for="silence_duration" class="col-4">Silence Duration</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<div class="input-group">
|
|
||||||
<input name="silence_duration" type="range" class="form-range" min="0" max="5" step="0.05"
|
|
||||||
value="{{ data.silence_duration }}" data-toggle="tooltip"
|
|
||||||
data-original-title="{{ data.silence_duration }}">
|
|
||||||
</div>
|
</div>
|
||||||
<span class="form-text text-muted">Time in seconds between TTS comments.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col text-center">
|
|
||||||
<br>
|
|
||||||
<button id="defaultSettingsBtn" type="button" class="btn btn-secondary">Default
|
|
||||||
Settings</button>
|
|
||||||
<button id="submitButton" type="submit" class="btn btn-success">Save
|
|
||||||
Changes</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 %}
|
||||||
|
|||||||
@ -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!')
|
||||||
Loading…
Reference in new issue